File test_e2e.cpp
File List > src > tests > test_e2e.cpp
Go to the documentation of this file
#include <gtest/gtest.h>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include "config_input/config_input.hpp"
#include "contour/contour.hpp"
#include "gdal_priv.h"
#include "io/gdal_init.hpp"
#include "io/gpkg.hpp"
#include "isom/colors.hpp"
#include "las/las_file.hpp"
#include "las/las_point.hpp"
#include "lib/grid/grid.hpp"
#include "ogr_srs_api.h"
#include "process.hpp"
#include "tif/tif.hpp"
#include "utilities/filesystem.hpp"
#include "utilities/progress_tracker.hpp"
#include "utilities/resources.hpp"
namespace fs = std::filesystem;
// Helper to get WGS84 WKT projection string using GDAL
std::string get_wgs84_wkt() {
ensure_gdal_initialized();
OGRSpatialReference srs;
Assert(srs.importFromEPSG(4326) == OGRERR_NONE, "Failed to import EPSG:4326");
char* wkt_ptr = nullptr;
srs.exportToWkt(&wkt_ptr);
Assert(wkt_ptr != nullptr, "Failed to export WKT from EPSG:4326");
std::string wkt(wkt_ptr);
CPLFree(wkt_ptr);
Assert(!wkt.empty(), "WKT string must not be empty");
return wkt;
}
// Helper to create a simple synthetic elevation grid
class TestGrid : public GeoGrid<double> {
public:
explicit TestGrid(const std::vector<std::vector<double>>& data)
: GeoGrid<double>(data[0].size(), data.size(), GeoTransform(),
GeoProjection(get_wgs84_wkt())) {
for (size_t i = 0; i < data.size(); i++) {
for (size_t j = 0; j < data[0].size(); j++) {
(*this)[{j, i}] = data[i][j];
}
}
}
};
// Create a minimal config for testing
Config create_minimal_test_config(const fs::path& output_dir) {
Config config = Config::Default();
config.set_output_directory(output_dir);
config.grid.bin_resolution = 1.0;
config.grid.downsample_factor = 2;
config.ground.outlier_removal_height_diff = 0.5;
config.ground.min_ground_intensity = 0;
config.ground.max_ground_intensity = 1000;
config.border_width = 10.0;
// Minimal contour config - use CMYKColor like the default config
std::map<std::string, ContourConfig> contour_map;
ContourConfig normal_contour;
normal_contour.interval = 5.0;
normal_contour.min_points = 3;
normal_contour.color = CMYKColor(0, 56, 100, 18); // brown
normal_contour.width = 0.14;
contour_map["normal"] = normal_contour;
config.contours = ContourConfigs(contour_map);
// Use default water config (already set from Config::Default())
// Just ensure it has at least one config
if (config.water.configs.empty()) {
WaterConfigs water_configs;
WaterConfig minor_water;
minor_water.catchment = 0.03;
minor_water.color = CMYKColor(100, 0, 0, 0); // blue
minor_water.width = 0.18;
water_configs.configs["minor"] = minor_water;
config.water = water_configs;
}
// Minimal vegetation config
VegeHeightConfig tall_veg;
tall_veg.name = "tall_veg";
tall_veg.min_height = 2.0;
tall_veg.max_height = 100.0;
BlockingThresholdColorPair green_veg;
green_veg.blocking_threshold = 0.01;
green_veg.color = CMYKColor(100, 0, 100, 0); // Green
tall_veg.colors.push_back(green_veg);
config.vege = VegeConfig{CMYKColor(0, 27, 79, 0), // yellow
{tall_veg}};
// Minimal render config
config.render.scale = 1000;
config.render.dpi = 300;
return config;
}
// Create synthetic LAS data from a simple grid
LASData create_synthetic_las_data_ext(const Extent2D& extent,
const std::function<double(double, double)>& height_function,
bool with_vegetation) {
const std::string proj_str = get_wgs84_wkt();
Assert(!proj_str.empty(), "get_wgs84_wkt() must return a non-empty string");
LASData las_data(extent, GeoProjection(proj_str));
// Generate ground points
for (double x = extent.minx; x < extent.maxx; x += 1.0) {
for (double y = extent.miny; y < extent.maxy; y += 1.0) {
double z = height_function(x, y);
las_data.insert(LASPoint(x, y, z, 1000, LASClassification::Ground));
}
}
// Generate vegetation points if requested
if (with_vegetation) {
// Use denser vegetation points (2m intervals) to ensure they're detected
// after binning and low-pass filtering
for (double x = extent.minx + 2.0; x < extent.maxx - 2.0; x += 2.0) {
for (double y = extent.miny + 2.0; y < extent.maxy - 2.0; y += 2.0) {
double ground_z = height_function(x, y);
double vege_z = ground_z + 5.0; // 5m tall vegetation
las_data.insert(LASPoint(x, y, vege_z, 1000, LASClassification::HighVegetation));
}
}
}
return las_data;
}
// Create synthetic LAS data from a simple grid
LASData create_synthetic_las_data() {
// Create a simple 10x10 grid with a hill in the middle
std::vector<std::vector<double>> data(10, std::vector<double>(10, 10.0));
// Add a hill
for (size_t i = 3; i < 7; i++) {
for (size_t j = 3; j < 7; j++) {
double dist = std::sqrt((i - 5.0) * (i - 5.0) + (j - 5.0) * (j - 5.0));
data[i][j] = 10.0 + std::max(0.0, 5.0 - dist);
}
}
TestGrid grid(data);
// Get projection string before creating LASData (workaround for projection being lost in move)
const std::string proj_str = get_wgs84_wkt();
Assert(!proj_str.empty(), "get_wgs84_wkt() must return a non-empty string");
// Create LAS data from grid using explicit projection
std::unique_ptr<Extent2D> extent = grid.extent();
LASData las_data(*extent, GeoProjection(proj_str));
// Copy the grid data into LASData
for (size_t i = 0; i < grid.height(); i++) {
for (size_t j = 0; j < grid.width(); j++) {
Coordinate2D<double> coord =
grid.transform().pixel_to_projection({(double)j + 0.5, (double)i + 0.5});
las_data.insert(
LASPoint(coord.x(), coord.y(), grid[{j, i}], 1000, LASClassification::Ground));
}
}
return las_data;
}
// Test end-to-end processing with synthetic data
TEST(E2E, ProcessSyntheticData) {
fs::path test_output_dir = fs::temp_directory_path() / "blaze_e2e_test";
// Clean up if exists
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
fs::create_directories(test_output_dir);
// Create config
Config config = create_minimal_test_config(test_output_dir);
// Create synthetic LAS data
LASData las_data = create_synthetic_las_data();
// Process the data
ProgressTracker tracker;
process_las_data(las_data, test_output_dir, config, std::move(tracker));
// Verify outputs were created (some may not exist if data is too sparse)
// At minimum, the output directory should exist
EXPECT_TRUE(fs::exists(test_output_dir));
// Check for key output files (may not all exist depending on data)
bool has_ground = fs::exists(test_output_dir / "ground.tif");
bool has_smooth = fs::exists(test_output_dir / "smooth_ground.tif");
bool has_contours = fs::exists(test_output_dir / "contours.gpkg");
// At least some outputs should be created
EXPECT_TRUE(has_ground || has_smooth || has_contours);
// If files exist, verify they're not empty
if (has_ground) {
EXPECT_GT(fs::file_size(test_output_dir / "ground.tif"), 0);
}
if (has_smooth) {
EXPECT_GT(fs::file_size(test_output_dir / "smooth_ground.tif"), 0);
}
if (has_contours) {
EXPECT_GT(fs::file_size(test_output_dir / "contours.gpkg"), 0);
}
// Verify contours can be read back if file exists
if (fs::exists(test_output_dir / "contours.gpkg")) {
// If file exists, it must be valid and readable
std::vector<Contour> contours = read_gpkg(test_output_dir / "contours.gpkg");
// Should be able to read without errors
EXPECT_GE(contours.size(), 0);
}
// Clean up
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
}
// Test that processing handles empty data gracefully
TEST(E2E, ProcessEmptyData) {
fs::path test_output_dir = fs::temp_directory_path() / "blaze_e2e_test_empty";
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
fs::create_directories(test_output_dir);
Config config = create_minimal_test_config(test_output_dir);
// Create empty LAS data
Extent2D bounds = {0.0, 10.0, 0.0, 10.0};
GeoProjection proj;
LASData las_data(bounds, proj);
ProgressTracker tracker;
process_las_data(las_data, test_output_dir, config, std::move(tracker));
// Should still create output directory structure
EXPECT_TRUE(fs::exists(test_output_dir));
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
}
// Test processing with different grid resolutions
TEST(E2E, ProcessDifferentResolutions) {
fs::path test_output_dir = fs::temp_directory_path() / "blaze_e2e_test_res";
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
fs::create_directories(test_output_dir);
Config config = create_minimal_test_config(test_output_dir);
// Test with different bin resolution
config.grid.bin_resolution = 0.5;
config.grid.downsample_factor = 1;
LASData las_data = create_synthetic_las_data();
ProgressTracker tracker;
process_las_data(las_data, test_output_dir, config, std::move(tracker));
// Both files should be created
fs::path ground_file = test_output_dir / "ground.tif";
fs::path smooth_file = test_output_dir / "smooth_ground.tif";
// List directory contents for debugging
std::string dir_contents;
if (fs::exists(test_output_dir)) {
for (const auto& entry : fs::directory_iterator(test_output_dir)) {
dir_contents += entry.path().filename().string() + " ";
}
}
EXPECT_TRUE(fs::exists(ground_file))
<< "ground.tif was not created. Output directory contents: " << dir_contents;
EXPECT_TRUE(fs::exists(smooth_file))
<< "smooth_ground.tif was not created. Output directory contents: " << dir_contents;
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
}
// Test that output files have valid structure
TEST(E2E, VerifyOutputStructure) {
fs::path test_output_dir = fs::temp_directory_path() / "blaze_e2e_test_structure";
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
fs::create_directories(test_output_dir);
Config config = create_minimal_test_config(test_output_dir);
LASData las_data = create_synthetic_las_data();
ProgressTracker tracker;
process_las_data(las_data, test_output_dir, config, std::move(tracker));
// Verify TIF files can be read
if (fs::exists(test_output_dir / "ground.tif")) {
auto ground_grid = read_tif(test_output_dir / "ground.tif");
EXPECT_GT(ground_grid.width(), 0);
EXPECT_GT(ground_grid.height(), 0);
}
// Verify GPKG files can be read
if (fs::exists(test_output_dir / "contours.gpkg")) {
// If file exists, it must be valid and readable
std::vector<Contour> contours = read_gpkg(test_output_dir / "contours.gpkg");
// Should be able to read without errors
EXPECT_GE(contours.size(), 0);
}
if (fs::exists(test_output_dir)) {
fs::remove_all(test_output_dir);
}
}
// Helper to verify vegetation TIF
void verify_vegetation_tif(const fs::path& vege_tif_path, bool should_have_vegetation,
const Config& config) {
if (should_have_vegetation) {
ASSERT_TRUE(fs::exists(vege_tif_path)) << "vege_color.tif was not created.";
} else {
// If it doesn't exist, that's also a pass.
if (!fs::exists(vege_tif_path)) {
return;
}
}
// Common code: read and validate TIF
auto grid = read_tif(vege_tif_path);
ASSERT_GE(grid.size(), 3);
const auto& r_band = grid[0];
const auto& g_band = grid[1];
const auto& b_band = grid[2];
RGBColor background = to_cmyk(config.vege.background_color).toRGB();
// Determine expected vegetation color(s) from config
std::vector<RGBColor> expected_vege_colors;
for (const VegeHeightConfig& vege_config : config.vege.height_configs) {
for (const BlockingThresholdColorPair& color_pair : vege_config.colors) {
expected_vege_colors.push_back(to_rgb(color_pair.color));
}
}
ASSERT_FALSE(expected_vege_colors.empty()) << "No vegetation colors configured.";
// Helper lambda to get pixel RGB color
auto get_pixel_color = [&](size_t i, size_t j) -> RGBColor {
return RGBColor(
static_cast<unsigned char>(r_band.get<std::byte>({(long long)j, (long long)i})),
static_cast<unsigned char>(g_band.get<std::byte>({(long long)j, (long long)i})),
static_cast<unsigned char>(b_band.get<std::byte>({(long long)j, (long long)i})));
};
// Helper lambda to check if a pixel matches a color
auto matches_color = [](const RGBColor& pixel, const RGBColor& expected) {
return pixel.getRed() == expected.getRed() && pixel.getGreen() == expected.getGreen() &&
pixel.getBlue() == expected.getBlue();
};
// Check pixels based on expected behavior
if (should_have_vegetation) {
// Since vegetation points are added across the whole area, most pixels should be vegetation
// color Pixels can be either background or vegetation colors, but we expect mostly vegetation
size_t vegetation_pixel_count = 0;
size_t background_pixel_count = 0;
size_t invalid_pixel_count = 0;
for (size_t i = 0; i < grid.height(); ++i) {
for (size_t j = 0; j < grid.width(); ++j) {
RGBColor pixel = get_pixel_color(i, j);
bool matches_background = matches_color(pixel, background);
bool matches_vege = false;
if (matches_background) {
background_pixel_count++;
} else {
for (const RGBColor& vege_color : expected_vege_colors) {
if (matches_color(pixel, vege_color)) {
matches_vege = true;
vegetation_pixel_count++;
break;
}
}
if (!matches_vege) {
invalid_pixel_count++;
FAIL() << "Pixel at (" << j << ", " << i
<< ") does not match expected color (background or vegetation). Got RGB("
<< static_cast<int>(pixel.getRed()) << ", " << static_cast<int>(pixel.getGreen())
<< ", " << static_cast<int>(pixel.getBlue()) << "), expected background RGB("
<< static_cast<int>(background.getRed()) << ", "
<< static_cast<int>(background.getGreen()) << ", "
<< static_cast<int>(background.getBlue()) << ") or vegetation color RGB("
<< static_cast<int>(expected_vege_colors[0].getRed()) << ", "
<< static_cast<int>(expected_vege_colors[0].getGreen()) << ", "
<< static_cast<int>(expected_vege_colors[0].getBlue()) << ")";
}
}
}
}
size_t total_pixels = grid.width() * grid.height();
double vegetation_ratio = static_cast<double>(vegetation_pixel_count) / total_pixels;
// Expect at least 50% of pixels to be vegetation color (allowing for edges and smoothing
// effects)
ASSERT_GE(vegetation_ratio, 0.5)
<< "Expected at least 50% of pixels to be vegetation color, but got "
<< (vegetation_ratio * 100.0) << "% (vegetation: " << vegetation_pixel_count
<< ", background: " << background_pixel_count << ", total: " << total_pixels << ")";
} else {
// All pixels must be background
for (size_t i = 0; i < grid.height(); ++i) {
for (size_t j = 0; j < grid.width(); ++j) {
RGBColor pixel = get_pixel_color(i, j);
if (!matches_color(pixel, background)) {
FAIL() << "Pixel at (" << j << ", " << i
<< ") should be background color but is not. Got RGB("
<< static_cast<int>(pixel.getRed()) << ", " << static_cast<int>(pixel.getGreen())
<< ", " << static_cast<int>(pixel.getBlue()) << "), expected RGB("
<< static_cast<int>(background.getRed()) << ", "
<< static_cast<int>(background.getGreen()) << ", "
<< static_cast<int>(background.getBlue()) << ")";
}
}
}
}
}
// Helper to verify raw vegetation TIF (blocked proportion)
void verify_raw_vegetation_tif(const fs::path& raw_vege_tif_path, bool should_have_vegetation,
const Config& config) {
// Find the corresponding vege config by matching the filename
std::string filename = raw_vege_tif_path.stem().string();
bool is_smoothed = filename.find("smoothed_") == 0;
std::string vege_name = is_smoothed ? filename.substr(9) : filename;
const VegeHeightConfig* vege_config = nullptr;
for (const VegeHeightConfig& vc : config.vege.height_configs) {
if (vc.name == vege_name) {
vege_config = &vc;
break;
}
}
ASSERT_NE(vege_config, nullptr) << "No vegetation config found for name: " << vege_name;
if (should_have_vegetation) {
ASSERT_TRUE(fs::exists(raw_vege_tif_path))
<< "Raw vegetation TIF " << raw_vege_tif_path << " was not created.";
} else {
// If it doesn't exist, that's also a pass.
if (!fs::exists(raw_vege_tif_path)) {
return;
}
}
// Read and validate TIF
auto grid = read_tif(raw_vege_tif_path);
if (is_smoothed) {
// Smoothed version is single-band float
ASSERT_EQ(grid.size(), 1) << "Smoothed raw vege TIF should have 1 band";
const auto& band = grid[0];
if (should_have_vegetation) {
// Every pixel should have a value >= 0.0 and <= 1.0 (blocked proportion)
for (size_t i = 0; i < band.height(); ++i) {
for (size_t j = 0; j < band.width(); ++j) {
float value = band.get<float>({(long long)j, (long long)i});
if (value < 0.0f || value > 1.0f) {
FAIL() << "Pixel at (" << j << ", " << i
<< ") has invalid blocked proportion value: " << value
<< " (expected 0.0 to 1.0)";
}
// With vegetation, we expect at least some pixels to have non-zero values
// But we can't require all to be > 0 since some areas might not have vegetation
}
}
} else {
// All pixels should be 0.0 (no vegetation)
for (size_t i = 0; i < band.height(); ++i) {
for (size_t j = 0; j < band.width(); ++j) {
float value = band.get<float>({(long long)j, (long long)i});
if (value != 0.0f) {
FAIL() << "Pixel at (" << j << ", " << i
<< ") should be 0.0 (no vegetation) but is: " << value;
}
}
}
}
} else {
// Raw version is two-band: band 0 = value, band 1 = validity mask
ASSERT_EQ(grid.size(), 2) << "Raw vege TIF should have 2 bands (value and validity)";
const auto& value_band = grid[0];
const auto& validity_band = grid[1];
if (should_have_vegetation) {
// For unsmoothed raw vege: verify that cells with vegetation points have valid blocked
// proportion This checks "has vege point if and only if has valid blocked proportion" Note:
// We don't compare to vege_color here because vege_color uses smoothed data which spreads
// values
bool has_valid_vegetation = false;
for (size_t i = 0; i < value_band.height(); ++i) {
for (size_t j = 0; j < value_band.width(); ++j) {
float value = value_band.get<float>({(long long)j, (long long)i});
float validity = validity_band.get<float>({(long long)j, (long long)i});
if (validity == 255.0f) {
// Valid value means there are vegetation points - should be in range [0.0, 1.0]
if (value < 0.0f || value > 1.0f) {
FAIL() << "Pixel at (" << j << ", " << i
<< ") has invalid blocked proportion value: " << value
<< " (expected 0.0 to 1.0)";
}
if (value > 0.0f) {
has_valid_vegetation = true;
}
// Note: validity = 255 means there are vegetation points in this cell
// The blocked proportion should be > 0 if there are points in the height range
} else if (validity != 0.0f) {
FAIL() << "Pixel at (" << j << ", " << i << ") has invalid validity mask: " << validity
<< " (expected 0.0 or 255.0)";
}
// validity = 0 means no vegetation points in this cell (which is correct)
}
}
ASSERT_TRUE(has_valid_vegetation)
<< "Raw vegetation TIF should contain at least some valid vegetation data";
} else {
// All pixels should be nullopt (validity = 0) or have value = 0.0
for (size_t i = 0; i < value_band.height(); ++i) {
for (size_t j = 0; j < value_band.width(); ++j) {
float value = value_band.get<float>({(long long)j, (long long)i});
float validity = validity_band.get<float>({(long long)j, (long long)i});
if (validity == 255.0f) {
// If valid, value should be 0.0 (no vegetation)
if (value != 0.0f) {
FAIL() << "Pixel at (" << j << ", " << i
<< ") should be 0.0 (no vegetation) but is: " << value;
}
} else if (validity != 0.0f) {
FAIL() << "Pixel at (" << j << ", " << i << ") has invalid validity mask: " << validity
<< " (expected 0.0 or 255.0)";
}
}
}
}
}
}
struct TerrainTestParams {
std::string name;
std::function<double(double, double)> height_function;
bool with_vegetation;
bool expect_contours;
friend std::ostream& operator<<(std::ostream& os, const TerrainTestParams& params) {
os << params.name;
return os;
}
};
class E2ETerrainTest : public ::testing::TestWithParam<TerrainTestParams> {
public:
// Ensure GDAL is initialized once before any tests run (thread-safe)
static void SetUpTestSuite() { ensure_gdal_initialized(); }
};
TEST_P(E2ETerrainTest, ProcessTerrain) {
const TerrainTestParams& params = GetParam();
// Check if we should keep output files (check early so files are preserved even on test failure)
const char* keep_output = std::getenv("BLAZE_KEEP_TEST_OUTPUT");
bool should_keep_output = (keep_output != nullptr && std::string(keep_output) != "0");
fs::path test_output_dir = fs::temp_directory_path() / ("blaze_e2e_" + params.name);
if (fs::exists(test_output_dir)) fs::remove_all(test_output_dir);
fs::create_directories(test_output_dir);
Config config = create_minimal_test_config(test_output_dir);
Extent2D extent = {0, 100, 0, 100};
LASData las_data =
create_synthetic_las_data_ext(extent, params.height_function, params.with_vegetation);
ProgressTracker tracker;
process_las_data(las_data, test_output_dir, config, std::move(tracker));
EXPECT_TRUE(fs::exists(test_output_dir / "ground.tif"));
fs::path contours_path = test_output_dir / "contours.gpkg";
EXPECT_TRUE(fs::exists(contours_path));
std::vector<Contour> contours = read_gpkg(contours_path);
if (params.expect_contours) {
EXPECT_GT(contours.size(), 0);
} else {
EXPECT_EQ(contours.size(), 0);
auto ground_grid = read_tif(test_output_dir / "ground.tif");
const auto& band = ground_grid[0];
// For flat terrain tests, check that elevation matches the expected value
// (102.5m to avoid being exactly on a 5.0m contour interval)
double expected_elevation = params.height_function(0, 0);
for (size_t i = 0; i < band.height(); ++i) {
for (size_t j = 0; j < band.width(); ++j) {
if (band.get<double>({(long long)j, (long long)i}) != std::numeric_limits<double>::max()) {
EXPECT_NEAR(band.get<double>({(long long)j, (long long)i}), expected_elevation, 1e-6);
}
}
}
}
verify_vegetation_tif(test_output_dir / "vege_color.tif", params.with_vegetation, config);
// Verify raw vegetation TIFs for each height config
for (const VegeHeightConfig& vege_config : config.vege.height_configs) {
fs::path raw_vege_path = test_output_dir / "raw_vege" / (vege_config.name + ".tif");
verify_raw_vegetation_tif(raw_vege_path, params.with_vegetation, config);
fs::path smoothed_vege_path =
test_output_dir / "raw_vege" / ("smoothed_" + vege_config.name + ".tif");
verify_raw_vegetation_tif(smoothed_vege_path, params.with_vegetation, config);
}
// Check contour properties based on terrain type
if (params.expect_contours && contours.size() > 0) {
// Check based on test name to determine expected behavior
if (params.name.find("Hill") != std::string::npos) {
// Hill terrain should have ALL closed loops
for (const Contour& contour : contours) {
EXPECT_TRUE(contour.is_loop()) << "Hill terrain should have all contours as closed loops, "
"but found an open contour at height "
<< contour.height();
}
} else if (params.name.find("Slope") != std::string::npos) {
// Slope terrain should have ALL open lines
for (const Contour& contour : contours) {
EXPECT_FALSE(contour.is_loop()) << "Slope terrain should have all contours as open lines, "
"but found a closed loop at height "
<< contour.height();
}
}
}
// Keep output files if BLAZE_KEEP_TEST_OUTPUT environment variable is set
if (should_keep_output) {
std::cout << "Test output kept at: " << test_output_dir << std::endl;
} else {
fs::remove_all(test_output_dir);
}
}
INSTANTIATE_TEST_SUITE_P(
E2E_New, E2ETerrainTest,
::testing::Values(
TerrainTestParams{"FlatTerrainGroundOnly", [](double, double) { return 102.5; }, false,
false},
TerrainTestParams{"FlatTerrainWithVegetation", [](double, double) { return 102.5; }, true,
false},
TerrainTestParams{"SlopedTerrainGroundOnly",
[](double x, double) { return 100.0 + x * 0.2; }, false, true},
TerrainTestParams{"SlopedTerrainWithVegetation",
[](double x, double) { return 100.0 + x * 0.2; }, true, true},
TerrainTestParams{"HillTerrainGroundOnly",
[](double x, double y) {
double dist = std::sqrt(std::pow(x - 50, 2) + std::pow(y - 50, 2));
return 99.0 + std::max(0.0, 20.0 - dist * 0.5);
},
false, true},
TerrainTestParams{"HillTerrainWithVegetation",
[](double x, double y) {
double dist = std::sqrt(std::pow(x - 50, 2) + std::pow(y - 50, 2));
return 99.0 + std::max(0.0, 20.0 - dist * 0.5);
},
true, true}));