Files
OrcaSlicer/tests/libnest2d/test_nfp_placer.cpp
raistlin7447 7b3228d10d 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.
2026-06-18 23:40:37 +08:00

141 lines
5.2 KiB
C++

#include <catch2/catch_all.hpp>
#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<RectangleItem> &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<RectangleItem> &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<RectangleItem> squares(size_t n, Coord side) {
return std::vector<RectangleItem>(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<RectangleItem> 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);
}
}