File test_vegetation_polygon.cpp
File List > lib > vegetation > tests > test_vegetation_polygon.cpp
Go to the documentation of this file
#include <gtest/gtest.h>
#include <algorithm>
#include <cmath>
#include <map>
#include <set>
#include <vector>
#include "config_input/config_input.hpp"
#include "contour/contour.hpp"
#include "contour/contour_gen.hpp"
#include "geometry/polygon.hpp"
#include "grid/grid.hpp"
#include "utilities/progress_tracker.hpp"
#include "vegetation/vegetation_polygon.hpp"
namespace {
VegeConfig canopy_config(double forest_min_area_m2, double forest_min_hole_area_m2) {
VegeConfig config;
config.background_color = ColorVariant(RGBColor(255, 255, 255, 255));
VegeHeightConfig canopy;
canopy.name = "canopy";
canopy.min_height = 2.5;
canopy.max_height = 100.0;
canopy.colors.push_back({0.1, ColorVariant(RGBColor(0, 0, 0, 0)), "405_Forest",
forest_min_area_m2, forest_min_hole_area_m2});
config.height_configs.push_back(std::move(canopy));
return config;
}
VegeConfig forest_and_green_config() {
VegeConfig config;
config.background_color = ColorVariant(RGBColor(255, 255, 255, 255));
VegeHeightConfig canopy;
canopy.name = "canopy";
canopy.min_height = 2.5;
canopy.max_height = 100.0;
canopy.colors.push_back({0.1, ColorVariant(RGBColor(0, 0, 0, 0)), "405_Forest", 0, 0});
VegeHeightConfig green;
green.name = "green";
green.min_height = 0.5;
green.max_height = 2.5;
green.colors.push_back({0.1, ColorVariant(RGBColor(0, 0, 0, 0)), "408_Walk", 0, 0});
config.height_configs.push_back(std::move(canopy));
config.height_configs.push_back(std::move(green));
return config;
}
bool point_in_polygon(const Coordinate2D<double>& pt, const VegePolygon& poly) {
if (!point_in_ring(pt, poly.exterior_ring)) return false;
for (const auto& hole : poly.holes) {
if (point_in_ring(pt, hole)) return false;
}
return true;
}
const VegePolygon* largest_forest_with_hole(const std::vector<VegePolygon>& polygons) {
const VegePolygon* best = nullptr;
double best_area = 0.0;
for (const VegePolygon& poly : polygons) {
if (poly.layer != "405_Forest" || poly.holes.empty()) continue;
double area = signed_area(poly.exterior_ring);
if (area > best_area) {
best_area = area;
best = &poly;
}
}
return best;
}
GeoGrid<float> grid_at_3m(const std::vector<std::vector<float>>& data) {
const double height_m = static_cast<double>(data.size()) * 3.0;
return GeoGrid<float>(data, GeoTransform(0.0, height_m, 3.0, -3.0));
}
const VegePolygon* find_polygon(const std::vector<VegePolygon>& polygons,
const std::string& layer) {
for (const VegePolygon& poly : polygons) {
if (poly.layer == layer) return &poly;
}
return nullptr;
}
size_t count_layer(const std::vector<VegePolygon>& polygons, const std::string& layer) {
size_t count = 0;
for (const VegePolygon& poly : polygons) {
if (poly.layer == layer) ++count;
}
return count;
}
std::vector<Coordinate2D<double>> square_ccw(double x0, double y0, double x1, double y1) {
return {{x0, y0}, {x1, y0}, {x1, y1}, {x0, y1}, {x0, y0}};
}
std::vector<Coordinate2D<double>> square_cw(double x0, double y0, double x1, double y1) {
return reverse_ring(square_ccw(x0, y0, x1, y1));
}
Contour loop_contour(double threshold, std::vector<Coordinate2D<double>> ring) {
return Contour(threshold, std::move(ring));
}
} // namespace
TEST(ExtractThresholdLayers, UsesDefaultLayerName) {
VegeHeightConfig config;
config.name = "green";
config.colors = {
{0.3, ColorVariant(RGBColor(0, 0, 0, 0)), "406_Slow_Running"},
{0.6, ColorVariant(RGBColor(0, 0, 0, 0)), ""},
{0.85, ColorVariant(RGBColor(0, 0, 0, 0)), "410_Fight"},
};
std::map<double, std::string> layers = extract_threshold_layers(config);
EXPECT_EQ(layers.size(), 3u);
EXPECT_EQ(layers.at(0.3), "406_Slow_Running");
EXPECT_EQ(layers.at(0.6), "408_Walk");
EXPECT_EQ(layers.at(0.85), "410_Fight");
}
TEST(ExtractThresholdLayers, SortedByThreshold) {
VegeHeightConfig config;
config.colors = {
{0.85, ColorVariant(RGBColor(0, 0, 0, 0)), "410_Fight"},
{0.3, ColorVariant(RGBColor(0, 0, 0, 0)), "406_Slow_Running"},
{0.6, ColorVariant(RGBColor(0, 0, 0, 0)), "408_Walk"},
};
std::map<double, std::string> layers = extract_threshold_layers(config);
EXPECT_EQ(layers.size(), 3u);
// Verify sorted order (map is ordered by key)
auto it = layers.begin();
EXPECT_DOUBLE_EQ(it->first, 0.3);
EXPECT_EQ(it->second, "406_Slow_Running");
++it;
EXPECT_DOUBLE_EQ(it->first, 0.6);
EXPECT_EQ(it->second, "408_Walk");
++it;
EXPECT_DOUBLE_EQ(it->first, 0.85);
EXPECT_EQ(it->second, "410_Fight");
}
TEST(ExtractThresholdLayers, EmptyConfig) {
VegeHeightConfig config;
config.colors = {};
std::map<double, std::string> layers = extract_threshold_layers(config);
EXPECT_TRUE(layers.empty());
}
// =============================================================================
// ContoursToPolygons tests
// =============================================================================
TEST(ContoursToPolygons, SingleThresholdNoHoles) {
// Create a simple closed contour at threshold 0.5
const std::vector<Coordinate2D<double>> expected_ring = {
{0.0, 0.0}, {10.0, 0.0}, {10.0, 10.0}, {0.0, 10.0}, {0.0, 0.0},
};
Contour c(0.5, std::vector<Coordinate2D<double>>(expected_ring));
EXPECT_TRUE(c.is_loop());
std::map<double, std::vector<Contour>> contours_by_height;
contours_by_height[0.5].push_back(std::move(c));
std::map<double, std::string> height_to_layer = {{0.5, "405_Forest"}};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
ASSERT_EQ(polygons.size(), 1u);
EXPECT_EQ(polygons[0].layer, "405_Forest");
EXPECT_EQ(polygons[0].name, "405");
EXPECT_EQ(polygons[0].exterior_ring.size(), 5u);
EXPECT_EQ(polygons[0].exterior_ring, expected_ring);
EXPECT_TRUE(polygons[0].holes.empty());
}
TEST(ContoursToPolygons, NestedContoursAreIndependent) {
// Outer contour (0.25 threshold) containing an inner contour (0.5 threshold).
// Higher thresholds should be independent, overlapping polygons — NOT holes.
// OCAD/OOM renders higher-density symbols on top of lower ones.
std::vector<Coordinate2D<double>> outer_ring = {
{0.0, 0.0}, {20.0, 0.0}, {20.0, 20.0}, {0.0, 20.0}, {0.0, 0.0},
};
std::vector<Coordinate2D<double>> inner_ring = {
{5.0, 5.0}, {15.0, 5.0}, {15.0, 15.0}, {5.0, 15.0}, {5.0, 5.0},
};
Contour outer(0.25, std::move(outer_ring));
Contour inner(0.5, std::move(inner_ring));
std::map<double, std::vector<Contour>> contours_by_height;
contours_by_height[0.25].push_back(std::move(outer));
contours_by_height[0.5].push_back(std::move(inner));
std::map<double, std::string> height_to_layer = {
{0.25, "405_Forest"},
{0.5, "408_Walk"},
};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
// Both should be independent polygons, no cross-threshold holes
ASSERT_EQ(polygons.size(), 2u);
bool has_forest = false, has_walk = false;
for (const auto& p : polygons) {
if (p.layer == "405_Forest") {
has_forest = true;
EXPECT_EQ(p.exterior_ring.size(), 5u);
}
if (p.layer == "408_Walk") {
has_walk = true;
EXPECT_EQ(p.exterior_ring.size(), 5u);
}
EXPECT_TRUE(p.holes.empty());
}
EXPECT_TRUE(has_forest);
EXPECT_TRUE(has_walk);
}
TEST(ContoursToPolygons, DetectsOuterRingVsHole) {
// Donut-shaped density patch: high ring around a low interior. At one threshold
// this yields two closed contours; the inner one is classified as a hole.
std::vector<std::vector<float>> data = {
{0.8f, 0.8f, 0.8f, 0.8f, 0.8f},
{0.8f, 0.0f, 0.0f, 0.0f, 0.8f},
{0.8f, 0.0f, 0.0f, 0.0f, 0.8f},
{0.8f, 0.8f, 0.8f, 0.8f, 0.8f},
};
GeoGrid<float> grid(data);
std::vector<double> heights = {0.5};
auto contours_by_height = generate_contours_at_heights(grid, heights, ProgressTracker(), 3, 0.0f);
ASSERT_EQ(contours_by_height[0.5].size(), 2u);
for (const auto& c : contours_by_height[0.5]) {
EXPECT_TRUE(c.is_loop());
}
std::map<double, std::string> height_to_layer = {{0.5, "405_Forest"}};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
ASSERT_EQ(polygons.size(), 1u);
EXPECT_EQ(polygons[0].layer, "405_Forest");
EXPECT_EQ(polygons[0].exterior_ring.size(), 19u);
ASSERT_EQ(polygons[0].holes.size(), 1u);
EXPECT_EQ(polygons[0].holes[0].size(), 11u);
EXPECT_GT(signed_area(polygons[0].exterior_ring), 0.0);
EXPECT_LT(signed_area(polygons[0].holes[0]), 0.0);
EXPECT_TRUE(point_in_ring(polygons[0].holes[0][0], polygons[0].exterior_ring));
}
TEST(ContoursToPolygons, CwOnlyRingProducesNoPolygon) {
// CW rings are holes only; a mis-oriented outer loop is not promoted to an exterior.
std::vector<Coordinate2D<double>> cw_outer = {
{0.0, 0.0}, {0.0, 1.0}, {1.0, 1.0}, {1.0, 0.0}, {0.0, 0.0},
};
Contour outer(0.1, std::move(cw_outer));
ASSERT_LT(signed_area(outer.points()), 0.0);
std::map<double, std::vector<Contour>> contours_by_height;
contours_by_height[0.1].push_back(std::move(outer));
std::map<double, std::string> height_to_layer = {{0.1, "405_Forest"}};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
EXPECT_TRUE(polygons.empty());
}
TEST(TrimVegePolygons, ClipsToExportExtent) {
VegePolygon poly;
poly.layer = "405_Forest";
poly.name = "405";
poly.exterior_ring = square_ccw(0, 0, 100, 100);
poly.holes.push_back(square_cw(10, 10, 90, 90));
std::vector<VegePolygon> polygons = {poly};
trim_vege_polygons_to_extent(polygons, {50.0, 100.0, 0.0, 100.0}, ProgressTracker());
ASSERT_EQ(polygons.size(), 1u);
for (const Coordinate2D<double>& p : polygons[0].exterior_ring) {
EXPECT_GE(p.x(), 50.0 - 1e-9);
EXPECT_LE(p.x(), 100.0 + 1e-9);
}
EXPECT_EQ(polygons[0].layer, "405_Forest");
}
TEST(TrimVegePolygons, RemovesPolygonOutsideExtent) {
VegePolygon poly;
poly.layer = "405_Forest";
poly.name = "405";
poly.exterior_ring = square_ccw(0, 0, 10, 10);
std::vector<VegePolygon> polygons = {poly};
trim_vege_polygons_to_extent(polygons, {20.0, 30.0, 0.0, 10.0}, ProgressTracker());
EXPECT_TRUE(polygons.empty());
}
TEST(ContoursToPolygons, DeepNestedPolygonInHoleInPolygon) {
// Four nesting levels: outer → hole → island → hole → island → hole → island.
// Expect four polygons; the three outermost each have one direct hole; the innermost has none.
constexpr double THRESHOLD = 0.1;
std::map<double, std::vector<Contour>> contours_by_height;
contours_by_height[THRESHOLD] = {
loop_contour(THRESHOLD, square_ccw(0, 0, 100, 100)), // E0
loop_contour(THRESHOLD, square_cw(10, 10, 90, 90)), // H1
loop_contour(THRESHOLD, square_ccw(20, 20, 80, 80)), // E1
loop_contour(THRESHOLD, square_cw(30, 30, 70, 70)), // H2
loop_contour(THRESHOLD, square_ccw(40, 40, 60, 60)), // E2
loop_contour(THRESHOLD, square_cw(45, 45, 55, 55)), // H3
loop_contour(THRESHOLD, square_ccw(47, 47, 53, 53)), // E3
};
std::map<double, std::string> height_to_layer = {{THRESHOLD, "405_Forest"}};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
ASSERT_EQ(polygons.size(), 4u);
auto by_exterior_area = polygons;
std::sort(by_exterior_area.begin(), by_exterior_area.end(),
[](const VegePolygon& a, const VegePolygon& b) {
return signed_area(a.exterior_ring) > signed_area(b.exterior_ring);
});
EXPECT_EQ(by_exterior_area[0].holes.size(), 1u);
EXPECT_EQ(by_exterior_area[1].holes.size(), 1u);
EXPECT_EQ(by_exterior_area[2].holes.size(), 1u);
EXPECT_TRUE(by_exterior_area[3].holes.empty());
for (const VegePolygon& poly : by_exterior_area) {
EXPECT_GT(signed_area(poly.exterior_ring), 0.0);
for (const auto& hole : poly.holes) {
EXPECT_LT(signed_area(hole), 0.0);
}
}
const Coordinate2D<double> e1_pt{25.0, 50.0}; // E1 solid (outside H2)
const Coordinate2D<double> e2_pt{42.0, 50.0}; // E2 solid (outside H3)
const Coordinate2D<double> e3_pt{50.0, 50.0}; // innermost island
EXPECT_TRUE(point_in_polygon(e1_pt, by_exterior_area[1]));
EXPECT_TRUE(point_in_polygon(e2_pt, by_exterior_area[2]));
EXPECT_TRUE(point_in_polygon(e3_pt, by_exterior_area[3]));
EXPECT_TRUE(point_in_ring(e1_pt, by_exterior_area[0].holes[0]));
EXPECT_TRUE(point_in_ring(e2_pt, by_exterior_area[1].holes[0]));
EXPECT_TRUE(point_in_ring(e3_pt, by_exterior_area[2].holes[0]));
}
TEST(ContoursToPolygons, TwoSeparatePeaks) {
// Two separate dense patches at the same threshold → two polygons
std::vector<std::vector<float>> data = {
{0.8f, 0.8f, 0.0f, 0.8f, 0.0f},
{0.8f, 0.8f, 0.0f, 0.0f, 0.0f},
};
GeoGrid<float> grid(data);
std::vector<double> heights = {0.5};
auto contours_by_height = generate_contours_at_heights(grid, heights, ProgressTracker(), 3, 0.0f);
std::map<double, std::string> height_to_layer = {{0.5, "405_Forest"}};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
ASSERT_EQ(polygons.size(), 2u);
std::vector<size_t> ring_sizes;
for (const auto& p : polygons) {
EXPECT_EQ(p.layer, "405_Forest");
ring_sizes.push_back(p.exterior_ring.size());
}
std::sort(ring_sizes.begin(), ring_sizes.end());
EXPECT_EQ(ring_sizes, (std::vector<size_t>{5, 9}));
}
TEST(ContoursToPolygons, LayerAssignmentFromThreshold) {
// Verify that different thresholds get assigned the correct layer names
std::vector<std::vector<float>> data = {
{0.4f, 0.4f, 0.4f},
{0.4f, 0.9f, 0.4f},
{0.4f, 0.4f, 0.4f},
};
GeoGrid<float> grid(data);
std::vector<double> heights = {0.3, 0.7};
auto contours_by_height = generate_contours_at_heights(grid, heights, ProgressTracker(), 3, 0.0f);
std::map<double, std::string> height_to_layer = {
{0.3, "406_Slow_Running"},
{0.7, "410_Fight"},
};
std::vector<VegePolygon> polygons = contours_to_polygons(contours_by_height, height_to_layer);
ASSERT_EQ(polygons.size(), 2u);
bool has_slow_running = false;
bool has_fight = false;
for (const auto& p : polygons) {
if (p.layer == "406_Slow_Running") {
has_slow_running = true;
EXPECT_EQ(p.exterior_ring.size(), 13u);
}
if (p.layer == "410_Fight") {
has_fight = true;
EXPECT_EQ(p.exterior_ring.size(), 5u);
}
}
EXPECT_TRUE(has_slow_running);
EXPECT_TRUE(has_fight);
}
// =============================================================================
// Minimum area filter tests
// =============================================================================
TEST(FilterByMinArea, RemovesSmallPolygons) {
std::vector<VegePolygon> polygons;
VegePolygon large;
large.layer = "405_Forest";
large.name = "405";
large.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}}; // 10000 m²
polygons.push_back(large);
VegePolygon small;
small.layer = "405_Forest";
small.name = "405";
small.exterior_ring = {{0, 0}, {5, 0}, {5, 5}, {0, 5}}; // 25 m²
polygons.push_back(small);
std::map<std::string, double> min_areas = {{"405_Forest", 30.0}};
filter_by_min_area(polygons, min_areas, ProgressTracker());
ASSERT_EQ(polygons.size(), 1u);
EXPECT_EQ(polygons[0].layer, "405_Forest");
EXPECT_DOUBLE_EQ(signed_area(polygons[0].exterior_ring), 10000.0);
}
TEST(FilterByMinArea, KeepsAllWhenNoThreshold) {
std::vector<VegePolygon> polygons;
VegePolygon p;
p.layer = "408_Walk";
p.name = "408";
p.exterior_ring = {{0, 0}, {3, 0}, {3, 3}, {0, 3}}; // 9 m²
polygons.push_back(p);
std::map<std::string, double> min_areas; // empty → keep all
filter_by_min_area(polygons, min_areas, ProgressTracker());
EXPECT_EQ(polygons.size(), 1u);
}
TEST(FilterByMinArea, RespectsDifferentLimits) {
std::vector<VegePolygon> polygons;
VegePolygon fight;
fight.layer = "410_Fight";
fight.name = "410";
fight.exterior_ring = {{0, 0}, {5, 0}, {5, 5}, {0, 5}}; // 25 m²
polygons.push_back(fight);
VegePolygon walk;
walk.layer = "408_Walk";
walk.name = "408";
walk.exterior_ring = {{0, 0}, {6, 0}, {6, 6}, {0, 6}}; // 36 m²
polygons.push_back(walk);
std::map<std::string, double> min_areas = {
{"410_Fight", 45}, // Fight < 45 → removed
{"408_Walk", 15.0}, // Walk >= 15 → kept
};
filter_by_min_area(polygons, min_areas, ProgressTracker());
ASSERT_EQ(polygons.size(), 1u);
EXPECT_EQ(polygons[0].layer, "408_Walk");
}
TEST(FilterByMinArea, UsesNetAreaNotExterior) {
// Large exterior but thin forest ring: net area below min should be dropped.
std::vector<VegePolygon> polygons;
VegePolygon donut;
donut.layer = "405_Forest";
donut.name = "405";
donut.exterior_ring = {{0, 0}, {8, 0}, {8, 8}, {0, 8}};
donut.holes.push_back({{0.5, 7.5}, {7.5, 7.5}, {7.5, 0.5}, {0.5, 0.5}});
polygons.push_back(donut);
EXPECT_GT(std::abs(signed_area(donut.exterior_ring)), 30.0);
EXPECT_LT(polygon_net_area_m2(donut), 30.0);
std::map<std::string, double> min_areas = {{"405_Forest", 30.0}};
filter_by_min_area(polygons, min_areas, ProgressTracker());
EXPECT_TRUE(polygons.empty());
}
// =============================================================================
// Filter small holes tests
// =============================================================================
TEST(FilterSmallHoles, RemovesTinyHoles) {
std::vector<VegePolygon> polygons;
VegePolygon forest;
forest.layer = "405_Forest";
forest.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
// Add a tiny 2x2 hole (4 m²) and a bigger 10x10 hole (100 m²), CW for OGR
forest.holes.push_back({{20, 22}, {20, 20}, {22, 20}, {22, 22}});
forest.holes.push_back({{50, 60}, {60, 60}, {60, 50}, {50, 50}});
polygons.push_back(forest);
std::map<std::string, double> min_hole = {{"405_Forest", 30.0}};
filter_small_holes(polygons, min_hole, ProgressTracker());
ASSERT_EQ(polygons.size(), 1u);
// Only the 100 m² hole should remain
EXPECT_EQ(polygons[0].holes.size(), 1u);
EXPECT_DOUBLE_EQ(signed_area(polygons[0].holes[0]), -100.0);
}
TEST(FilterSmallHoles, NoFilterWhenZero) {
std::vector<VegePolygon> polygons;
VegePolygon p;
p.layer = "408_Walk";
p.exterior_ring = {{0, 0}, {50, 0}, {50, 50}, {0, 50}};
p.holes.push_back({{5, 5}, {10, 5}, {10, 10}, {5, 10}}); // 25 m²
polygons.push_back(p);
std::map<std::string, double> empty;
filter_small_holes(polygons, empty, ProgressTracker());
EXPECT_EQ(polygons[0].holes.size(), 1u); // kept
}
TEST(FilterSmallHoles, DifferentPerLayer) {
std::vector<VegePolygon> polygons;
VegePolygon forest;
forest.layer = "405_Forest";
forest.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
forest.holes.push_back({{5, 5}, {10, 5}, {10, 10}, {5, 10}}); // 25 m²
polygons.push_back(forest);
VegePolygon walk;
walk.layer = "408_Walk";
walk.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
walk.holes.push_back({{5, 5}, {10, 5}, {10, 10}, {5, 10}}); // 25 m²
polygons.push_back(walk);
// Forest min hole 100 m², Walk min hole 0
std::map<std::string, double> min_hole = {{"405_Forest", 100.0}};
filter_small_holes(polygons, min_hole, ProgressTracker());
// Forest: hole removed (25 < 100), Walk: hole kept (no filter)
bool found_forest = false, found_walk = false;
for (const auto& p : polygons) {
if (p.layer == "405_Forest") {
EXPECT_EQ(p.holes.size(), 0u);
found_forest = true;
}
if (p.layer == "408_Walk") {
EXPECT_EQ(p.holes.size(), 1u);
found_walk = true;
}
}
EXPECT_TRUE(found_forest);
EXPECT_TRUE(found_walk);
}
// =============================================================================
// Polygon subtraction — vegetation wrapper
// =============================================================================
TEST(SubtractFromPolygon, PreservesLayerMetadata) {
VegePolygon forest;
forest.layer = "405_Forest";
forest.name = "405";
forest.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
VegePolygon walk;
walk.layer = "408_Walk";
walk.exterior_ring = {{40, 0}, {60, 0}, {60, 100}, {40, 100}};
std::vector<VegePolygon> result = subtract_from_polygon(forest, {walk});
ASSERT_EQ(result.size(), 2u);
for (const auto& piece : result) {
EXPECT_EQ(piece.layer, "405_Forest");
EXPECT_EQ(piece.name, "405");
}
}
TEST(SubtractFromPolygon, GreenDonutLeavesForestRing) {
VegePolygon forest;
forest.layer = "405_Forest";
forest.name = "405";
forest.exterior_ring = {{0, 0}, {100, 0}, {100, 100}, {0, 100}};
VegePolygon walk;
walk.layer = "408_Walk";
walk.exterior_ring = {{20, 20}, {80, 20}, {80, 80}, {20, 80}};
walk.holes.push_back({{30, 30}, {70, 30}, {70, 70}, {30, 70}});
const double forest_before = polygon_net_area_m2(forest);
std::vector<VegePolygon> result = subtract_from_polygon(forest, {walk});
ASSERT_EQ(result.size(), 2u);
const VegePolygon* outer_donut = largest_forest_with_hole(result);
ASSERT_NE(outer_donut, nullptr);
EXPECT_EQ(outer_donut->layer, "405_Forest");
ASSERT_EQ(outer_donut->holes.size(), 1u);
EXPECT_DOUBLE_EQ(polygon_net_area_m2(*outer_donut), 6400.0);
double forest_after = 0.0;
for (const VegePolygon& piece : result) {
forest_after += polygon_net_area_m2(piece);
}
EXPECT_LT(forest_after, forest_before);
EXPECT_TRUE(point_in_polygon({5, 5}, *outer_donut));
EXPECT_TRUE(point_in_ring({35, 35}, outer_donut->holes[0]));
EXPECT_FALSE(point_in_polygon({25, 50}, *outer_donut));
}
TEST(SubtractFromPolygon, SmallHolesRemovedByPostSubtractFilter) {
// Mirrors generate_vege_polygons: after green is cut from forest, holes below
// min_hole_area_m2 must be filtered (default forest min hole is 100 m²).
VegePolygon forest;
forest.layer = "405_Forest";
forest.name = "405";
forest.exterior_ring = {{0, 0}, {60, 0}, {60, 60}, {0, 60}};
VegePolygon walk;
walk.layer = "408_Walk";
walk.exterior_ring = {{25.5, 25.5}, {34.5, 25.5}, {34.5, 34.5}, {25.5, 34.5}};
std::vector<VegePolygon> result = subtract_from_polygon(forest, {walk});
ASSERT_EQ(result.size(), 1u);
ASSERT_EQ(result[0].holes.size(), 1u);
EXPECT_LT(-signed_area(result[0].holes[0]), 100.0);
std::map<std::string, double> min_hole = {{"405_Forest", 100.0}};
filter_small_holes(result, min_hole, ProgressTracker());
EXPECT_TRUE(result[0].holes.empty());
}
// =============================================================================
// generate_vege_polygons — rough open land hole quality
// =============================================================================
TEST(GenerateVegePolygons, RoughOpenLandHolesMatchRemainingForest) {
// Rough open land is contoured from the same canopy grid as forest (pad 1.0).
// Small speckles below min_area_m2 are dropped from 405_Forest and should not
// appear as holes in rough open land either.
VegeConfig config = canopy_config(/*min_area_m2=*/100.0, /*min_hole_area_m2=*/100.0);
std::vector<std::vector<float>> data(20, std::vector<float>(20, 0.0f));
// Large forest patch (~324 m² at 3 m resolution)
for (int y = 2; y < 8; ++y) {
for (int x = 2; x < 8; ++x) data[y][x] = 0.9f;
}
// Small canopy speckle (~36 m²) — below min_area_m2 and min_hole_area_m2
for (int y = 14; y < 16; ++y) {
for (int x = 14; x < 16; ++x) data[y][x] = 0.9f;
}
GeoGrid<float> grid = grid_at_3m(data);
std::map<std::string, GeoGrid<float>> vege_maps = {{"canopy", grid}};
std::vector<VegePolygon> polygons = generate_vege_polygons(config, vege_maps, ProgressTracker());
EXPECT_EQ(count_layer(polygons, "405_Forest"), 1u)
<< "small canopy speckles below min_area_m2 should be dropped";
const VegePolygon* rough_open = find_polygon(polygons, "403_Rough_Open_Land");
ASSERT_NE(rough_open, nullptr);
EXPECT_EQ(rough_open->holes.size(), 1u)
<< "rough open land should have one hole per remaining forest patch";
EXPECT_GE(-signed_area(rough_open->holes[0]), 100.0)
<< "rough open land min hole uses forest min_area from config";
}
TEST(GenerateVegePolygons, RoughOpenLandHasNoTinySpeckleHoles) {
// Default-style config: min_area_m2=30, min_hole_area_m2=100 on 405_Forest.
// Rough open land min hole ← 30 (forest min area); min area ← 100 (forest min hole).
VegeConfig config = canopy_config(/*min_area_m2=*/30.0, /*min_hole_area_m2=*/100.0);
std::vector<std::vector<float>> data(15, std::vector<float>(15, 0.0f));
// One legitimate forest patch (~144 m²)
for (int y = 2; y < 6; ++y) {
for (int x = 2; x < 6; ++x) data[y][x] = 0.9f;
}
// Several sub-threshold speckles (2×2 cells = 36 m² each)
const std::pair<int, int> speckles[] = {{10, 2}, {12, 5}, {8, 10}, {11, 12}};
for (auto [sy, sx] : speckles) {
for (int y = sy; y < sy + 2; ++y) {
for (int x = sx; x < sx + 2; ++x) data[y][x] = 0.9f;
}
}
GeoGrid<float> grid = grid_at_3m(data);
std::map<std::string, GeoGrid<float>> vege_maps = {{"canopy", grid}};
std::vector<VegePolygon> polygons = generate_vege_polygons(config, vege_maps, ProgressTracker());
const VegePolygon* rough_open = find_polygon(polygons, "403_Rough_Open_Land");
ASSERT_NE(rough_open, nullptr);
for (const auto& hole : rough_open->holes) {
EXPECT_GE(-signed_area(hole), 30.0)
<< "rough open land min hole uses forest min_area (30 m²), not min_hole";
}
}
TEST(GenerateVegePolygons, GreenDonutCutLeavesForestRing) {
// Solid white forest with a ring-shaped green understory patch. After cutting
// green out of forest, 405 should include an outer donut with an inner hole.
VegeConfig config = forest_and_green_config();
const int n = 15;
std::vector<std::vector<float>> canopy(n, std::vector<float>(n, 0.0f));
std::vector<std::vector<float>> green(n, std::vector<float>(n, 0.0f));
for (int y = 2; y < 13; ++y) {
for (int x = 2; x < 13; ++x) canopy[y][x] = 0.9f;
}
// Green ring (408) with open center inside the forest block.
for (int x = 5; x <= 9; ++x) {
green[5][x] = 0.8f;
green[9][x] = 0.8f;
}
for (int y = 6; y <= 8; ++y) {
green[y][5] = 0.8f;
green[y][9] = 0.8f;
}
std::map<std::string, GeoGrid<float>> vege_maps = {
{"canopy", grid_at_3m(canopy)},
{"green", grid_at_3m(green)},
};
std::vector<VegePolygon> polygons = generate_vege_polygons(config, vege_maps, ProgressTracker());
const VegePolygon* walk = find_polygon(polygons, "408_Walk");
ASSERT_NE(walk, nullptr);
EXPECT_EQ(walk->holes.size(), 1u) << "green understory should polygonize as a donut";
const VegePolygon* outer_donut = largest_forest_with_hole(polygons);
ASSERT_NE(outer_donut, nullptr)
<< "forest should include an outer ring polygon with an inner hole";
EXPECT_EQ(outer_donut->holes.size(), 1u);
EXPECT_GT(signed_area(outer_donut->exterior_ring), 0.0);
EXPECT_LT(signed_area(outer_donut->holes[0]), 0.0);
EXPECT_LT(polygon_net_area_m2(*outer_donut), signed_area(outer_donut->exterior_ring))
<< "cutting the green ring should reduce forest to a donut, not a solid fill";
EXPECT_TRUE(point_in_ring(walk->holes[0][0], outer_donut->holes[0]))
<< "forest and green should share the same open center hole";
}
// =============================================================================
// Forest winding on soft near-threshold canopy (act_2025 regressions)
// =============================================================================
std::vector<VegePolygon> forest_contours_polygonize(const GeoGrid<float>& grid) {
constexpr double FOREST_THRESHOLD = 0.1;
std::map<double, std::string> layers = {{FOREST_THRESHOLD, "405_Forest"}};
auto contours = generate_contours_at_heights(grid, {FOREST_THRESHOLD}, ProgressTracker(),
/*min_points=*/5, 0.0f);
return contours_to_polygons(contours, layers);
}
TEST(ContoursToPolygons, NestedHoles) {
std::vector<std::vector<float>> data = {
{0.20f, 0.13f, 0.18f, 0.38f, 0.20f}, {0.19f, 0.03f, 0.05f, 0.07f, 0.23f},
{0.23f, 0.06f, 0.16f, 0.09f, 0.43f}, {0.24f, 0.06f, 0.07f, 0.03f, 0.26f},
{0.23f, 0.29f, 0.15f, 0.20f, 0.59f},
};
auto polygons = forest_contours_polygonize(grid_at_3m(data));
EXPECT_EQ(polygons.size(), 2u);
}
TEST(ContoursToPolygons, BigGrid) {
std::vector<std::vector<float>> data = {
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.749016f, 0.02f, 0.749016f, 0.9f, 0.9f, 0.9f, 0.9f,
0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.02f, 0.02f, 0.02f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.749016f, 0.02f, 0.749016f, 0.9f, 0.9f, 0.9f, 0.9f,
0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
{0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f, 0.9f},
};
auto polygons = forest_contours_polygonize(grid_at_3m(data));
EXPECT_EQ(polygons.size(), 1u);
}