mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-05 06:41:10 +00:00
On a belt the parts print in belt order, so every colour change between parts is a filament change. Arrange packs items in extruder order already, but it grew the pile around its centre, so the colours ended up interleaved. A belt print now packs from the leading end of the bed, each row filling across the belt before the pile advances, and the objective charges an item for every packed part of another colour it does not fully follow along the belt, counting the tilted layers that reach cot(angle) * height past a part, so each colour prints as one run. The direction follows the slicing rotation: a rotation about X prints toward +Y, one about Y toward -X, and a negative angle flips it. Packing from the edge also means the brim has to be kept on the bed: a belt brim is printed brim_width wide for every brim type, so that much is reserved along every edge (between parts the brims may overlap, as on any printer). The purge prism is regenerated from the arranged parts, flush with the far edge of the bed, yet arrange moved it about like a part and packed parts into the strip it comes back to. Arrange now skips the prism and reserves its strip with a fixed virtual item, like a bed exclusion area, whenever the parts use more than one filament. The prism's length follows the parts plus a ramp per unit of height; with the height at its cap that ran 100 mm past the end of a 500 mm belt and the project could not print. The bar now stops at the plate end, and the purge planner's existing warning reports what the shortened bar cannot absorb.
389 lines
14 KiB
C++
389 lines
14 KiB
C++
#include <limits>
|
|
#include <catch2/catch_all.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);
|
|
}
|