From 7b3228d10d5c14edae138f075fb3a4d5df515e29 Mon Sep 17 00:00:00 2001 From: raistlin7447 Date: Thu, 18 Jun 2026 10:40:37 -0500 Subject: [PATCH] Cover the libnest2d nesting engine and fix an NfpPlacer crash (#14267) * fix(libnest2d): skip the excluded-region alignment pass when there are none NfpPlacer::finalAlign(), run from clearItems() and the destructor, always ran the "find a best position inside the NFP of fixed items" pass even when no items are fixed. With nothing to avoid, calcnfp() computes the inner-fit NFP of the pile and can feed clipper a coordinate outside its allowed range. On Linux/clang the value stays in range so it went unnoticed; on MSVC the clipper "Coordinate outside allowed range" exception escapes the noexcept destructor and aborts the process (exit 0xC0000409). Build the excluded set up front and only run the pass when it is non-empty. The block exists solely to keep the pile clear of fixed items (excluded regions / wipe tower), so it is a no-op when there are none and the wipe-tower behaviour is unchanged. * test(libnest2d): remove dead nesting tests and split the suite by feature Seven of the suite's hidden [.] test cases drove code paths Orca abandoned at the BambuStudio fork: BottomLeftPlacer (used nowhere in src/) and the stock default NfpPlacer backend, which returns zero bins in Orca. They have been red since the fork and are never registered with ctest. Remove them. Split the 1,000-line libnest2d_tests_main.cpp into per-feature files, per the repo convention, sharing a header for the no-fit-polygon backend setup that every translation unit must agree on (ODR): libnest2d_tests.cpp Item and nest() basics test_geometry.cpp geometry primitives test_nfp.cpp no-fit-polygon machinery libnest2d_test_utils.hpp shared includes and the NFP backend specialisation Along the way: drop a debug exportSVG() helper that only wrote a file on test failure (so the suite never leaves stray assets), convert the deprecated Catch::Approx to WithinRel/WithinAbs matchers, and give the tests descriptive names. * test(libnest2d): add NfpPlacer unit tests NfpPlacer is the placement engine the arranger drives, but the suite only covered the geometry primitives. Add a fixture and five tests that exercise pack()/accept() directly: a single item lands in the bin, an oversized item is rejected, the first item is seeded for every starting point, many items pack without overlap, and the rotation candidates are searched. This lifts nfpplacer.hpp line coverage from 42% to 87% in the libnest2d suite. * test(libslic3r): add arrangement::arrange() integration coverage The libnest2d suite cannot reach Orca's real nesting entry point because it does not link libslic3r. Add test_arrange.cpp driving arrangement::arrange(): items land on the bed and within bounds, do not overlap, are spaced by their inflation, an oversized item stays unplaced, overflow spills onto virtual beds, an empty input is a no-op, and the DONT_ALIGN and USER_DEFINED final-alignment paths are exercised. A self-test guards the overlap check the other cases use. --- .../include/libnest2d/placers/nfpplacer.hpp | 13 +- tests/libnest2d/CMakeLists.txt | 6 +- tests/libnest2d/libnest2d_test_utils.hpp | 46 + tests/libnest2d/libnest2d_tests.cpp | 49 + tests/libnest2d/libnest2d_tests_main.cpp | 1227 ----------------- tests/libnest2d/test_geometry.cpp | 190 +++ tests/libnest2d/test_nfp.cpp | 267 ++++ tests/libnest2d/test_nfp_placer.cpp | 140 ++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_arrange.cpp | 224 +++ 10 files changed, 928 insertions(+), 1235 deletions(-) create mode 100644 tests/libnest2d/libnest2d_test_utils.hpp create mode 100644 tests/libnest2d/libnest2d_tests.cpp delete mode 100644 tests/libnest2d/libnest2d_tests_main.cpp create mode 100644 tests/libnest2d/test_geometry.cpp create mode 100644 tests/libnest2d/test_nfp.cpp create mode 100644 tests/libnest2d/test_nfp_placer.cpp create mode 100644 tests/libslic3r/test_arrange.cpp diff --git a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp index 65a3344b04..dc7733ebd6 100644 --- a/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp +++ b/deps_src/libnest2d/include/libnest2d/placers/nfpplacer.hpp @@ -1123,18 +1123,17 @@ private: std::vector objs,excludes; for (const Item &item : items_) { - if (item.isFixed()) continue; - objs.push_back(item.transformedShape()); + if (item.isFixed()) + excludes.push_back(item.transformedShape()); + else + objs.push_back(item.transformedShape()); } if (objs.empty()) return; + // Without fixed items this inner-fit NFP can exceed clipper's range and crash MSVC. + if (!excludes.empty()) { // find a best position inside NFP of fixed items (excluded regions), so the center of pile is cloest to bed center RawShape objs_convex_hull = sl::convexHull(objs); - for (const Item &item : items_) { - if (item.isFixed()) { - excludes.push_back(item.transformedShape()); - } - } auto nfps = calcnfp(objs_convex_hull, excludes, bbin, Lvl()); if (nfps.empty()) { diff --git a/tests/libnest2d/CMakeLists.txt b/tests/libnest2d/CMakeLists.txt index 2c36b9cdcb..d693577bde 100644 --- a/tests/libnest2d/CMakeLists.txt +++ b/tests/libnest2d/CMakeLists.txt @@ -1,8 +1,12 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests - ${_TEST_NAME}_tests_main.cpp + ${_TEST_NAME}_tests.cpp + test_geometry.cpp + test_nfp.cpp + test_nfp_placer.cpp printer_parts.cpp printer_parts.hpp + libnest2d_test_utils.hpp ) target_link_libraries(${_TEST_NAME}_tests test_common libnest2d Catch2::Catch2WithMain) diff --git a/tests/libnest2d/libnest2d_test_utils.hpp b/tests/libnest2d/libnest2d_test_utils.hpp new file mode 100644 index 0000000000..55f184f60c --- /dev/null +++ b/tests/libnest2d/libnest2d_test_utils.hpp @@ -0,0 +1,46 @@ +#pragma once + +// Shared setup for the libnest2d test suite. +// +// The no-fit-polygon numeric backend specialised below changes how NFP is +// computed for the whole program, so every translation unit that instantiates +// NFP (the geometry, nfp and placer tests) must see the same definition. Keep +// it here and include this header from every libnest2d test file. + +#include + +#include +#include + +#if defined(_MSC_VER) && defined(__clang__) +#define BOOST_NO_CXX17_HDR_STRING_VIEW +#endif + +#include "boost/multiprecision/integer.hpp" +#include "boost/rational.hpp" + +namespace libnest2d { + +#if !defined(_MSC_VER) && defined(__SIZEOF_INT128__) && !defined(__APPLE__) +using LargeInt = __int128; +#else +using LargeInt = boost::multiprecision::int128_t; +template<> struct _NumTag { using Type = ScalarTag; }; +#endif +template struct _NumTag> { using Type = RationalTag; }; + +using RectangleItem = libnest2d::Rectangle; + +namespace nfp { + +// Use exact rational arithmetic for the convex NFP so the tests are not at the +// mercy of floating-point rounding. +template +struct NfpImpl { + NfpResult operator()(const S &sh, const S &other) { + return nfpConvexOnly>(sh, other); + } +}; + +} // namespace nfp +} // namespace libnest2d diff --git a/tests/libnest2d/libnest2d_tests.cpp b/tests/libnest2d/libnest2d_tests.cpp new file mode 100644 index 0000000000..1657d45601 --- /dev/null +++ b/tests/libnest2d/libnest2d_tests.cpp @@ -0,0 +1,49 @@ +#include + +#include "libnest2d_test_utils.hpp" + +using namespace libnest2d; + +// Basic behaviour of the Item type and the high-level nest() entry point: +// items copy independently, and nest() leaves degenerate or oversized items +// untouched. + +TEST_CASE("Item construction and copy", "[Nesting]") { + Item sh = { {0, 0}, {1, 0}, {1, 1}, {0, 1} }; + REQUIRE(sh.vertexCount() == 4u); + + Item sh2({ {0, 0}, {1, 0}, {1, 1}, {0, 1} }); + REQUIRE(sh2.vertexCount() == 4u); + + Item sh3 = sh2; // copy + REQUIRE(sh3.vertexCount() == 4u); + + sh2 = {}; // clearing the original leaves the copy intact + REQUIRE(sh2.vertexCount() == 0u); + REQUIRE(sh3.vertexCount() == 4u); +} + +TEST_CASE("nest() leaves an empty or zero-area item untouched", "[Nesting]") { + auto bin = Box(250000000, 210000000); + + std::vector items; + items.emplace_back(Item{}); // empty item + items.emplace_back(Item{ {0, 200} }); // zero-area item + + size_t bins = nest(items, bin); + + REQUIRE(bins == 0u); + for (const auto &itm : items) REQUIRE(itm.binId() == BIN_ID_UNSET); +} + +TEST_CASE("nest() leaves an item larger than the bin untouched", "[Nesting]") { + auto bin = Box(250000000, 210000000); + + std::vector items; + items.emplace_back(RectangleItem{250000001, 210000001}); // larger than the bin + + size_t bins = nest(items, bin); + + REQUIRE(bins == 0u); + REQUIRE(items.front().binId() == BIN_ID_UNSET); +} diff --git a/tests/libnest2d/libnest2d_tests_main.cpp b/tests/libnest2d/libnest2d_tests_main.cpp deleted file mode 100644 index 156573e6ce..0000000000 --- a/tests/libnest2d/libnest2d_tests_main.cpp +++ /dev/null @@ -1,1227 +0,0 @@ -#include - -#include -#include - -#include -#include "printer_parts.hpp" -//#include -#include "../tools/svgtools.hpp" -#include - -#if defined(_MSC_VER) && defined(__clang__) -#define BOOST_NO_CXX17_HDR_STRING_VIEW -#endif - -#include "boost/multiprecision/integer.hpp" -#include "boost/rational.hpp" - -//#include "../tools/libnfpglue.hpp" -//#include "../tools/nfp_svgnest_glue.hpp" - -namespace libnest2d { -#if !defined(_MSC_VER) && defined(__SIZEOF_INT128__) && !defined(__APPLE__) -using LargeInt = __int128; -#else -using LargeInt = boost::multiprecision::int128_t; -template<> struct _NumTag { using Type = ScalarTag; }; -#endif -template struct _NumTag> { using Type = RationalTag; }; - -using RectangleItem = libnest2d::Rectangle; - -namespace nfp { - -template -struct NfpImpl -{ - NfpResult operator()(const S &sh, const S &other) - { - return nfpConvexOnly>(sh, other); - } -}; - -} -} - -namespace { -using namespace libnest2d; - -template -void exportSVG(const char *loc, It from, It to) { - - static const char* svg_header = - R"raw( - - -)raw"; - - // for(auto r : result) { - std::fstream out(loc, std::fstream::out); - if(out.is_open()) { - out << svg_header; - // Item rbin( RectangleItem(bin.width(), bin.height()) ); - // for(unsigned j = 0; j < rbin.vertexCount(); j++) { - // auto v = rbin.vertex(j); - // setY(v, -getY(v)/SCALE + 500 ); - // setX(v, getX(v)/SCALE); - // rbin.setVertex(j, v); - // } - // out << shapelike::serialize(rbin.rawShape()) << std::endl; - for(auto it = from; it != to; ++it) { - const Item &itm = *it; - Item tsh(itm.transformedShape()); - for(unsigned j = 0; j < tsh.vertexCount(); j++) { - auto v = tsh.vertex(j); - setY(v, -getY(v)/SCALE + 500); - setX(v, getX(v)/SCALE); - tsh.setVertex(j, v); - } - out << shapelike::serialize(tsh.rawShape()) << std::endl; - } - out << "\n" << std::endl; - } - out.close(); - - // i++; - // } -} - -template -void exportSVG(std::vector>& result, int idx = 0) { - exportSVG((std::string("out") + std::to_string(idx) + ".svg").c_str(), - result.begin(), result.end()); -} -} - -static std::vector& prusaParts() { - using namespace libnest2d; - - static std::vector ret; - - if(ret.empty()) { - ret.reserve(PRINTER_PART_POLYGONS.size()); - for(auto& inp : PRINTER_PART_POLYGONS) { - auto inp_cpy = inp; - - if (ClosureTypeV == Closure::OPEN) - inp_cpy.points.pop_back(); - - if constexpr (!libnest2d::is_clockwise()) - std::reverse(inp_cpy.begin(), inp_cpy.end()); - - ret.emplace_back(inp_cpy); - } - } - - return ret; -} - -TEST_CASE("Angles", "[Geometry]") -{ - - using namespace libnest2d; - - Degrees deg(180); - Radians rad(deg); - Degrees deg2(rad); - - REQUIRE(Catch::Approx(rad) == Pi); - REQUIRE(Catch::Approx(deg) == 180); - REQUIRE(Catch::Approx(deg2) == 180); - REQUIRE(Catch::Approx(rad) == Radians(deg)); - REQUIRE(Catch::Approx(Degrees(rad)) == deg); - - REQUIRE(rad == deg); - - Segment seg = {{0, 0}, {12, -10}}; - - REQUIRE(Degrees(seg.angleToXaxis()) > 270); - REQUIRE(Degrees(seg.angleToXaxis()) < 360); - - seg = {{0, 0}, {12, 10}}; - - REQUIRE(Degrees(seg.angleToXaxis()) > 0); - REQUIRE(Degrees(seg.angleToXaxis()) < 90); - - seg = {{0, 0}, {-12, 10}}; - - REQUIRE(Degrees(seg.angleToXaxis()) > 90); - REQUIRE(Degrees(seg.angleToXaxis()) < 180); - - seg = {{0, 0}, {-12, -10}}; - - REQUIRE(Degrees(seg.angleToXaxis()) > 180); - REQUIRE(Degrees(seg.angleToXaxis()) < 270); - - seg = {{0, 0}, {1, 0}}; - - REQUIRE(Degrees(seg.angleToXaxis()) == Catch::Approx(0.)); - - seg = {{0, 0}, {0, 1}}; - - REQUIRE(Degrees(seg.angleToXaxis()) == Catch::Approx(90.)); - - seg = {{0, 0}, {-1, 0}}; - - REQUIRE(Degrees(seg.angleToXaxis()) == Catch::Approx(180.)); - - seg = {{0, 0}, {0, -1}}; - - REQUIRE(Degrees(seg.angleToXaxis()) == Catch::Approx(270.)); -} - -// Simple TEST_CASE, does not use gmock -TEST_CASE("ItemCreationAndDestruction", "[Nesting]") -{ - using namespace libnest2d; - - Item sh = { {0, 0}, {1, 0}, {1, 1}, {0, 1} }; - - REQUIRE(sh.vertexCount() == 4u); - - Item sh2 ({ {0, 0}, {1, 0}, {1, 1}, {0, 1} }); - - REQUIRE(sh2.vertexCount() == 4u); - - // copy - Item sh3 = sh2; - - REQUIRE(sh3.vertexCount() == 4u); - - sh2 = {}; - - REQUIRE(sh2.vertexCount() == 0u); - REQUIRE(sh3.vertexCount() == 4u); -} - -TEST_CASE("boundingCircle", "[Geometry]") { - using namespace libnest2d; - using placers::boundingCircle; - - PolygonImpl p = {{{0, 10}, {10, 0}, {0, -10}, {0, 10}}, {}}; - Circle c = boundingCircle(p); - - REQUIRE(getX(c.center()) == 0); - REQUIRE(getY(c.center()) == 0); - REQUIRE(c.radius() == Catch::Approx(10)); - - shapelike::translate(p, PointImpl{10, 10}); - c = boundingCircle(p); - - REQUIRE(getX(c.center()) == 10); - REQUIRE(getY(c.center()) == 10); - REQUIRE(c.radius() == Catch::Approx(10)); - - auto parts = prusaParts(); - - int i = 0; - for(auto& part : parts) { - c = boundingCircle(part.transformedShape()); - if(std::isnan(c.radius())) std::cout << "fail: radius is nan" << std::endl; - - else for(auto v : shapelike::contour(part.transformedShape()) ) { - auto d = pointlike::distance(v, c.center()); - if(d > c.radius() ) { - auto e = std::abs( 1.0 - d/c.radius()); - REQUIRE(e <= 1e-3); - } - } - i++; - } - -} - -TEST_CASE("Distance", "[Geometry]") { - using namespace libnest2d; - - Point p1 = {0, 0}; - - Point p2 = {10, 0}; - Point p3 = {10, 10}; - - REQUIRE(pointlike::distance(p1, p2) == Catch::Approx(10)); - REQUIRE(pointlike::distance(p1, p3) == Catch::Approx(sqrt(200))); - - Segment seg(p1, p3); - - // REQUIRE(pointlike::distance(p2, seg) == Catch::Approx(7.0710678118654755)); - - auto result = pointlike::horizontalDistance(p2, seg); - - auto check = [](TCompute val, TCompute expected) { - if(std::is_floating_point>::value) - REQUIRE(static_cast(val) == - Catch::Approx(static_cast(expected))); - else - REQUIRE(val == expected); - }; - - REQUIRE(result.second); - check(result.first, 10); - - result = pointlike::verticalDistance(p2, seg); - REQUIRE(result.second); - check(result.first, -10); - - result = pointlike::verticalDistance(Point{10, 20}, seg); - REQUIRE(result.second); - check(result.first, 10); - - - Point p4 = {80, 0}; - Segment seg2 = { {0, 0}, {0, 40} }; - - result = pointlike::horizontalDistance(p4, seg2); - - REQUIRE(result.second); - check(result.first, 80); - - result = pointlike::verticalDistance(p4, seg2); - // Point should not be related to the segment - REQUIRE_FALSE(result.second); - -} - -TEST_CASE("Area", "[Geometry]") { - using namespace libnest2d; - - RectangleItem rect(10, 10); - - REQUIRE(rect.area() == Catch::Approx(100)); - - RectangleItem rect2 = {100, 100}; - - REQUIRE(rect2.area() == Catch::Approx(10000)); - - Item item = { - {61, 97}, - {70, 151}, - {176, 151}, - {189, 138}, - {189, 59}, - {70, 59}, - {61, 77}, - {61, 97} - }; - - REQUIRE(std::abs(shapelike::area(item.transformedShape())) > 0 ); -} - -TEST_CASE("IsPointInsidePolygon", "[Geometry]") { - using namespace libnest2d; - - RectangleItem rect(10, 10); - - Point p = {1, 1}; - - REQUIRE(rect.isInside(p)); - - p = {11, 11}; - - REQUIRE_FALSE(rect.isInside(p)); - - - p = {11, 12}; - - REQUIRE_FALSE(rect.isInside(p)); - - - p = {3, 3}; - - REQUIRE(rect.isInside(p)); - -} - -//TEST_CASE(GeometryAlgorithms, Intersections) { -// using namespace binpack2d; - -// RectangleItem rect(70, 30); - -// rect.translate({80, 60}); - -// RectangleItem rect2(80, 60); -// rect2.translate({80, 0}); - -//// REQUIRE_FALSE(Item::intersects(rect, rect2)); - -// Segment s1({0, 0}, {10, 10}); -// Segment s2({1, 1}, {11, 11}); -// REQUIRE_FALSE(ShapeLike::intersects(s1, s1)); -// REQUIRE_FALSE(ShapeLike::intersects(s1, s2)); -//} - -TEST_CASE("LeftAndDownPolygon", "[Geometry]") -{ - using namespace libnest2d; - - Box bin(100, 100); - BottomLeftPlacer placer(bin); - - PathImpl pitem = {{70, 75}, {88, 60}, {65, 50}, {60, 30}, {80, 20}, - {42, 20}, {35, 35}, {35, 55}, {40, 75}}; - - PathImpl pleftControl = {{40, 75}, {35, 55}, {35, 35}, - {42, 20}, {0, 20}, {0, 75}}; - - PathImpl pdownControl = {{88, 60}, {88, 0}, {35, 0}, {35, 35}, - {42, 20}, {80, 20}, {60, 30}, {65, 50}}; - - if constexpr (!is_clockwise()) { - std::reverse(sl::begin(pitem), sl::end(pitem)); - std::reverse(sl::begin(pleftControl), sl::end(pleftControl)); - std::reverse(sl::begin(pdownControl), sl::end(pdownControl)); - } - - if constexpr (ClosureTypeV == Closure::CLOSED) { - sl::addVertex(pitem, sl::front(pitem)); - sl::addVertex(pleftControl, sl::front(pleftControl)); - sl::addVertex(pdownControl, sl::front(pdownControl)); - } - - Item item{pitem}, leftControl{pleftControl}, downControl{pdownControl}; - Item leftp(placer.leftPoly(item)); - - auto valid = sl::isValid(leftp.rawShape()); - - std::vector> to_export{ leftp, leftControl }; - exportSVG<1>("leftp.svg", to_export.begin(), to_export.end()); - - REQUIRE(valid.first); - REQUIRE(leftp.vertexCount() == leftControl.vertexCount()); - - for(unsigned long i = 0; i < leftControl.vertexCount(); i++) { - REQUIRE(getX(leftp.vertex(i)) == getX(leftControl.vertex(i))); - REQUIRE(getY(leftp.vertex(i)) == getY(leftControl.vertex(i))); - } - - Item downp(placer.downPoly(item)); - - REQUIRE(shapelike::isValid(downp.rawShape()).first); - REQUIRE(downp.vertexCount() == downControl.vertexCount()); - - for(unsigned long i = 0; i < downControl.vertexCount(); i++) { - REQUIRE(getX(downp.vertex(i)) == getX(downControl.vertex(i))); - REQUIRE(getY(downp.vertex(i)) == getY(downControl.vertex(i))); - } -} - -TEST_CASE("ArrangeRectanglesTight", "[Nesting][NotWorking][.]") -{ - using namespace libnest2d; - - std::vector rects = { - {80, 80}, - {60, 90}, - {70, 30}, - {80, 60}, - {60, 60}, - {60, 40}, - {40, 40}, - {10, 10}, - {10, 10}, - {10, 10}, - {10, 10}, - {10, 10}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {20, 20} }; - - Box bin(210, 250, {105, 125}); - - REQUIRE(bin.width() == 210); - REQUIRE(bin.height() == 250); - REQUIRE(getX(bin.center()) == 105); - REQUIRE(getY(bin.center()) == 125); - - _Nester arrange(bin); - - arrange.execute(rects.begin(), rects.end()); - - auto max_group = std::max_element(rects.begin(), rects.end(), - [](const Item &i1, const Item &i2) { - return i1.binId() < i2.binId(); - }); - - int groups = max_group == rects.end() ? 0 : max_group->binId() + 1; - - REQUIRE(groups == 1u); - REQUIRE( - std::all_of(rects.begin(), rects.end(), [](const RectangleItem &itm) { - return itm.binId() != BIN_ID_UNSET; - })); - - // check for no intersections, no containment: - - // exportSVG<1>("arrangeRectanglesTight.svg", rects.begin(), rects.end()); - - bool valid = true; - for(Item& r1 : rects) { - for(Item& r2 : rects) { - if(&r1 != &r2 ) { - valid = !Item::intersects(r1, r2) || Item::touches(r1, r2); - REQUIRE(valid); - valid = (valid && !r1.isInside(r2) && !r2.isInside(r1)); - REQUIRE(valid); - } - } - } -} - -TEST_CASE("ArrangeRectanglesLoose", "[Nesting][.]") -{ - using namespace libnest2d; - - // std::vector rects = { {40, 40}, {10, 10}, {20, 20} }; - std::vector rects = { - {80, 80}, - {60, 90}, - {70, 30}, - {80, 60}, - {60, 60}, - {60, 40}, - {40, 40}, - {10, 10}, - {10, 10}, - {10, 10}, - {10, 10}, - {10, 10}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {5, 5}, - {20, 20} }; - - Box bin(210, 250, {105, 125}); - - REQUIRE(bin.width() == 210); - REQUIRE(bin.height() == 250); - REQUIRE(getX(bin.center()) == 105); - REQUIRE(getY(bin.center()) == 125); - - Coord min_obj_distance = 5; - - _Nester arrange(bin, min_obj_distance); - - arrange.execute(rects.begin(), rects.end()); - - auto max_group = std::max_element(rects.begin(), rects.end(), - [](const Item &i1, const Item &i2) { - return i1.binId() < i2.binId(); - }); - - auto groups = size_t(max_group == rects.end() ? 0 : max_group->binId() + 1); - - REQUIRE(groups == 1u); - REQUIRE( - std::all_of(rects.begin(), rects.end(), [](const RectangleItem &itm) { - return itm.binId() != BIN_ID_UNSET; - })); - - // check for no intersections, no containment: - bool valid = true; - for(Item& r1 : rects) { - for(Item& r2 : rects) { - if(&r1 != &r2 ) { - valid = !Item::intersects(r1, r2); - valid = (valid && !r1.isInside(r2) && !r2.isInside(r1)); - REQUIRE(valid); - } - } - } - -} - -TEST_CASE("BottomLeftStressTest", "[Geometry][NotWorking][.]") { - using namespace libnest2d; - - const Coord SCALE = 1000000; - auto& input = prusaParts(); - - Box bin(210*SCALE, 250*SCALE); - BottomLeftPlacer placer(bin); - - auto it = input.begin(); - auto next = it; - int i = 0; - while(it != input.end() && ++next != input.end()) { - placer.pack(*it); - placer.pack(*next); - - auto result = placer.getItems(); - bool valid = true; - - if(result.size() == 2) { - Item& r1 = result[0]; - Item& r2 = result[1]; - valid = !Item::intersects(r1, r2) || Item::touches(r1, r2); - valid = (valid && !r1.isInside(r2) && !r2.isInside(r1)); - if(!valid) { - std::cout << "error index: " << i << std::endl; - exportSVG(result, i); - } - REQUIRE(valid); - } else { - std::cout << "something went terribly wrong!" << std::endl; - FAIL(); - } - - placer.clearItems(); - it++; - i++; - } -} - -TEST_CASE("convexHull", "[Geometry]") { - using namespace libnest2d; - - PathImpl poly = PRINTER_PART_POLYGONS[0]; - - auto chull = sl::convexHull(poly); - - REQUIRE(chull.size() == poly.size()); -} - -TEST_CASE("PrusaPartsShouldFitIntoTwoBins", "[Nesting][.]") { - - // Get the input items and define the bin. - std::vector input = prusaParts(); - auto bin = Box(250000000, 210000000); - - // Do the nesting. Check in each step if the remaining items are less than - // in the previous step. (Some algorithms can place more items in one step) - size_t pcount = input.size(); - - size_t bins = libnest2d::nest(input, bin, 0, {}, - ProgressFunction{[&pcount](unsigned cnt) { - REQUIRE(cnt < pcount); - pcount = cnt; - }}); - - // For prusa parts, 2 bins should be enough... - REQUIRE(bins > 0u); - REQUIRE(bins <= 2u); - - // All parts should be processed by the algorithm - REQUIRE( - std::all_of(input.begin(), input.end(), [](const Item &itm) { - return itm.binId() != BIN_ID_UNSET; - })); - - // Gather the items into piles of arranged polygons... - using Pile = TMultiShape; - std::vector piles(bins); - - for (auto &itm : input) - piles[size_t(itm.binId())].emplace_back(itm.transformedShape()); - - // Now check all the piles, the bounding box of each pile should be inside - // the defined bin. - for (auto &pile : piles) { - auto bb = sl::boundingBox(pile); - REQUIRE(sl::isInside(bb, bin)); - } - - // Check the area of merged pile vs the sum of area of all the parts - // They should match, otherwise there is an overlap which should not happen. - for (auto &pile : piles) { - double area_sum = 0.; - - for (auto &obj : pile) - area_sum += sl::area(obj); - - auto pile_m = nfp::merge(pile); - double area_merge = sl::area(pile_m); - - REQUIRE(area_sum == Catch::Approx(area_merge)); - } -} - -TEST_CASE("EmptyItemShouldBeUntouched", "[Nesting]") { - auto bin = Box(250000000, 210000000); // dummy bin - - std::vector items; - items.emplace_back(Item{}); // Emplace empty item - items.emplace_back(Item{ {0, 200} }); // Emplace zero area item - - size_t bins = libnest2d::nest(items, bin); - - REQUIRE(bins == 0u); - for (auto &itm : items) REQUIRE(itm.binId() == BIN_ID_UNSET); -} - -TEST_CASE("LargeItemShouldBeUntouched", "[Nesting]") { - auto bin = Box(250000000, 210000000); // dummy bin - - std::vector items; - items.emplace_back(RectangleItem{250000001, 210000001}); // Emplace large item - - size_t bins = libnest2d::nest(items, bin); - - REQUIRE(bins == 0u); - REQUIRE(items.front().binId() == BIN_ID_UNSET); -} - -TEST_CASE("Items can be preloaded", "[Nesting][.]") { - auto bin = Box({0, 0}, {250000000, 210000000}); // dummy bin - - std::vector items; - items.reserve(2); - - NestConfig<> cfg; - cfg.placer_config.alignment = NestConfig<>::Placement::Alignment::DONT_ALIGN; - - items.emplace_back(RectangleItem{10000000, 10000000}); - Item &fixed_rect = items.back(); - fixed_rect.translate(bin.center()); - - items.emplace_back(RectangleItem{20000000, 20000000}); - Item &movable_rect = items.back(); - movable_rect.translate(bin.center()); - - SECTION("Preloaded Item should be untouched") { - fixed_rect.markAsFixedInBin(0); - - size_t bins = libnest2d::nest(items, bin, 0, cfg); - - REQUIRE(bins == 1); - - REQUIRE(fixed_rect.binId() == 0); - REQUIRE(getX(fixed_rect.translation()) == getX(bin.center())); - REQUIRE(getY(fixed_rect.translation()) == getY(bin.center())); - - REQUIRE(movable_rect.binId() == 0); - REQUIRE(getX(movable_rect.translation()) != getX(bin.center())); - REQUIRE(getY(movable_rect.translation()) != getY(bin.center())); - } - - SECTION("Preloaded Item should not affect free bins") { - fixed_rect.markAsFixedInBin(1); - - size_t bins = libnest2d::nest(items, bin, 0, cfg); - - REQUIRE(bins == 2); - - REQUIRE(fixed_rect.binId() == 1); - REQUIRE(getX(fixed_rect.translation()) == getX(bin.center())); - REQUIRE(getY(fixed_rect.translation()) == getY(bin.center())); - - REQUIRE(movable_rect.binId() == 0); - - auto bb = movable_rect.boundingBox(); - REQUIRE(getX(bb.center()) == getX(bin.center())); - REQUIRE(getY(bb.center()) == getY(bin.center())); - } -} - -namespace { - -struct ItemPair { - Item orbiter; - Item stationary; -}; - -std::vector nfp_testdata = { - { - { - {80, 50}, - {100, 70}, - {120, 50} - }, - { - {10, 10}, - {10, 40}, - {40, 40}, - {40, 10} - } - }, - { - { - {80, 50}, - {60, 70}, - {80, 90}, - {120, 90}, - {140, 70}, - {120, 50} - }, - { - {10, 10}, - {10, 40}, - {40, 40}, - {40, 10} - } - }, - { - { - {40, 10}, - {30, 10}, - {20, 20}, - {20, 30}, - {30, 40}, - {40, 40}, - {50, 30}, - {50, 20} - }, - { - {80, 0}, - {80, 30}, - {110, 30}, - {110, 0} - } - }, - { - { - {117, 107}, - {118, 109}, - {120, 112}, - {122, 113}, - {128, 113}, - {130, 112}, - {132, 109}, - {133, 107}, - {133, 103}, - {132, 101}, - {130, 98}, - {128, 97}, - {122, 97}, - {120, 98}, - {118, 101}, - {117, 103} - }, - { - {102, 116}, - {111, 126}, - {114, 126}, - {144, 106}, - {148, 100}, - {148, 85}, - {147, 84}, - {102, 84} - } - }, - { - { - {99, 122}, - {108, 140}, - {110, 142}, - {139, 142}, - {151, 122}, - {151, 102}, - {142, 70}, - {139, 68}, - {111, 68}, - {108, 70}, - {99, 102} - }, - { - {107, 124}, - {128, 125}, - {133, 125}, - {136, 124}, - {140, 121}, - {142, 119}, - {143, 116}, - {143, 109}, - {141, 93}, - {139, 89}, - {136, 86}, - {134, 85}, - {108, 85}, - {107, 86} - } - }, - { - { - {91, 100}, - {94, 144}, - {117, 153}, - {118, 153}, - {159, 112}, - {159, 110}, - {156, 66}, - {133, 57}, - {132, 57}, - {91, 98} - }, - { - {101, 90}, - {103, 98}, - {107, 113}, - {114, 125}, - {115, 126}, - {135, 126}, - {136, 125}, - {144, 114}, - {149, 90}, - {149, 89}, - {148, 87}, - {145, 84}, - {105, 84}, - {102, 87}, - {101, 89} - } - } -}; - - std::vector nfp_concave_testdata = { - { // ItemPair - { - { - {533726, 142141}, - {532359, 143386}, - {530141, 142155}, - {528649, 160091}, - {533659, 157607}, - {538669, 160091}, - {537178, 142155}, - {534959, 143386} - } - }, - { - { - {118305, 11603}, - {118311, 26616}, - {113311, 26611}, - {109311, 29604}, - {109300, 44608}, - {109311, 49631}, - {113300, 52636}, - {118311, 52636}, - {118308, 103636}, - {223830, 103636}, - {236845, 90642}, - {236832, 11630}, - {232825, 11616}, - {210149, 11616}, - {211308, 13625}, - {209315, 17080}, - {205326, 17080}, - {203334, 13629}, - {204493, 11616} - } - }, - } -}; - -template -void testNfp(const std::vector& testdata) { - using namespace libnest2d; - - Box bin(210*SCALE, 250*SCALE); - - int TEST_CASEcase = 0; - - auto& exportfun = exportSVG; - - auto onetest = [&](Item& orbiter, Item& stationary, unsigned /*testidx*/){ - TEST_CASEcase++; - - orbiter.translate({210*SCALE, 0}); - - auto&& nfp = nfp::noFitPolygon(stationary.rawShape(), - orbiter.transformedShape()); - - placers::correctNfpPosition(nfp, stationary, orbiter); - - auto valid = shapelike::isValid(nfp.first); - - /*Item infp(nfp.first); - if(!valid.first) { - std::cout << "TEST_CASE instance: " << TEST_CASEidx << " " - << valid.second << std::endl; - std::vector> inp = {std::ref(infp)}; - exportfun(inp, bin, TEST_CASEidx); - }*/ - - REQUIRE(valid.first); - - Item infp(nfp.first); - - int i = 0; - auto rorbiter = orbiter.transformedShape(); - auto vo = nfp::referenceVertex(rorbiter); - - REQUIRE(stationary.isInside(infp)); - - for(auto v : infp) { - auto dx = getX(v) - getX(vo); - auto dy = getY(v) - getY(vo); - - Item tmp = orbiter; - - tmp.translate({dx, dy}); - - bool touching = Item::touches(tmp, stationary); - - if(!touching || !valid.first) { - std::vector> inp = { - std::ref(stationary), std::ref(tmp), std::ref(infp) - }; - - exportfun(inp, TEST_CASEcase*i++); - } - - REQUIRE(touching); - } - }; - - unsigned tidx = 0; - for(auto& td : testdata) { - auto orbiter = td.orbiter; - auto stationary = td.stationary; - if (!libnest2d::is_clockwise()) { - auto porb = orbiter.rawShape(); - auto pstat = stationary.rawShape(); - std::reverse(sl::begin(porb), sl::end(porb)); - std::reverse(sl::begin(pstat), sl::end(pstat)); - orbiter = Item{porb}; - stationary = Item{pstat}; - } - onetest(orbiter, stationary, tidx++); - } - - tidx = 0; - for(auto& td : testdata) { - auto orbiter = td.stationary; - auto stationary = td.orbiter; - if (!libnest2d::is_clockwise()) { - auto porb = orbiter.rawShape(); - auto pstat = stationary.rawShape(); - std::reverse(sl::begin(porb), sl::end(porb)); - std::reverse(sl::begin(pstat), sl::end(pstat)); - orbiter = Item{porb}; - stationary = Item{pstat}; - } - onetest(orbiter, stationary, tidx++); - } -} -} - -TEST_CASE("nfpConvexConvex", "[Geometry]") { - testNfp(nfp_testdata); -} - -//TEST_CASE(GeometryAlgorithms, nfpConcaveConcave) { -// TEST_CASENfp(nfp_concave_TEST_CASEdata); -//} - -TEST_CASE("pointOnPolygonContour", "[Geometry]") { - using namespace libnest2d; - - RectangleItem input(10, 10); - - placers::EdgeCache ecache(input); - - auto first = *input.begin(); - REQUIRE(getX(first) == getX(ecache.coords(0))); - REQUIRE(getY(first) == getY(ecache.coords(0))); - - auto last = *std::prev(input.end()); - REQUIRE(getX(last) == getX(ecache.coords(1.0))); - REQUIRE(getY(last) == getY(ecache.coords(1.0))); - - for(int i = 0; i <= 100; i++) { - auto v = ecache.coords(i*(0.01)); - REQUIRE(shapelike::touches(v, input.transformedShape())); - } -} - -TEST_CASE("mergePileWithPolygon", "[Geometry]") { - using namespace libnest2d; - - RectangleItem rect1(10, 15); - RectangleItem rect2(15, 15); - RectangleItem rect3(20, 15); - - rect2.translate({10, 0}); - rect3.translate({25, 0}); - - TMultiShape pile; - pile.push_back(rect1.transformedShape()); - pile.push_back(rect2.transformedShape()); - - auto result = nfp::merge(pile, rect3.transformedShape()); - - REQUIRE(result.size() == 1); - - RectangleItem ref(45, 15); - - REQUIRE(shapelike::area(result.front()) == Catch::Approx(ref.area())); -} - -namespace { - -long double refMinAreaBox(const PolygonImpl& p) { - - auto it = sl::cbegin(p), itx = std::next(it); - - long double min_area = std::numeric_limits::max(); - - - auto update_min = [&min_area, &it, &itx, &p]() { - Segment s(*it, *itx); - - PolygonImpl rotated = p; - sl::rotate(rotated, -s.angleToXaxis()); - auto bb = sl::boundingBox(rotated); - auto area = cast(sl::area(bb)); - if(min_area > area) min_area = area; - }; - - while(itx != sl::cend(p)) { - update_min(); - ++it; ++itx; - } - - it = std::prev(sl::cend(p)); itx = sl::cbegin(p); - update_min(); - - return min_area; -} - -template struct BoostGCD { - T operator()(const T &a, const T &b) { return boost::gcd(a, b); } -}; - -using Unit = int64_t; -using Ratio = boost::rational; - -} - -//TEST_CASE(GeometryAlgorithms, MinAreaBBCClk) { -// auto u = [](ClipperLib::cInt n) { return n*1000000; }; -// PolygonImpl poly({ {u(0), u(0)}, {u(4), u(1)}, {u(2), u(4)}}); - -// long double arearef = refMinAreaBox(poly); -// long double area = minAreaBoundingBox(poly).area(); - -// REQUIRE(std::abs(area - arearef) <= 500e6 ); -//} - -TEST_CASE("MinAreaBBWithRotatingCalipers", "[Geometry]") { - long double err_epsilon = 500e6l; - - for(PathImpl rinput : PRINTER_PART_POLYGONS) { - PolygonImpl poly(rinput); - - long double arearef = refMinAreaBox(poly); - auto bb = minAreaBoundingBox(rinput); - long double area = cast(bb.area()); - - bool succ = std::abs(arearef - area) < err_epsilon; - - REQUIRE(succ); - } - - for(PathImpl rinput : STEGOSAUR_POLYGONS) { -// rinput.pop_back(); - std::reverse(rinput.begin(), rinput.end()); - - PolygonImpl poly(removeCollinearPoints(rinput, 1000000)); - - long double arearef = refMinAreaBox(poly); - auto bb = minAreaBoundingBox(poly); - long double area = cast(bb.area()); - - - bool succ = std::abs(arearef - area) < err_epsilon; - - REQUIRE(succ); - } -} - -template MultiPolygon merged_pile(It from, It to, int bin_id) -{ - MultiPolygon pile; - pile.reserve(size_t(to - from)); - - for (auto it = from; it != to; ++it) { - if (it->binId() == bin_id) pile.emplace_back(it->transformedShape()); - } - - return nfp::merge(pile); -} - -TEST_CASE("Test for bed center distance optimization", "[Nesting][NestKernels][.]") -{ - static const constexpr Slic3r::ClipperLib::cInt W = 10000000; - - // Get the input items and define the bin. - std::vector input(9, {W, W}); - - auto bin = Box::infinite(); - - NfpPlacer::Config pconfig; - - pconfig.object_function = [](const Item &item, const std::vector>&) -> double { - return pl::magnsq(item.boundingBox().center()); - }; - - size_t bins = nest(input, bin, 0, NestConfig{pconfig}); - - REQUIRE(bins == 1); - - // Gather the items into piles of arranged polygons... - MultiPolygon pile; - pile.reserve(input.size()); - - for (auto &itm : input) { - REQUIRE(itm.binId() == 0); - pile.emplace_back(itm.transformedShape()); - } - - MultiPolygon m = merged_pile(input.begin(), input.end(), 0); - - REQUIRE(m.size() == 1); - - REQUIRE(sl::area(m) == Catch::Approx(9. * W * W)); -} - -TEST_CASE("Test for biggest bounding box area", "[Nesting][NestKernels][.]") -{ - static const constexpr Slic3r::ClipperLib::cInt W = 10000000; - static const constexpr size_t N = 100; - - // Get the input items and define the bin. - std::vector input(N, {W, W}); - - auto bin = Box::infinite(); - - NfpPlacer::Config pconfig; - pconfig.rotations = {0.}; - Box pile_box; - pconfig.before_packing = - [&pile_box](const MultiPolygon &pile, - const _ItemGroup &/*packed_items*/, - const _ItemGroup &/*remaining_items*/) { - pile_box = sl::boundingBox(pile); - }; - - pconfig.object_function = [&pile_box](const Item &item, const std::vector>&) -> double { - Box b = sl::boundingBox(item.boundingBox(), pile_box); - double area = b.area() / (double(W) * W); - return -area; - }; - - size_t bins = nest(input, bin, 0, NestConfig{pconfig}); - - // To debug: - exportSVG<1000000>("out", input.begin(), input.end()); - - REQUIRE(bins == 1); - - MultiPolygon pile = merged_pile(input.begin(), input.end(), 0); - Box bb = sl::boundingBox(pile); - - // Here the result shall be a stairway of boxes - REQUIRE(pile.size() == N); - REQUIRE(bb.area() == double(N) * N * W * W); -} diff --git a/tests/libnest2d/test_geometry.cpp b/tests/libnest2d/test_geometry.cpp new file mode 100644 index 0000000000..9c58a299e4 --- /dev/null +++ b/tests/libnest2d/test_geometry.cpp @@ -0,0 +1,190 @@ +#include + +#include "libnest2d_test_utils.hpp" +#include "printer_parts.hpp" + +using namespace libnest2d; + +namespace { + +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +// Geometry values round-trip through floating point, so compare with a small +// tolerance that works both near and away from zero. +void require_close(double value, double expected) { + REQUIRE_THAT(value, WithinRel(expected, 1e-9) || WithinAbs(expected, 1e-9)); +} + +// The printer parts as nestable items, computed once. +const std::vector &prusa_parts() { + static const std::vector parts = [] { + std::vector ret; + ret.reserve(PRINTER_PART_POLYGONS.size()); + for (auto &inp : PRINTER_PART_POLYGONS) { + auto inp_cpy = inp; + if (ClosureTypeV == Closure::OPEN) + inp_cpy.points.pop_back(); + if constexpr (!is_clockwise()) + std::reverse(inp_cpy.begin(), inp_cpy.end()); + ret.emplace_back(inp_cpy); + } + return ret; + }(); + return parts; +} + +} // namespace + +TEST_CASE("Degree and radian conversion round-trips", "[Geometry]") { + Degrees deg(180); + Radians rad(deg); + + require_close(rad, Pi); + require_close(deg, 180); + require_close(Degrees(rad), 180); + require_close(rad, Radians(deg)); + require_close(Degrees(rad), deg); + REQUIRE(rad == deg); +} + +TEST_CASE("Segment angle to the X axis", "[Geometry]") { + auto quadrant = [](Point to) { return Degrees(Segment({0, 0}, to).angleToXaxis()); }; + + REQUIRE(quadrant({12, -10}) > 270); REQUIRE(quadrant({12, -10}) < 360); + REQUIRE(quadrant({12, 10}) > 0); REQUIRE(quadrant({12, 10}) < 90); + REQUIRE(quadrant({-12, 10}) > 90); REQUIRE(quadrant({-12, 10}) < 180); + REQUIRE(quadrant({-12, -10}) > 180); REQUIRE(quadrant({-12, -10}) < 270); + + require_close(quadrant({1, 0}), 0); + require_close(quadrant({0, 1}), 90); + require_close(quadrant({-1, 0}), 180); + require_close(quadrant({0, -1}), 270); +} + +TEST_CASE("Point to segment distance", "[Geometry]") { + Point p2 = {10, 0}; + Segment seg({0, 0}, {10, 10}); + + auto check = [](TCompute val, TCompute expected) { + if (std::is_floating_point>::value) + require_close(double(val), double(expected)); + else + REQUIRE(val == expected); + }; + + auto h = pointlike::horizontalDistance(p2, seg); + REQUIRE(h.second); + check(h.first, 10); + + auto v = pointlike::verticalDistance(p2, seg); + REQUIRE(v.second); + check(v.first, -10); + + v = pointlike::verticalDistance(Point{10, 20}, seg); + REQUIRE(v.second); + check(v.first, 10); + + Point p4 = {80, 0}; + Segment seg2({0, 0}, {0, 40}); + + h = pointlike::horizontalDistance(p4, seg2); + REQUIRE(h.second); + check(h.first, 80); + + v = pointlike::verticalDistance(p4, seg2); + REQUIRE_FALSE(v.second); // the point does not project onto the segment +} + +TEST_CASE("Item area", "[Geometry]") { + require_close(RectangleItem(10, 10).area(), 100); + require_close(RectangleItem(100, 100).area(), 10000); + + Item item = { + {61, 97}, {70, 151}, {176, 151}, {189, 138}, + {189, 59}, {70, 59}, {61, 77}, {61, 97} + }; + REQUIRE(std::abs(shapelike::area(item.transformedShape())) > 0); +} + +TEST_CASE("Point inside polygon", "[Geometry]") { + RectangleItem rect(10, 10); + + REQUIRE(rect.isInside(Point{1, 1})); + REQUIRE(rect.isInside(Point{3, 3})); + REQUIRE_FALSE(rect.isInside(Point{11, 11})); + REQUIRE_FALSE(rect.isInside(Point{11, 12})); +} + +TEST_CASE("Bounding circle of the printer parts", "[Geometry]") { + PolygonImpl p = {{{0, 10}, {10, 0}, {0, -10}, {0, 10}}, {}}; + Circle c = placers::boundingCircle(p); + + require_close(getX(c.center()), 0); + require_close(getY(c.center()), 0); + require_close(c.radius(), 10); + + shapelike::translate(p, PointImpl{10, 10}); + c = placers::boundingCircle(p); + require_close(getX(c.center()), 10); + require_close(getY(c.center()), 10); + require_close(c.radius(), 10); + + for (auto &part : prusa_parts()) { + c = placers::boundingCircle(part.transformedShape()); + REQUIRE_FALSE(std::isnan(c.radius())); + for (auto v : shapelike::contour(part.transformedShape())) { + auto d = pointlike::distance(v, c.center()); + if (d > c.radius()) + REQUIRE(std::abs(1.0 - d / c.radius()) <= 1e-3); // on the circle + } + } +} + +TEST_CASE("Convex hull of a printer part", "[Geometry]") { + PathImpl poly = PRINTER_PART_POLYGONS[0]; + auto chull = sl::convexHull(poly); + + REQUIRE(chull.size() == poly.size()); // the part is already convex +} + +namespace { + +using Unit = int64_t; +using Ratio = boost::rational; + +// Reference minimum-area bounding box, found by brute force over every edge +// direction, to validate the rotating-calipers implementation. +long double ref_min_area_box(const PolygonImpl &p) { + long double min_area = std::numeric_limits::max(); + + auto update_min = [&](const Point &a, const Point &b) { + PolygonImpl rotated = p; + sl::rotate(rotated, -Segment(a, b).angleToXaxis()); + min_area = std::min(min_area, cast(sl::area(sl::boundingBox(rotated)))); + }; + + auto it = sl::cbegin(p), itx = std::next(it); + while (itx != sl::cend(p)) { update_min(*it, *itx); ++it; ++itx; } + update_min(*std::prev(sl::cend(p)), *sl::cbegin(p)); + + return min_area; +} + +} // namespace + +TEST_CASE("Minimum-area bounding box via rotating calipers", "[Geometry]") { + const long double tolerance = 500e6l; + + for (const PathImpl &part : PRINTER_PART_POLYGONS) { + auto area = cast(minAreaBoundingBox(part).area()); + REQUIRE(std::abs(ref_min_area_box(PolygonImpl(part)) - area) < tolerance); + } + + for (PathImpl part : STEGOSAUR_POLYGONS) { + std::reverse(part.begin(), part.end()); + PolygonImpl poly(removeCollinearPoints(part, 1000000)); + auto area = cast(minAreaBoundingBox(poly).area()); + REQUIRE(std::abs(ref_min_area_box(poly) - area) < tolerance); + } +} diff --git a/tests/libnest2d/test_nfp.cpp b/tests/libnest2d/test_nfp.cpp new file mode 100644 index 0000000000..1b5c5d3a1c --- /dev/null +++ b/tests/libnest2d/test_nfp.cpp @@ -0,0 +1,267 @@ +#include + +#include "libnest2d_test_utils.hpp" + +using namespace libnest2d; + +namespace { + +struct ItemPair { + Item orbiter; + Item stationary; +}; + +std::vector nfp_testdata = { + { + { + {80, 50}, + {100, 70}, + {120, 50} + }, + { + {10, 10}, + {10, 40}, + {40, 40}, + {40, 10} + } + }, + { + { + {80, 50}, + {60, 70}, + {80, 90}, + {120, 90}, + {140, 70}, + {120, 50} + }, + { + {10, 10}, + {10, 40}, + {40, 40}, + {40, 10} + } + }, + { + { + {40, 10}, + {30, 10}, + {20, 20}, + {20, 30}, + {30, 40}, + {40, 40}, + {50, 30}, + {50, 20} + }, + { + {80, 0}, + {80, 30}, + {110, 30}, + {110, 0} + } + }, + { + { + {117, 107}, + {118, 109}, + {120, 112}, + {122, 113}, + {128, 113}, + {130, 112}, + {132, 109}, + {133, 107}, + {133, 103}, + {132, 101}, + {130, 98}, + {128, 97}, + {122, 97}, + {120, 98}, + {118, 101}, + {117, 103} + }, + { + {102, 116}, + {111, 126}, + {114, 126}, + {144, 106}, + {148, 100}, + {148, 85}, + {147, 84}, + {102, 84} + } + }, + { + { + {99, 122}, + {108, 140}, + {110, 142}, + {139, 142}, + {151, 122}, + {151, 102}, + {142, 70}, + {139, 68}, + {111, 68}, + {108, 70}, + {99, 102} + }, + { + {107, 124}, + {128, 125}, + {133, 125}, + {136, 124}, + {140, 121}, + {142, 119}, + {143, 116}, + {143, 109}, + {141, 93}, + {139, 89}, + {136, 86}, + {134, 85}, + {108, 85}, + {107, 86} + } + }, + { + { + {91, 100}, + {94, 144}, + {117, 153}, + {118, 153}, + {159, 112}, + {159, 110}, + {156, 66}, + {133, 57}, + {132, 57}, + {91, 98} + }, + { + {101, 90}, + {103, 98}, + {107, 113}, + {114, 125}, + {115, 126}, + {135, 126}, + {136, 125}, + {144, 114}, + {149, 90}, + {149, 89}, + {148, 87}, + {145, 84}, + {105, 84}, + {102, 87}, + {101, 89} + } + } +}; + +// libnest2d's vertex order depends on the backend; normalise to clockwise. +Item reversed_if_ccw(Item it) { + if (!is_clockwise()) { + auto raw = it.rawShape(); + std::reverse(sl::begin(raw), sl::end(raw)); + it = Item{raw}; + } + return it; +} + +// Sliding `orbiter` around `stationary` along their no-fit polygon must keep the +// two shapes touching at every NFP vertex, and `stationary` must lie inside the +// resulting inner-fit polygon. +template +void check_nfp(const std::vector &testdata) { + auto check_pair = [](Item orbiter, Item stationary) { + orbiter.translate({210 * SCALE, 0}); + + auto &&nfp = nfp::noFitPolygon(stationary.rawShape(), orbiter.transformedShape()); + placers::correctNfpPosition(nfp, stationary, orbiter); + REQUIRE(shapelike::isValid(nfp.first).first); + + Item infp(nfp.first); + REQUIRE(stationary.isInside(infp)); + + auto vo = nfp::referenceVertex(orbiter.transformedShape()); + for (auto v : infp) { + Item moved = orbiter; + moved.translate({getX(v) - getX(vo), getY(v) - getY(vo)}); + REQUIRE(Item::touches(moved, stationary)); + } + }; + + for (const ItemPair &td : testdata) { + check_pair(reversed_if_ccw(td.orbiter), reversed_if_ccw(td.stationary)); + check_pair(reversed_if_ccw(td.stationary), reversed_if_ccw(td.orbiter)); + } +} + +} // namespace + +TEST_CASE("No-fit polygon of convex shapes keeps the items touching", "[Geometry][NFP]") { + check_nfp(nfp_testdata); +} + +TEST_CASE("BottomLeftPlacer left and down polygons", "[Geometry][NFP]") { + Box bin(100, 100); + BottomLeftPlacer placer(bin); + + PathImpl pitem = {{70, 75}, {88, 60}, {65, 50}, {60, 30}, {80, 20}, + {42, 20}, {35, 35}, {35, 55}, {40, 75}}; + PathImpl left_control = {{40, 75}, {35, 55}, {35, 35}, {42, 20}, {0, 20}, {0, 75}}; + PathImpl down_control = {{88, 60}, {88, 0}, {35, 0}, {35, 35}, + {42, 20}, {80, 20}, {60, 30}, {65, 50}}; + + if constexpr (!is_clockwise()) { + std::reverse(sl::begin(pitem), sl::end(pitem)); + std::reverse(sl::begin(left_control), sl::end(left_control)); + std::reverse(sl::begin(down_control), sl::end(down_control)); + } + if constexpr (ClosureTypeV == Closure::CLOSED) { + sl::addVertex(pitem, sl::front(pitem)); + sl::addVertex(left_control, sl::front(left_control)); + sl::addVertex(down_control, sl::front(down_control)); + } + + auto require_same_vertices = [](const Item &got, const Item &expected) { + REQUIRE(shapelike::isValid(got.rawShape()).first); + REQUIRE(got.vertexCount() == expected.vertexCount()); + for (unsigned long i = 0; i < expected.vertexCount(); ++i) { + REQUIRE(getX(got.vertex(i)) == getX(expected.vertex(i))); + REQUIRE(getY(got.vertex(i)) == getY(expected.vertex(i))); + } + }; + + Item item{pitem}; + require_same_vertices(Item(placer.leftPoly(item)), Item{left_control}); + require_same_vertices(Item(placer.downPoly(item)), Item{down_control}); +} + +TEST_CASE("EdgeCache maps a parameter to a contour point", "[Geometry][NFP]") { + RectangleItem input(10, 10); + placers::EdgeCache ecache(input); + + auto first = *input.begin(); + REQUIRE(getX(first) == getX(ecache.coords(0))); + REQUIRE(getY(first) == getY(ecache.coords(0))); + + auto last = *std::prev(input.end()); + REQUIRE(getX(last) == getX(ecache.coords(1.0))); + REQUIRE(getY(last) == getY(ecache.coords(1.0))); + + for (int i = 0; i <= 100; ++i) + REQUIRE(shapelike::touches(ecache.coords(i * 0.01), input.transformedShape())); +} + +TEST_CASE("Merging a pile with a polygon", "[Geometry][NFP]") { + RectangleItem rect1(10, 15), rect2(15, 15), rect3(20, 15); + rect2.translate({10, 0}); + rect3.translate({25, 0}); + + TMultiShape pile; + pile.push_back(rect1.transformedShape()); + pile.push_back(rect2.transformedShape()); + + auto result = nfp::merge(pile, rect3.transformedShape()); + REQUIRE(result.size() == 1); // the three abutting rectangles merge into one + + RectangleItem ref(45, 15); + REQUIRE_THAT(shapelike::area(result.front()), + Catch::Matchers::WithinRel(ref.area(), 1e-9)); +} diff --git a/tests/libnest2d/test_nfp_placer.cpp b/tests/libnest2d/test_nfp_placer.cpp new file mode 100644 index 0000000000..8219dda71d --- /dev/null +++ b/tests/libnest2d/test_nfp_placer.cpp @@ -0,0 +1,140 @@ +#include + +#include "libnest2d_test_utils.hpp" + +using namespace libnest2d; + +// NfpPlacer is the No-Fit-Polygon placement engine that Orca's arranger drives +// (via _Nester/FirstFitSelection in Arrange.cpp). These exercise the placer +// directly: pack()/accept() are the core geometric placement primitives. +namespace { + +struct NfpPlacerFixture { + using Cfg = NfpPlacer::Config; + Box bin{250000000, 210000000}; // 250 x 210 mm bed at 1e6 scale + + NfpPlacer placer_with(Cfg cfg = {}) const { + cfg.parallel = false; // deterministic, single-threaded for tests + NfpPlacer p{bin}; + p.configure(cfg); + return p; + } + + // pack + accept; returns whether the item was placed. + static bool place(NfpPlacer &p, Item &item) { + auto res = p.pack(item); + if (res) p.accept(res); + return bool(res); + } + + // Place every item and REQUIRE each one is packed. + static void place_all(NfpPlacer &p, std::vector &items) { + for (size_t i = 0; i < items.size(); ++i) { + INFO("packing item " << i); + REQUIRE(place(p, items[i])); + } + } + + // No two items overlap (a shared edge is allowed) and each stays in the bin. + void require_disjoint_in_bin(std::vector &items) const { + for (size_t i = 0; i < items.size(); ++i) { + REQUIRE(sl::isInside(items[i].boundingBox(), bin)); + for (size_t j = i + 1; j < items.size(); ++j) { + const bool overlaps = Item::intersects(items[i], items[j]) && + !Item::touches(items[i], items[j]); + INFO("items " << i << " and " << j); + REQUIRE_FALSE(overlaps); + } + } + } + + static std::vector squares(size_t n, Coord side) { + return std::vector(n, RectangleItem{side, side}); + } +}; + +} // namespace + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer places a single item inside the bin", "[Nesting][Placer]") { + NfpPlacer placer = placer_with(); + RectangleItem item{100000000, 100000000}; + + REQUIRE(place(placer, item)); + REQUIRE(placer.getItems().size() == 1u); + REQUIRE(sl::isInside(item.boundingBox(), bin)); +} + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer rejects an item larger than the bin", "[Nesting][Placer]") { + NfpPlacer placer = placer_with(); + RectangleItem big{300000000, 300000000}; // wider and taller than the bin + + auto res = placer.pack(big); + REQUIRE_FALSE(bool(res)); + REQUIRE(placer.getItems().empty()); +} + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer positions the first item for any starting point", "[Nesting][Placer]") { + // setInitialPosition() seeds the first item from the configured starting + // corner; pack() (without accept()) drives that switch for every value. + using A = Cfg::Alignment; + auto start = GENERATE(A::CENTER, A::BOTTOM_LEFT, A::BOTTOM_RIGHT, + A::TOP_LEFT, A::TOP_RIGHT, A::USER_DEFINED, A::DONT_ALIGN); + CAPTURE(int(start)); + + Cfg cfg; + cfg.starting_point = start; + cfg.best_object_pos = bin.center(); + NfpPlacer placer = placer_with(cfg); + + RectangleItem item{100000000, 100000000}; + auto res = placer.pack(item); + REQUIRE(bool(res)); + REQUIRE(sl::isInside(item.boundingBox(), bin)); +} + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer packs many items without overlap", "[Nesting][Placer]") { + // Each item is placed against the no-fit polygon of the growing pile. + auto items = squares(GENERATE(2u, 6u, 9u), 60000000); + NfpPlacer placer = placer_with(); + + place_all(placer, items); + REQUIRE(placer.getItems().size() == items.size()); + require_disjoint_in_bin(items); +} + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer evaluates the rotation candidates", "[Nesting][Placer]") { + Cfg cfg; + cfg.rotations = {0.0, Pi / 2.0}; // exercise the rotation search loop + NfpPlacer placer = placer_with(cfg); + + std::vector rects = { + {180000000, 40000000}, {180000000, 40000000}, {180000000, 40000000}}; + place_all(placer, rects); + require_disjoint_in_bin(rects); +} + +TEST_CASE_METHOD(NfpPlacerFixture, "NfpPlacer's final alignment keeps the pile clear of a fixed obstacle", "[Nesting][Placer]") { + // A preloaded fixed item makes finalAlign's recentring keep the pile clear of + // it instead of dropping it straight onto the bin centre. Box{w,h} centres on + // the origin, so the obstacle sits there too; virtual keeps it in place. + RectangleItem obstacle{80000000, 80000000}; + obstacle.translation({-40000000, -40000000}); // 80x80 mm centred in the bin (origin) + obstacle.markAsFixedInBin(0); + obstacle.is_virt_object = true; + + auto items = squares(4, 30000000); + { + NfpPlacer placer = placer_with(); + NfpPlacer::ItemGroup fixed; + fixed.emplace_back(obstacle); + placer.preload(fixed); + place_all(placer, items); + } // the placer's destructor runs finalAlign, translating the packed items + + for (size_t i = 0; i < items.size(); ++i) { + INFO("item " << i); + const bool overlaps = Item::intersects(items[i], obstacle) && + !Item::touches(items[i], obstacle); + REQUIRE_FALSE(overlaps); + } +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9c451e0456..bbd4bc0eeb 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable(${_TEST_NAME}_tests test_aabbindirect.cpp test_appconfig.cpp test_arachne_walls.cpp + test_arrange.cpp test_bambu_networking.cpp test_clipper_offset.cpp test_clipper_utils.cpp diff --git a/tests/libslic3r/test_arrange.cpp b/tests/libslic3r/test_arrange.cpp new file mode 100644 index 0000000000..a9fb51e352 --- /dev/null +++ b/tests/libslic3r/test_arrange.cpp @@ -0,0 +1,224 @@ +#include + +#include "libslic3r/Arrange.hpp" +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/ExPolygon.hpp" + +using namespace Slic3r; +using namespace Slic3r::arrangement; + +namespace { + +using Catch::Matchers::WithinRel; + +// Square of the given (scaled) side, lower-left at the origin. bed_idx starts at +// 0 because arrange() seeds the nester's bin from it (see ModelArrange.cpp). +ArrangePolygon make_square(coord_t side) +{ + ArrangePolygon ap; + Polygon p; + p.points = {Point(0, 0), Point(side, 0), Point(side, side), Point(0, side)}; + ap.poly = ExPolygon(p); + ap.bed_idx = 0; + return ap; +} + +ArrangePolygons squares(int n, double side_mm) +{ + ArrangePolygons items; + for (int i = 0; i < n; ++i) + items.emplace_back(make_square(scaled(side_mm))); + return items; +} + +// Bed [0,0]..[w,h] in scaled coordinates. +BoundingBox bed(double w_mm, double h_mm) +{ + return BoundingBox(Point(0, 0), Point(scaled(w_mm), scaled(h_mm))); +} + +// The default progress callback prints to stdout; silence it. +ArrangeParams quiet_params(coord_t min_dist = 0) +{ + ArrangeParams p{min_dist}; + p.progressind = [](unsigned, std::string) {}; + return p; +} + +ExPolygons placed_shapes(const ArrangePolygons &items) +{ + ExPolygons out; + out.reserve(items.size()); + for (const ArrangePolygon &ap : items) + out.emplace_back(ap.transformed_poly()); + return out; +} + +// Area double-counted across the shapes: the sum counts overlaps twice, the +// union once, so the difference is the overlapping area (0 when disjoint). +double overlap_area(const ExPolygons &shapes) +{ + double sum = 0; + for (const ExPolygon &e : shapes) + sum += e.area(); + double uni = 0; + for (const ExPolygon &e : union_ex(shapes)) + uni += e.area(); + return sum - uni; +} + +// Relative tolerance absorbs the area-unit rounding the clipper union introduces. +bool disjoint(const ExPolygons &shapes) +{ + double total = 0; + for (const ExPolygon &e : shapes) + total += e.area(); + return overlap_area(shapes) <= total * 1e-9; +} + +void require_no_overlap(const ArrangePolygons &items) +{ + REQUIRE(disjoint(placed_shapes(items))); +} + +} // namespace + +// Prove the overlap check the other tests rely on actually detects overlap. +TEST_CASE("overlap_area detects overlap and ignores touching edges", "[Arrange]") +{ + auto square_at = [](double x_mm) { + ArrangePolygon ap = make_square(scaled(20.)); + ap.translation = Vec2crd(scaled(x_mm), 0); + return ap.transformed_poly(); + }; + ExPolygon a = square_at(0.); + + SECTION("disjoint shapes are reported disjoint") { + REQUIRE(disjoint({a, square_at(30.)})); + } + SECTION("edge-touching shapes are reported disjoint") { + REQUIRE(disjoint({a, square_at(20.)})); + } + SECTION("overlapping shapes are not, and the area is measured") { + REQUIRE_FALSE(disjoint({a, square_at(10.)})); + REQUIRE_THAT(overlap_area({a, square_at(10.)}), + WithinRel(double(scaled(10.)) * scaled(20.), 1e-9)); // 10x20 mm + } +} + +TEST_CASE("Arrange places every item on the physical bed", "[Arrange]") +{ + ArrangePolygons items = squares(5, 20.); + arrange(items, bed(200, 200), quiet_params(scaled(1.))); + + for (const ArrangePolygon &ap : items) + REQUIRE(ap.bed_idx == 0); +} + +TEST_CASE("Arranged items stay within the bed", "[Arrange]") +{ + ArrangePolygons items = squares(6, 30.); + arrange(items, bed(200, 200), quiet_params(scaled(1.))); + + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx == 0); + REQUIRE(bed(200, 200).contains(ap.transformed_poly().contour.bounding_box())); + } +} + +TEST_CASE("Arranged items do not overlap", "[Arrange]") +{ + ArrangePolygons items = squares(6, 40.); + arrange(items, bed(250, 250), quiet_params(scaled(2.))); + + require_no_overlap(items); +} + +TEST_CASE("Arrange spaces items by their inflation", "[Arrange]") +{ + // Per-item inflation is how the arranger enforces clearance (the GUI fills it + // from min_obj_distance). Two items inflated 4mm each end up >= 8mm apart. + ArrangePolygons items = squares(4, 20.); + for (ArrangePolygon &ap : items) + ap.inflation = scaled(4.); + arrange(items, bed(200, 200), quiet_params()); + + // Axis-aligned squares are their own bounding boxes, so the clearance between + // a pair is the distance between their boxes (1mm slack for nester rounding). + std::vector boxes; + for (const ExPolygon &e : placed_shapes(items)) + boxes.push_back(e.contour.bounding_box()); + + double min_gap = std::numeric_limits::max(); + for (size_t i = 0; i < boxes.size(); ++i) + for (size_t j = i + 1; j < boxes.size(); ++j) { + coord_t sx = std::max(0, std::max(boxes[j].min.x() - boxes[i].max.x(), + boxes[i].min.x() - boxes[j].max.x())); + coord_t sy = std::max(0, std::max(boxes[j].min.y() - boxes[i].max.y(), + boxes[i].min.y() - boxes[j].max.y())); + min_gap = std::min(min_gap, std::sqrt(double(sx) * sx + double(sy) * sy)); + } + + REQUIRE(min_gap >= double(scaled(8.)) - double(scaled(0.5))); +} + +TEST_CASE("An item larger than the bed cannot be placed", "[Arrange]") +{ + ArrangePolygons items; + items.emplace_back(make_square(scaled(20.))); + items.emplace_back(make_square(scaled(400.))); // far bigger than the bed + + arrange(items, bed(200, 200), quiet_params(scaled(1.))); + + REQUIRE(items[0].bed_idx == 0); + REQUIRE(items[1].bed_idx == UNARRANGED); +} + +TEST_CASE("Items overflowing one bed spill onto virtual beds", "[Arrange]") +{ + ArrangePolygons items = squares(8, 90.); // eight 90mm squares cannot share a 200x200 bed + arrange(items, bed(200, 200), quiet_params(scaled(2.))); + + int max_bed = 0; + for (const ArrangePolygon &ap : items) { + REQUIRE(ap.bed_idx >= 0); // placed somewhere + max_bed = std::max(max_bed, ap.bed_idx); + } + REQUIRE(max_bed >= 1); // at least one on a virtual bed +} + +TEST_CASE("Arrange handles an empty input", "[Arrange]") +{ + ArrangePolygons items; + REQUIRE_NOTHROW(arrange(items, bed(200, 200), quiet_params())); + REQUIRE(items.empty()); +} + +TEST_CASE("Arrange without final alignment keeps items disjoint", "[Arrange]") +{ + // do_final_align = false selects Alignment::DONT_ALIGN (skips recentering). + ArrangePolygons items = squares(6, 40.); + ArrangeParams params = quiet_params(scaled(2.)); + params.do_final_align = false; + + arrange(items, bed(250, 250), params); + + for (const ArrangePolygon &ap : items) + REQUIRE(ap.bed_idx == 0); + require_no_overlap(items); +} + +TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]") +{ + // align_center != (0.5, 0.5) selects Alignment::USER_DEFINED. + ArrangePolygons items = squares(5, 30.); + ArrangeParams params = quiet_params(scaled(2.)); + params.align_center = Vec2d(0.3, 0.7); + + arrange(items, bed(250, 250), params); + + for (const ArrangePolygon &ap : items) + REQUIRE(ap.bed_idx == 0); + require_no_overlap(items); +}