From 17371301149de17df7fd59b2dc3aa1a24360a861 Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 10:56:00 +0100 Subject: [PATCH 1/6] Update build.yml --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9b95a3..6c9584e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,12 @@ name: Build -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master jobs: From b0ab108c5eb7fb1578be7d6a1377f229609c68f6 Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 10:57:25 +0100 Subject: [PATCH 2/6] Add compute_line/circle_circle_intersections --- include/shape/elements_intersections.hpp | 25 +++ src/elements_intersections.cpp | 226 ++++++++++++----------- 2 files changed, 140 insertions(+), 111 deletions(-) diff --git a/include/shape/elements_intersections.hpp b/include/shape/elements_intersections.hpp index bd26fd5..1cbae09 100644 --- a/include/shape/elements_intersections.hpp +++ b/include/shape/elements_intersections.hpp @@ -11,6 +11,31 @@ std::pair compute_line_intersection( const Point& p21, const Point& p22); +/** + * Compute the intersection points of the infinite line through line_point_1 + * and line_point_2 with the circle (circle_center, circle_radius). + * + * Returns 0 points when there is no intersection, 1 point when the line is + * tangent to the circle, and 2 points otherwise. + */ +std::vector compute_line_circle_intersections( + const Point& line_point_1, + const Point& line_point_2, + const Point& circle_center, + LengthDbl circle_radius); + +/** + * Compute the intersection points of two circles. + * + * Returns 0 points for no intersection or concentric circles, 1 point for + * an external/internal tangency, and 2 points otherwise. + */ +std::vector compute_circle_circle_intersections( + const Point& center_1, + LengthDbl radius_1, + const Point& center_2, + LengthDbl radius_2); + struct ShapeElementIntersectionsOutput { std::vector overlapping_parts; diff --git a/src/elements_intersections.cpp b/src/elements_intersections.cpp index ce61e03..a266b7f 100644 --- a/src/elements_intersections.cpp +++ b/src/elements_intersections.cpp @@ -169,6 +169,114 @@ std::pair shape::compute_line_intersection( } } +std::vector shape::compute_line_circle_intersections( + const Point& line_point_1, + const Point& line_point_2, + const Point& circle_center, + LengthDbl circle_radius) +{ + std::vector points; + + if (line_point_1.x == line_point_2.x) { + LengthDbl dx = line_point_1.x - circle_center.x; + LengthDbl diff = circle_radius * circle_radius - dx * dx; + if (strictly_lesser(diff, 0)) + return {}; + if (diff < 0) + diff = 0; + LengthDbl v = std::sqrt(diff); + points.push_back({line_point_1.x, circle_center.y + v}); + points.push_back({line_point_1.x, circle_center.y - v}); + } else if (line_point_1.y == line_point_2.y) { + LengthDbl dy = line_point_1.y - circle_center.y; + LengthDbl diff = circle_radius * circle_radius - dy * dy; + if (strictly_lesser(diff, 0)) + return {}; + if (diff < 0) + diff = 0; + LengthDbl v = std::sqrt(diff); + points.push_back({circle_center.x + v, line_point_1.y}); + points.push_back({circle_center.x - v, line_point_1.y}); + } else { + LengthDbl line_a = line_point_1.y - line_point_2.y; + LengthDbl line_b = line_point_2.x - line_point_1.x; + LengthDbl line_c = line_point_2.x * line_point_1.y + - line_point_1.x * line_point_2.y; + LengthDbl c_prime = line_c + - line_a * circle_center.x + - line_b * circle_center.y; + LengthDbl rsq = circle_radius * circle_radius; + LengthDbl denom = line_a * line_a + line_b * line_b; + if (strictly_lesser(rsq * denom, c_prime * c_prime)) + return {}; + LengthDbl discriminant = rsq * denom - c_prime * c_prime; + if (discriminant < 0) + discriminant = 0; + LengthDbl sqrt_disc = std::sqrt(discriminant); + LengthDbl eta_1 = (line_a * c_prime + line_b * sqrt_disc) / denom; + LengthDbl eta_2 = (line_a * c_prime - line_b * sqrt_disc) / denom; + LengthDbl teta_1 = (line_b * c_prime - line_a * sqrt_disc) / denom; + LengthDbl teta_2 = (line_b * c_prime + line_a * sqrt_disc) / denom; + points.push_back({circle_center.x + eta_1, circle_center.y + teta_1}); + points.push_back({circle_center.x + eta_2, circle_center.y + teta_2}); + } + + // Collapse to a single tangent point when the two computed points coincide. + if (points.size() == 2) { + Point midpoint = 0.5 * (points[0] + points[1]); + if (equal(distance(midpoint, circle_center), circle_radius)) + return {midpoint}; + } + + return points; +} + +std::vector shape::compute_circle_circle_intersections( + const Point& center_1, + LengthDbl radius_1, + const Point& center_2, + LengthDbl radius_2) +{ + if (equal(center_1, center_2)) + return {}; + + LengthDbl rsq = radius_1 * radius_1; + LengthDbl r2sq = radius_2 * radius_2; + LengthDbl line_a = 2 * (center_2.x - center_1.x); + LengthDbl line_b = 2 * (center_2.y - center_1.y); + LengthDbl line_c = rsq + - center_1.x * center_1.x - center_1.y * center_1.y + - r2sq + + center_2.x * center_2.x + center_2.y * center_2.y; + LengthDbl c_prime = line_c + - line_a * center_1.x + - line_b * center_1.y; + LengthDbl denom = line_a * line_a + line_b * line_b; + if (strictly_lesser(rsq * denom, c_prime * c_prime)) + return {}; + LengthDbl discriminant = rsq * denom - c_prime * c_prime; + if (discriminant < 0) + discriminant = 0; + LengthDbl sqrt_disc = std::sqrt(discriminant); + LengthDbl eta_1 = (line_a * c_prime + line_b * sqrt_disc) / denom; + LengthDbl eta_2 = (line_a * c_prime - line_b * sqrt_disc) / denom; + LengthDbl teta_1 = (line_b * c_prime - line_a * sqrt_disc) / denom; + LengthDbl teta_2 = (line_b * c_prime + line_a * sqrt_disc) / denom; + std::vector points = { + {center_1.x + eta_1, center_1.y + teta_1}, + {center_1.x + eta_2, center_1.y + teta_2}, + }; + + // Collapse to a single tangent point when the two computed points coincide. + Point midpoint = 0.5 * (points[0] + points[1]); + if (equal(distance(midpoint, center_1), radius_1) + || equal(distance(midpoint, center_2), radius_2)) { + return {midpoint}; + } + + return points; +} + namespace { @@ -259,82 +367,10 @@ ShapeElementIntersectionsOutput compute_line_arc_intersections( LengthDbl radius = distance(arc.start, arc.center); - std::vector points; - if (line.start.x == line.end.x) { - LengthDbl radius = distance(arc.center, arc.start); - LengthDbl dx = line.start.x - arc.center.x; - LengthDbl diff = radius * radius - (dx * dx); - if (strictly_lesser(diff, 0)) - return {}; - if (diff < 0) - diff = 0; - LengthDbl v = std::sqrt(diff); - Point point_1; - point_1.x = line.start.x; - point_1.y = arc.center.y + v; - points.push_back(point_1); - Point point_2; - point_2.x = line.start.x; - point_2.y = arc.center.y - v; - points.push_back(point_2); - - } else if (line.start.y == line.end.y) { - LengthDbl radius = distance(arc.center, arc.start); - LengthDbl dy = line.start.y - arc.center.y; - LengthDbl diff = radius * radius - (dy * dy); - if (strictly_lesser(diff, 0)) - return {}; - if (diff < 0) - diff = 0; - LengthDbl v = std::sqrt(diff); - Point point_1; - point_1.x = arc.center.x + v; - point_1.y = line.start.y; - points.push_back(point_1); - Point point_2; - point_2.x = arc.center.x - v; - point_2.y = line.start.y; - points.push_back(point_2); - - } else { - // x (y1 - y2) + y (x2 - x1) + (x1 y2 - x2 y1) = 0 - LengthDbl xm = arc.center.x; - LengthDbl ym = arc.center.y; - LengthDbl a = line.start.y - line.end.y; - LengthDbl b = line.end.x - line.start.x; - LengthDbl c = line.end.x * line.start.y - line.start.x * line.end.y; - LengthDbl rsq = squared_distance(arc.center, arc.start); - LengthDbl c_prime = c - a * xm - b * ym; - - // No intersection. - if (strictly_lesser(rsq * (a * a + b * b), c_prime * c_prime)) - return {}; - - LengthDbl discriminant = rsq * (a * a + b * b) - c_prime * c_prime; - //std::cout << "discriminant " << discriminant << std::endl; - if (discriminant < 0) - discriminant = 0; - LengthDbl denom = a * a + b * b; - LengthDbl eta_1 = (a * c_prime + b * std::sqrt(discriminant)) / denom; - LengthDbl eta_2 = (a * c_prime - b * std::sqrt(discriminant)) / denom; - LengthDbl teta_1 = (b * c_prime - a * std::sqrt(discriminant)) / denom; - LengthDbl teta_2 = (b * c_prime + a * std::sqrt(discriminant)) / denom; - Point point_1; - point_1.x = xm + eta_1; - point_1.y = ym + teta_1; - points.push_back(point_1); - Point point_2; - point_2.x = xm + eta_2; - point_2.y = ym + teta_2; - points.push_back(point_2); - } - //std::cout << "p1 " << points[0].to_string() << std::endl; - //std::cout << "p2 " << points[1].to_string() << std::endl; - - // Single intersection point. - Point middle = 0.5 * (points[0] + points[1]); - if (equal(distance(middle, arc.center), radius)) - points = {middle}; + std::vector points = shape::compute_line_circle_intersections( + line.start, line.end, arc.center, radius); + if (points.empty()) + return {}; bool circle_contains_line_start = equal(distance(line.start, arc.center), radius); if (circle_contains_line_start) { @@ -489,46 +525,14 @@ ShapeElementIntersectionsOutput compute_arc_arc_intersections( } } - std::vector points; LengthDbl radius_1 = distance(arc.start, arc.center); LengthDbl radius_2 = distance(arc_2.start, arc_2.center); - LengthDbl xm = arc.center.x; - LengthDbl ym = arc.center.y; - LengthDbl xm2 = arc_2.center.x; - LengthDbl ym2 = arc_2.center.y; - LengthDbl a = 2 * (xm2 - xm); - LengthDbl b = 2 * (ym2 - ym); - LengthDbl c = rsq - (xm * xm) - (ym * ym) - - r2sq + (xm2 * xm2) + (ym2 * ym2); - //std::cout << "a " << a << " b " << b << " c " << c << std::endl; - - LengthDbl c_prime = c - a * xm - b * ym; - - // No intersection. - if (strictly_lesser(rsq * (a * a + b * b), c_prime * c_prime)) + std::vector points = shape::compute_circle_circle_intersections( + arc.center, radius_1, arc_2.center, radius_2); + if (points.empty()) return {}; - std::vector intersections; - LengthDbl discriminant = rsq * (a * a + b * b) - c_prime * c_prime; - //std::cout << "discriminant " << discriminant << std::endl; - if (discriminant < 0) - discriminant = 0; - LengthDbl denom = a * a + b * b; - LengthDbl eta_1 = (a * c_prime + b * std::sqrt(discriminant)) / denom; - LengthDbl eta_2 = (a * c_prime - b * std::sqrt(discriminant)) / denom; - LengthDbl teta_1 = (b * c_prime - a * std::sqrt(discriminant)) / denom; - LengthDbl teta_2 = (b * c_prime + a * std::sqrt(discriminant)) / denom; - points = {{xm + eta_1, ym + teta_1}, {xm + eta_2, ym + teta_2}}; - //std::cout << "p0 " << points[0].to_string() << std::endl; - //std::cout << "p1 " << points[1].to_string() << std::endl; - - Point middle = 0.5 * (points[0] + points[1]); - if (equal(distance(middle, arc.center), radius_1) - || equal(distance(middle, arc.center), radius_2)) { - points = {middle}; - } - bool circle_1_contains_arc_2_start = equal(distance(arc_2.start, arc.center), radius_1); if (circle_1_contains_arc_2_start) { if (points.size() == 1 From 575785bad6079a5f015f546547372c607d2950b5 Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 10:58:38 +0100 Subject: [PATCH 3/6] Add try_extend_to_intersection --- include/shape/simplification.hpp | 44 +++++++++ src/simplification.cpp | 153 +++++++++++++++++++++++++++++ test/simplification_test.cpp | 159 ++++++++++++++++--------------- 3 files changed, 279 insertions(+), 77 deletions(-) diff --git a/include/shape/simplification.hpp b/include/shape/simplification.hpp index 4ebd202..02cb979 100644 --- a/include/shape/simplification.hpp +++ b/include/shape/simplification.hpp @@ -5,6 +5,50 @@ namespace shape { +/** + * Output of try_extend_to_intersection. + */ +struct ExtendToIntersectionOutput +{ + /** True iff the two extended elements intersect at a valid point. */ + bool feasible = false; + + /** + * Copy of element_prev with its end moved forward to the intersection + * point. Meaningful only when feasible is true. + */ + ShapeElement new_element_prev; + + /** + * Copy of element_next with its start moved backward to the intersection + * point. Meaningful only when feasible is true. + */ + ShapeElement new_element_next; +}; + +/** + * Given two elements that are not yet connected, try to extend the first one + * beyond its end and the second one before its start along their underlying + * geometries (infinite line for line segments, full circle for circular arcs) + * until the two extended elements meet at a common point. + * + * This is the key primitive for removing a middle element that sits between + * element_prev and element_next: if the operation is feasible the two + * neighbours can be stitched directly at the intersection point. + * + * Returns a struct whose feasible flag is true only when a geometrically + * valid intersection exists, i.e. the candidate point is: + * - at or beyond element_prev.end in element_prev's forward direction, and + * - at or before element_next.start in element_next's forward direction. + * + * Full-circle arcs (ShapeElementOrientation::Full) are not supported; the + * function returns feasible = false if either element is a full circle. + */ +ExtendToIntersectionOutput try_extend_to_intersection( + const ShapeElement& element_prev, + const ShapeElement& element_next); + + struct SimplifyInputShape { ShapeWithHoles shape; diff --git a/src/simplification.cpp b/src/simplification.cpp index 817b812..c8dfe11 100644 --- a/src/simplification.cpp +++ b/src/simplification.cpp @@ -2,6 +2,7 @@ #include "shape/clean.hpp" #include "shape/boolean_operations.hpp" +#include "shape/elements_intersections.hpp" #include "shape/shapes_intersections.hpp" //#include "shape/writer.hpp" @@ -380,6 +381,158 @@ void apply_approximation( } +namespace +{ + +/** + * Check whether a point is at or beyond an element's end in the forward + * direction along the element's underlying infinite geometry. + * + * - LineSegment: the projection of (point - end) onto (end - start) is >= 0. + * - CircularArc CCW: arc-length from start to point >= arc span. + * - CircularArc CW: arc-length from start to point (CW) >= arc span. + * - Full arc: always false. + */ +bool is_forward_extension(const ShapeElement& element, const Point& point) +{ + switch (element.type) { + case ShapeElementType::LineSegment: { + Point direction = element.end - element.start; + return !strictly_lesser( + dot_product(point - element.end, direction), + 0.0); + } + case ShapeElementType::CircularArc: { + LengthDbl radius = distance(element.start, element.center); + switch (element.orientation) { + case ShapeElementOrientation::Full: + return false; + case ShapeElementOrientation::Anticlockwise: { + Angle span = angle_radian( + element.start - element.center, + element.end - element.center); + Angle angle_to_point = angle_radian( + element.start - element.center, + point - element.center); + return !strictly_lesser(angle_to_point * radius, span * radius); + } + case ShapeElementOrientation::Clockwise: { + Angle span_cw = angle_radian( + element.end - element.center, + element.start - element.center); + Angle angle_cw_to_point = angle_radian( + point - element.center, + element.start - element.center); + return !strictly_lesser(angle_cw_to_point * radius, span_cw * radius); + } + } + } + } + return false; +} + +} + +ExtendToIntersectionOutput shape::try_extend_to_intersection( + const ShapeElement& element_prev, + const ShapeElement& element_next) +{ + if (element_prev.orientation == ShapeElementOrientation::Full + || element_next.orientation == ShapeElementOrientation::Full) { + return {}; + } + + // Collect candidate intersection points from the underlying infinite + // geometries of both elements. + std::vector candidates; + + switch (element_prev.type) { + case ShapeElementType::LineSegment: { + switch (element_next.type) { + case ShapeElementType::LineSegment: { + auto result = compute_line_intersection( + element_prev.start, + element_prev.end, + element_next.start, + element_next.end); + if (result.first) + candidates.push_back(result.second); + break; + } + case ShapeElementType::CircularArc: { + LengthDbl radius_next = distance(element_next.start, element_next.center); + candidates = compute_line_circle_intersections( + element_prev.start, + element_prev.end, + element_next.center, + radius_next); + break; + } + } + break; + } + case ShapeElementType::CircularArc: { + switch (element_next.type) { + case ShapeElementType::LineSegment: { + LengthDbl radius_prev = distance(element_prev.start, element_prev.center); + candidates = compute_line_circle_intersections( + element_next.start, + element_next.end, + element_prev.center, + radius_prev); + break; + } + case ShapeElementType::CircularArc: { + LengthDbl radius_prev = distance(element_prev.start, element_prev.center); + LengthDbl radius_next = distance(element_next.start, element_next.center); + candidates = compute_circle_circle_intersections( + element_prev.center, + radius_prev, + element_next.center, + radius_next); + break; + } + } + break; + } + } + + // Gap midpoint used to prefer the closest valid candidate. + Point gap_midpoint = 0.5 * (element_prev.end + element_next.start); + + ShapeElement element_next_reversed = element_next.reverse(); + + bool best_found = false; + LengthDbl best_sq_dist = std::numeric_limits::infinity(); + Point best_point; + + for (const Point& candidate: candidates) { + if (!is_forward_extension(element_prev, candidate)) + continue; + // Backward extension of element_next == forward extension of its reverse. + if (!is_forward_extension(element_next_reversed, candidate)) + continue; + + LengthDbl sq_dist = squared_distance(candidate, gap_midpoint); + if (!best_found || strictly_lesser(sq_dist, best_sq_dist)) { + best_found = true; + best_sq_dist = sq_dist; + best_point = candidate; + } + } + + if (!best_found) + return {}; + + ExtendToIntersectionOutput output; + output.feasible = true; + output.new_element_prev = element_prev; + output.new_element_prev.end = best_point; + output.new_element_next = element_next; + output.new_element_next.start = best_point; + return output; +} + std::vector shape::simplify( const std::vector& shapes, AreaDbl maximum_approximation_area) diff --git a/test/simplification_test.cpp b/test/simplification_test.cpp index ebbc0b3..2839890 100644 --- a/test/simplification_test.cpp +++ b/test/simplification_test.cpp @@ -1,93 +1,98 @@ -/* - #include "shape/simplification.hpp" -#include "shape/shapes_intersections.hpp" -//#include "shape/writer.hpp" - #include -#include - -#include - using namespace shape; -namespace fs = boost::filesystem; - -struct SimplificationTestParams -{ - std::vector shapes; - AreaDbl maximum_approximation_area; - std::vector expected_output; - - - template - static SimplificationTestParams from_json( - basic_json& json_item) - { - SimplificationTestParams test_params; - for (const auto& json_shape: json_item["shapes"]) { - SimplifyInputShape input_shape; - input_shape.shape = ShapeWithHoles::from_json(json_shape["shape"]); - input_shape.copies = json_shape["copies"]; - test_params.shapes.push_back(input_shape); - } - test_params.maximum_approximation_area = json_item["maximum_approximation_area"]; - if (json_item.contains("expected_output")) { - for (const auto& json_shape: json_item["expected_output"]) { - ShapeWithHoles shape = ShapeWithHoles::from_json(json_shape); - test_params.expected_output.push_back(shape); - } - } - return test_params; - } - static SimplificationTestParams read_json( - const std::string& file_path) - { - std::ifstream file(file_path); - if (!file.good()) { - throw std::runtime_error( - FUNC_SIGNATURE + ": " - "unable to open file \"" + file_path + "\"."); - } - nlohmann::json json; - file >> json; - return from_json(json); - } +struct TryExtendToIntersectionTestParams +{ + ShapeElement element_prev; + ShapeElement element_next; + bool expected_feasible; + /** Meaningful only when expected_feasible is true. */ + Point expected_intersection; }; -class SimplificationTest: public testing::TestWithParam { }; +class TryExtendToIntersectionTest: + public testing::TestWithParam { }; -TEST_P(SimplificationTest, Simplification) +TEST_P(TryExtendToIntersectionTest, TryExtendToIntersection) { - SimplificationTestParams test_params = GetParam(); - - //Writer writer; - //for (const SimplifyInputShape& shape: test_params.shapes) - // writer.add_shape(shape.shape); - //writer.write_json("simplify_input.json"); - - std::vector output = simplify( - test_params.shapes, - test_params.maximum_approximation_area); + TryExtendToIntersectionTestParams test_params = GetParam(); + std::cout << "element_prev " << test_params.element_prev.to_string() << std::endl; + std::cout << "element_next " << test_params.element_next.to_string() << std::endl; + + ExtendToIntersectionOutput output = try_extend_to_intersection( + test_params.element_prev, + test_params.element_next); + + std::cout << "feasible " << output.feasible << std::endl; + if (output.feasible) { + std::cout << "new_element_prev.end " + << output.new_element_prev.end.to_string() << std::endl; + std::cout << "new_element_next.start " + << output.new_element_next.start.to_string() << std::endl; + } - if (!test_params.expected_output.empty()) - for (ShapePos pos = 0; pos < (ShapePos)test_params.shapes.size(); ++pos) - EXPECT_TRUE(equal(output[pos], test_params.expected_output[pos])); + EXPECT_EQ(output.feasible, test_params.expected_feasible); + if (test_params.expected_feasible && output.feasible) { + EXPECT_TRUE(equal(output.new_element_prev.end, test_params.expected_intersection)); + EXPECT_TRUE(equal(output.new_element_next.start, test_params.expected_intersection)); + } } INSTANTIATE_TEST_SUITE_P( Shape, - SimplificationTest, - testing::ValuesIn(std::vector{ - //SimplificationTestParams::read_json( - // (fs::path("data") / "tests" / "simplification" / "0.json").string()), - //SimplificationTestParams::read_json( - // (fs::path("data") / "tests" / "simplification" / "1.json").string()), - //SimplificationTestParams::read_json( - // (fs::path("data") / "tests" / "simplification" / "2.json").string()), - })); - -*/ + TryExtendToIntersectionTest, + testing::ValuesIn(std::vector{ + { // Line + Line, feasible: horizontal then vertical. + // prev goes right; next goes up. Extending both meets at (3, 0). + build_line_segment({0, 0}, {1, 0}), + build_line_segment({3, 1}, {3, 3}), + true, + {3, 0}, + }, { // Line + Line, infeasible: intersection lies behind prev's end. + // The lines meet at (3, 0), but (3, 0) is behind end (5, 0) of prev. + build_line_segment({0, 0}, {5, 0}), + build_line_segment({3, 1}, {3, 3}), + false, + {0, 0}, + }, { // Line + Line, infeasible: intersection lies inside next. + // The lines meet at (3, 0), which is strictly between next's endpoints. + build_line_segment({0, 0}, {1, 0}), + build_line_segment({3, -1}, {3, 3}), + false, + {0, 0}, + }, { // Line + Line, infeasible: parallel lines never meet. + build_line_segment({0, 0}, {1, 0}), + build_line_segment({0, 1}, {1, 1}), + false, + {0, 0}, + }, { // CCW Arc + Line, feasible: arc extends from 0° to 60° (r=2), + // vertical line starts above arc end; both extend to meet at (0, 2). + build_circular_arc({2, 0}, {1, sqrt(3.0)}, {0, 0}, ShapeElementOrientation::Anticlockwise), + build_line_segment({0, 3}, {0, 5}), + true, + {0, 2}, + }, { // Line + CCW Arc, feasible: horizontal line ending at (-1.5, 0), + // arc starts at (1, 0); extending the line forward and the arc + // backward meets the circle at (-1, 0), the candidate closest + // to the gap midpoint (-0.25, 0). + build_line_segment({-3, 0}, {-1.5, 0}), + build_circular_arc({1, 0}, {0, 1}, {0, 0}, ShapeElementOrientation::Anticlockwise), + true, + {-1, 0}, + }, { // CCW Arc + CCW Arc, feasible: two unit-radius arcs on intersecting + // circles; extending both meets at (-sqrt(3)/2, 1/2). + build_circular_arc({1, 0}, {0, 1}, {0, 0}, ShapeElementOrientation::Anticlockwise), + build_circular_arc({0, 2}, {-1, 2}, {0, 1}, ShapeElementOrientation::Anticlockwise), + true, + {-sqrt(3.0) / 2, 0.5}, + }, { // CCW Arc + CCW Arc, infeasible: circles too far apart to intersect. + build_circular_arc({1, 0}, {0, 1}, {0, 0}, ShapeElementOrientation::Anticlockwise), + build_circular_arc({6, 0}, {5, 1}, {5, 0}, ShapeElementOrientation::Anticlockwise), + false, + {0, 0}, + }, + })); From a73f839155aab99cb1ee40259b007a4c6cd6bfdc Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 13:01:41 +0100 Subject: [PATCH 4/6] Add try_round_corner --- include/shape/simplification.hpp | 39 +++++++++++ src/simplification.cpp | 69 ++++++++++++++++++++ test/simplification_test.cpp | 107 +++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/include/shape/simplification.hpp b/include/shape/simplification.hpp index 02cb979..4173718 100644 --- a/include/shape/simplification.hpp +++ b/include/shape/simplification.hpp @@ -49,6 +49,45 @@ ExtendToIntersectionOutput try_extend_to_intersection( const ShapeElement& element_next); +/** + * Output of try_round_corner. + */ +struct RoundCornerOutput +{ + /** True iff a valid rounded corner was found. */ + bool feasible = false; + + /** + * The replacement elements in path order: trimmed first segment (if + * non-zero length), the circular arc, trimmed second segment (if + * non-zero length). Contains 1 to 3 elements; zero-length segments + * are omitted. Meaningful only when feasible is true. + */ + std::vector elements; +}; + +/** + * Given two consecutive line segments sharing a common endpoint (the corner) + * and a target arc radius, replace the sharp corner with a smooth + * line - arc - line transition. + * + * The arc is tangent to both segments at points T1 (on element_prev) and T2 + * (on element_next), located at equal distances from the corner along each + * segment. The arc orientation (CCW/CW) matches the turn direction of the + * path. + * + * Returns feasible = false when: + * - either input element is not a line segment, + * - the two segments are collinear or nearly antiparallel, or + * - the tangent length exceeds the length of either segment (radius too + * large for the available geometry). + */ +RoundCornerOutput try_round_corner( + const ShapeElement& element_prev, + const ShapeElement& element_next, + LengthDbl radius); + + struct SimplifyInputShape { ShapeWithHoles shape; diff --git a/src/simplification.cpp b/src/simplification.cpp index c8dfe11..b1f8acf 100644 --- a/src/simplification.cpp +++ b/src/simplification.cpp @@ -433,6 +433,75 @@ bool is_forward_extension(const ShapeElement& element, const Point& point) } +RoundCornerOutput shape::try_round_corner( + const ShapeElement& element_prev, + const ShapeElement& element_next, + LengthDbl radius) +{ + if (element_prev.type != ShapeElementType::LineSegment + || element_next.type != ShapeElementType::LineSegment) { + return {}; + } + + LengthDbl len_prev = element_prev.length(); + LengthDbl len_next = element_next.length(); + if (equal(len_prev, 0.0) || equal(len_next, 0.0)) + return {}; + + // Unit tangent directions at the shared corner. + Point dir_prev = element_prev.tangent(element_prev.end); + Point dir_next = element_next.tangent(element_next.start); + + // CCW angle from the incoming direction to the outgoing direction. + // In (0, π): left/CCW turn. In (π, 2π): right/CW turn. + Angle turn_angle = angle_radian(dir_prev, dir_next); + + // Collinear: no corner to round. + if (equal(turn_angle, 0.0)) + return {}; + + // Antiparallel (hairpin): tangent length would be infinite. + if (equal(turn_angle, M_PI)) + return {}; + + bool ccw = strictly_lesser(turn_angle, M_PI); + + // Interior angle of the corner (always in (0, π)). + Angle interior_angle = ccw ? turn_angle : 2.0 * M_PI - turn_angle; + + // Distance from the corner to each tangent point. + LengthDbl tangent_length = radius * std::tan(interior_angle / 2.0); + + // Tangent points must lie within their respective segments. + if (strictly_greater(tangent_length, len_prev) + || strictly_greater(tangent_length, len_next)) { + return {}; + } + + Point tangent_prev = element_prev.end - tangent_length * dir_prev; + Point tangent_next = element_next.start + tangent_length * dir_next; + + // Arc center: 90° rotation of dir_prev toward the inside of the turn. + // CCW turn → center is to the left (rotate +90°). + // CW turn → center is to the right (rotate −90° = +270°). + Angle rotation = ccw? 90.0: 270.0; + Point center = tangent_prev + radius * dir_prev.rotate(rotation); + + ShapeElementOrientation arc_orientation = ccw? + ShapeElementOrientation::Anticlockwise: + ShapeElementOrientation::Clockwise; + + RoundCornerOutput output; + output.feasible = true; + if (!equal(element_prev.start, tangent_prev)) + output.elements.push_back(build_line_segment(element_prev.start, tangent_prev)); + output.elements.push_back( + build_circular_arc(tangent_prev, tangent_next, center, arc_orientation)); + if (!equal(tangent_next, element_next.end)) + output.elements.push_back(build_line_segment(tangent_next, element_next.end)); + return output; +} + ExtendToIntersectionOutput shape::try_extend_to_intersection( const ShapeElement& element_prev, const ShapeElement& element_next) diff --git a/test/simplification_test.cpp b/test/simplification_test.cpp index 2839890..d402323 100644 --- a/test/simplification_test.cpp +++ b/test/simplification_test.cpp @@ -2,6 +2,8 @@ #include +#include + using namespace shape; @@ -96,3 +98,108 @@ INSTANTIATE_TEST_SUITE_P( {0, 0}, }, })); + + +//////////////////////////////////////////////////////////////////////////////// +// try_round_corner +//////////////////////////////////////////////////////////////////////////////// + +struct TryRoundCornerTestParams +{ + ShapeElement element_prev; + ShapeElement element_next; + LengthDbl radius; + bool expected_feasible; + /** Only meaningful when expected_feasible is true. */ + std::vector expected_elements; +}; + +class TryRoundCornerTest: + public testing::TestWithParam { }; + +TEST_P(TryRoundCornerTest, TryRoundCorner) +{ + TryRoundCornerTestParams test_params = GetParam(); + std::cout << "element_prev " << test_params.element_prev.to_string() << std::endl; + std::cout << "element_next " << test_params.element_next.to_string() << std::endl; + std::cout << "radius " << test_params.radius << std::endl; + + RoundCornerOutput output = try_round_corner( + test_params.element_prev, + test_params.element_next, + test_params.radius); + + std::cout << "feasible " << output.feasible << std::endl; + for (const ShapeElement& element: output.elements) + std::cout << " " << element.to_string() << std::endl; + + EXPECT_EQ(output.feasible, test_params.expected_feasible); + if (test_params.expected_feasible && output.feasible) { + ASSERT_EQ(output.elements.size(), test_params.expected_elements.size()); + for (ElementPos pos = 0; + pos < (ElementPos)output.elements.size(); + ++pos) { + EXPECT_TRUE(equal(output.elements[pos], test_params.expected_elements[pos])); + } + } +} + +INSTANTIATE_TEST_SUITE_P( + Shape, + TryRoundCornerTest, + testing::ValuesIn(std::vector{ + { // 90° CCW (left) turn: horizontal then up, r=1. + // tangent_length = r*tan(45°) = 1. + // T1=(1,0), T2=(2,1), center=(1,1), arc CCW. + build_line_segment({0, 0}, {2, 0}), + build_line_segment({2, 0}, {2, 2}), + 1.0, + true, + { + build_line_segment({0, 0}, {1, 0}), + build_circular_arc({1, 0}, {2, 1}, {1, 1}, ShapeElementOrientation::Anticlockwise), + build_line_segment({2, 1}, {2, 2}), + }, + }, { // 90° CW (right) turn: horizontal then down, r=1. + // tangent_length = r*tan(45°) = 1. + // T1=(1,0), T2=(2,-1), center=(1,-1), arc CW. + build_line_segment({0, 0}, {2, 0}), + build_line_segment({2, 0}, {2, -2}), + 1.0, + true, + { + build_line_segment({0, 0}, {1, 0}), + build_circular_arc({1, 0}, {2, -1}, {1, -1}, ShapeElementOrientation::Clockwise), + build_line_segment({2, -1}, {2, -2}), + }, + }, { // 90° CCW turn with tangent point at segment start: prev segment + // is exactly r long, so the trimmed prev has zero length and is + // omitted. T1=(0,0)=element_prev.start, T2=(1,1). + build_line_segment({0, 0}, {1, 0}), + build_line_segment({1, 0}, {1, 2}), + 1.0, + true, + { + build_circular_arc({0, 0}, {1, 1}, {0, 1}, ShapeElementOrientation::Anticlockwise), + build_line_segment({1, 1}, {1, 2}), + }, + }, { // Infeasible: element_prev is a circular arc, not a line segment. + build_circular_arc({1, 0}, {0, 1}, {0, 0}, ShapeElementOrientation::Anticlockwise), + build_line_segment({0, 1}, {0, 3}), + 1.0, + false, + {}, + }, { // Infeasible: collinear segments, no corner to round. + build_line_segment({0, 0}, {1, 0}), + build_line_segment({1, 0}, {3, 0}), + 1.0, + false, + {}, + }, { // Infeasible: radius too large (tangent_length = 2 > len_prev = 0.5). + build_line_segment({0, 0}, {0.5, 0}), + build_line_segment({0.5, 0}, {0.5, 2}), + 2.0, + false, + {}, + }, + })); From 30587a24c1d6130a4a9f5d616892b6497fe99f07 Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 13:02:15 +0100 Subject: [PATCH 5/6] Add no_fit_polygon --- include/shape/no_fit_polygon.hpp | 45 ++++++++ src/CMakeLists.txt | 1 + src/no_fit_polygon.cpp | 184 +++++++++++++++++++++++++++++ test/CMakeLists.txt | 3 +- test/no_fit_polygon_test.cpp | 191 +++++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 include/shape/no_fit_polygon.hpp create mode 100644 src/no_fit_polygon.cpp create mode 100644 test/no_fit_polygon_test.cpp diff --git a/include/shape/no_fit_polygon.hpp b/include/shape/no_fit_polygon.hpp new file mode 100644 index 0000000..f6b2b21 --- /dev/null +++ b/include/shape/no_fit_polygon.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "shape/shape.hpp" + +namespace shape +{ + +/** + * Compute the No-Fit Polygon (NFP) of two convex shapes. + * + * The NFP of a fixed shape A and an orbiting shape B is the locus of the + * reference point of B (its origin) as B slides around the boundary of A + * without overlap. Equivalently, it is the Minkowski sum A ⊕ (−B). + * + * A point p lies inside the NFP iff placing B's origin at p causes B and A + * to overlap. A point on the boundary means they just touch. + * + * Both shapes must be convex polygons (only line-segment elements, CCW + * winding). An exception is thrown otherwise. + * + * The returned shape is CCW and free of collinear (aligned) vertices. + * + * Algorithm: rotating-calipers Minkowski sum on convex polygons. + * Complexity: O(m + n) where m, n are the vertex counts of A and B. + */ +Shape no_fit_polygon( + const Shape& fixed_shape, + const Shape& orbiting_shape); + +/** + * Compute the No-Fit Polygon (NFP) of two general (possibly non-convex) + * shapes. + * + * The algorithm decomposes each shape into convex parts via + * compute_convex_partition, computes the convex NFP for every pair of parts, + * then returns the union of all those convex NFPs. + * + * The result may consist of several disconnected components or contain holes, + * hence the return type is a vector of ShapeWithHoles. + */ +std::vector no_fit_polygon( + const ShapeWithHoles& fixed_shape, + const ShapeWithHoles& orbiting_shape); + +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c904509..f1fd0bb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -17,6 +17,7 @@ target_sources(Shape_shape PRIVATE approximation.cpp supports.cpp rasterization.cpp + no_fit_polygon.cpp writer.cpp) target_include_directories(Shape_shape PUBLIC ${PROJECT_SOURCE_DIR}/include) diff --git a/src/no_fit_polygon.cpp b/src/no_fit_polygon.cpp new file mode 100644 index 0000000..9aa9896 --- /dev/null +++ b/src/no_fit_polygon.cpp @@ -0,0 +1,184 @@ +#include "shape/no_fit_polygon.hpp" + +#include "shape/boolean_operations.hpp" +#include "shape/clean.hpp" +#include "shape/convex_partition.hpp" + +using namespace shape; + +Shape shape::no_fit_polygon( + const Shape& fixed_shape, + const Shape& orbiting_shape) +{ + if (!fixed_shape.is_polygon()) { + throw std::runtime_error( + FUNC_SIGNATURE + "; " + "fixed_shape is not a polygon."); + } + if (!orbiting_shape.is_polygon()) { + throw std::runtime_error( + FUNC_SIGNATURE + "; " + "orbiting_shape is not a polygon."); + } + if (!fixed_shape.is_convex()) { + throw std::runtime_error( + FUNC_SIGNATURE + "; " + "fixed_shape is not convex."); + } + if (!orbiting_shape.is_convex()) { + throw std::runtime_error( + FUNC_SIGNATURE + "; " + "orbiting_shape is not convex."); + } + + // Find the bottom-most vertex of fixed_shape (min y, then min x for ties). + // Starting the edge sequence here ensures the first outgoing edge + // has angle in [0°, 180°), suitable for the angular merge below. + ElementPos fixed_start_pos = 0; + for (ElementPos fixed_pos = 1; + fixed_pos < (ElementPos)fixed_shape.elements.size(); + ++fixed_pos) { + const Point& point = fixed_shape.elements[fixed_pos].start; + const Point& best = fixed_shape.elements[fixed_start_pos].start; + if (strictly_lesser(point.y, best.y) + || (equal(point.y, best.y) + && strictly_lesser(point.x, best.x))) { + fixed_start_pos = fixed_pos; + } + } + + // Find the top-most vertex of orbiting_shape (max y, then max x for ties). + // Negating it gives the bottom-most vertex of −orbiting_shape + // (= orbiting_shape rotated 180°), which also has its first outgoing + // edge in [0°, 180°). + ElementPos orbiting_start_pos = 0; + for (ElementPos orbiting_pos = 1; + orbiting_pos < (ElementPos)orbiting_shape.elements.size(); + ++orbiting_pos) { + const Point& point = orbiting_shape.elements[orbiting_pos].start; + const Point& best = orbiting_shape.elements[orbiting_start_pos].start; + if (strictly_greater(point.y, best.y) + || (equal(point.y, best.y) + && strictly_greater(point.x, best.x))) { + orbiting_start_pos = orbiting_pos; + } + } + + // Starting vertex of the NFP: bottom of fixed minus top of orbiting. + const Point nfp_start = fixed_shape.elements[fixed_start_pos].start + - orbiting_shape.elements[orbiting_start_pos].start; + + // Build the edge-vector sequences for the angular merge. + // + // Edges of fixed_shape in CCW order starting from fixed_start_pos: + // edges_fixed[edge_index] = + // fixed_shape.elements[(fixed_start_pos + edge_index) % m].end + // − fixed_shape.elements[(fixed_start_pos + edge_index) % m].start + // + // Edges of −orbiting_shape in CCW order starting from the negated top vertex: + // Since −orbiting_shape = rotate(orbiting_shape, 180°) it is also CCW. + // Going CCW from the negated top vertex follows the same index direction, + // so each edge of −orbiting_shape is the negation of the corresponding + // edge of orbiting_shape (offset by orbiting_start_pos): + // edges_neg_orbiting[edge_index] = + // −(orbiting_shape.elements[(orbiting_start_pos + edge_index) % n].end + // − orbiting_shape.elements[(orbiting_start_pos + edge_index) % n].start) + const ElementPos fixed_num_elements + = (ElementPos)fixed_shape.elements.size(); + const ElementPos orbiting_num_elements + = (ElementPos)orbiting_shape.elements.size(); + + std::vector edges_fixed(fixed_num_elements); + for (ElementPos edge_index = 0; edge_index < fixed_num_elements; ++edge_index) { + const ElementPos element_pos = (fixed_start_pos + edge_index) % fixed_num_elements; + edges_fixed[edge_index] = fixed_shape.elements[element_pos].end + - fixed_shape.elements[element_pos].start; + } + + std::vector edges_neg_orbiting(orbiting_num_elements); + for (ElementPos edge_index = 0; edge_index < orbiting_num_elements; ++edge_index) { + const ElementPos element_pos + = (orbiting_start_pos + edge_index) % orbiting_num_elements; + const Point edge = orbiting_shape.elements[element_pos].end + - orbiting_shape.elements[element_pos].start; + edges_neg_orbiting[edge_index] = {-edge.x, -edge.y}; + } + + // Merge both edge sequences in non-decreasing angular order (rotating + // calipers) and trace the Minkowski-sum polygon. + // + // When two edges have the same direction (parallel) they are both added + // in sequence, producing collinear intermediate vertices that are removed + // by remove_aligned_vertices at the end. + std::vector nfp_vertices; + nfp_vertices.reserve(fixed_num_elements + orbiting_num_elements); + nfp_vertices.push_back(nfp_start); + + Point current_vertex = nfp_start; + ElementPos fixed_edge_pos = 0; + ElementPos orbiting_edge_pos = 0; + + while (fixed_edge_pos < fixed_num_elements + || orbiting_edge_pos < orbiting_num_elements) { + if (fixed_edge_pos >= fixed_num_elements) { + current_vertex = current_vertex + edges_neg_orbiting[orbiting_edge_pos++]; + } else if (orbiting_edge_pos >= orbiting_num_elements) { + current_vertex = current_vertex + edges_fixed[fixed_edge_pos++]; + } else if (strictly_lesser_angle( + edges_fixed[fixed_edge_pos], + edges_neg_orbiting[orbiting_edge_pos])) { + current_vertex = current_vertex + edges_fixed[fixed_edge_pos++]; + } else if (strictly_lesser_angle( + edges_neg_orbiting[orbiting_edge_pos], + edges_fixed[fixed_edge_pos])) { + current_vertex = current_vertex + edges_neg_orbiting[orbiting_edge_pos++]; + } else { + // Parallel edges: advance fixed first, then negated orbiting. + current_vertex = current_vertex + edges_fixed[fixed_edge_pos++]; + nfp_vertices.push_back(current_vertex); + current_vertex = current_vertex + edges_neg_orbiting[orbiting_edge_pos++]; + } + + // Only push if we have not yet closed the polygon. + if (fixed_edge_pos < fixed_num_elements + || orbiting_edge_pos < orbiting_num_elements) + nfp_vertices.push_back(current_vertex); + } + // After all edges, current_vertex == nfp_start (the polygon is closed). + + // Build the result Shape from consecutive vertex pairs. + Shape result; + result.elements.reserve(nfp_vertices.size()); + for (ElementPos vertex_pos = 0; + vertex_pos < (ElementPos)nfp_vertices.size(); + ++vertex_pos) { + ShapeElement element; + element.type = ShapeElementType::LineSegment; + element.start = nfp_vertices[vertex_pos]; + element.end = nfp_vertices[(vertex_pos + 1) % nfp_vertices.size()]; + result.elements.push_back(element); + } + + // Remove collinear vertices introduced by parallel-edge pairs. + return remove_aligned_vertices(result).second; +} + +std::vector shape::no_fit_polygon( + const ShapeWithHoles& fixed_shape, + const ShapeWithHoles& orbiting_shape) +{ + std::vector fixed_parts = compute_convex_partition(fixed_shape); + std::vector orbiting_parts = compute_convex_partition(orbiting_shape); + + std::vector nfp_parts; + nfp_parts.reserve(fixed_parts.size() * orbiting_parts.size()); + + for (const Shape& fixed_part: fixed_parts) { + for (const Shape& orbiting_part: orbiting_parts) { + Shape convex_nfp = no_fit_polygon(fixed_part, orbiting_part); + nfp_parts.push_back({convex_nfp, {}}); + } + } + + return compute_union(nfp_parts); +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 27b6e58..26e4d84 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -20,7 +20,8 @@ target_sources(Shape_shape_test PRIVATE approximation_test.cpp simplification_test.cpp convex_partition_test.cpp - rasterization_test.cpp) + rasterization_test.cpp + no_fit_polygon_test.cpp) target_include_directories(Shape_shape_test PRIVATE ${PROJECT_SOURCE_DIR}/src) target_link_libraries(Shape_shape_test diff --git a/test/no_fit_polygon_test.cpp b/test/no_fit_polygon_test.cpp new file mode 100644 index 0000000..5366d49 --- /dev/null +++ b/test/no_fit_polygon_test.cpp @@ -0,0 +1,191 @@ +#include "shape/no_fit_polygon.hpp" + +#include "shape/shapes_intersections.hpp" + +#include + +using namespace shape; + + +//////////////////////////////////////////////////////////////////////////////// +// Convex overload +//////////////////////////////////////////////////////////////////////////////// + +struct NoFitPolygonConvexTestParams +{ + Shape fixed_shape; + Shape orbiting_shape; + Shape expected_nfp; +}; + +class NoFitPolygonConvexTest: + public testing::TestWithParam { }; + +TEST_P(NoFitPolygonConvexTest, NoFitPolygonConvex) +{ + NoFitPolygonConvexTestParams test_params = GetParam(); + std::cout << "fixed_shape " << test_params.fixed_shape.to_string(0) << std::endl; + std::cout << "orbiting_shape " << test_params.orbiting_shape.to_string(0) << std::endl; + + Shape nfp = no_fit_polygon(test_params.fixed_shape, test_params.orbiting_shape); + + std::cout << "nfp " << nfp.to_string(0) << std::endl; + + EXPECT_TRUE(equal(nfp, test_params.expected_nfp)); + + // Oracle check: sample a grid around the NFP bounding box and verify that + // strictly-inside positions cause overlap and strictly-outside ones do not. + AxisAlignedBoundingBox aabb = nfp.compute_min_max(); + const double margin = 0.5; + const double step = 0.25; + for (double px = aabb.x_min - margin; px <= aabb.x_max + margin; px += step) { + for (double py = aabb.y_min - margin; py <= aabb.y_max + margin; py += step) { + Point position = {px, py}; + + if (nfp.contains(position, /*strict=*/true)) { + Shape translated_orbiting = test_params.orbiting_shape; + translated_orbiting.shift(position.x, position.y); + EXPECT_TRUE(intersect(test_params.fixed_shape, translated_orbiting)) + << "Position (" << px << ", " << py << ") is inside the NFP " + << "but the translated orbiting shape does not intersect the fixed shape."; + } + if (!nfp.contains(position, /*strict=*/false)) { + Shape translated_orbiting = test_params.orbiting_shape; + translated_orbiting.shift(position.x, position.y); + EXPECT_FALSE(intersect(test_params.fixed_shape, translated_orbiting)) + << "Position (" << px << ", " << py << ") is outside the NFP " + << "but the translated orbiting shape intersects the fixed shape."; + } + } + } +} + +INSTANTIATE_TEST_SUITE_P( + Shape, + NoFitPolygonConvexTest, + testing::ValuesIn(std::vector{ + { // Square and smaller square: NFP is a larger square. + build_rectangle(0, 4, 0, 4), + build_rectangle(0, 2, 0, 2), + build_rectangle(-2, 4, -2, 4), + }, { // Rectangle with itself. + build_shape({{0, 0}, {3, 0}, {3, 2}, {0, 2}}), + build_shape({{0, 0}, {3, 0}, {3, 2}, {0, 2}}), + build_rectangle(-3, 3, -2, 2), + }, { // Square and triangle. + build_rectangle(0, 4, 0, 4), + build_shape({{0, 0}, {2, 0}, {1, 2}}), + build_shape({{-1, -2}, {3, -2}, {4, 0}, {4, 4}, {-2, 4}, {-2, 0}}), + }, { // Triangle and triangle. + build_shape({{0, 0}, {4, 0}, {2, 4}}), + build_shape({{0, 0}, {2, 0}, {1, 2}}), + build_shape({{-1, -2}, {3, -2}, {4, 0}, {2, 4}, {0, 4}, {-2, 0}}), + }, { // Convex pentagon and unit square. + build_shape({{0, 0}, {4, 0}, {5, 2}, {3, 4}, {1, 4}}), + build_rectangle(0, 1, 0, 1), + build_shape({{-1, -1}, {4, -1}, {5, 1}, {5, 2}, {3, 4}, {0, 4}, {-1, 0}}), + }, + })); + + +//////////////////////////////////////////////////////////////////////////////// +// General (non-convex) overload +//////////////////////////////////////////////////////////////////////////////// + +struct NoFitPolygonGeneralTestParams +{ + ShapeWithHoles fixed_shape; + ShapeWithHoles orbiting_shape; + ShapePos expected_num_components; +}; + +class NoFitPolygonGeneralTest: + public testing::TestWithParam { }; + +TEST_P(NoFitPolygonGeneralTest, NoFitPolygonGeneral) +{ + NoFitPolygonGeneralTestParams test_params = GetParam(); + std::cout << "fixed_shape " << test_params.fixed_shape.to_string(0) << std::endl; + std::cout << "orbiting_shape " << test_params.orbiting_shape.to_string(0) << std::endl; + + std::vector nfp = no_fit_polygon( + test_params.fixed_shape, + test_params.orbiting_shape); + + std::cout << "nfp (" << nfp.size() << " component(s))" << std::endl; + for (const ShapeWithHoles& component: nfp) + std::cout << " " << component.to_string(0) << std::endl; + + EXPECT_EQ((ShapePos)nfp.size(), test_params.expected_num_components); + + // Oracle check: sample a grid around the union of all NFP components. + AxisAlignedBoundingBox aabb; + for (const ShapeWithHoles& component: nfp) + aabb = merge(aabb, component.compute_min_max()); + + auto inside_nfp = [&](const Point& point) -> bool { + for (const ShapeWithHoles& component: nfp) { + if (component.contains(point, /*strict=*/true)) + return true; + } + return false; + }; + + auto outside_nfp = [&](const Point& point) -> bool { + for (const ShapeWithHoles& component: nfp) { + if (component.contains(point, /*strict=*/false)) + return false; + } + return true; + }; + + const double margin = 0.5; + const double step = 0.25; + for (double px = aabb.x_min - margin; px <= aabb.x_max + margin; px += step) { + for (double py = aabb.y_min - margin; py <= aabb.y_max + margin; py += step) { + Point position = {px, py}; + + if (inside_nfp(position)) { + ShapeWithHoles translated_orbiting = test_params.orbiting_shape; + translated_orbiting.shift(position.x, position.y); + EXPECT_TRUE(intersect( + test_params.fixed_shape.shape, + translated_orbiting.shape)) + << "Position (" << px << ", " << py << ") is inside the NFP " + << "but the translated orbiting shape does not intersect the fixed shape."; + } + if (outside_nfp(position)) { + ShapeWithHoles translated_orbiting = test_params.orbiting_shape; + translated_orbiting.shift(position.x, position.y); + EXPECT_FALSE(intersect( + test_params.fixed_shape.shape, + translated_orbiting.shape)) + << "Position (" << px << ", " << py << ") is outside the NFP " + << "but the translated orbiting shape intersects the fixed shape."; + } + } + } +} + +INSTANTIATE_TEST_SUITE_P( + Shape, + NoFitPolygonGeneralTest, + testing::ValuesIn(std::vector{ + { // Convex inputs: same result as the convex overload, one component. + {build_rectangle(0, 4, 0, 4), {}}, + {build_rectangle(0, 2, 0, 2), {}}, + 1, + }, { // L-shape fixed, unit square orbiting: one connected NFP. + {build_shape({{0, 0}, {4, 0}, {4, 2}, {2, 2}, {2, 4}, {0, 4}}), {}}, + {build_rectangle(0, 1, 0, 1), {}}, + 1, + }, { // Two L-shapes. + {build_shape({{0, 0}, {4, 0}, {4, 2}, {2, 2}, {2, 4}, {0, 4}}), {}}, + {build_shape({{0, 0}, {2, 0}, {2, 1}, {1, 1}, {1, 2}, {0, 2}}), {}}, + 1, + }, { // T-shape fixed, unit square orbiting. + {build_shape({{0, 2}, {1, 2}, {1, 0}, {2, 0}, {2, 2}, {3, 2}, {3, 3}, {0, 3}}), {}}, + {build_rectangle(0, 1, 0, 1), {}}, + 1, + }, + })); From ce004299c746fa764f64f1528ede807e861bb70e Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Sat, 28 Mar 2026 13:03:06 +0100 Subject: [PATCH 6/6] Update build.yml --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c9584e..1821d50 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,10 +3,10 @@ name: Build on: push: branches: - - master + - main pull_request: branches: - - master + - main jobs: