From 0db923e6719375b929b3ae00833119e35fe67519 Mon Sep 17 00:00:00 2001 From: Florian Fontan Date: Wed, 1 Jul 2026 01:14:25 +0200 Subject: [PATCH] Fix self-intersecting output in clean_extreme_slopes_outer/inner The in-place vertex removal/relocation in the _1/_2 passes could produce a self-intersecting shape on degenerate "extreme slope" inputs (e.g. thin needle shapes). Track the corner triangles each pass cuts away, and if the final shape self-intersects, robustly reconstruct it by unioning (outer) or differencing (inner) those triangles against the original shape, mirroring the fallback used in simplify(). Add CleanExtremeSlopesInnerTest with inline and JSON-loaded cases, including a real repro that used to throw and now collapses to an empty result. --- .../clean/clean_extreme_slopes_inner/0.json | 43 +++++++++ src/clean.cpp | 94 ++++++++++++++++--- test/clean_test.cpp | 79 ++++++++++++++++ 3 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 data/tests/clean/clean_extreme_slopes_inner/0.json diff --git a/data/tests/clean/clean_extreme_slopes_inner/0.json b/data/tests/clean/clean_extreme_slopes_inner/0.json new file mode 100644 index 0000000..463d4dd --- /dev/null +++ b/data/tests/clean/clean_extreme_slopes_inner/0.json @@ -0,0 +1,43 @@ +{ + "shape": { + "elements": [ + { + "end": { + "x": 29.641303008447174, + "y": 48.08931284909132 + }, + "start": { + "x": 29.773738644876477, + "y": 12.823728609920455 + }, + "type": "LineSegment" + }, + { + "end": { + "x": 29.640719144732877, + "y": 48.089310656461755 + }, + "start": { + "x": 29.641303008447174, + "y": 48.08931284909132 + }, + "type": "LineSegment" + }, + { + "end": { + "x": 29.773738644876477, + "y": 12.823728609920455 + }, + "start": { + "x": 29.640719144732877, + "y": 48.089310656461755 + }, + "type": "LineSegment" + } + ], + "is_path": false, + "type": "general" + }, + "expected_output": [ + ] +} diff --git a/src/clean.cpp b/src/clean.cpp index 8a89bae..2e68143 100644 --- a/src/clean.cpp +++ b/src/clean.cpp @@ -5,8 +5,10 @@ #include "shape/elements_intersections.hpp" #include "shape/equalize.hpp" #include "shape/boolean_operations.hpp" +#include "shape/shapes_intersections.hpp" #ifdef CLEAN_ENABLE_DEBUG #include "shape/writer.hpp" +#include #endif //#include @@ -282,7 +284,8 @@ namespace { Shape clean_extreme_slopes_outer_1( - Shape shape_orig) + Shape shape_orig, + std::vector& triangles_added) { Shape shape = shape_orig; @@ -344,9 +347,8 @@ Shape clean_extreme_slopes_outer_1( shape_new.elements.push_back(element_cur); start = element_cur.end; } else { - //std::cout << "remove" << std::endl; - //std::cout << "element_cur: " << element_cur.to_string() << std::endl; - //std::cout << "element_next: " << element_next.to_string() << std::endl; + triangles_added.push_back(build_triangle( + start, element_cur.end, element_next.end)); } } shape_new.elements[0].start = start; @@ -414,9 +416,8 @@ Shape clean_extreme_slopes_outer_1( shape_new.elements.push_back(element_cur); end = element_cur.start; } else { - //std::cout << "remove" << std::endl; - //std::cout << "element_cur: " << element_cur.to_string() << std::endl; - //std::cout << "element_prev: " << element_prev.to_string() << std::endl; + triangles_added.push_back(build_triangle( + element_prev.start, element_cur.start, end)); } } shape_new.elements[0].end = end; @@ -428,7 +429,8 @@ Shape clean_extreme_slopes_outer_1( } Shape clean_extreme_slopes_inner_1( - const Shape& shape_orig) + const Shape& shape_orig, + std::vector& triangles_removed) { Shape shape = shape_orig; @@ -489,6 +491,9 @@ Shape clean_extreme_slopes_inner_1( if (!remove) { shape_new.elements.push_back(element_cur); start = element_cur.end; + } else { + triangles_removed.push_back(build_triangle( + start, element_cur.end, element_next.end)); } } shape_new.elements[0].start = start; @@ -552,6 +557,9 @@ Shape clean_extreme_slopes_inner_1( if (!remove) { shape_new.elements.push_back(element_cur); end = element_cur.start; + } else { + triangles_removed.push_back(build_triangle( + element_prev.start, element_cur.start, end)); } } shape_new.elements[0].end = end; @@ -563,7 +571,8 @@ Shape clean_extreme_slopes_inner_1( } Shape clean_extreme_slopes_outer_2( - const Shape& shape_orig) + const Shape& shape_orig, + std::vector& triangles_added) { if (!shape_orig.check()) { throw std::invalid_argument( @@ -594,6 +603,8 @@ Shape clean_extreme_slopes_outer_2( element_prev.start, element_prev.end, {element_cur.end.x, element_cur.start.y}, element_cur.end); if (p.first) { + triangles_added.push_back(build_triangle( + element_prev.start, element_cur.start, element_cur.end)); element_prev.end = p.second; element_cur.start = p.second; } @@ -608,6 +619,8 @@ Shape clean_extreme_slopes_outer_2( element_prev.start, element_prev.end, {element_cur.start.x, element_cur.end.y}, element_cur.end); if (p.first) { + triangles_added.push_back(build_triangle( + element_prev.start, element_cur.start, element_cur.end)); element_prev.end = p.second; element_cur.start = p.second; } @@ -637,6 +650,8 @@ Shape clean_extreme_slopes_outer_2( element_cur.start, {element_cur.start.x, element_cur.end.y}, element_next.start, element_next.end); if (p.first) { + triangles_added.push_back(build_triangle( + element_cur.start, element_cur.end, element_next.end)); element_cur.end = p.second; element_next.start = p.second; } @@ -651,6 +666,8 @@ Shape clean_extreme_slopes_outer_2( element_cur.start, {element_cur.end.x, element_cur.start.y}, element_next.start, element_next.end); if (p.first) { + triangles_added.push_back(build_triangle( + element_cur.start, element_cur.end, element_next.end)); element_cur.end = p.second; element_next.start = p.second; } @@ -664,7 +681,8 @@ Shape clean_extreme_slopes_outer_2( } Shape clean_extreme_slopes_inner_2( - const Shape& shape_orig) + const Shape& shape_orig, + std::vector& triangles_removed) { Shape shape = shape_orig; ElementPos element_prev_pos = shape.elements.size() - 1; @@ -691,6 +709,8 @@ Shape clean_extreme_slopes_inner_2( element_prev.start, element_prev.end, {element_cur.end.x, element_cur.start.y}, element_cur.end); if (p.first) { + triangles_removed.push_back(build_triangle( + element_prev.start, element_cur.start, element_cur.end)); element_prev.end = p.second; element_cur.start = p.second; } @@ -705,6 +725,8 @@ Shape clean_extreme_slopes_inner_2( element_prev.start, element_prev.end, {element_cur.start.x, element_cur.end.y}, element_cur.end); if (p.first) { + triangles_removed.push_back(build_triangle( + element_prev.start, element_cur.start, element_cur.end)); element_prev.end = p.second; element_cur.start = p.second; } @@ -737,6 +759,8 @@ Shape clean_extreme_slopes_inner_2( element_cur.start, {element_cur.start.x, element_cur.end.y}, element_next.start, element_next.end); if (p.first) { + triangles_removed.push_back(build_triangle( + element_cur.start, element_cur.end, element_next.end)); element_cur.end = p.second; element_next.start = p.second; } @@ -751,6 +775,8 @@ Shape clean_extreme_slopes_inner_2( element_cur.start, {element_cur.end.x, element_cur.start.y}, element_next.start, element_next.end); if (p.first) { + triangles_removed.push_back(build_triangle( + element_cur.start, element_cur.end, element_next.end)); element_cur.end = p.second; element_next.start = p.second; } @@ -775,23 +801,38 @@ ShapeWithHoles shape::clean_extreme_slopes_outer( } Shape shape = shape_orig; + std::vector triangles_added; shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; - shape = clean_extreme_slopes_outer_1(shape); + shape = clean_extreme_slopes_outer_1(shape, triangles_added); shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; - shape = clean_extreme_slopes_outer_2(shape); + shape = clean_extreme_slopes_outer_2(shape, triangles_added); shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; + if (!triangles_added.empty() && intersect(shape)) { + // The naive in-place mutations performed by clean_extreme_slopes_outer_1 + // and clean_extreme_slopes_outer_2 can produce a self-intersecting + // shape. When that happens, fall back to a robust reconstruction: + // union the corner triangles identified by those two passes with the + // original shape, the same approach used by simplify() to recover + // from invalid in-place simplifications. + std::vector shapes_to_union = {{shape_orig}}; + for (const Shape& triangle: triangles_added) + shapes_to_union.push_back({triangle}); + std::vector u = compute_union(shapes_to_union); + return u.front(); + } + if (!shape.check()) { throw std::invalid_argument( FUNC_SIGNATURE + ": " @@ -810,26 +851,51 @@ std::vector shape::clean_extreme_slopes_inner( } Shape shape = shape_orig; + std::vector triangles_removed; shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; - shape = clean_extreme_slopes_inner_1(shape); + shape = clean_extreme_slopes_inner_1(shape, triangles_removed); shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; - shape = clean_extreme_slopes_inner_2(shape); + shape = clean_extreme_slopes_inner_2(shape, triangles_removed); shape = equalize_shape(shape); shape = remove_redundant_vertices(shape).second; shape = remove_aligned_vertices(shape).second; + if (!triangles_removed.empty() && intersect(shape)) { + // The naive in-place mutations performed by clean_extreme_slopes_inner_1 + // and clean_extreme_slopes_inner_2 can produce a self-intersecting + // shape. When that happens, fall back to a robust reconstruction: cut + // the corner triangles identified by those two passes directly out of + // the original shape using a boolean difference, the same approach + // used by simplify() to recover from invalid in-place simplifications. + std::vector triangles_removed_swh; + for (const Shape& triangle: triangles_removed) + triangles_removed_swh.push_back({triangle}); + std::vector difference = compute_difference( + {{shape_orig}}, + triangles_removed_swh); + + std::vector output; + for (const ShapeWithHoles& shape_with_holes: difference) + output.push_back(shape_with_holes.shape); + return output; + } + if (!shape.check()) { #ifdef CLEAN_ENABLE_DEBUG Writer().add_shape(shape_orig).add_shape(shape).write_json("tmp.json"); + std::ofstream file{"clean_extreme_slopes_inner_inputs.json"}; + nlohmann::json json; + json["shape"] = shape_orig.to_json(); + file << std::setw(4) << json << std::endl; #endif throw std::invalid_argument( FUNC_SIGNATURE + ": " diff --git a/test/clean_test.cpp b/test/clean_test.cpp index f7dbbc8..8fb30cc 100644 --- a/test/clean_test.cpp +++ b/test/clean_test.cpp @@ -118,6 +118,85 @@ INSTANTIATE_TEST_SUITE_P( }); +struct CleanExtremeSlopesInnerTestParams +{ + std::string name; + Shape shape; + std::vector expected_output; + + + static CleanExtremeSlopesInnerTestParams 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; + CleanExtremeSlopesInnerTestParams test_params; + test_params.name = file_path; + test_params.shape = Shape::from_json(json["shape"]); + for (auto& json_shape: json["expected_output"].items()) + test_params.expected_output.emplace_back(Shape::from_json(json_shape.value())); + return test_params; + } +}; + +void PrintTo(const CleanExtremeSlopesInnerTestParams& params, std::ostream* os) +{ + *os << "shape " << params.shape.to_string(2) << "\n"; + *os << "expected_output\n"; + for (const Shape& shape: params.expected_output) + *os << shape.to_string(2) << "\n"; +} + +class CleanExtremeSlopesInnerTest: public testing::TestWithParam { }; + +TEST_P(CleanExtremeSlopesInnerTest, CleanExtremeSlopesInner) +{ + CleanExtremeSlopesInnerTestParams test_params = GetParam(); + PrintTo(test_params, &std::cout); + + std::vector output = clean_extreme_slopes_inner(test_params.shape); + std::cout << "output" << std::endl; + for (const Shape& shape: output) + std::cout << shape.to_string(2) << std::endl; + + ASSERT_EQ(output.size(), test_params.expected_output.size()); + for (const Shape& expected_shape: test_params.expected_output) { + EXPECT_NE(std::find_if( + output.begin(), + output.end(), + [&expected_shape](const Shape& shape) { return equal(shape, expected_shape); }), + output.end()); + } +} + +INSTANTIATE_TEST_SUITE_P( + Shape, + CleanExtremeSlopesInnerTest, + testing::ValuesIn(std::vector{ + { + "Square", + build_shape({{0, 0}, {1, 0}, {1, 1}, {0, 1}}), + {build_shape({{0, 0}, {1, 0}, {1, 1}, {0, 1}})}, + }, { + "NearVerticalSpike", + build_shape({{0, 0}, {10, -10}, {10.1, -110}, {20, 0}}), + {build_shape({{0, 0}, {10.1, -110}, {20, 0}})}, + }, + CleanExtremeSlopesInnerTestParams::read_json( + (fs::path("data") / "tests" / "clean" / "clean_extreme_slopes_inner" / "0.json").string()), + }), + [](const testing::TestParamInfo& info) { + return fs::path(info.param.name).stem().string(); + }); + + struct FixSelfIntersectionsTestParams { std::string name;