Files
OrcaSlicer/tests/libslic3r/test_arrange.cpp
T
harrierpigeon bbb94724ba Merge upstream/main into belt-printer
Brings belt-printer up to main 4b4a261787. Resolutions:

- G-code header (#15897, #15915): main moved the header, config and
  thumbnail block later in _do_export; write_belt_header() moves with it,
  still after the thumbnails and outside the BTT_TFT gate.
- _extrude: first-layer acceleration keeps the per-path first-layer plane
  test with main's cached nozzle index (#16028); main's set_speed out-param
  form (#16108) everywhere else.
- GCodeWriter (#16108): the arc-to-polyline fallback for machine mappings
  that cannot express G2/G3 now runs in the out-param extrude_arc_to_xy,
  which is the overload GCode calls, and appends to the caller's string.
- GCodeProcessorResult: the belt fields join main's forwarding assign.
- Clipper2 (#15969): belt arrange helpers take Slic3r::Point; the tree
  support join types lose their ClipperLib qualifier.
- CLI arrange (#15837): belt printers still reserve no wipe tower.
- Wipe tower options (#15841): the two new sparse-layer toggles are hidden
  for belt printers like the rest of the tower options.
- Keyboard shortcuts (#15706): main's registry replaces the old key switch;
  the belt view toggle is re-registered in the next commit.
- Print::process: the belt purge-plan undo runs before main's SliceStarted
  event.
- scripts/filament_id_snapshot.json: deleted on main (a77209af8f).
- Includes and appended tests: union of both sides.
2026-10-04 19:20:33 -05:00

402 lines
15 KiB
C++

#include <algorithm>
#include <catch2/catch_all.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Point.hpp"
#include <string>
#include "libslic3r/Config.hpp"
#include <vector>
#include <limits>
#include <cstddef>
#include <cmath>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/generators/catch_generators.hpp>
#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<double> &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<BoundingBox> boxes;
for (const ExPolygon &e : placed_shapes(items))
boxes.push_back(e.contour.bounding_box());
double min_gap = std::numeric_limits<double>::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<coord_t>(0, std::max(boxes[j].min.x() - boxes[i].max.x(),
boxes[i].min.x() - boxes[j].max.x()));
coord_t sy = std::max<coord_t>(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);
}
// A belt printer starts its parts at the leading end of the belt (best_object_pos 0.5, 0.05).
// Centring a pile on a point that close to the edge pushed everything longer than the room
// around it off the bed: four 90 mm parts on a 95 x 500 mm belt ended with one across the
// edge and one outside, with 290 mm of belt free behind them. The pile stops at the edge.
TEST_CASE("Arrange keeps a pile aligned near an edge on the bed", "[Arrange]")
{
const BoundingBox belt = bed(95, 500);
ArrangePolygons items = squares(4, 90.);
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.5, 0.05);
arrange(items, belt, params);
coord_t lowest = std::numeric_limits<coord_t>::max();
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
const BoundingBox bb = ap.transformed_poly().contour.bounding_box();
CHECK(belt.contains(bb));
lowest = std::min(lowest, bb.min.y());
}
// Snapped to the edge it was aimed at, less the spacing margin, not re-centred.
CHECK(lowest < scaled(10.));
require_no_overlap(items);
}
// On a belt the parts print in belt order, so two colours that alternate along the
// belt, or sit side by side, cost a filament change on every shared layer. Arrange
// keeps each colour together: no part shares belt length with a part of another
// colour, counting the tilted layers that run cot(angle) * height past its far edge,
// whichever end of the belt prints first.
TEST_CASE("Arrange groups the colours of a belt print along the belt", "[Arrange][belt]")
{
const bool reversed = GENERATE(false, true);
CAPTURE(reversed);
const BoundingBox belt = bed(95, 500);
ArrangePolygons items = squares(6, 30., 20.);
for (size_t i = 0; i < items.size(); ++i)
items[i].extrude_ids = { int(i % 3) + 1 }; // three colours, two parts each
ArrangeParams params = quiet_params(scaled(2.));
params.align_center = Vec2d(0.5, 0.05);
params.is_belt = true;
params.belt_axis = 1;
params.belt_reversed = reversed;
params.belt_tilt_slope = 1.f; // 45 degrees
arrange(items, belt, params);
require_no_overlap(items);
// Belt position in print order, so the same check serves both directions.
const coord_t dir = reversed ? -1 : 1;
auto start = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.max.y() : bb.min.y()); };
auto end = [&](const ArrangePolygon &ap) { const BoundingBox bb = ap.transformed_poly().contour.bounding_box(); return dir * (reversed ? bb.min.y() : bb.max.y()) + scaled(ap.height * params.belt_tilt_slope); };
for (const ArrangePolygon &ap : items) {
REQUIRE(ap.bed_idx == 0);
CHECK(belt.contains(ap.transformed_poly().contour.bounding_box()));
}
for (const ArrangePolygon &a : items)
for (const ArrangePolygon &b : items) {
if (a.extrude_ids == b.extrude_ids)
continue;
// The part printed later starts after the earlier one has finished.
const coord_t earlier_end = start(a) <= start(b) ? end(a) : end(b);
const coord_t later_start = std::max(start(a), start(b));
INFO("colour " << a.extrude_ids.front() << " vs " << b.extrude_ids.front());
CHECK(earlier_end <= later_start);
}
}
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<double> heights;
double skirt_offset_mm;
double expected_floor_mm;
};
auto c = GENERATE(values<Case>({
{"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);
}