#include #include "libslic3r/Arrange.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/Print.hpp" #include "libslic3r/PrintConfig.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, double height_mm = 0.) { ArrangePolygons items; for (int i = 0; i < n; ++i) { items.emplace_back(make_square(scaled(side_mm))); items.back().height = height_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))); } // The sequential-print floor is chosen by comparing object height against the nozzle, // so the two are defined together and every expectation is derived from them. constexpr double NOZZLE_HEIGHT_MM = 2.5; constexpr double CLEARANCE_MM = 30.; constexpr double NOZZLE_FLOOR_MM = MAX_OUTER_NOZZLE_DIAMETER / 2.; ArrangeParams seq_print_params(coord_t min_dist) { ArrangeParams p = quiet_params(min_dist); p.is_seq_print = true; p.clearance_radius = float(CLEARANCE_MM); p.nozzle_height = float(NOZZLE_HEIGHT_MM); p.object_skirt_offset = 0.f; return p; } // update_selected_items_inflation reads the bed out of the config to cap inflation. DynamicPrintConfig bed_config() { DynamicPrintConfig c; c.set_key_value("printable_area", new ConfigOptionPoints{{0, 0}, {200, 0}, {200, 200}, {0, 200}}); return c; } ArrangePolygons squares_of_heights(const std::vector &heights_mm) { ArrangePolygons items; for (double height_mm : heights_mm) items.push_back(squares(1, 20., height_mm).front()); return 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); } TEST_CASE("Sequential print floors the object distance by object height", "[Arrange]") { // The only place sequential-print clearance is enforced. The arrange menu offers // no floor of its own, so a stored 0 has to be raised here or not at all. struct Case { std::string description; std::vector heights; double skirt_offset_mm; double expected_floor_mm; }; auto c = GENERATE(values({ {"objects taller than the nozzle need the full clearance", {NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}, 0., CLEARANCE_MM}, {"an object exactly at the nozzle height counts as tall", {NOZZLE_HEIGHT_MM, NOZZLE_HEIGHT_MM}, 0., CLEARANCE_MM}, {"one tall object among short ones is enough", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM * 2}, 0., CLEARANCE_MM}, {"objects the nozzle clears keep only the nozzle-width floor", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM / 2}, 0., NOZZLE_FLOOR_MM}, {"a wide skirt raises the floor for short objects", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM / 2}, 3., 6.}, })); DYNAMIC_SECTION(c.description) { ArrangePolygons items = squares_of_heights(c.heights); DynamicPrintConfig cfg = bed_config(); ArrangeParams p = seq_print_params(0); p.object_skirt_offset = float(c.skirt_offset_mm); update_selected_items_inflation(items, &cfg, p); CHECK(p.min_obj_distance >= scaled(c.expected_floor_mm)); CHECK(p.min_obj_distance <= scaled(c.expected_floor_mm + 0.01)); // Half each, so a pair ends up a full min_obj_distance apart. CHECK(items.front().inflation == p.min_obj_distance / 2); } } TEST_CASE("Sequential print keeps an object distance already above the floor", "[Arrange]") { const coord_t stored = scaled(CLEARANCE_MM * 2); ArrangePolygons items = squares_of_heights({NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}); DynamicPrintConfig cfg = bed_config(); ArrangeParams p = seq_print_params(stored); update_selected_items_inflation(items, &cfg, p); CHECK(p.min_obj_distance == stored); } TEST_CASE("Layered printing does not floor the object distance", "[Arrange]") { ArrangePolygons items = squares_of_heights({NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}); DynamicPrintConfig cfg = bed_config(); ArrangeParams p = seq_print_params(0); p.is_seq_print = false; update_selected_items_inflation(items, &cfg, p); CHECK(p.min_obj_distance == 0); }