From 550a932e46f4c4f723c0bd3f46a43137e2e8e9bf Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Mon, 19 Oct 2026 23:37:33 +0200 Subject: [PATCH] Add outer_approximation_by_rectangles Approximate shapes by axis-aligned rectangles whose union contains them, within a maximum waste shared by all the shapes, as simplify does with its maximum approximation area: the shapes are decomposed into trapezoids, each covered by its bounding box, then the rectangles are split while the waste is too large, and merged while it stays within the maximum waste. --- include/shape/rectangular_approximation.hpp | 70 ++++ src/CMakeLists.txt | 1 + src/rectangular_approximation.cpp | 333 ++++++++++++++++++++ test/CMakeLists.txt | 1 + test/rectangular_approximation_test.cpp | 162 ++++++++++ 5 files changed, 567 insertions(+) create mode 100644 include/shape/rectangular_approximation.hpp create mode 100644 src/rectangular_approximation.cpp create mode 100644 test/rectangular_approximation_test.cpp diff --git a/include/shape/rectangular_approximation.hpp b/include/shape/rectangular_approximation.hpp new file mode 100644 index 0000000..c48df68 --- /dev/null +++ b/include/shape/rectangular_approximation.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "shape/shape.hpp" + +namespace shape +{ + +struct OuterApproximationByRectanglesInputShape +{ + /** Shape to approximate. */ + ShapeWithHoles shape; + + /** + * Number of copies of the shape: the waste of its rectangles is counted + * once for each copy. + */ + ShapePos copies = 1; +}; + +/** + * Approximate each shape of 'shapes' by axis-aligned rectangles whose union + * contains it. + * + * The waste of a shape is the area of its rectangles minus the area of the + * shape. The waste of all the shapes, counted once for each copy, is at most + * 'maximum_waste', and, within it, the number of rectangles is reduced as + * much as possible. + * + * The circular arcs of each shape are first replaced by line segments + * outside of them (see approximate_by_line_segments); then, the shape is + * decomposed into trapezoids (see trapezoidation), and each trapezoid is + * covered by its bounding box. A shape whose edges are all horizontal or + * vertical, such as a rectangular frame, is thus covered exactly. Then: + * - as long as the waste is larger than 'maximum_waste', the rectangle of a + * single trapezoid whose split into two rectangles of half its height + * reduces the waste the most is split, as long as its shape has fewer than + * 'maximum_number_of_rectangles' rectangles; + * - then, as long as the waste stays at most 'maximum_waste', the two + * rectangles of a same shape whose replacement by their bounding box + * increases the waste the least are merged. + * Both choices are made across all the shapes, weighted by their copies. + * + * Where rectangles of a shape overlap, the area of the overlap counts + * several times in their total area: the waste of the union of the + * rectangles is at most the waste as defined above. + * + * If the waste can't be reduced to 'maximum_waste' within + * 'maximum_number_of_rectangles' rectangles per shape (for example, with + * 'maximum_waste' equal to 0 and a shape with an oblique edge), the waste + * is larger than 'maximum_waste'. + * + * Return, for each shape, its rectangles. + */ +std::vector> outer_approximation_by_rectangles( + const std::vector& shapes, + AreaDbl maximum_waste, + ElementPos maximum_number_of_rectangles = 256); + +/** + * Write the inputs of outer_approximation_by_rectangles to a JSON file in + * the format of the test fixtures in + * data/tests/rectangular_approximation/outer_approximation_by_rectangles/. + */ +void outer_approximation_by_rectangles_export_inputs( + const std::string& file_path, + const std::vector& shapes, + AreaDbl maximum_waste, + ElementPos maximum_number_of_rectangles = 256); + +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 76740f5..8fe5f5c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,7 @@ target_sources(Shape_shape PRIVATE offset.cpp simplification.cpp approximation.cpp + rectangular_approximation.cpp supports.cpp rasterization.cpp no_fit_polygon.cpp diff --git a/src/rectangular_approximation.cpp b/src/rectangular_approximation.cpp new file mode 100644 index 0000000..0ce1984 --- /dev/null +++ b/src/rectangular_approximation.cpp @@ -0,0 +1,333 @@ +#include "shape/rectangular_approximation.hpp" + +#include "shape/approximation.hpp" +#include "shape/trapezoidation.hpp" + +#include +#include +#include + +using namespace shape; + +namespace +{ + +AreaDbl rectangle_area(const AxisAlignedBoundingBox& rectangle) +{ + return (rectangle.x_max - rectangle.x_min) + * (rectangle.y_max - rectangle.y_min); +} + +AxisAlignedBoundingBox trapezoid_rectangle(const GeneralizedTrapezoid& trapezoid) +{ + AxisAlignedBoundingBox rectangle; + rectangle.x_min = trapezoid.x_min(); + rectangle.x_max = trapezoid.x_max(); + rectangle.y_min = trapezoid.y_bottom(); + rectangle.y_max = trapezoid.y_top(); + return rectangle; +} + +AxisAlignedBoundingBox merge_rectangles( + const AxisAlignedBoundingBox& rectangle_1, + const AxisAlignedBoundingBox& rectangle_2) +{ + AxisAlignedBoundingBox rectangle; + rectangle.x_min = (std::min)(rectangle_1.x_min, rectangle_2.x_min); + rectangle.x_max = (std::max)(rectangle_1.x_max, rectangle_2.x_max); + rectangle.y_min = (std::min)(rectangle_1.y_min, rectangle_2.y_min); + rectangle.y_max = (std::max)(rectangle_1.y_max, rectangle_2.y_max); + return rectangle; +} + +/** + * Rectangle of the outer approximation of a shape. + */ +struct ApproximationRectangle +{ + AxisAlignedBoundingBox rectangle; + + /** + * The trapezoid covered by the rectangle, if it covers a single one (only + * these rectangles are split); empty otherwise. + */ + std::vector trapezoid; + + /** Incremented each time the rectangle changes. */ + Counter version = 0; + + bool removed = false; +}; + +/** + * Split of a trapezoid into the two trapezoids of half its height. + */ +struct TrapezoidSplit +{ + /** + * Trapezoid split, or empty if it is too thin to be split. + */ + std::vector trapezoids; + + /** Waste removed by the split. */ + AreaDbl waste_reduction = 0.0; +}; + +TrapezoidSplit split_trapezoid(const GeneralizedTrapezoid& trapezoid) +{ + TrapezoidSplit split; + LengthDbl y_middle = (trapezoid.y_bottom() + trapezoid.y_top()) / 2; + if (!strictly_lesser(trapezoid.y_bottom(), y_middle) + || !strictly_lesser(y_middle, trapezoid.y_top())) { + return split; + } + LengthDbl x_middle_left = trapezoid.x_left(y_middle); + LengthDbl x_middle_right = trapezoid.x_right(y_middle); + split.trapezoids.push_back(GeneralizedTrapezoid( + trapezoid.y_bottom(), + y_middle, + trapezoid.x_bottom_left(), + trapezoid.x_bottom_right(), + x_middle_left, + x_middle_right)); + split.trapezoids.push_back(GeneralizedTrapezoid( + y_middle, + trapezoid.y_top(), + x_middle_left, + x_middle_right, + trapezoid.x_top_left(), + trapezoid.x_top_right())); + split.waste_reduction = rectangle_area(trapezoid_rectangle(trapezoid)) + - rectangle_area(trapezoid_rectangle(split.trapezoids[0])) + - rectangle_area(trapezoid_rectangle(split.trapezoids[1])); + return split; +} + +/** + * Candidate split of a rectangle, in the priority queue of the splits. + */ +struct SplitCandidate +{ + /** Waste removed by the split, counted once for each copy. */ + AreaDbl waste_reduction; + + ShapePos shape_pos; + + ElementPos rectangle_pos; + + Counter version; + + bool operator<(const SplitCandidate& candidate) const + { + return waste_reduction < candidate.waste_reduction; + } +}; + +/** + * Candidate merge of two rectangles, in the priority queue of the merges. + */ +struct MergeCandidate +{ + /** Waste added by the merge, counted once for each copy. */ + AreaDbl waste_increase; + + ShapePos shape_pos; + + ElementPos rectangle_pos_1; + + Counter version_1; + + ElementPos rectangle_pos_2; + + Counter version_2; + + bool operator<(const MergeCandidate& candidate) const + { + // Smallest waste increase first. + return waste_increase > candidate.waste_increase; + } +}; + +} + +std::vector> shape::outer_approximation_by_rectangles( + const std::vector& shapes, + AreaDbl maximum_waste, + ElementPos maximum_number_of_rectangles) +{ + // Initial rectangles: the bounding boxes of the trapezoids of each shape. + std::vector> rectangles(shapes.size()); + AreaDbl waste = 0.0; + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)shapes.size(); + ++shape_pos) { + const ShapeWithHoles& shape = shapes[shape_pos].shape; + if (shape.shape.elements.empty()) + continue; + ShapeWithHoles polygon = shape; + if (!shape.is_polygon()) { + AxisAlignedBoundingBox aabb = shape.compute_min_max(); + LengthDbl segment_length = (std::max)( + aabb.x_max - aabb.x_min, + aabb.y_max - aabb.y_min) / 64; + polygon = approximate_by_line_segments(shape, segment_length); + } + AreaDbl rectangles_area = 0.0; + for (const GeneralizedTrapezoid& trapezoid: trapezoidation(polygon)) { + ApproximationRectangle rectangle; + rectangle.rectangle = trapezoid_rectangle(trapezoid); + rectangle.trapezoid.push_back(trapezoid); + rectangles_area += rectangle_area(rectangle.rectangle); + rectangles[shape_pos].push_back(rectangle); + } + waste += shapes[shape_pos].copies * (rectangles_area - shape.compute_area()); + } + + // Split the rectangles while the waste is larger than the maximum waste. + std::priority_queue split_candidates; + auto add_split_candidate = [&shapes, &rectangles, &split_candidates]( + ShapePos shape_pos, + ElementPos rectangle_pos) + { + const ApproximationRectangle& rectangle = rectangles[shape_pos][rectangle_pos]; + if (rectangle.trapezoid.empty()) + return; + TrapezoidSplit split = split_trapezoid(rectangle.trapezoid.front()); + if (split.trapezoids.empty() || !strictly_greater(split.waste_reduction, 0.0)) + return; + SplitCandidate candidate; + candidate.waste_reduction = shapes[shape_pos].copies * split.waste_reduction; + candidate.shape_pos = shape_pos; + candidate.rectangle_pos = rectangle_pos; + candidate.version = rectangle.version; + split_candidates.push(candidate); + }; + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)shapes.size(); + ++shape_pos) { + for (ElementPos rectangle_pos = 0; + rectangle_pos < (ElementPos)rectangles[shape_pos].size(); + ++rectangle_pos) { + add_split_candidate(shape_pos, rectangle_pos); + } + } + while (strictly_greater(waste, maximum_waste) && !split_candidates.empty()) { + SplitCandidate candidate = split_candidates.top(); + split_candidates.pop(); + std::vector& shape_rectangles = rectangles[candidate.shape_pos]; + if (shape_rectangles[candidate.rectangle_pos].version != candidate.version) + continue; + if ((ElementPos)shape_rectangles.size() >= maximum_number_of_rectangles) + continue; + TrapezoidSplit split = split_trapezoid( + shape_rectangles[candidate.rectangle_pos].trapezoid.front()); + ApproximationRectangle& rectangle_bottom = shape_rectangles[candidate.rectangle_pos]; + rectangle_bottom.rectangle = trapezoid_rectangle(split.trapezoids[0]); + rectangle_bottom.trapezoid = {split.trapezoids[0]}; + rectangle_bottom.version++; + ApproximationRectangle rectangle_top; + rectangle_top.rectangle = trapezoid_rectangle(split.trapezoids[1]); + rectangle_top.trapezoid = {split.trapezoids[1]}; + shape_rectangles.push_back(rectangle_top); + waste -= candidate.waste_reduction; + add_split_candidate(candidate.shape_pos, candidate.rectangle_pos); + add_split_candidate(candidate.shape_pos, (ElementPos)shape_rectangles.size() - 1); + } + + // Merge rectangles while the waste stays at most the maximum waste. + std::priority_queue merge_candidates; + auto add_merge_candidate = [&shapes, &rectangles, &merge_candidates]( + ShapePos shape_pos, + ElementPos rectangle_pos_1, + ElementPos rectangle_pos_2) + { + const ApproximationRectangle& rectangle_1 = rectangles[shape_pos][rectangle_pos_1]; + const ApproximationRectangle& rectangle_2 = rectangles[shape_pos][rectangle_pos_2]; + MergeCandidate candidate; + candidate.waste_increase = shapes[shape_pos].copies * ( + rectangle_area(merge_rectangles(rectangle_1.rectangle, rectangle_2.rectangle)) + - rectangle_area(rectangle_1.rectangle) + - rectangle_area(rectangle_2.rectangle)); + candidate.shape_pos = shape_pos; + candidate.rectangle_pos_1 = rectangle_pos_1; + candidate.version_1 = rectangle_1.version; + candidate.rectangle_pos_2 = rectangle_pos_2; + candidate.version_2 = rectangle_2.version; + merge_candidates.push(candidate); + }; + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)shapes.size(); + ++shape_pos) { + for (ElementPos rectangle_pos_1 = 0; + rectangle_pos_1 < (ElementPos)rectangles[shape_pos].size(); + ++rectangle_pos_1) { + for (ElementPos rectangle_pos_2 = rectangle_pos_1 + 1; + rectangle_pos_2 < (ElementPos)rectangles[shape_pos].size(); + ++rectangle_pos_2) { + add_merge_candidate(shape_pos, rectangle_pos_1, rectangle_pos_2); + } + } + } + while (!merge_candidates.empty()) { + MergeCandidate candidate = merge_candidates.top(); + merge_candidates.pop(); + std::vector& shape_rectangles = rectangles[candidate.shape_pos]; + ApproximationRectangle& rectangle_1 = shape_rectangles[candidate.rectangle_pos_1]; + ApproximationRectangle& rectangle_2 = shape_rectangles[candidate.rectangle_pos_2]; + if (rectangle_1.removed + || rectangle_2.removed + || rectangle_1.version != candidate.version_1 + || rectangle_2.version != candidate.version_2) { + continue; + } + // The other candidates increase the waste at least as much. + if (strictly_greater(waste + candidate.waste_increase, maximum_waste)) + break; + rectangle_1.rectangle = merge_rectangles(rectangle_1.rectangle, rectangle_2.rectangle); + rectangle_1.trapezoid.clear(); + rectangle_1.version++; + rectangle_2.removed = true; + waste += candidate.waste_increase; + for (ElementPos rectangle_pos = 0; + rectangle_pos < (ElementPos)shape_rectangles.size(); + ++rectangle_pos) { + if (rectangle_pos == candidate.rectangle_pos_1 + || shape_rectangles[rectangle_pos].removed) { + continue; + } + add_merge_candidate( + candidate.shape_pos, + (std::min)(rectangle_pos, candidate.rectangle_pos_1), + (std::max)(rectangle_pos, candidate.rectangle_pos_1)); + } + } + + std::vector> output(shapes.size()); + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)shapes.size(); + ++shape_pos) { + for (const ApproximationRectangle& rectangle: rectangles[shape_pos]) + if (!rectangle.removed) + output[shape_pos].push_back(rectangle.rectangle); + } + return output; +} + +void shape::outer_approximation_by_rectangles_export_inputs( + const std::string& file_path, + const std::vector& shapes, + AreaDbl maximum_waste, + ElementPos maximum_number_of_rectangles) +{ + std::ofstream file{file_path}; + nlohmann::json json; + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)shapes.size(); + ++shape_pos) { + json["shapes"][shape_pos]["shape"] = shapes[shape_pos].shape.to_json(); + json["shapes"][shape_pos]["copies"] = shapes[shape_pos].copies; + } + json["maximum_waste"] = maximum_waste; + json["maximum_number_of_rectangles"] = maximum_number_of_rectangles; + file << std::setw(4) << json << std::endl; +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a7f0320..c539c2a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -18,6 +18,7 @@ target_sources(Shape_shape_test PRIVATE supports_test.cpp approximation_test.cpp simplification_test.cpp + rectangular_approximation_test.cpp convex_partition_test.cpp basic_shapes_test.cpp rasterization_test.cpp diff --git a/test/rectangular_approximation_test.cpp b/test/rectangular_approximation_test.cpp new file mode 100644 index 0000000..bdfc0fe --- /dev/null +++ b/test/rectangular_approximation_test.cpp @@ -0,0 +1,162 @@ +#include "shape/rectangular_approximation.hpp" + +#include "shape/boolean_operations.hpp" + +#include + +using namespace shape; + +struct OuterApproximationByRectanglesTestParams +{ + std::string name; + std::vector shapes; + AreaDbl maximum_waste; + ElementPos maximum_number_of_rectangles = 256; + // Number of rectangles of each shape. Not checked if empty. + std::vector expected_numbers_of_rectangles; + // False if the maximum waste can't be reached within the maximum number + // of rectangles. + bool maximum_waste_reachable = true; +}; + +void PrintTo(const OuterApproximationByRectanglesTestParams& params, std::ostream* os) +{ + *os << "shapes (" << params.shapes.size() << ")\n"; + for (const OuterApproximationByRectanglesInputShape& shape: params.shapes) + *os << "- copies " << shape.copies << " " << shape.shape.to_string(1) << "\n"; + *os << "maximum_waste " << params.maximum_waste << "\n"; + *os << "maximum_number_of_rectangles " << params.maximum_number_of_rectangles << "\n"; +} + +class OuterApproximationByRectanglesTest: public testing::TestWithParam { }; + +TEST_P(OuterApproximationByRectanglesTest, OuterApproximationByRectangles) +{ + OuterApproximationByRectanglesTestParams test_params = GetParam(); + PrintTo(test_params, &std::cout); + std::vector> output = outer_approximation_by_rectangles( + test_params.shapes, + test_params.maximum_waste, + test_params.maximum_number_of_rectangles); + ASSERT_EQ(output.size(), test_params.shapes.size()); + + // The rectangles of each shape contain it, and their waste, counted once + // for each copy, sums up to at most the maximum waste. + AreaDbl waste = 0.0; + for (ShapePos shape_pos = 0; + shape_pos < (ShapePos)output.size(); + ++shape_pos) { + const OuterApproximationByRectanglesInputShape& input_shape = test_params.shapes[shape_pos]; + std::cout << "shape " << shape_pos << ": " << output[shape_pos].size() << " rectangles" << std::endl; + if (!test_params.expected_numbers_of_rectangles.empty()) { + EXPECT_EQ( + (ElementPos)output[shape_pos].size(), + test_params.expected_numbers_of_rectangles[shape_pos]); + } + EXPECT_LE((ElementPos)output[shape_pos].size(), test_params.maximum_number_of_rectangles); + MultiShapeWithHoles rectangles; + AreaDbl rectangles_area = 0.0; + for (const AxisAlignedBoundingBox& rectangle: output[shape_pos]) { + std::cout << "- " << rectangle.x_min << " " << rectangle.x_max + << " " << rectangle.y_min << " " << rectangle.y_max << std::endl; + rectangles.shapes_with_holes.push_back({build_rectangle(rectangle)}); + rectangles_area += (rectangle.x_max - rectangle.x_min) * (rectangle.y_max - rectangle.y_min); + } + MultiShapeWithHoles shape; + shape.shapes_with_holes.push_back(input_shape.shape); + AreaDbl area_outside = 0.0; + for (const ShapeWithHoles& part: compute_difference(shape, rectangles).shapes_with_holes) + area_outside += part.compute_area(); + EXPECT_TRUE(equal(area_outside, 0.0)) << "shape " << shape_pos << " area outside " << area_outside; + waste += input_shape.copies * (rectangles_area - input_shape.shape.compute_area()); + } + std::cout << "waste " << waste << std::endl; + if (test_params.maximum_waste_reachable) { + EXPECT_FALSE(strictly_greater(waste, test_params.maximum_waste)); + } else { + EXPECT_TRUE(strictly_greater(waste, test_params.maximum_waste)); + } +} + +INSTANTIATE_TEST_SUITE_P( + Shape, + OuterApproximationByRectanglesTest, + testing::ValuesIn(std::vector{ + { + "Rectangle", + {{{build_rectangle(10, 5)}, 1}}, + 0, + 256, + {1}, + }, { + // A rectangular frame (a 400 x 300 rectangle with a 320 x 220 + // hole) is covered exactly by 4 rectangles: its hole is left + // uncovered. + "Frame", + {{{build_rectangle(400, 300), {build_rectangle(40, 360, 40, 260)}}, 1}}, + 0, + 256, + {4}, + }, { + // An L shape: its 2 rectangles are merged into its 20 x 20 + // bounding box if the maximum waste covers the missing 10 x 10 + // corner. + "LShapeMerged", + {{{build_shape({{0, 0}, {20, 0}, {20, 10}, {10, 10}, {10, 20}, {0, 20}})}, 1}}, + 100, + 256, + {1}, + }, { + "LShapeNotMerged", + {{{build_shape({{0, 0}, {20, 0}, {20, 10}, {10, 10}, {10, 20}, {0, 20}})}, 1}}, + 99, + 256, + {2}, + }, { + // A right triangle of area 50: its bounding box wastes 50, + // so its rectangle is split until the waste is at most 10. + "Triangle", + {{{build_shape({{0, 0}, {10, 0}, {0, 10}})}, 1}}, + 10, + }, { + // With no waste allowed, a shape with an oblique edge is + // split until it has the maximum number of rectangles, and + // the waste stays positive. + "TriangleMaximumNumberOfRectangles", + {{{build_shape({{0, 0}, {10, 0}, {0, 10}})}, 1}}, + 0, + 16, + {16}, + false, + }, { + // A V-shaped notch: the bounding boxes of the trapezoids on + // both sides of the notch overlap. + "VNotch", + {{{build_shape({{0, 0}, {20, 0}, {20, 20}, {15, 20}, {10, 5}, {5, 20}, {0, 20}})}, 1}}, + 20, + }, { + // A circle, whose arcs are replaced by line segments outside + // of them. + "Circle", + {{{build_circle(10)}, 1}}, + 20, + }, { + // A square with a circular hole, whose arcs are replaced by + // line segments inside the hole: the rectangles leave most + // of the hole uncovered. + "SquareWithCircularHole", + {{{build_rectangle(40, 40), {build_circle(10).shift(20, 20)}}, 1}}, + 100, + }, { + // Two triangles sharing the maximum waste: the waste of the + // first one counts 10 times. + "Copies", + { + {{build_shape({{0, 0}, {10, 0}, {0, 10}})}, 10}, + {{build_shape({{0, 0}, {10, 0}, {0, 10}})}, 1}, + }, + 100, + }}), + [](const testing::TestParamInfo& info) { + return info.param.name; + });