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