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; + });