Merge main

This commit is contained in:
Lam Wei Lun
2026-10-12 19:55:28 +08:00
895 changed files with 86139 additions and 78120 deletions
+3
View File
@@ -8,10 +8,13 @@ add_executable(${_TEST_NAME}_tests
test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_processor.cpp
test_gcode_timing.cpp
test_gcode.cpp
test_gcodeprocessor.cpp
test_gcodewriter.cpp
test_imex_mode_gcode.cpp
test_imex_slice_offset.cpp
test_model.cpp
test_multifilament.cpp
test_perimeters.cpp
+59
View File
@@ -4,6 +4,7 @@
#include "test_helpers.hpp"
#include <algorithm>
#include <cstddef>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/TriangleMesh.hpp"
@@ -35,6 +36,64 @@ TEST_CASE("Cooling consumes its internal speed markers", "[Cooling]")
CHECK(gcode.find(";_EXTRUDE_SET_SPEED") == std::string::npos);
}
TEST_CASE("Arc fitting remains active with overhang fan control", "[Cooling][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "enable_arc_fitting", true },
{ "enable_overhang_bridge_fan", true },
{ "enable_overhang_speed", true },
{ "initial_layer_print_height", 0.2 },
{ "layer_height", 0.2 },
{ "slow_down_for_layer_cooling", false },
});
config.set_key_value("fan_max_speed", new ConfigOptionFloats{20.0});
config.set_key_value("fan_min_speed", new ConfigOptionFloats{20.0});
config.set_key_value("overhang_fan_speed", new ConfigOptionInts{100});
config.set_key_value("overhang_fan_threshold", new ConfigOptionEnumsGeneric{Overhang_threshold_2_4});
config.set_key_value("layer_change_gcode", new ConfigOptionString{";TEST_LAYER=[layer_num]"});
const auto has_arc_after_first_layer = [](const std::string &gcode) {
std::istringstream input(gcode);
std::string line;
size_t layers_seen = 0;
while (std::getline(input, line)) {
if (line.rfind(";TEST_LAYER=", 0) == 0)
++layers_seen;
else if (layers_seen > 1 && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
return true;
}
return false;
};
const auto has_arc_with_overhang_fan = [](const std::string &gcode) {
std::istringstream input(gcode);
std::string line;
bool overhang_fan_active = false;
bool outer_wall = false;
while (std::getline(input, line)) {
if (line.rfind("M106 S255", 0) == 0)
overhang_fan_active = true;
else if (line.rfind("M106 S0", 0) == 0 || line.rfind("M107", 0) == 0)
overhang_fan_active = false;
else if (line.rfind(";TYPE:", 0) == 0)
outer_wall = line == ";TYPE:Outer wall";
else if (overhang_fan_active && outer_wall && (line.rfind("G2 ", 0) == 0 || line.rfind("G3 ", 0) == 0))
return true;
}
return false;
};
const std::string with_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_after_first_layer(with_overhang_speed));
config.set_deserialize_strict({ { "enable_overhang_speed", false } });
const std::string without_overhang_speed = slice({ make_cylinder(25.0, 10.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_after_first_layer(without_overhang_speed));
const std::string uniform_overhang_gcode = slice({ make_sphere(50.0, 2.0 * PI / 72.0) }, config);
CHECK(has_arc_with_overhang_fan(uniform_overhang_gcode));
}
TEST_CASE("Overhang fan transitions do not depend on overhang speed", "[Cooling][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+564 -1
View File
@@ -1,6 +1,7 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
@@ -36,6 +37,7 @@
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Fill/FillGyroid.hpp"
#include "libslic3r/Fill/FillTpmsAdaptive.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
@@ -1779,7 +1781,7 @@ TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][Internal
const AABBTreeLines::LinesDistancer<Line> printed_tree(to_lines(printed));
// Orca: Exclude perimeter connections: anchoring and extrusion can trim those differently.
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr, nullptr),
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr),
shrink(to_polygons(layer.lslices), scale_(3.)));
REQUIRE_FALSE(anchors.empty());
double max_distance = 0.;
@@ -1790,3 +1792,564 @@ TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][Internal
// would hide anchors that no longer coincide with printed lines.
CHECK(unscale<double>(max_distance) <= config.opt_float("resolution"));
}
// Orca: Slices the meshes as the parts of one object, where they are, with modifiers of their own config.
static Print &slice_parts(Print &print, DynamicPrintConfig config, const std::vector<TriangleMesh> &parts,
const std::vector<std::pair<TriangleMesh, DynamicPrintConfig>> &modifiers = {})
{
config.set_deserialize_strict({{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"elefant_foot_compensation", 0},
{"top_shell_thickness", 0},
{"bottom_shell_thickness", 0}});
Model model;
Slic3r::Test::init_print({parts.front()}, print, model, config, nullptr, false);
for (size_t i = 1; i < parts.size(); ++ i)
model.objects.front()->add_volume(TriangleMesh(parts[i]), ModelVolumeType::MODEL_PART, false);
for (const auto &[mesh, modifier_config] : modifiers)
model.objects.front()->add_volume(TriangleMesh(mesh), ModelVolumeType::PARAMETER_MODIFIER, false)->config.apply(modifier_config);
print.apply(model, config);
print.process();
return print;
}
// Orca: Two identical cubes in one mesh that never touch, so each is a body of its own.
static Print &slice_two_bodies(Print &print, const DynamicPrintConfig &config, double height)
{
TriangleMesh mesh = make_cube(20, 20, height);
TriangleMesh second = make_cube(20, 20, height);
second.translate(33, 7, 0);
mesh.merge(second);
return slice_parts(print, config, {mesh});
}
// Orca: Counts the points sampled along both sets that the other set does not repeat.
static void count_unmatched(const Polylines &a, const Polylines &b, size_t &sampled, size_t &unmatched)
{
const std::array<const Polylines *, 2> sets{&a, &b};
for (size_t i = 0; i < 2; ++ i) {
const Polylines &other = *sets[1 - i];
const AABBTreeLines::LinesDistancer<Line> distancer(to_lines(other));
for (const Polyline &path : *sets[i])
for (const Point &point : path.equally_spaced_points(scale_(0.2))) {
++ sampled;
unmatched += other.empty() || distancer.distance_from_lines<false>(point) > scale_(0.05);
}
}
}
static Polylines layer_paths(const Layer &layer, ExtrusionRole role)
{
Polylines polylines;
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == role)
entity->collect_polylines(polylines);
return polylines;
}
// Orca: Share of the paths of a role that the other body does not repeat around its own center.
static double unmatched_between_bodies(const Print &print, ExtrusionRole role)
{
size_t sampled = 0, unmatched = 0;
for (const Layer *layer : print.objects().front()->layers()) {
REQUIRE(layer->lslices.size() == 2);
const Polylines polylines = layer_paths(*layer, role);
std::array<Polylines, 2> paths;
for (size_t body = 0; body < 2; ++ body) {
// Orca: Exclude the links along the walls, which each body may chain differently.
paths[body] = intersection_pl(polylines, shrink(to_polygons(layer->lslices[body]), scale_(3.)));
for (Polyline &path : paths[body])
path.translate(-layer->lslices_bboxes[body].center());
}
count_unmatched(paths[0], paths[1], sampled, unmatched);
}
REQUIRE(sampled > 0);
return double(unmatched) / double(sampled);
}
// Orca: Share of the paths of a role inside a bed region that two slices of the same body do not share.
static double unmatched_between_prints(const Print &a, const Print &b, ExtrusionRole role, const Polygons &region)
{
const PrintObject &object_a = *a.objects().front(), &object_b = *b.objects().front();
REQUIRE(object_a.layer_count() == object_b.layer_count());
size_t sampled = 0, unmatched = 0;
for (size_t i = 0; i < object_a.layer_count(); ++ i) {
std::array<Polylines, 2> paths;
for (const PrintObject *object : {&object_a, &object_b}) {
Polylines &out = paths[object == &object_b];
out = layer_paths(*object->get_layer(int(i)), role);
for (Polyline &path : out)
path.translate(object->instances().front().shift);
out = intersection_pl(out, region);
}
count_unmatched(paths[0], paths[1], sampled, unmatched);
}
REQUIRE(sampled > 0);
return double(unmatched) / double(sampled);
}
TEST_CASE("Separated infill centers the sparse infill of each body on itself", "[Fill][Regression]")
{
const std::string pattern = GENERATE("line", "zigzag", "crosszag", "honeycomb", "3dhoneycomb", "crosshatch", "tpmsd", "tpmsfk", "gyroid");
const bool separated = GENERATE(false, true);
CAPTURE(pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
// Orca: The Zig Zag patterns mirror each body about its own center.
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"symmetric_infill_y_axis", true},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
Print print;
const double unmatched = unmatched_between_bodies(slice_two_bodies(print, config, 2.), erInternalInfill);
// Orca: Without separation both bodies cut one object-wide pattern at different places.
if (separated)
CHECK(unmatched < 0.02);
else
CHECK(unmatched > 0.5);
}
TEST_CASE("Separated infill centers monotonic and rectilinear bridges on each body", "[Fill][InternalBridge][Regression]")
{
// Orca: Bridges use the Monotonic pattern below monotonic top surfaces and Rectilinear otherwise.
const std::string top_pattern = GENERATE("monotonicline", "rectilinear");
const bool separated = GENERATE(false, true);
CAPTURE(top_pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", "rectilinear"},
{"sparse_infill_density", "15%"},
{"top_surface_pattern", top_pattern},
{"top_shell_layers", 4},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
Print print;
const double unmatched = unmatched_between_bodies(slice_two_bodies(print, config, 4.), erInternalBridgeInfill);
if (separated)
CHECK(unmatched < 0.02);
else
CHECK(unmatched > 0.5);
}
// Orca: Share of the infill of an off center pillar, and of the frame of four overlapping bars around it,
// that each body sliced alone does not repeat. The frame is one body of several parts that holds the pillar.
static std::pair<double, double> frame_and_pillar_unmatched(const DynamicPrintConfig &config)
{
auto box = [](double x, double y, double size_x, double size_y) {
TriangleMesh mesh = make_cube(size_x, size_y, 6);
mesh.translate(x, y, 0);
return mesh;
};
const std::vector<TriangleMesh> frame{box(0, 0, 60, 14), box(0, 46, 60, 14), box(0, 0, 14, 60), box(46, 0, 14, 60)};
const std::vector<TriangleMesh> pillar{box(18, 20, 16, 16)};
std::vector<TriangleMesh> both = frame;
both.push_back(pillar.front());
Print print_both, print_frame, print_pillar;
slice_parts(print_both, config, both);
slice_parts(print_frame, config, frame);
slice_parts(print_pillar, config, pillar);
// Orca: Bed regions 3 mm inside the walls, away from the links along them.
auto rect = [](double x0, double y0, double x1, double y1) {
return Polygon({Point::new_scale(x0, y0), Point::new_scale(x1, y0), Point::new_scale(x1, y1), Point::new_scale(x0, y1)});
};
return {unmatched_between_prints(print_both, print_pillar, erInternalInfill, {rect(21, 23, 31, 33)}),
unmatched_between_prints(print_both, print_frame, erInternalInfill, diff(Polygons{rect(3, 3, 57, 57)}, Polygons{rect(11, 11, 49, 49)}))};
}
TEST_CASE("Separated infill fills each body like the body sliced alone", "[Fill][Regression]")
{
// Orca: Hilbert Curve and the Zig Zag links follow the extent of the box, not only its center.
const std::string pattern = GENERATE("hilbertcurve", "zigzag", "crosszag", "gyroid");
CAPTURE(pattern);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"symmetric_infill_y_axis", true},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", true}});
const std::pair<double, double> unmatched = frame_and_pillar_unmatched(config);
CHECK(unmatched.first < 0.02);
CHECK(unmatched.second < 0.02);
}
TEST_CASE("Adaptive infill fills each body like the body sliced alone", "[Fill][Regression]")
{
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
// Orca: Octree infill centers each body whether or not separated infills are enabled.
const bool separated = GENERATE(false, true);
CAPTURE(pattern, separated);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "40%"},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0},
{"separated_infills", separated}});
// Orca: The octree of the whole object is laid out from its center, which the off center pillar does not share.
const std::pair<double, double> unmatched = frame_and_pillar_unmatched(config);
CHECK(unmatched.first < 0.02);
CHECK(unmatched.second < 0.02);
}
TEST_CASE("Adaptive infill of a modifier leaves the density of the other regions", "[Fill][Regression]")
{
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
CAPTURE(pattern);
auto config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "15%"},
{"top_shell_layers", 0},
{"bottom_shell_layers", 0}});
TriangleMesh bodies = make_cube(30, 30, 6), second = make_cube(30, 30, 6);
second.translate(40, 0, 0);
bodies.merge(second);
// Orca: A denser modifier over the right half of the second body.
TriangleMesh modifier = make_cube(20, 40, 10);
modifier.translate(55, -5, -2);
DynamicPrintConfig dense = config;
dense.set_deserialize_strict({{"sparse_infill_density", "60%"}});
Print print, print_sparse, print_dense;
slice_parts(print, config, {bodies}, {{modifier, dense}});
slice_parts(print_sparse, config, {bodies});
slice_parts(print_dense, dense, {bodies});
// Orca: Bed regions 3 mm inside the walls and the modifier, away from the links along them.
auto rect = [](double x0, double y0, double x1, double y1) {
return Polygon({Point::new_scale(x0, y0), Point::new_scale(x1, y0), Point::new_scale(x1, y1), Point::new_scale(x0, y1)});
};
CHECK(unmatched_between_prints(print, print_sparse, erInternalInfill, {rect(3, 3, 27, 27), rect(43, 3, 52, 27)}) < 0.02);
CHECK(unmatched_between_prints(print, print_dense, erInternalInfill, {rect(58, 3, 67, 27)}) < 0.02);
}
// Length of the sparse infill of the first object over the layers with print_z in [z_min, z_max], inside clip.
static double sparse_infill_length(const Print &print, coordf_t z_min, coordf_t z_max, const Polygons &clip)
{
Polylines polylines;
for (const Layer *layer : print.objects().front()->layers())
if (layer->print_z >= z_min && layer->print_z <= z_max)
for (const LayerRegion *region : layer->regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(polylines);
return unscale<double>(total_length(intersection_pl(polylines, clip)));
}
static Polygons centered_square(double half)
{
return {Polygon({Point::new_scale(-half, -half), Point::new_scale(half, -half), Point::new_scale(half, half), Point::new_scale(-half, half)})};
}
TEST_CASE("Adaptive TPMS infill thins out from the surface to the interior density", "[Fill]")
{
// The modes following the distance print more lines than their target where its levels meet: along the shells, or
// where the patterns blend.
const auto row = GENERATE(table<std::string, std::string, double>({{"tpmsd", "lobes", 0.5},
{"tpmsfk", "lobes", 0.5},
{"gyroid", "lobes", 0.5},
{"gyroid", "stepped_shells", 0.6},
{"tpmsfk", "smooth_blend", 0.6},
{"tpmsd", "distance_warp", 0.5}}));
const std::string pattern = std::get<0>(row);
const std::string mode = std::get<1>(row);
const double max_core = std::get<2>(row);
CAPTURE(pattern, mode);
// A 60 mm cube in 0.4 mm layers, centered on the origin in XY. Its center is 30 mm from every face.
auto slice = [&pattern](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(60)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice(mode, adaptive);
// The middle of the cube, at most 10 mm from the center: at most 5% + 20% * 10 / 30 = 11.7% dense.
const Polygons core = centered_square(10.);
// Along the sides at mid height, 24 to 28 mm from the center: at least 5% + 20% * 24 / 30 = 21% dense.
const Polygons shell = diff(centered_square(28.), centered_square(24.));
const double core_uniform = sparse_infill_length(uniform, 25., 35., core);
const double shell_uniform = sparse_infill_length(uniform, 25., 35., shell);
REQUIRE(core_uniform > 0.);
REQUIRE(shell_uniform > 0.);
CHECK(sparse_infill_length(adaptive, 25., 35., core) < max_core * core_uniform);
CHECK(sparse_infill_length(adaptive, 25., 35., shell) > 0.75 * shell_uniform);
}
TEST_CASE("Adaptive TPMS infill of a tall object is sparsest at its middle height", "[Fill]")
{
// A 30 x 30 x 90 mm box in 0.4 mm layers: its center is 45 mm high, the middle of its core, not a column.
Print print;
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
const Polygons core = centered_square(5.);
const double middle = sparse_infill_length(print, 40., 50., core);
const double low = sparse_infill_length(print, 10., 20., core);
REQUIRE(low > 0.);
CHECK(middle < 0.7 * low);
}
TEST_CASE("2D adaptive TPMS infill does not change along its axis", "[Fill]")
{
// A box of 30 x 30 mm sections, 90 mm long along the axis, centered on the origin in XY.
const std::string mode = GENERATE("normal_x", "normal_z");
CAPTURE(mode);
const bool along_x = mode == "normal_x";
auto slice = [&](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({along_x ? make_cube(90., 30., 30.) : make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice(mode, adaptive);
// The core of the sections near an end and at the middle of the box, at most 5 mm from their center.
auto rectangle = [](double x0, double x1) {
return Polygons{Polygon({Point::new_scale(x0, -5.), Point::new_scale(x1, -5.), Point::new_scale(x1, 5.), Point::new_scale(x0, 5.)})};
};
auto core = [&](const Print &print, bool end) {
return along_x ? sparse_infill_length(print, 10., 20., rectangle(end ? -40. : -5., end ? -30. : 5.)) :
sparse_infill_length(print, end ? 10. : 40., end ? 20. : 50., centered_square(5.));
};
const double end = core(adaptive, true);
const double middle = core(adaptive, false);
REQUIRE(end > 0.);
CHECK(middle > 0.8 * end);
CHECK(middle < 1.25 * end);
CHECK(middle < 0.7 * core(uniform, false));
}
TEST_CASE("Adaptive TPMS shells and blend keep the whole deep core of a tall object sparse", "[Fill]")
{
// Distance warp grades it partly along the height, like Lobes, as its warp is centered on the middle.
const std::string mode = GENERATE("stepped_shells", "smooth_blend");
CAPTURE(mode);
// A 30 x 30 x 90 mm box: from 15 to 75 mm high its axis is 15 mm deep, as deep as its center.
auto slice = [](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({make_cube(30., 30., 90.)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, distance;
slice("disabled", uniform);
slice(mode, distance);
// At 15 to 25 mm high the axis is as sparse as at the middle, where Lobes grades it towards the bottom.
const Polygons core = centered_square(5.);
const double uniform_low = sparse_infill_length(uniform, 15., 25., core);
const double low = sparse_infill_length(distance, 15., 25., core);
const double middle = sparse_infill_length(distance, 40., 50., core);
CAPTURE(uniform_low, low, middle);
REQUIRE(middle > 0.);
CHECK(low < 0.7 * uniform_low);
CHECK(low < 1.25 * middle);
}
TEST_CASE("Adaptive TPMS infill is sparse at the center of both of two united spheres", "[Fill]")
{
// Two spheres of 20 mm, their centers 32 mm apart at 20 mm high: centered on the origin, they are at x = -16 and 16.
TriangleMesh spheres = make_sphere(20., 2. * PI / 90.);
spheres.translate(-16.f, 0.f, 20.f);
TriangleMesh second = make_sphere(20., 2. * PI / 90.);
second.translate(16.f, 0.f, 20.f);
spheres.merge(second);
auto slice = [&spheres](const std::string &adaptive, Print &print) {
Slic3r::Test::init_and_process_print({TriangleMesh(spheres)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", "linear"},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
};
Print uniform, adaptive;
slice("disabled", uniform);
slice("lobes", adaptive);
// Within 5 mm of either center: at most 5% + 20% * 7.1 / 20 = 12.1% dense, where one center for both
// would leave the other sphere at more than 60% of the uniform density.
for (const double x : {-16., 16.}) {
CAPTURE(x);
Polygons core = centered_square(5.);
for (Polygon &square : core)
square.translate(Point::new_scale(x, 0.));
const double core_uniform = sparse_infill_length(uniform, 18., 22., core);
REQUIRE(core_uniform > 0.);
CHECK(sparse_infill_length(adaptive, 18., 22., core) < 0.45 * core_uniform);
}
}
TEST_CASE("Adaptive TPMS gradients keep the surface density deeper in the order quadratic, linear, exponential", "[Fill]")
{
// With a denser surface, t^2 <= t and the geometric interpolation is below the linear one at every depth.
auto length_for = [](const std::string &gradient) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(40)}, print,
{{"sparse_infill_pattern", "tpmsd"},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"tpms_adaptive_gradient", gradient},
{"layer_height", 0.4},
{"initial_layer_print_height", 0.4}});
return sparse_infill_length(print, 0., 40., centered_square(20.));
};
const double quadratic = length_for("quadratic");
const double linear = length_for("linear");
const double exponential = length_for("exponential");
CHECK(quadratic > linear);
CHECK(linear > exponential);
}
TEST_CASE("Adaptive TPMS settings leave the infill unchanged when they do not apply", "[Fill]")
{
// Adaptive density turned off, or turned on for a pattern that is no TPMS.
const auto [pattern, adaptive] = GENERATE(
table<std::string, std::string>({{"tpmsd", "disabled"}, {"tpmsfk", "disabled"}, {"gyroid", "disabled"}, {"grid", "lobes"}, {"grid", "stepped_shells"}, {"grid", "smooth_blend"}, {"grid", "distance_warp"}, {"grid", "normal_z"}}));
CAPTURE(pattern, adaptive);
Print reference, tuned;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, reference,
{{"sparse_infill_pattern", pattern}, {"sparse_infill_density", "20%"}, {"layer_height", 0.2}});
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, tuned,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "20%"},
{"layer_height", 0.2},
{"tpms_adaptive", adaptive},
{"tpms_interior_density", "40%"},
{"tpms_adaptive_gradient", "exponential"}});
const SparseInfillShape expected = sparse_infill_shape(reference);
REQUIRE(expected.path_count > 0);
CHECK(sparse_infill_shape(tuned).sequence == expected.sequence);
}
TEST_CASE("Adaptive gyroid infill ignores the Z-buckling optimization", "[Fill]")
{
auto shape_for = [](const std::string &gyroid_optimized) {
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print,
{{"sparse_infill_pattern", "gyroid"},
{"sparse_infill_density", "10%"},
{"gyroid_optimized", gyroid_optimized},
{"tpms_adaptive", "lobes"},
{"layer_height", 0.2}});
return sparse_infill_shape(print);
};
const SparseInfillShape expected = shape_for("0");
REQUIRE(expected.path_count > 0);
CHECK(shape_for("1").sequence == expected.sequence);
}
TEST_CASE("Adaptive TPMS infill of a region matches the infill of a larger region of the same object", "[Fill]")
{
const InfillPattern pattern = GENERATE(ipGyroid, ipTpmsD, ipTpmsFK);
CAPTURE(pattern);
// An 80 x 80 x 40 mm box around both regions, which share its radial field.
const ExPolygons box{ExPolygon(Points{Point::new_scale(60., 20.), Point::new_scale(140., 20.), Point::new_scale(140., 100.),
Point::new_scale(60., 100.)})};
std::vector<TpmsRadialField::Slice> slices;
for (int i = 0; i < 200; ++i)
slices.push_back({0.2 * i, 0.2 * (i + 1), &box});
const TpmsRadialField field(slices, get_extents(box), TpmsAdaptiveMode::Lobes, [] {});
auto circle = [](double radius) {
Polygon contour = make_circle_num_segments(scale_(radius), 120);
contour.translate(Point::new_scale(100., 60.));
return ExPolygon(std::move(contour));
};
const ExPolygon region = circle(20.);
const ExPolygon larger = circle(30.);
auto fill = [pattern, &field](const ExPolygon &expolygon, double z) {
std::unique_ptr<Fill> filler(Fill::new_from_type(pattern));
filler->spacing = 0.45;
filler->angle = float(M_PI / 7.);
filler->z = z;
filler->tpms_radial_field = &field;
FillParams params;
params.density = 0.2f;
params.tpms_adaptive = TpmsAdaptiveMode::Lobes;
params.tpms_interior_density = 0.05f;
params.tpms_adaptive_gradient = TpmsAdaptiveGradient::Linear;
params.layer_height = 0.2;
params.dont_adjust = true;
Surface surface(stInternal, expolygon);
return filler->fill_surface(&surface, params);
};
// Away from the boundary of the region, where both are clipped and connected the same way.
const Polygons inner = shrink(to_polygons(region), scale_(1.));
auto farthest = [&inner](const Polylines &from, const Polylines &to) {
const AABBTreeLines::LinesDistancer<Line> tree(to_lines(to));
double distance = 0.;
for (const Polyline &path : intersection_pl(from, inner))
for (const Point &point : path.equally_spaced_points(scale_(0.2)))
distance = std::max(distance, tree.distance_from_lines<false>(point));
return unscale<double>(distance);
};
// Marching squares simplifies rings that start elsewhere in each region. At 15.325 mm TPMS-FK has a saddle,
// whose lines connect one way or the other with the sampling grid.
const double tolerance = SPARSE_INFILL_RESOLUTION + 0.01;
for (const double z : {5.1, 12.3, 15.325, 20.1, 27.9, 34.7}) {
CAPTURE(z);
const Polylines paths = fill(region, z);
REQUIRE_FALSE(paths.empty());
const Polylines reference = fill(larger, z);
CHECK(farthest(reference, paths) < tolerance);
CHECK(farthest(paths, reference) < tolerance);
}
}
TEST_CASE("Adaptive TPMS anchors match the printed infill", "[Fill][InternalBridge]")
{
const std::string pattern = GENERATE("tpmsd", "tpmsfk", "gyroid");
CAPTURE(pattern);
Print print;
Slic3r::Test::init_and_process_print({Slic3r::Test::cube(30)}, print,
{{"sparse_infill_pattern", pattern},
{"sparse_infill_density", "25%"},
{"tpms_adaptive", "lobes"},
{"tpms_interior_density", "5%"},
{"layer_height", 0.2},
{"initial_layer_print_height", 0.2},
{"resolution", 0.012}});
const Layer &layer = *print.objects().front()->get_layer(40);
Polylines printed;
for (const LayerRegion *region : layer.regions())
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
if (entity->role() == erInternalInfill)
entity->collect_polylines(printed);
REQUIRE_FALSE(printed.empty());
const AABBTreeLines::LinesDistancer<Line> printed_tree(to_lines(printed));
// Exclude the perimeter connections, which anchoring and extrusion trim differently.
const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr),
shrink(to_polygons(layer.lslices), scale_(3.)));
REQUIRE_FALSE(anchors.empty());
double max_distance = 0.;
for (const Polyline &path : anchors)
for (const Point &point : path.equally_spaced_points(scale_(0.25)))
max_distance = std::max(max_distance, printed_tree.distance_from_lines<false>(point));
CHECK(unscale<double>(max_distance) <= 0.012);
}
+95
View File
@@ -0,0 +1,95 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <fstream>
#include <string>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
namespace {
float processed_belt_tilt(const std::string &gcode)
{
ScopedTemporaryFile temp(".gcode");
{
std::ofstream os(temp.string());
os << gcode;
}
GCodeProcessor proc;
proc.apply_config(FullPrintConfig{});
proc.process_file(temp.string());
return proc.get_result().belt_tilt_angle;
}
constexpr const char *body = "G1 X10 Y10 Z0.2 F3000\nG1 X20 Y10 E1 F1200\n";
} // namespace
TEST_CASE("The config block's belt angle does not mark G-code as belt G-code", "[GCodeProcessor][belt]")
{
// Every printer's config block lists belt_slice_rotation_angle (default 45), belt or not.
const std::string gcode = std::string("; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n") + body;
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(0., 1e-6));
}
TEST_CASE("The belt header's angle marks G-code as belt G-code", "[GCodeProcessor][belt]")
{
const std::string gcode = std::string("; belt_slice_rotation_angle = -45.0\n") + body +
"; CONFIG_BLOCK_START\n; belt_printer = 1\n; belt_slice_rotation_angle = -45\n; CONFIG_BLOCK_END\n";
CHECK_THAT(processed_belt_tilt(gcode), WithinAbs(45., 1e-6));
}
TEST_CASE("Non-belt start G-code moves keep the first-layer Z in the processor", "[GCodeProcessor]")
{
// The belt path (GCodeWriter tests: "start-gcode prepare-stage moves keep their real Z")
// stores the real Z of a move inside the start G-code. Every other printer must keep
// the historical behaviour: a prepare-stage move is pinned to the first-layer height
// so the preview does not draw the start sequence's travel. The gate is the belt
// header, so a file without one, whatever its config block says, takes this path.
struct BBLPrinterGuard {
bool prev = GCodeProcessor::s_IsBBLPrinter;
BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; }
~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; }
} bbl_guard;
const std::string gcode =
"G90\n"
"G21\n"
"M83\n"
";TYPE:Custom\n"
"G1 E-1.5 F2100\n"
"G1 X45 Y0.3 Z50 F12000\n" // prepare-stage travel to a high Z
"G1 E1.5 F1800\n"
";TYPE:Outer wall\n"
"G1 X46 Y0.3 Z50 E0.05\n"
"; CONFIG_BLOCK_START\n; belt_printer = 0\n; belt_slice_rotation_angle = 45\n; CONFIG_BLOCK_END\n";
GCodeProcessor processor;
FullPrintConfig config;
config.initial_layer_print_height.value = 0.3;
processor.apply_config(config);
processor.process_buffer(gcode);
const GCodeProcessorResult &result = processor.get_result();
REQUIRE_THAT(result.belt_tilt_angle, WithinAbs(0., 1e-6));
size_t first_extrude = result.moves.size();
for (size_t i = 0; i < result.moves.size(); ++i)
if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; }
REQUIRE(first_extrude < result.moves.size());
REQUIRE(first_extrude > 0);
// The extrusion keeps its real Z; the prepare-stage move before it is pinned to the
// first-layer height.
CHECK_THAT(result.moves[first_extrude].position.z(), WithinAbs(50., 1e-3));
CHECK_THAT(result.moves[first_extrude - 1].position.z(), WithinAbs(0.3, 1e-3));
}
+293 -12
View File
@@ -6,9 +6,12 @@
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
@@ -17,6 +20,7 @@
#include <algorithm>
#include <cstddef>
#include <fstream>
#include <initializer_list>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Point.hpp"
#include <sstream>
@@ -98,6 +102,22 @@ TEST_CASE("Reserved keyword detection reports every offending line", "[GCodeProc
namespace {
void process_gcode(const std::string &gcode, GCodeProcessorResult &result)
{
FullPrintConfig config;
config.gcode_flavor.value = gcfMarlinFirmware;
// s_IsBBLPrinter selects the "; FEATURE: " role tags the G-code uses.
const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; });
GCodeProcessor::s_IsBBLPrinter = true;
ScopedTemporaryFile temp(".gcode");
std::ofstream(temp.string()) << gcode;
GCodeProcessor processor;
processor.apply_config(config);
processor.process_file(temp.string());
result = std::move(processor.extract_result());
}
// Closed outer-wall squares, each after a fast travel and before an inner-wall move, so the processor
// records seams and inserts actual speed moves. virtual_moves adds a VG1 move after each square.
void process_squares(int squares, GCodeProcessorResult &result, bool virtual_moves = false)
@@ -113,18 +133,42 @@ void process_squares(int squares, GCodeProcessorResult &result, bool virtual_mov
if (virtual_moves)
gcode << "VG1 X20 Y30 F12000\n";
}
FullPrintConfig config;
config.gcode_flavor.value = gcfMarlinFirmware;
// s_IsBBLPrinter selects the "; FEATURE: " role tags this G-code uses.
const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; });
GCodeProcessor::s_IsBBLPrinter = true;
ScopedTemporaryFile temp(".gcode");
std::ofstream(temp.string()) << gcode.str();
GCodeProcessor processor;
processor.apply_config(config);
processor.process_file(temp.string());
result = std::move(processor.extract_result());
process_gcode(gcode.str(), result);
}
// Objects A and B on the first layer and A again on the second, with A's brim and support. The skirt and the
// prime tower belong to neither.
void process_two_objects(GCodeProcessorResult &result)
{
std::ostringstream gcode;
gcode << "M83\nG90\n"
<< "; CHANGE_LAYER\n; LAYER_HEIGHT: 0.2\nG1 Z0.2 F12000\n"
<< "; FEATURE: Skirt\nG1 X0 Y100 E5 F3000\n"
<< "; FEATURE: Brim\nG1 X8 Y8 F12000\nG1 X12 Y8 E1 F3000\n"
<< "; FEATURE: Support\nG1 X10 Y20 F12000\nG1 X10 Y30 E1 F3000\n"
<< "; FEATURE: Outer wall\nG1 X10 Y10 F12000\nG1 X20 Y10 E1 F3000\n"
<< "; FEATURE: Outer wall\nG1 X50 Y50 F12000\nG1 X60 Y50 E2 F3000\n"
<< "; FEATURE: Prime tower\nG1 X80 Y80 F12000\nG1 X90 Y80 E1 F3000\n"
<< "; CHANGE_LAYER\n; LAYER_HEIGHT: 0.2\nG1 Z0.4 F12000\n"
<< "; FEATURE: Outer wall\nG1 X10 Y10 F12000\nG1 X20 Y10 E1 F3000\n";
process_gcode(gcode.str(), result);
}
// Bead centers of process_two_objects(), half the 0.2 mm layer below the nozzle.
const Vec3d a_brim(10., 8., 0.1), a_support(10., 25., 0.1), a_wall_0(15., 10., 0.1), a_wall_1(15., 10., 0.3), b_wall(55., 50., 0.1);
Vec3d center_of(const GCodeProcessorResult::ObjectMass::Sum &sum) { return sum.moment / sum.mass; }
// One filament, so each bead weighs as much as the E it was extruded with.
Vec3d weighted_center(std::initializer_list<std::pair<double, Vec3d>> beads)
{
double mass = 0.;
Vec3d moment = Vec3d::Zero();
for (const auto &[e, center] : beads) {
mass += e;
moment += e * center;
}
return moment / mass;
}
bool is_block_move(const GCodeProcessorResult::MoveVertex &move)
@@ -221,3 +265,240 @@ TEST_CASE("Line ends of the exported G-code mark every newline in the file", "[G
INFO("first difference at line " << difference.first - result.lines_ends.begin() + 1);
CHECK(difference.first == result.lines_ends.end());
}
TEST_CASE("Reloaded moves name their lines in G-code a script rewrote in place", "[GCodeProcessor]")
{
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model);
GCodeProcessorResult result;
const std::string gcode = Test::gcode(print, &result);
const auto exported_moves = result.moves;
// A script that prepends one comment and, writing in text mode on Windows, turns every LF into CRLF.
const std::string prepended = ";EDITED\r\n";
std::string edited = prepended;
for (const char c : gcode) {
if (c == '\n')
edited += '\r';
edited += c;
}
ScopedTemporaryFile temp(".gcode");
save_string_file(temp.path(), edited);
result.filename = temp.string();
print.reload_gcode_moves(&result);
std::vector<size_t> newline_ends;
for (size_t i = edited.find('\n'); i != std::string::npos; i = edited.find('\n', i + 1))
newline_ends.push_back(i + 1);
CHECK(result.lines_ends == newline_ends);
// Every move that came from a line now names the same line one further down.
REQUIRE(result.moves.size() == exported_moves.size());
const auto difference = std::mismatch(exported_moves.begin(), exported_moves.end(), result.moves.begin(),
[](const auto &exported, const auto &reloaded) {
return reloaded.gcode_id == (exported.gcode_id == 0 ? 0 : exported.gcode_id + 1);
});
INFO("first difference at move " << difference.first - exported_moves.begin());
CHECK(difference.first == exported_moves.end());
}
TEST_CASE("Rewritten G-code that cannot be re-read keeps the moves and hides the G-code window", "[GCodeProcessor]")
{
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model);
GCodeProcessorResult result;
const std::string gcode = Test::gcode(print, &result);
const auto exported_moves = result.moves;
// A script that strips the trailing config block, which the G-code reader needs.
const size_t config_block = gcode.find("; CONFIG_BLOCK_START");
REQUIRE(config_block != std::string::npos);
ScopedTemporaryFile temp(".gcode");
save_string_file(temp.path(), gcode.substr(0, config_block));
result.filename = temp.string();
print.reload_gcode_moves(&result);
CHECK(result.lines_ends.empty());
REQUIRE(result.moves.size() == exported_moves.size());
CHECK(result.moves.back().gcode_id == exported_moves.back().gcode_id);
}
TEST_CASE("The plate's center of mass takes every extrusion of G-code without a print behind it", "[GCodeProcessor]")
{
GCodeProcessorResult result;
process_two_objects(result);
CHECK(result.object_masses.empty());
CHECK(result.body_masses.empty());
const GCodeProcessorResult::ObjectMass &plate = result.plate_mass;
REQUIRE(plate.printed_up_to_layer.size() == 2);
CHECK_THAT((center_of(plate.printed_up_to_layer.front()) -
weighted_center({ { 1., a_brim }, { 1., a_support }, { 1., a_wall_0 }, { 2., b_wall } })).norm(),
Catch::Matchers::WithinAbs(0., 1e-5));
CHECK_THAT((center_of(plate.printed_up_to_layer.back()) -
weighted_center({ { 1., a_brim }, { 1., a_support }, { 1., a_wall_0 }, { 2., b_wall }, { 1., a_wall_1 } })).norm(),
Catch::Matchers::WithinAbs(0., 1e-5));
// Each bead weighs its volume at the default density and spreads along its move, (a^2 + ab + b^2) / 3 for one from
// a to b: the brim from x 8 to 12 at y 8, the support at x 10 from y 20 to 30, A's walls from x 10 to 20 at y 10 and
// B's from x 50 to 60 at y 50 with twice the filament, all at z 0.1 but A's second wall at 0.3.
const GCodeProcessorResult::ObjectMass::Sum total = plate.total();
CHECK_THAT(total.mass / total.volume, Catch::Matchers::WithinRel(double(DEFAULT_FILAMENT_DENSITY), 1e-6));
const Vec3d second = total.second / total.mass;
CHECK_THAT(second.x(), Catch::Matchers::WithinRel((304. / 3. + 100. + 2. * 700. / 3. + 2. * 9100. / 3.) / 6., 1e-6));
CHECK_THAT(second.y(), Catch::Matchers::WithinRel((64. + 1900. / 3. + 2. * 100. + 2. * 2500.) / 6., 1e-6));
CHECK_THAT(second.z(), Catch::Matchers::WithinRel((5. * 0.01 + 0.09) / 6., 1e-5));
// The beads' center lines, brim and support included, from the first layer's bottom to the second's top.
CHECK_THAT((plate.box.min - Vec3d(8., 8., 0.)).norm(), Catch::Matchers::WithinAbs(0., 1e-5));
CHECK_THAT((plate.box.max - Vec3d(60., 50., 0.4)).norm(), Catch::Matchers::WithinAbs(0., 1e-5));
}
TEST_CASE("Each sliced cube's center of mass is its center, and the brim lowers the plate's printed one", "[GCodeProcessor]")
{
const bool copies = GENERATE(false, true);
INFO((copies ? "two copies of one cube" : "two cubes"));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "outer_only" }, { "brim_width", 5 }, { "combine_brims", 0 } });
std::vector<TriangleMesh> cubes{ Test::cube(20) };
if (!copies)
cubes.emplace_back(Test::cube(20));
Print print;
Model model;
Test::init_print(std::move(cubes), print, model, config, nullptr, true, copies ? 2 : 1);
GCodeProcessorResult result;
Test::gcode(print, &result);
CHECK(result.body_masses.empty());
REQUIRE(result.object_masses.size() == 2);
for (const ModelObject *object : model.objects)
for (size_t instance = 0; instance < object->instances.size(); ++instance) {
const Vec3d center = object->instance_bounding_box(instance).center();
const auto mass = std::min_element(result.object_masses.begin(), result.object_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
});
// Off the center only by the infill's alignment and the top and bottom shells.
const Vec3d part = center_of(mass->total());
CHECK_THAT(part.x(), Catch::Matchers::WithinAbs(center.x(), 0.5));
CHECK_THAT(part.y(), Catch::Matchers::WithinAbs(center.y(), 0.5));
CHECK_THAT(part.z(), Catch::Matchers::WithinAbs(center.z(), 1.));
// The outer walls' center lines run half a line inside the cube's sides, of copies touching each other too.
const BoundingBoxf3 box = object->instance_bounding_box(instance);
for (int axis = 0; axis < 3; ++axis) {
CHECK_THAT(mass->box.min[axis], Catch::Matchers::WithinAbs(box.min[axis], 0.3));
CHECK_THAT(mass->box.max[axis], Catch::Matchers::WithinAbs(box.max[axis], 0.3));
}
}
GCodeProcessorResult::ObjectMass::Sum objects;
for (const GCodeProcessorResult::ObjectMass &object : result.object_masses)
objects.add(object.total());
const GCodeProcessorResult::ObjectMass::Sum plate = result.plate_mass.total();
CHECK(plate.mass > objects.mass);
CHECK(center_of(plate).z() < center_of(objects).z());
}
TEST_CASE("Each cube's raft is its support, centered below it", "[GCodeProcessor]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "raft_layers", 3 } });
Print print;
Model model;
Test::init_print({ Test::cube(20), Test::cube(20) }, print, model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.support_masses.size() == 2);
for (size_t i = 0; i < 2; ++i) {
const GCodeProcessorResult::ObjectMass::Sum support = result.support_masses[i].total();
const Vec3d object = center_of(result.object_masses[i].total());
REQUIRE(support.mass > 0.);
CHECK_THAT(center_of(support).x(), Catch::Matchers::WithinAbs(object.x(), 1.));
CHECK_THAT(center_of(support).y(), Catch::Matchers::WithinAbs(object.y(), 1.));
CHECK(center_of(support).z() < 1.);
}
}
TEST_CASE("A spiral vase cube counts all its extrusions, rising through each layer", "[GCodeProcessor]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "spiral_mode", 1 }, { "wall_loops", 1 },
{ "top_shell_layers", 0 }, { "sparse_infill_density", 0 } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 1);
CHECK_THAT(result.object_masses.front().total().mass, Catch::Matchers::WithinRel(result.plate_mass.total().mass, 1e-6));
}
TEST_CASE("Each separate part of an assembly gets its center of mass, overlapping parts one", "[GCodeProcessor]")
{
const bool overlapping = GENERATE(false, true);
// Separated infills finds the bodies first, which the G-code export then takes.
const bool separated = GENERATE(false, true);
INFO((overlapping ? "overlapping parts" : "separate parts") << (separated ? ", separated infills" : ""));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" }, { "separated_infills", separated ? 1 : 0 } });
TriangleMesh first = make_cube(20, 20, 20);
TriangleMesh second = make_cube(20, 20, 20);
first.translate(50, 50, 0);
second.translate(overlapping ? 60 : 90, 50, 0);
Print print;
Model model;
Test::init_print({ first }, print, model, config, nullptr, false);
model.objects.front()->add_volume(std::move(second), ModelVolumeType::MODEL_PART, false);
print.apply(model, config);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 1);
CHECK(result.object_masses.front().assembly);
// One body is the object itself.
if (overlapping) {
CHECK(result.body_masses.empty());
return;
}
REQUIRE(result.body_masses.size() == 2);
CHECK(print.objects().front()->separated_body_bboxes().size() == (separated ? 2 : 0));
const ModelObject &object = *model.objects.front();
for (const ModelVolume *volume : object.volumes) {
const Vec3d center = volume->mesh().transformed_bounding_box(object.instances.front()->get_matrix() * volume->get_matrix()).center();
const auto body = std::min_element(result.body_masses.begin(), result.body_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
});
const Vec3d part = center_of(body->total());
CHECK_THAT(part.x(), Catch::Matchers::WithinAbs(center.x(), 0.5));
CHECK_THAT(part.y(), Catch::Matchers::WithinAbs(center.y(), 0.5));
CHECK_THAT(part.z(), Catch::Matchers::WithinAbs(center.z(), 1.));
}
}
TEST_CASE("Each extrusion weighs its filament's density", "[GCodeProcessor]")
{
// Two like cubes, the second's filament three times as dense.
DynamicPrintConfig config = Test::multifilament_config(2, { { "filament_density", "1,3" }, { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", 1 } }, { { "extruder", 2 } } };
Print print;
Model model;
Test::init_print({ Test::cube(20), Test::cube(20) }, print, model, config, &overrides);
GCodeProcessorResult result;
Test::gcode(print, &result);
REQUIRE(result.object_masses.size() == 2);
std::vector<const GCodeProcessorResult::ObjectMass *> masses;
for (const ModelObject *object : model.objects) {
const Vec3d center = object->instance_bounding_box(0).center();
masses.emplace_back(&*std::min_element(result.object_masses.begin(), result.object_masses.end(), [&center](const auto &l, const auto &r) {
return (center_of(l.total()) - center).squaredNorm() < (center_of(r.total()) - center).squaredNorm();
}));
}
CHECK_THAT(masses[1]->total().mass / masses[0]->total().mass, Catch::Matchers::WithinRel(3., 0.02));
// The plate's center lies three quarters of the way to the dense cube.
const Vec3d plate = center_of(result.plate_mass.total());
const Vec3d light = center_of(masses[0]->total());
const Vec3d dense = center_of(masses[1]->total());
CHECK_THAT((plate - light).dot(dense - light) / (dense - light).squaredNorm(), Catch::Matchers::WithinAbs(0.75, 0.01));
}
+740
View File
@@ -8,6 +8,7 @@
#include "libslic3r/Point.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Extruder.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/libslic3r.h"
#include <map>
#include <memory>
@@ -36,11 +37,52 @@
#include <boost/filesystem.hpp>
#include "test_helpers.hpp"
#include <cmath>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include <algorithm>
#include <limits>
#include "libslic3r/GCode/BeltKinematics.hpp"
#include "libslic3r/BeltTransform.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Arrange.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
TEST_CASE("Belt machine coordinates retain a non-45-degree slicing angle", "[GCodeWriter][belt]")
{
PrintConfig config;
config.belt_printer.value = true;
config.belt_slice_rotation.value = BeltRotationAxis::X;
config.belt_slice_rotation_angle.value = 30.;
config.gcode_remap_x.value = RemapAxis::PosX;
config.gcode_remap_y.value = RemapAxis::PosZ;
config.gcode_remap_z.value = RemapAxis::PosY;
GCodeWriter writer;
install_belt_kinematics(writer, config);
writer.set_axis_remap(int(config.gcode_remap_x.value),
int(config.gcode_remap_y.value),
int(config.gcode_remap_z.value));
// Start with a point in the unrotated model frame, then feed the writer the
// same rotated coordinate produced by the pre-slice mesh transform. The
// back-transform must recover the model point before the axis swap and
// machine-frame shear/scale are applied.
const Vec3d model(4., 10., 3.);
Transform3d forward = BeltTransformPipeline::build_forward_transform(config);
const Vec3d machine = writer.kinematics().to_machine(forward * model);
// The conventional X-tilt remap produces (x, z, y). At 30 degrees the
// gantry coordinate is z/sin(30) and belt travel is y + z*cot(30).
// The complementary tan/inv-cos formulas accidentally used by the unified
// transform are indistinguishable at 45 degrees, but fail this case.
REQUIRE_THAT(machine.x(), Catch::Matchers::WithinAbs(4., 1e-9));
REQUIRE_THAT(machine.y(), Catch::Matchers::WithinAbs(3. / std::sin(Geometry::deg2rad(30.)), 1e-9));
REQUIRE_THAT(machine.z(), Catch::Matchers::WithinAbs(10. + 3. / std::tan(Geometry::deg2rad(30.)), 1e-9));
}
// Arrange on a 500x500 bed, which keeps coordinates small while still covering large printers.
static void arrange_objects_on_test_bed(Model &model, const DynamicPrintConfig &config)
{
@@ -939,6 +981,270 @@ SCENARIO("Shipped dual-nozzle change_filament_gcode resolves during a real slice
}
}
SCENARIO("set_pressure_advance emits nothing for negative PA", "[GCodeWriter][PressureAdvance]") {
GIVEN("A default GCodeWriter") {
GCodeWriter writer;
THEN("Negative PA returns empty regardless of firmware flavor") {
writer.config.gcode_flavor.value = gcfKlipper;
REQUIRE(writer.set_pressure_advance(-1.0).empty());
writer.config.gcode_flavor.value = gcfRepRapFirmware;
REQUIRE(writer.set_pressure_advance(-0.001).empty());
writer.config.gcode_flavor.value = gcfMarlinFirmware;
REQUIRE(writer.set_pressure_advance(-100.0).empty());
}
}
}
SCENARIO("set_pressure_advance emits Klipper form with optional EXTRUDER=extruder<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("A Klipper-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfKlipper;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output contains SET_PRESSURE_ADVANCE ADVANCE=0.05 with no EXTRUDER qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("SET_PRESSURE_ADVANCE"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("ADVANCE=0.05; Override"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER="));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.04, 0);
THEN("Output targets EXTRUDER=extruder (no trailing index)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("EXTRUDER=extruder;"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER=extruder0"));
}
}
WHEN("set_pressure_advance is called with tool=3") {
std::string out = writer.set_pressure_advance(0.06, 3);
THEN("Output targets EXTRUDER=extruder3") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("EXTRUDER=extruder3"));
}
}
}
}
SCENARIO("set_pressure_advance emits RepRapFirmware form with D<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("An RRF-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfRepRapFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.07);
THEN("Output keeps the historical D0 rather than depending on the selected tool") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D0 S0.07"));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.08, 0);
THEN("Output contains D0, same as the tool-less form") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D0 S0.08"));
}
}
WHEN("set_pressure_advance is called with tool=2") {
std::string out = writer.set_pressure_advance(0.09, 2);
THEN("Output contains D2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M572 D2 S0.09"));
}
}
}
}
SCENARIO("set_pressure_advance emits Marlin 2.x form with optional T<N>", "[GCodeWriter][PressureAdvance]") {
GIVEN("A Marlin 2-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.10);
THEN("Output is bare M900 K... with no T qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.1; Override"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
WHEN("set_pressure_advance is called with tool=0") {
std::string out = writer.set_pressure_advance(0.11, 0);
THEN("Output contains T0") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.11 T0"));
}
}
WHEN("set_pressure_advance is called with tool=1") {
std::string out = writer.set_pressure_advance(0.12, 1);
THEN("Output contains T1") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.12 T1"));
}
}
}
}
SCENARIO("set_pressure_advance emits Marlin Legacy form without tool qualifier even when tool index is supplied",
"[GCodeWriter][PressureAdvance]") {
GIVEN("A Marlin Legacy-flavored GCodeWriter") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinLegacy;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output is bare M900 K...") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
WHEN("set_pressure_advance is called with tool=2 (a hypothetical IMEX secondary)") {
std::string out = writer.set_pressure_advance(0.06, 2);
THEN("Output is still bare M900 — Marlin Legacy has no per-tool LA") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.06"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
}
}
}
}
SCENARIO("set_temperature per-flavor command routing", "[GCodeWriter][Temperature]") {
GIVEN("temperature=210, no tool index, no wait") {
WHEN("flavor is Marlin 2") {
std::string out = GCodeWriter::set_temperature(210, gcfMarlinFirmware, false, -1, std::string());
THEN("output is M104 S210 with no tool qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M109"));
}
}
WHEN("flavor is RepRapFirmware") {
std::string out = GCodeWriter::set_temperature(210, gcfRepRapFirmware, false, -1, std::string());
THEN("output is G10 S210 (M104 is deprecated on RRF)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M104"));
}
}
WHEN("flavor is Mach3 or Machinekit") {
std::string mach3 = GCodeWriter::set_temperature(210, gcfMach3, false, -1, std::string());
std::string machinekit = GCodeWriter::set_temperature(210, gcfMachinekit, false, -1, std::string());
THEN("output uses P-prefix for the value instead of S") {
REQUIRE_THAT(mach3, Catch::Matchers::ContainsSubstring("M104 P210"));
REQUIRE_THAT(machinekit, Catch::Matchers::ContainsSubstring("M104 P210"));
REQUIRE_THAT(mach3, !Catch::Matchers::ContainsSubstring("S210"));
}
}
}
}
SCENARIO("set_temperature wait=true handling per firmware", "[GCodeWriter][Temperature]") {
WHEN("flavor is Marlin 2 with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfMarlinFirmware, true, -1, std::string());
THEN("output is M109 S210 (blocking wait)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M109 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M104"));
}
}
WHEN("flavor is MakerWare or Sailfish with wait") {
std::string mw = GCodeWriter::set_temperature(210, gcfMakerWare, true, -1, std::string());
std::string sf = GCodeWriter::set_temperature(210, gcfSailfish, true, -1, std::string());
THEN("output is empty — these flavors don't support blocking waits") {
REQUIRE(mw.empty());
REQUIRE(sf.empty());
}
}
WHEN("flavor is Teacup with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfTeacup, true, -1, std::string());
THEN("output emits M104 + a separate M116 poll (Teacup doesn't support M109)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M116"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("M109"));
}
}
WHEN("flavor is RepRapFirmware with wait") {
std::string out = GCodeWriter::set_temperature(210, gcfRepRapFirmware, true, -1, std::string());
THEN("output emits G10 + M116 (same poll pattern as Teacup)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S210"));
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M116"));
}
}
}
SCENARIO("set_temperature per-tool qualifier routing for IMEX secondary carriages",
"[GCodeWriter][Temperature]") {
// IMEX secondary tools never go through a tool-change, so layer-change temperature
// for them is emitted via the tool-qualified static set_temperature overload.
GIVEN("temperature=220, tool=2, no wait") {
WHEN("flavor is Marlin 2") {
std::string out = GCodeWriter::set_temperature(220, gcfMarlinFirmware, false, 2, std::string());
THEN("output contains T2 qualifier") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S220 T2"));
}
}
WHEN("flavor is RepRapFirmware") {
std::string out = GCodeWriter::set_temperature(220, gcfRepRapFirmware, false, 2, std::string());
THEN("output uses P-prefix for tool (RRF convention), not T") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("G10 S220 P2"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
}
}
WHEN("flavor is Klipper") {
std::string out = GCodeWriter::set_temperature(220, gcfKlipper, false, 1, std::string());
THEN("output contains T1 qualifier (Klipper layer-change temperature uses T)") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S220 T1"));
}
}
}
}
SCENARIO("set_temperature instance overload forces tool=-1 on single-extruder writers",
"[GCodeWriter][Temperature]") {
// Guards against spuriously emitting `T0` on printers that only have one extruder.
// The instance overload discards the tool argument when !multiple_extruders.
GIVEN("A default GCodeWriter (multiple_extruders=false)") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_temperature is called with tool=2") {
std::string out = writer.set_temperature(210, false, 2);
THEN("output has no T qualifier despite the caller passing tool=2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T"));
}
}
}
GIVEN("A GCodeWriter with multiple_extruders=true (not SEMM)") {
GCodeWriter writer;
writer.config.gcode_flavor.value = gcfMarlinFirmware;
writer.multiple_extruders = true;
WHEN("set_temperature is called with tool=2") {
std::string out = writer.set_temperature(210, false, 2);
THEN("tool argument passes through — output contains T2") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M104 S210 T2"));
}
}
}
}
SCENARIO("set_pressure_advance emits BBL M900 L1000 M10 regardless of tool index",
"[GCodeWriter][PressureAdvance]") {
GIVEN("A BBL-flagged GCodeWriter (the flag overrides firmware flavor routing)") {
GCodeWriter writer;
writer.set_is_bbl_machine(true);
// Flavor intentionally set to something other than the BBL branch to prove the flag wins.
writer.config.gcode_flavor.value = gcfMarlinFirmware;
WHEN("set_pressure_advance is called without a tool index") {
std::string out = writer.set_pressure_advance(0.05);
THEN("Output is the BBL-specific M900 Kx L1000 M10 form") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05 L1000 M10"));
}
}
WHEN("set_pressure_advance is called with a tool index") {
std::string out = writer.set_pressure_advance(0.05, 2);
THEN("BBL output is unchanged — no per-tool qualifier is emitted on BBL printers") {
REQUIRE_THAT(out, Catch::Matchers::ContainsSubstring("M900 K0.05 L1000 M10"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring(" T2"));
REQUIRE_THAT(out, !Catch::Matchers::ContainsSubstring("EXTRUDER="));
}
}
}
}
TEST_CASE("Custom G-code motion limits are restored before generated moves", "[GCodeWriter]")
{
const std::string gcode = Slic3r::Test::slice({ cube(20) }, {
@@ -966,6 +1272,440 @@ TEST_CASE("Custom G-code motion limits are restored before generated moves", "[G
REQUIRE(gcode.find("M205 X8 Y8 ; adjust jerk", custom_gcode_pos) != std::string::npos);
}
// Regression test for the belt-printer "illegal gantry move at print start" bug.
//
// On a belt printer the layer-change z-hop is deferred (lazy_lift) and consumed
// by the first travel_to_xyz, whose NormalLift branch lifts in place via
// _travel_to_z(). On a normal printer _travel_to_z emits a Z-only move, but in
// belt mode Z is coupled to Y/X, so _travel_to_z re-emits the current m_pos
// through the belt shear. At print start (and after custom gcode)
// is_current_position_clear() is false and m_pos.xy is still the uninitialised
// origin (0,0), which shears into machine (X=bed_max, Y=layer_z) — a move far up
// the gantry, e.g. "G1 X95 Y168.19 Z237.857". The fix guards that lift on
// is_current_position_clear(), mirroring the SlopeLift branch.
SCENARIO("Belt: the first travel does not lift through the uninitialised origin", "[GCodeWriter][belt]")
{
GIVEN("A fresh belt-kinematics GCodeWriter configured for an X-tilt 45 degree belt") {
// Machine-frame + slicer->world back-transform config (X tilt, 45 deg).
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
belt_config.belt_frame_tilt_decouple.value = false;
belt_config.belt_frame_tilt_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
// travel_speed became per-extruder (ConfigOptionFloatsNullable) upstream.
writer.config.travel_speed.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
// A fresh writer has not established its planar position yet — this is the
// precondition that made the origin leak into the first move.
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a layer-change z-hop is pending and we travel to the first object point") {
// Defer a z-hop, exactly as a retract on layer change leaves it.
writer.lazy_lift(LiftType::NormalLift);
// First object point in slicing coordinates: a near-belt point (y ~= -z)
// so its transformed gantry Y is small (~1mm). The bogus origin lift, in
// contrast, would shear to machine Y ~= nominal_z.
const double nominal_z = 100.0;
std::string gcode = writer.travel_to_xyz(Vec3d(10.0, -(nominal_z - 1.0), nominal_z));
THEN("no emitted move flies up the gantry; machine Y stays near the part") {
double max_y = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_y](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(Y))
max_y = std::max(max_y, double(line.y()));
});
// The destination shears to machine Y ~= 1mm. The old origin-lift bug
// produced a separate move at machine Y ~= nominal_z (100mm), so any
// Y well above the part means the origin leaked into a move.
REQUIRE(max_y > 0.0); // the destination move was emitted and parsed
REQUIRE(max_y < 10.0); // ... and nothing flew up the gantry
}
}
}
}
// Regression test for the belt-printer "phantom extrusion line from Y=0" bug.
//
// GCodeProcessor::store_move_vertex pins a move's stored Z to the first-layer
// height while m_processing_start_custom_gcode is set (the start G-code "prepare"
// stage), because on a normal printer the toolhead Z there is not yet a real print
// height. On a belt printer that override is wrong: Z is written explicitly and the
// designed-view back-transform couples machine Z into the rendered model Y (the
// belt tilt mixes the height and belt-feed axes). Overriding it back-transforms the
// last prepare-stage move (the unretract right before the first extrusion) to
// model Y ~= 0, and libvgcode then draws a phantom extrusion segment from Y ~= 0 to
// the first real toolpath — rendered in the first extrusion role's color. The fix
// keeps the real Z for belt printers (gated on belt_tilt_angle). Here we assert the
// prepare-stage move keeps its real Z so it can no longer leak to Y ~= 0.
SCENARIO("Belt: start-gcode prepare-stage moves keep their real Z", "[GCode][belt]")
{
// Belt printers are non-Bambu, so the G-code uses the "compatible" reserved
// tags ("TYPE:" for the extrusion role). The processor selects the tag table
// from the static s_IsBBLPrinter flag, so mirror the belt-printer setting here
// (saved/restored so test ordering stays unaffected).
struct BBLPrinterGuard {
bool prev = GCodeProcessor::s_IsBBLPrinter;
BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = false; }
~BBLPrinterGuard() { GCodeProcessor::s_IsBBLPrinter = prev; }
} bbl_guard;
GIVEN("A belt G-code whose start sequence travels to a high machine Z before the first extrusion") {
// The leading "; belt_slice_rotation_angle = 45" header sets belt_tilt_angle
// (parsed before the body), enabling the belt code path. ;TYPE:Custom before
// any G1 turns on the prepare stage; ;TYPE:Outer wall turns it off, exactly
// as a sliced belt print is laid out.
const std::string gcode =
"; belt_slice_rotation_angle = 45\n"
"G90\n"
"G21\n"
"M83\n"
";TYPE:Custom\n"
"G1 E-1.5 F2100\n" // retract at the (0,0,0) origin
"G1 X45 Y0.3 Z50 F12000\n" // travel to the approach point (prepare stage)
"G1 E1.5 F1800\n" // unretract in place (prepare stage)
";TYPE:Outer wall\n"
"G1 X46 Y0.3 Z50 E0.05\n"; // first extrusion, same Z as the approach
GCodeProcessor processor;
processor.process_buffer(gcode);
const GCodeProcessorResult& result = processor.get_result();
THEN("the belt code path is active") {
REQUIRE_THAT(result.belt_tilt_angle, Catch::Matchers::WithinAbs(45.0, 1e-4));
}
WHEN("locating the first extrusion and the move that precedes it") {
size_t first_extrude = result.moves.size();
for (size_t i = 0; i < result.moves.size(); ++i)
if (result.moves[i].type == EMoveType::Extrude) { first_extrude = i; break; }
THEN("an extrusion and a preceding move exist") {
REQUIRE(first_extrude < result.moves.size());
REQUIRE(first_extrude > 0);
}
THEN("the preceding prepare-stage move shares the extrusion's real Z (no leak to Y=0)") {
const float extrude_z = result.moves[first_extrude].position.z();
const float prev_z = result.moves[first_extrude - 1].position.z();
// The first extrusion is at the real Z=50; before the fix the
// prepare-stage move's Z was pinned to the first-layer height
// (0 here) instead, which back-transforms to model Y ~= 0 and
// produces the phantom extrusion segment.
REQUIRE_THAT(extrude_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
REQUIRE_THAT(prev_z, Catch::Matchers::WithinAbs(50.0, 1e-3));
}
}
}
}
// ---------------------------------------------------------------------------
// Regression tests for the two latent bugs the MachineKinematics refactor
// preserved deliberately and the follow-up commit fixed.
// ---------------------------------------------------------------------------
// Bug 1. _travel_to_z() emits full XYZ whenever the mapping must emit every
// axis, and it builds that point from m_pos. While the position is unknown,
// m_pos.xy is the uninitialised origin, which a reverse remap maps to the far
// corner of the bed. Belt kinematics guarded this; a Cartesian writer with an
// axis remap did not, and would command a rapid across the whole bed.
static void configure_lift_writer(GCodeWriter &writer)
{
std::vector<unsigned int> extruder_ids { 0 };
writer.set_extruders(extruder_ids);
writer.set_extruder(0);
writer.config.travel_speed.values = { 100.0 };
writer.config.travel_speed_z.values = { 100.0 };
writer.config.z_hop.values = { 0.4 };
writer.config.retract_lift_above.values = { 0.0 };
writer.config.retract_lift_below.values = { 0.0 };
}
// Largest X word in a chunk of emitted G-code, or lowest() if none.
static double max_emitted_x(const std::string &gcode)
{
double max_x = std::numeric_limits<double>::lowest();
GCodeReader reader;
reader.parse_buffer(gcode, [&max_x](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1") && line.has(X))
max_x = std::max(max_x, double(line.x()));
});
return max_x;
}
static size_t count_g1(const std::string &gcode)
{
size_t n = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&n](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (line.cmd_is("G1")) ++n;
});
return n;
}
SCENARIO("Axis remap: no lift is commanded through the uninitialised origin", "[GCodeWriter][remap]")
{
// Reverse X: machine X = build_vol_max.x - logical X, so the uninitialised
// origin maps to the far edge of the bed and is unmistakable in the output.
const double bed_x = 250.0;
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("nothing is commanded at the image of the origin") {
// The destination maps to machine X = 250 - 10 = 240; the bogus
// origin lift would have mapped to machine X = 250.
REQUIRE(max_emitted_x(gcode) < bed_x - 1.0);
}
THEN("only the destination move is emitted") {
REQUIRE(count_g1(gcode) == 1);
}
}
}
GIVEN("the same writer once its position is known") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(bed_x, 250.0, 250.0));
writer.travel_to_xyz(Vec3d(20.0, 20.0, 5.0));
REQUIRE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel again") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(30.0, 30.0, 5.0));
THEN("the separate lift move is still emitted") {
// Suppression must be pinned to the unknown position, not to the
// presence of a remap.
REQUIRE(count_g1(gcode) == 2);
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
REQUIRE_FALSE(writer.kinematics().must_emit_all_axes());
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("a z-hop is pending and we travel to the first point") {
writer.lazy_lift(LiftType::NormalLift);
const std::string gcode = writer.travel_to_xyz(Vec3d(10.0, 10.0, 5.0));
THEN("behaviour is unchanged: the lift is still emitted") {
// Three moves, not two: with no remap and an unknown position the
// destination is emitted as a separate XY move followed by its own
// Z move, on top of the lift. That split is the pre-existing
// identity-mapping path and must not change.
REQUIRE(count_g1(gcode) == 3);
}
}
}
}
SCENARIO("Axis remap: eager_lift does not lift, or record a lift, at an unknown position",
"[GCodeWriter][remap]")
{
GIVEN("a writer with a reverse-X remap and an unknown current position") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(6, 1, 2);
writer.set_build_volume_max(Vec3d(250.0, 250.0, 250.0));
REQUIRE_FALSE(writer.is_current_position_clear());
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("no move is emitted") {
REQUIRE(lift.empty());
}
THEN("no lift is recorded, so unlift does not descend from it") {
// If m_lifted had been set while nothing was commanded, unlift()
// would emit a descent from a height the machine never reached.
REQUIRE(writer.unlift().empty());
}
}
}
GIVEN("an identity-mapping writer with an unknown position") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an eager lift is requested") {
const std::string lift = writer.eager_lift(LiftType::NormalLift);
THEN("behaviour is unchanged: the lift is emitted and can be undone") {
REQUIRE_FALSE(lift.empty());
REQUIRE_FALSE(writer.unlift().empty());
}
}
}
}
// Bug 2. extrude_arc_to_xy() emits G2/G3 with logical X/Y and I/J and never
// consulted the mapping. An arc is only representable when logical X and Y reach
// the machine unchanged -- which is a narrower question than "is the remap the
// identity", because a mapping that only touches Z leaves every emitted word alone.
SCENARIO("Arc support is decided by whether the mapping leaves X and Y alone", "[GCodeWriter][remap]")
{
GIVEN("a Cartesian writer") {
GCodeWriter writer;
THEN("the identity mapping supports arcs") {
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only negation still supports arcs") {
// (+X, +Y, -Z): non-identity, but X, Y, I and J are all untouched.
writer.set_axis_remap(0, 1, 5);
REQUIRE(writer.kinematics().must_emit_all_axes());
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("a Z-only reversal still supports arcs") {
writer.set_axis_remap(0, 1, 8);
REQUIRE(writer.kinematics().supports_arc_moves());
}
THEN("swapping X and Y does not support arcs") {
writer.set_axis_remap(1, 0, 2);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
THEN("the X-tilt style (x, z, y) remap does not support arcs") {
writer.set_axis_remap(0, 2, 1);
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
GIVEN("a belt writer") {
PrintConfig belt_config;
belt_config.belt_printer.value = true;
belt_config.belt_slice_rotation.value = BeltRotationAxis::X;
belt_config.belt_slice_rotation_angle.value = 45.0;
GCodeWriter writer;
install_belt_kinematics(writer, belt_config);
THEN("arcs are never supported, because the frame shears") {
REQUIRE_FALSE(writer.kinematics().supports_arc_moves());
}
}
}
SCENARIO("An unrepresentable arc degrades to its chord rather than emitting a wrong G2/G3",
"[GCodeWriter][remap]")
{
auto emitted_commands = [](const std::string &gcode) {
std::vector<std::string> cmds;
GCodeReader reader;
reader.parse_buffer(gcode, [&cmds](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (! line.cmd().empty()) cmds.emplace_back(line.cmd());
});
return cmds;
};
GIVEN("an identity-mapping writer") {
GCodeWriter writer;
configure_lift_writer(writer);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("it is still a G3") {
const auto cmds = emitted_commands(gcode);
REQUIRE(cmds.size() == 1);
REQUIRE(cmds.front() == "G3");
}
}
}
GIVEN("a writer whose mapping swaps X and Y") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
WHEN("an arc is extruded") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), 0.0, /*is_ccw=*/true, "", /*force_no_extrusion=*/true);
THEN("no arc is emitted; it is approximated with linear moves") {
const auto cmds = emitted_commands(gcode);
REQUIRE(! cmds.empty());
for (const auto &c : cmds)
REQUIRE(c == "G1");
}
}
}
// The first version of this test used dE = 0 with force_no_extrusion, which
// hid a real bug: the capability check sat AFTER filament()->extrude(dE), so
// the fallback into extrude_to_xy() advanced E twice. Extrusion accounting has
// to be asserted with a positive dE.
GIVEN("a writer whose mapping cannot express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
writer.set_axis_remap(1, 0, 2);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("exactly dE is accounted for, not twice dE") {
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
THEN("no G2/G3 survives") {
REQUIRE(gcode.find("G2") == std::string::npos);
REQUIRE(gcode.find("G3") == std::string::npos);
}
}
}
GIVEN("a writer whose mapping CAN express arcs, extruding a real amount") {
GCodeWriter writer;
configure_lift_writer(writer);
const double dE = 1.5;
// used_filament() accumulates across moves; E() is reset per line in
// relative-E mode, so it would only show the last segment.
const double used_before = writer.filament()->used_filament();
WHEN("an arc carrying that extrusion is emitted") {
const std::string gcode = writer.extrude_arc_to_xy(
Vec2d(10.0, 0.0), Vec2d(5.0, 0.0), dE, /*is_ccw=*/true, "", /*force_no_extrusion=*/false);
THEN("it is still a single arc and accounts for dE once") {
REQUIRE(emitted_commands(gcode).size() == 1);
REQUIRE_THAT(writer.filament()->used_filament() - used_before,
Catch::Matchers::WithinAbs(dE, 1e-6));
}
}
}
}
TEST_CASE("Percent accelerations resolve against the option they are a percentage of", "[GCodeWriter]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+17 -1
View File
@@ -234,6 +234,19 @@ DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer
return config;
}
void size_flush_to_nozzles(DynamicPrintConfig &config)
{
// One filaments x filaments flush block and one flush multiplier per nozzle, as the GUI
// sizes them when the nozzle count changes; a single block leaves each nozzle's slice short.
const size_t nozzles = config.option<ConfigOptionFloats>("nozzle_diameter")->values.size();
auto &flush = config.option<ConfigOptionFloats>("flush_volumes_matrix", true)->values;
const std::vector<double> block = flush;
for (size_t i = 1; i < nozzles; ++i)
flush.insert(flush.end(), block.begin(), block.end());
for (const char *key : { "flush_multiplier", "flush_multiplier_fast" })
config.option<ConfigOptionFloats>(key, true)->resize(nozzles);
}
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> *per_object_overrides, bool arrange, size_t instances)
{
@@ -457,7 +470,10 @@ int role_passes(const std::string &gcode, const std::string &role)
bool in_role = false;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (! line.extruding(self)) return;
// E-only unretraction moves have positive E but do not lay down material. Ignoring
// them keeps a role pass contiguous across travel/retraction bookkeeping.
if (! line.extruding(self) || (line.dist_XY(self) <= EPSILON && std::abs(line.dist_Z(self)) <= EPSILON))
return;
const bool is_role = line.comment().find(role) != std::string_view::npos;
if (is_role && ! in_role) ++passes;
in_role = is_role;
+4
View File
@@ -78,6 +78,10 @@ Slic3r::Model model(const std::string& model_name, TriangleMesh&& _mesh);
DynamicPrintConfig multifilament_config(unsigned int filaments,
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {});
// Repeat multifilament_config's single flush block, and the flush multipliers, once per nozzle.
// Call after setting nozzle_diameter to more than one nozzle.
void size_flush_to_nozzles(DynamicPrintConfig &config);
// Apply `meshes` and config to `print`/`model`, each object with `instances` copies; optional per-object overrides,
// auto-arranged unless `arrange` is false.
void init_print(std::vector<TriangleMesh> &&meshes, Slic3r::Print &print, Slic3r::Model &model, const DynamicPrintConfig &config_in,
+390
View File
@@ -0,0 +1,390 @@
#include <catch2/catch_all.hpp>
#include "catch2/catch_test_macros.hpp"
#include "catch2/generators/catch_generators.hpp"
#include "catch2/catch_message.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <cstddef>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
// The IMEX mode placeholders ({imex_mode}, {imex_mode_index}, {imex_mode_gcode}) and the mode
// script that consumes them are set up in GCode.cpp's _do_export, immediately before
// machine_start_gcode is processed. Two contracts live in that ordering:
// * the three placeholders exist for every G-code script the export runs, IMEX or not;
// * the mode script is itself run through the placeholder parser, and it runs FIRST, so a
// {global ...} it declares is in scope by the time machine_start_gcode is processed.
// Nothing else in the suite exercises them.
// Offset of the first line of `gcode` that equals `expected` once trailing whitespace is
// stripped, or npos. Whole-line matching is deliberate on both counts: prefix matching would
// let ";IMEX_MODE_INDEX:2" pass for an emitted index of 20, and the config block the exporter
// appends to every file repeats machine_start_gcode and imex_mode_gcodes verbatim (as
// "; key = value"), which would make any substring search for the templates below trivially
// true and every "not emitted" assertion trivially false.
static std::size_t find_exact_line(const std::string &gcode, const std::string &expected)
{
std::size_t pos = 0;
while (pos <= gcode.size()) {
const std::size_t eol = gcode.find('\n', pos);
const std::size_t end = (eol == std::string::npos) ? gcode.size() : eol;
std::string line = gcode.substr(pos, end - pos);
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t'))
line.pop_back();
if (line == expected)
return pos;
if (eol == std::string::npos)
break;
pos = eol + 1;
}
return std::string::npos;
}
static bool has_line(const std::string &gcode, const std::string &expected)
{
return find_exact_line(gcode, expected) != std::string::npos;
}
// Any command line -- not a comment -- containing `token`. Used where the assertion is that a
// carriage was addressed at all, rather than with a particular value: the config block the
// exporter appends restates every setting as "; key = value", so the comment lines have to be
// excluded or a search for a setting's own name matches itself.
static bool has_command_containing(const std::string &gcode, const std::string &token)
{
std::size_t pos = 0;
while (pos < gcode.size()) {
const std::size_t eol = gcode.find('\n', pos);
const std::string line = gcode.substr(pos, (eol == std::string::npos ? gcode.size() : eol) - pos);
if (!line.empty() && line.front() != ';' && line.find(token) != std::string::npos)
return true;
if (eol == std::string::npos)
break;
pos = eol + 1;
}
return false;
}
// Mode scripts. Free of placeholders so that a test can tell "the script was emitted" apart
// from "the script was expanded"; the expansion case below supplies its own template.
static const char *kCopyScript = "SET_DUAL_CARRIAGE MODE=COPY";
static const char *kQuadScript = "SET_QUAD_CARRIAGE MODE=COPY";
// Reports all three placeholders on their own lines so find_exact_line can pin each value.
static const char *kReportStartGcode = ";IMEX_MODE:{imex_mode}\n"
";IMEX_MODE_INDEX:{imex_mode_index}\n"
";IMEX_MODE_GCODE:{imex_mode_gcode}\n";
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads, three modes.
// physical_extruder_map is only honoured when its length matches the nozzle count
// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the nozzle
// keys must be sized to 7 or the map is silently replaced with the identity and IMEX quietly
// stops happening at all. Mirrors imex_7x4_printer() in test_multifilament.cpp; `iq-copy` is
// carried over from the IQEX case there, which is a mode combination known to slice.
//
// imex_mode_gcodes is left to each test: the position a mode's script occupies in this list is
// exactly what {imex_mode_index} is asserted against.
static void imex_7x4_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
// Authored 1..n, as every production path does: PresetBundle writes it in
// full_fff_config(), and config load synthesizes it when absent. The all-1s default is
// not a neutral placeholder -- get_config_index_base() keys the slot-to-column lookup
// on it, and with every entry equal each per-filament vector collapses to filament 1's
// value, leaving a per-slot assertion comparing a value against itself.
{ "filament_self_index", "1,2,3,4,5,6,7" },
{ "is_imex", "1" },
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
// Klipper skips the bed/extruder temperature block that would otherwise be written
// between the mode script and machine_start_gcode, so the ordering assertion below
// compares the two scripts and nothing else.
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Set the mode scripts positionally, one per name in imex_mode_names. Assigning the vector
// beats set_deserialize_strict here: a coStrings round trip through a ';'-joined string has to
// quote and escape empty entries and embedded braces, and the empty first entry (primary, which
// deliberately has no script) is exactly the entry that would be lost.
static void set_mode_gcodes(DynamicPrintConfig &config, const std::vector<std::string> &gcodes)
{
config.option<ConfigOptionStrings>("imex_mode_gcodes", true)->values = gcodes;
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into
// tool_ordering. Filament 1 is logical slot 0, which pem routes to head 0 -- the head every
// mode here declares Primary -- so Print::validate() accepts the plate.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// {imex_mode} is the plate's active mode, {imex_mode_index} its position in imex_mode_names and
// {imex_mode_gcode} the raw script at that same position. Two modes are exercised because a
// single one cannot tell a resolved index apart from a constant: `copy` sits at 1 and `iq-copy`
// at 2, so an index that stopped tracking imex_mode_names fails at least one of them.
TEST_CASE("IMEX mode placeholders resolve to the active mode's name, index and script",
"[ImexModeGcode][IMEX]")
{
auto [mode, mode_index, mode_script] = GENERATE(table<std::string, int, std::string>({
{ "copy", 1, kCopyScript },
{ "iq-copy", 2, kQuadScript },
}));
INFO("active mode: " << mode);
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", mode },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:" + mode));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:" + std::to_string(mode_index)));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + mode_script));
// The script for the active mode -- and only that one -- reaches the file.
CHECK(has_line(gcode, mode_script));
CHECK_FALSE(has_line(gcode, mode == "copy" ? kQuadScript : kCopyScript));
}
// The mode script is a G-code template, not a literal: it goes through
// placeholder_parser_process like machine_start_gcode does. Because the three IMEX placeholders
// are set before that call, the script can also read its own mode -- which is what
// distinguishes "the script is processed" from "the script is processed too early to see them".
TEST_CASE("A placeholder inside an IMEX mode's G-code script is expanded",
"[ImexModeGcode][IMEX]")
{
const std::string templ = "SET_DUAL_CARRIAGE MODE={imex_mode} INDEX={imex_mode_index}"
" TEMP={nozzle_temperature_initial_layer[0]}";
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", templ, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
// The multi-extruder normalization collapses the per-filament temperature vector to a
// single value, so [0] is this literal 200 rather than a per-slot default.
{ "nozzle_temperature_initial_layer", "200" },
{ "machine_start_gcode", ";START\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, "SET_DUAL_CARRIAGE MODE=copy INDEX=1 TEMP=200"));
// If the script were written straight to the file the braces would survive verbatim.
CHECK_FALSE(has_line(gcode, templ));
}
// The ordering guarantee, and the reason the placeholder block sits where it does: the mode
// script is processed BEFORE machine_start_gcode, so a {global} the mode declares is in scope
// for machine_start_gcode. Reorder the two and machine_start_gcode references an undefined
// variable, which the placeholder parser raises as an error out of the export -- so a
// regression here fails this test whether the export throws or merely writes nothing useful.
TEST_CASE("A global declared in the IMEX mode G-code is visible to machine_start_gcode",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", std::string("{global imex_carriage_count = 2}") + kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "machine_start_gcode", ";IMEX_CARRIAGES:{imex_carriage_count}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
// The value is the mode script's, not a default: nothing else in this config defines it.
const std::size_t start_gcode_at = find_exact_line(gcode, ";IMEX_CARRIAGES:2");
REQUIRE(start_gcode_at != std::string::npos);
// ...and the two land in the file in that same order. Ordering is the contract here, so it
// is asserted directly rather than left implicit in the global resolving.
const std::size_t mode_script_at = find_exact_line(gcode, kCopyScript);
REQUIRE(mode_script_at != std::string::npos);
CHECK(mode_script_at < start_gcode_at);
}
// Primary is single-carriage printing: it names a real mode (position 0 of imex_mode_names, not
// the not-found fallback) but carries no script, so the export must add nothing. This is what
// keeps the mode machinery out of the way of an IMEX printer that is not printing in parallel.
TEST_CASE("Primary-mode IMEX prints emit no mode G-code", "[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
CHECK_FALSE(has_line(gcode, kCopyScript));
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// A plate stores its IMEX mode as the mode's *name* and the exporter resolves that name against
// the printer's imex_mode_names at slice time, so the two drift apart whenever the mode is
// renamed or deleted after a plate was set to it, or the project is opened against a printer
// preset that names its modes differently. A name matching no row must be treated as Primary --
// not as a parallel mode whose every name-keyed lookup happens to come back empty, which is how
// it used to behave: no mode script (there is none to find), no active-tool roster, and yet the
// parallel branches taken all the way through the export.
//
// Primary is a row like any other, so falling back to it means running its script too; a
// non-empty script is used here so "fell back to Primary" is distinguishable from "resolved
// nothing and emitted nothing".
TEST_CASE("An IMEX mode the printer no longer defines falls back to Primary",
"[ImexModeGcode][IMEX]")
{
static const char *kPrimaryScript = "SET_DUAL_CARRIAGE MODE=PRIMARY";
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { kPrimaryScript, kCopyScript, kQuadScript });
config.set_deserialize_strict({
// A name no row in imex_mode_names carries -- what `copy` becomes once it is renamed.
{ "imex_parallel_mode", "copy-renamed" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
// The placeholders report the mode the export actually ran, so the stale name must not
// survive into them -- a mode script keyed on {imex_mode} would otherwise set the printer
// up for a mode the slicer did not slice for.
CHECK(has_line(gcode, ";IMEX_MODE:primary"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:" + std::string(kPrimaryScript)));
// Primary's own script runs; neither parallel mode's does.
CHECK(has_line(gcode, kPrimaryScript));
CHECK_FALSE(has_line(gcode, kCopyScript));
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// The whole block is gated on is_imex. A printer preset can carry a filled-in mode table and
// still not be an IMEX machine -- and a plate config can still carry a stale imex_parallel_mode
// -- so with the flag off the placeholders stay empty and no mode script is injected. Run on an
// ordinary single-extruder printer on purpose: the guard is one boolean, and the multi-head
// geometry the other cases need would only add ways for this one to fail for another reason.
TEST_CASE("A printer with IMEX disabled resolves the mode placeholders to nothing",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "is_imex", "0" },
{ "imex_parallel_mode", "copy" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "gcode_flavor", "klipper" },
{ "machine_start_gcode", kReportStartGcode },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(has_line(gcode, ";IMEX_MODE:"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
CHECK_FALSE(has_line(gcode, kCopyScript));
CHECK_FALSE(has_line(gcode, kQuadScript));
}
// The placeholders are set unconditionally, ahead of the is_imex check, so they are defined for
// every printer. A single-extruder preset that mentions {imex_mode} must expand it to an empty
// string rather than fail the export on an unknown variable.
TEST_CASE("IMEX mode placeholders are defined on an ordinary single-extruder printer",
"[ImexModeGcode][IMEX]")
{
const std::string gcode = slice({ cube(20) }, {
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "gcode_flavor", "klipper" },
{ "machine_start_gcode", kReportStartGcode },
});
CHECK(has_line(gcode, ";IMEX_MODE:"));
CHECK(has_line(gcode, ";IMEX_MODE_INDEX:0"));
CHECK(has_line(gcode, ";IMEX_MODE_GCODE:"));
}
// In a parallel mode no tool changes occur, so every active carriage has to be addressed
// explicitly: once before the print for pressure advance, and again at the second layer for the
// drop from the initial-layer temperature. Nothing else in the suite covers either emission, and
// a carriage that falls out of one of those loops emits nothing at all rather than emitting
// something wrong -- it silently holds the initial-layer temperature, or whatever pressure
// advance the firmware was last told, for the whole job. So the property worth pinning is that
// the set of carriages addressed is exactly the set the mode declares active.
TEST_CASE("Every carriage a parallel mode declares active is addressed, and no other",
"[ImexModeGcode][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
set_mode_gcodes(config, { "", kCopyScript, kQuadScript });
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "enable_pressure_advance", "1,1,1,1,1,1,1" },
// Distinct per slot, so each assertion below names one filament and no other. copy
// mode's secondary rides physical 1, which physical_extruder_map routes to the fifth
// slot -- 0.05 and 244, not the primary's 0.01 and 240.
{ "pressure_advance", "0.010,0.020,0.030,0.040,0.050,0.060,0.070" },
{ "nozzle_temperature_initial_layer", "235,235,235,235,235,235,235" },
{ "nozzle_temperature", "240,241,242,243,244,245,246" },
});
const std::string gcode = slice({ cube(20) }, config);
// imex_mode_active_tools declares copy as "0:P,1:C", so exactly these two carriages run, and
// each transitions to the temperature of the filament its own head is routed to. The other
// two heads this printer has must stay untouched.
CHECK(has_command_containing(gcode, "M104 S240 T0 ; set IMEX tool temperature"));
CHECK(has_command_containing(gcode, "M104 S244 T1 ; set IMEX tool temperature"));
CHECK_FALSE(has_command_containing(gcode, "T2 ; set IMEX tool temperature"));
CHECK_FALSE(has_command_containing(gcode, "T3 ; set IMEX tool temperature"));
// The companion loop, which skips the primary because set_extruder() has already emitted it
// on the ordinary path. The qualified line is therefore the one that belongs to this loop:
// Klipper names the first carriage as the unnumbered "extruder" and the rest by index, so
// dropping the secondary removes `extruder1` and leaves `extruder` in place.
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.05 EXTRUDER=extruder1;"));
CHECK(has_command_containing(gcode, "SET_PRESSURE_ADVANCE ADVANCE=0.01 EXTRUDER=extruder;"));
CHECK_FALSE(has_command_containing(gcode, "EXTRUDER=extruder2;"));
}
+495
View File
@@ -0,0 +1,495 @@
#include <catch2/catch_all.hpp>
#include "catch2/matchers/catch_matchers_floating_point.hpp"
#include "catch2/catch_test_macros.hpp"
#include "catch2/matchers/catch_matchers.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Print.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <exception>
#include <libslic3r/PrintConfig.hpp>
#include <limits>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
using Catch::Matchers::WithinAbs;
// The IMEX slice offset is the firmware-managed handoff: an extra XY shift applied at emission
// so the primary tool's zone lands centred on the bed origin and the firmware can fan copies /
// mirrors out from there. `Print::update_imex_slice_offset()` DERIVES it from the applied
// config -- nobody hands it in. That matters because the only thing that used to hand it in was
// the plater, so a headless `orca-slicer --slice` produced slicer-frame coordinates while the
// mode G-code told the firmware to apply its own offsets on top.
//
// This file covers both halves:
//
// * Derivation -- which config makes the offset non-zero, what value it takes, and (the part
// the GUI push got wrong) that it is in PLATE-LOCAL mm and therefore does not move when the
// plate origin does. `compute_imex_slice_offset()` and `compute_imex_zone_layout()` are
// covered as pure functions in tests/libslic3r/; what is covered here is the Print reaching
// them with the right inputs.
// * Consumption -- that a slice actually comes out shifted. Two sites consume the offset:
// GCode::set_gcode_offset_with_imex_shift() (GCode.hpp), which augments the writer offset
// so every emitted coordinate moves, and Print::translate_to_print_space() (Print.cpp),
// which feeds the first_layer_print_min/max placeholders a start-G-code template uses to
// declare the print area to firmware. The two must stay in one frame, so the last test
// cross-checks them against each other.
//
// The offset is applied at emission, after slicing, so it is a pure translation: assertions are
// on the SHIFT between two slices of the same plate, never on absolute coordinates or on a
// golden file. That keeps them independent of where the arranger puts the cube, and immune to
// the run-to-run variation in this slicer's parallel infill generation.
// ---------------------------------------------------------------------------------------------
// Helpers (unnamed namespace: every suite links into one binary, so nothing here may collide
// with a same-named helper in a sibling test file)
// ---------------------------------------------------------------------------------------------
namespace {
// The bed the derivation divides up, and the answer it must reach.
//
// With `imex_gantry_count` 1, `imex_tools_per_gantry` 2 and `imex_tool_layout` front-left, T0
// owns the left column and T1 the right one, so a mode declaring `0:P,1:C` gives the primary
// the left half of the bed: x [0, 150], y [0, 200]. Its centre is the offset. Both numbers are
// set by the config below, not inherited from a default.
constexpr double kBedWidth = 300.0;
constexpr double kBedDepth = 200.0;
const Vec2d kPrimaryZoneCentre(kBedWidth / 4.0, kBedDepth / 2.0);
// XY extent of a set of emitted moves. `empty()` means nothing matched.
struct XYBounds
{
double min_x = std::numeric_limits<double>::max();
double min_y = std::numeric_limits<double>::max();
double max_x = std::numeric_limits<double>::lowest();
double max_y = std::numeric_limits<double>::lowest();
bool empty() const { return min_x > max_x; }
double size_x() const { return max_x - min_x; }
double size_y() const { return max_y - min_y; }
};
// Extent of the END points of every extruding move commented "perimeter", up to `z_max`.
//
// Perimeters only: they are the outermost extrusions, so they carry the plate's extent, and
// unlike infill their geometry and ordering are stable run to run. End points only: the parse
// callback runs BEFORE the reader advances, so the start point of the first extrusion of a
// polyline is whatever the preceding travel left behind -- and a cube's perimeters are closed
// loops, whose last point is their first, so the end points alone already give the true extent.
static XYBounds perimeter_bounds(const std::string &gcode, double z_max = std::numeric_limits<double>::max())
{
XYBounds bounds;
GCodeReader reader;
reader.parse_buffer(gcode, [&bounds, z_max](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (!line.extruding(self))
return;
if (line.comment().find("perimeter") == std::string_view::npos)
return;
if (double(line.new_Z(self)) > z_max)
return;
const double x = line.new_X(self);
const double y = line.new_Y(self);
bounds.min_x = std::min(bounds.min_x, x);
bounds.max_x = std::max(bounds.max_x, x);
bounds.min_y = std::min(bounds.min_y, y);
bounds.max_y = std::max(bounds.max_y, y);
});
return bounds;
}
// Read "<tag>x,y" out of `gcode`. The config block the exporter appends restates
// machine_start_gcode verbatim, so the tag also occurs there with the placeholder still
// unexpanded; occurrences are tried in turn and the first one that parses as two numbers wins.
static bool read_marker(const std::string &gcode, const std::string &tag, Vec2d &out)
{
for (size_t at = gcode.find(tag); at != std::string::npos; at = gcode.find(tag, at + 1)) {
const size_t from = at + tag.size();
const size_t to = gcode.find('\n', from);
const std::string payload = gcode.substr(from, to == std::string::npos ? std::string::npos : to - from);
const size_t comma = payload.find(',');
if (comma == std::string::npos)
continue;
try {
out = Vec2d(std::stod(payload.substr(0, comma)), std::stod(payload.substr(comma + 1)));
} catch (const std::exception &) {
continue;
}
return true;
}
return false;
}
// An IMEX machine: 7 logical extruders across 4 physical heads, mirroring the geometry the IMEX
// cases in test_multifilament.cpp use, on a 2-tool single-gantry grid.
//
// physical_extruder_map is only honoured when its length matches the nozzle count (PrintApply
// hands effective_physical_extruder_map the nozzle_diameter size), so the nozzle keys have to be
// sized to 7 as well -- otherwise the map is silently replaced with the identity and the plate
// stops being an IMEX plate at all.
//
// The grid, the tool layout and the bed are all set explicitly: they are exactly the inputs the
// zone layout divides to reach kPrimaryZoneCentre, so none of them may come from a default.
// `imex_firmware_managed_zones` is deliberately NOT set here -- it is the switch under test, and
// every case states it for itself.
//
// Everything that would put extrusions outside the object footprint is off (skirt, brim, prime
// tower, infill, top/bottom shells): that leaves perimeters as the only extrusions, so the
// emitted extent and the first-layer convex hull both reduce to the cube's own outline.
static void imex_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
{ "printable_area", "0x0,300x0,300x200,0x200" },
{ "is_imex", "1" },
{ "imex_gantry_count", "1" },
{ "imex_tools_per_gantry", "2" },
{ "imex_tool_layout", "front-left" },
{ "imex_mode_names", "primary;copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C" },
// `copy` declares head 0 Primary, and filament 1 (logical slot 0) routes there, so the
// plate is well-formed and Print::validate() lets it through.
{ "imex_parallel_mode", "copy" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "enable_prime_tower", "0" },
{ "sparse_infill_density", "0%" },
{ "top_shell_layers", "0" },
{ "bottom_shell_layers", "0" },
{ "wall_loops", "2" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height","0.2" },
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, so leaving them unset would drag extra
// tools into tool_ordering. PrintObject.cpp's call to that function is the source of truth for
// this key list.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// A ready-to-slice firmware-managed IMEX plate. `firmware_managed` is the one thing that
// varies between a baseline slice and a shifted one.
static DynamicPrintConfig imex_config(bool firmware_managed)
{
DynamicPrintConfig config = multifilament_config(7);
imex_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
config.set_deserialize_strict({ { "imex_firmware_managed_zones", firmware_managed ? "1" : "0" } });
return config;
}
// The offset the Print works out for itself, with nothing pushed in. Applying the config is
// enough -- the derivation reads only the config and the objects, so this needs no slice.
static Vec2d derived_offset(const DynamicPrintConfig &config, const Vec3d &plate_origin = Vec3d::Zero())
{
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.set_plate_origin(plate_origin);
print.update_imex_slice_offset();
return print.get_imex_slice_offset();
}
// Slice one 20mm cube. Note what is NOT here: no offset is handed to the Print. Whatever shift
// the G-code comes out with, the Print derived on its own from `config`.
static std::string slice_cube(const DynamicPrintConfig &config, const Vec3d &plate_origin = Vec3d::Zero())
{
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
print.set_plate_origin(plate_origin);
return gcode(print);
}
} // namespace
// ---------------------------------------------------------------------------------------------
// Derivation
// ---------------------------------------------------------------------------------------------
// The bug this file exists for: the offset used to arrive only from PartPlate, which a headless
// slice never runs, so `--slice` on a firmware-managed plate emitted slicer-managed coordinates
// while the mode G-code told the firmware to fan copies out from them. Nothing pushes anything
// here; the Print is expected to reach the primary zone's centre from the config alone.
TEST_CASE("A firmware-managed IMEX plate derives its slice offset with nothing pushed in",
"[IMEXSliceOffset][IMEX]")
{
const Vec2d offset = derived_offset(imex_config(true));
CHECK_THAT(offset.x(), WithinAbs(kPrimaryZoneCentre.x(), 1e-9));
CHECK_THAT(offset.y(), WithinAbs(kPrimaryZoneCentre.y(), 1e-9));
}
// The offset is a PLATE-LOCAL quantity. Both consumers already subtract the plate origin
// separately, so an offset that moved with the plate would subtract it twice and put every
// plate after the first a full plate stride out. This is precisely what the old plater-side
// computation got wrong: it divided the plate's world-frame outline, not the bed.
TEST_CASE("The derived IMEX slice offset is plate-local and does not move with the plate origin",
"[IMEXSliceOffset][IMEX]")
{
const DynamicPrintConfig config = imex_config(true);
const Vec2d at_origin = derived_offset(config);
const Vec2d on_plate3 = derived_offset(config, Vec3d(kBedWidth * 2.0, -kBedDepth * 2.0, 0.0));
CHECK_THAT(on_plate3.x(), WithinAbs(at_origin.x(), 1e-9));
CHECK_THAT(on_plate3.y(), WithinAbs(at_origin.y(), 1e-9));
}
// The offset tracks the bed it divides, so it is not a constant that happens to match one
// printer. Halving the bed halves the primary zone and its centre with it.
TEST_CASE("The derived IMEX slice offset follows the printable area", "[IMEXSliceOffset][IMEX]")
{
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "printable_area", "0x0,150x0,150x100,0x100" } });
const Vec2d offset = derived_offset(config);
CHECK_THAT(offset.x(), WithinAbs(kPrimaryZoneCentre.x() / 2.0, 1e-9));
CHECK_THAT(offset.y(), WithinAbs(kPrimaryZoneCentre.y() / 2.0, 1e-9));
}
// Every case that must leave the offset at exactly Vec2d::Zero(). This is what protects
// existing users: an exactly-zero offset is what makes the firmware-managed path reduce to the
// old set_gcode_offset() behaviour, to the last digit.
TEST_CASE("The derived IMEX slice offset is zero unless firmware-managed zones are in play",
"[IMEXSliceOffset][IMEX]")
{
// A Print nobody has applied anything to starts at zero. This is the value every
// non-IMEX printer keeps, and the reason nothing else in the exporter had to change.
Print fresh;
REQUIRE_THAT(fresh.get_imex_slice_offset().x(), WithinAbs(0.0, 1e-12));
REQUIRE_THAT(fresh.get_imex_slice_offset().y(), WithinAbs(0.0, 1e-12));
Vec2d offset = Vec2d::Zero();
SECTION("an ordinary printer with no IMEX configuration at all") {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "sparse_infill_density", "0%" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
});
offset = derived_offset(config);
}
SECTION("an IMEX printer in a parallel mode, but firmware-managed zones off") {
offset = derived_offset(imex_config(false));
}
SECTION("firmware-managed zones on, but the plate is in Primary mode") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "primary" } });
offset = derived_offset(config);
}
SECTION("firmware-managed zones on, but the plate has no mode at all") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "" } });
offset = derived_offset(config);
}
// A plate can name a mode this printer does not define -- a mode renamed or deleted after
// the plate was set to it, or a project opened against a different printer preset. The
// exporter falls back to Primary and warns; the offset has to make the same choice, or the
// file would be shifted for a mode nothing ever activates.
SECTION("firmware-managed zones on, but the plate's mode is not one this printer defines") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_parallel_mode", "renamed-since" } });
offset = derived_offset(config);
}
// The layout hangs off the primary tool's cell. `imex_tools_per_gantry` 1 on a single
// gantry is a one-cell grid: there is nothing to divide, so there is nothing to shift by.
SECTION("firmware-managed zones on, but the tool grid holds a single tool") {
DynamicPrintConfig config = imex_config(true);
config.set_deserialize_strict({ { "imex_tools_per_gantry", "1" },
{ "imex_mode_active_tools", "0:P;0:P" } });
offset = derived_offset(config);
}
CHECK_THAT(offset.x(), WithinAbs(0.0, 1e-12));
CHECK_THAT(offset.y(), WithinAbs(0.0, 1e-12));
}
// ---------------------------------------------------------------------------------------------
// Consumption
// ---------------------------------------------------------------------------------------------
// The whole point of the firmware-managed path: the emitted toolpaths move, and they move by the
// derived offset. The writer subtracts plate origin + IMEX shift from every point it formats, so
// the firmware-managed slice comes out at (baseline - offset). Asserting the shift rather than
// absolute coordinates keeps this independent of wherever the arranger drops the cube.
TEST_CASE("A firmware-managed IMEX plate emits coordinates shifted by the offset it derived",
"[IMEXSliceOffset][IMEX]")
{
const XYBounds baseline = perimeter_bounds(slice_cube(imex_config(false)));
const XYBounds shifted = perimeter_bounds(slice_cube(imex_config(true)));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(shifted.empty());
CHECK_THAT(shifted.min_x, WithinAbs(baseline.min_x - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted.max_x, WithinAbs(baseline.max_x - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted.min_y, WithinAbs(baseline.min_y - kPrimaryZoneCentre.y(), 1e-3));
CHECK_THAT(shifted.max_y, WithinAbs(baseline.max_y - kPrimaryZoneCentre.y(), 1e-3));
// A translation, not a re-slice: the plate keeps its size.
CHECK_THAT(shifted.size_x(), WithinAbs(baseline.size_x(), 1e-3));
CHECK_THAT(shifted.size_y(), WithinAbs(baseline.size_y(), 1e-3));
}
// The IMEX shift is added to the plate origin, not substituted for it. A multi-plate project
// already carries a non-zero plate origin, so dropping either term from
// set_gcode_offset_with_imex_shift() would put every emitted coordinate on the wrong plate --
// invisibly to a test that only ever slices plate 1 at the origin. The expected total is
// origin + offset precisely because the derived offset does NOT itself contain the origin.
TEST_CASE("An IMEX slice offset composes with the plate origin rather than replacing it",
"[IMEXSliceOffset][IMEX]")
{
const Vec3d plate_origin(kBedWidth * 1.1, -kBedDepth * 1.1, 0.0);
const XYBounds baseline = perimeter_bounds(slice_cube(imex_config(false)));
const XYBounds shifted = perimeter_bounds(slice_cube(imex_config(true), plate_origin));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(shifted.empty());
const double expected_x = plate_origin.x() + kPrimaryZoneCentre.x();
const double expected_y = plate_origin.y() + kPrimaryZoneCentre.y();
CHECK_THAT(shifted.min_x, WithinAbs(baseline.min_x - expected_x, 1e-3));
CHECK_THAT(shifted.max_x, WithinAbs(baseline.max_x - expected_x, 1e-3));
CHECK_THAT(shifted.min_y, WithinAbs(baseline.min_y - expected_y, 1e-3));
CHECK_THAT(shifted.max_y, WithinAbs(baseline.max_y - expected_y, 1e-3));
}
// The guard for everyone who is not using this feature. `imex_firmware_managed_zones` is read
// by nothing else in the engine, so setting it on a printer the derivation refuses to shift must
// leave the slice where it was -- it must short-circuit on `is_imex` before it ever looks at a
// bed or a mode. The zero-offset sections above cover an ordinary printer that never mentions
// the option; this covers the option turned ON where nothing may act on it.
//
// Asserted as the derived offset plus the extent of the emitted perimeters, NOT as a line-by-line
// comparison of the two exports. This slicer's output is not stable run to run -- parallel infill
// generation is the documented source -- and the M73 time estimates, the seam placement and the
// travel ordering all ride on that variation, so comparing every emitted command would flake in
// CI rather than catch a shift. The offset is applied at emission as a pure translation, so a
// non-zero one moves the extent and this catches it; that it is exactly zero is what the
// derivation check states.
TEST_CASE("Turning on firmware-managed zones changes nothing on a non-IMEX printer",
"[IMEXSliceOffset][IMEX]")
{
auto ordinary_printer = [](bool firmware_managed) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "sparse_infill_density", "0%" },
{ "top_shell_layers", "0" },
{ "bottom_shell_layers", "0" },
{ "wall_loops", "2" },
{ "layer_height", "0.2" },
{ "initial_layer_print_height", "0.2" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
{ "enable_prime_tower", "0" },
{ "imex_firmware_managed_zones", firmware_managed ? "1" : "0" },
});
return config;
};
// Nothing to apply in the first place.
const Vec2d offset = derived_offset(ordinary_printer(true));
CHECK_THAT(offset.x(), WithinAbs(0.0, 1e-12));
CHECK_THAT(offset.y(), WithinAbs(0.0, 1e-12));
// ...and the toolpaths bear that out: same plate, same place. Infill, skirt, brim and the
// prime tower are all off in this config, so the perimeters carry the whole extent.
const XYBounds baseline = perimeter_bounds(slice_cube(ordinary_printer(false)));
const XYBounds with_flag = perimeter_bounds(slice_cube(ordinary_printer(true)));
REQUIRE_FALSE(baseline.empty());
REQUIRE_FALSE(with_flag.empty());
CHECK_THAT(with_flag.min_x, WithinAbs(baseline.min_x, 1e-3));
CHECK_THAT(with_flag.max_x, WithinAbs(baseline.max_x, 1e-3));
CHECK_THAT(with_flag.min_y, WithinAbs(baseline.min_y, 1e-3));
CHECK_THAT(with_flag.max_y, WithinAbs(baseline.max_y, 1e-3));
}
// first_layer_print_min/max are what a start-G-code template hands the firmware to declare the
// print area (bed mesh bounds, PRINT_MIN/PRINT_MAX). They come from the first-layer convex hull
// pushed through Print::translate_to_print_space(), which subtracts the same IMEX shift the
// writer does, so they have to travel with the toolpaths. If they did not, a firmware-managed
// plate would probe one area and print in another.
TEST_CASE("first_layer_print_min/max track the derived IMEX slice offset",
"[IMEXSliceOffset][IMEX]")
{
auto with_markers = [](bool firmware_managed) {
DynamicPrintConfig config = imex_config(firmware_managed);
config.set_deserialize_strict({
{ "machine_start_gcode",
";FLMIN:{first_layer_print_min[0]},{first_layer_print_min[1]}\n"
";FLMAX:{first_layer_print_max[0]},{first_layer_print_max[1]}\n" },
});
return config;
};
const std::string baseline_gcode = slice_cube(with_markers(false));
const std::string shifted_gcode = slice_cube(with_markers(true));
Vec2d baseline_min, baseline_max, shifted_min, shifted_max;
REQUIRE(read_marker(baseline_gcode, ";FLMIN:", baseline_min));
REQUIRE(read_marker(baseline_gcode, ";FLMAX:", baseline_max));
REQUIRE(read_marker(shifted_gcode, ";FLMIN:", shifted_min));
REQUIRE(read_marker(shifted_gcode, ";FLMAX:", shifted_max));
CHECK_THAT(shifted_min.x(), WithinAbs(baseline_min.x() - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted_min.y(), WithinAbs(baseline_min.y() - kPrimaryZoneCentre.y(), 1e-3));
CHECK_THAT(shifted_max.x(), WithinAbs(baseline_max.x() - kPrimaryZoneCentre.x(), 1e-3));
CHECK_THAT(shifted_max.y(), WithinAbs(baseline_max.y() - kPrimaryZoneCentre.y(), 1e-3));
// Declared area and toolpaths must be in ONE frame. The declared bounds come from the
// first-layer convex hull, which Print::first_layer_islands() builds from the object's SLICE
// CONTOUR (lslices) -- not from any extrusion path -- so it sits outside the emitted
// centrelines by whatever wall geometry lies between the two. That distance is a property of
// the wall generator, not of this feature, so it is not asserted as a constant here.
//
// What IS asserted: the declared bounds enclose the toolpaths, and the gap between the two is
// the SAME in both frames. A frame divergence -- one of the two consumers shifted, the other
// not -- moves the declared box relative to the toolpaths and breaks this by the offset.
const XYBounds baseline_layer = perimeter_bounds(baseline_gcode, 0.3); // initial_layer_print_height 0.2
const XYBounds shifted_layer = perimeter_bounds(shifted_gcode, 0.3);
REQUIRE_FALSE(baseline_layer.empty());
REQUIRE_FALSE(shifted_layer.empty());
CHECK(shifted_min.x() <= shifted_layer.min_x + 1e-3);
CHECK(shifted_min.y() <= shifted_layer.min_y + 1e-3);
CHECK(shifted_max.x() >= shifted_layer.max_x - 1e-3);
CHECK(shifted_max.y() >= shifted_layer.max_y - 1e-3);
CHECK_THAT(shifted_layer.min_x - shifted_min.x(),
WithinAbs(baseline_layer.min_x - baseline_min.x(), 1e-3));
CHECK_THAT(shifted_layer.min_y - shifted_min.y(),
WithinAbs(baseline_layer.min_y - baseline_min.y(), 1e-3));
CHECK_THAT(shifted_max.x() - shifted_layer.max_x,
WithinAbs(baseline_max.x() - baseline_layer.max_x, 1e-3));
CHECK_THAT(shifted_max.y() - shifted_layer.max_y,
WithinAbs(baseline_max.y() - baseline_layer.max_y, 1e-3));
}
+622
View File
@@ -20,7 +20,10 @@
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Print.hpp"
#include <libslic3r/PrintBase.hpp>
#include <libslic3r/CustomGCode.hpp>
#include <limits>
#include <map>
#include <optional>
#include <regex>
#include <set>
@@ -762,6 +765,587 @@ TEST_CASE("Multi-extruder slice stays in bounds with a short max_layer_height",
}
// Shared IMEX printer geometry: 7 logical extruders across 4 physical heads.
// physical_extruder_map is only honoured when its length matches the nozzle count
// (PrintApply feeds effective_physical_extruder_map the nozzle_diameter size), so the
// nozzle keys must be sized to 7 or the map is silently replaced with the identity and
// every logical slot resolves to its own head -- which hides the defects under test.
static void imex_7x4_printer(DynamicPrintConfig &config)
{
config.set_deserialize_strict({
{ "nozzle_diameter", "0.4,0.4,0.4,0.4,0.4,0.4,0.4" },
{ "printer_extruder_id", "1,2,3,4,5,6,7" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard,Direct Drive Standard,Direct Drive Standard,"
"Direct Drive Standard" },
{ "extruder_printable_height", "0,0,0,0,0,0,0" },
{ "physical_extruder_map", "0,0,0,0,1,2,3" },
{ "is_imex", "1" },
{ "imex_mode_names", "primary;copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C" },
{ "skirt_loops", "0" },
{ "brim_type", "no_brim" },
// Temperature assertions below spell "M104 S<t> T<n>"; RepRapFirmware would emit
// "G10 S<t> P<n>" from the same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
size_flush_to_nozzles(config);
}
// Route every region to one filament. An unset *_filament_id is not "inherit":
// clamp_feature_filament_to_valid rewrites <=0 to 1, which would drag tool 0 into
// tool_ordering and mask what these tests assert. PrintObject.cpp's call to that
// function is the source of truth for this key list -- a new one has to be added here.
static void all_regions_on_filament(DynamicPrintConfig &config, int filament_1based)
{
for (const char *key : { "outer_wall_filament_id", "inner_wall_filament_id",
"sparse_infill_filament_id", "internal_solid_filament_id",
"top_surface_filament_id", "bottom_surface_filament_id" })
config.set_deserialize_strict({ { key, std::to_string(filament_1based) } });
}
// IMEX parallel modes emit per-carriage temperatures from a branch that is mutually
// exclusive with the standard per-extruder path, and that branch skipped the head the
// print's own toolpaths run on. That head therefore never received its 1st->2nd layer
// transition and held nozzle_temperature_initial_layer for the whole job.
TEST_CASE("Parallel-mode IMEX prints transition the printing head to its second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
// The multi-extruder normalization collapses per-filament temperature vectors to a
// single value, so heads are told apart by their tool qualifier, not by temperature.
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// Head 0 runs the print's own toolpaths and must step 200 -> 240 at the second layer.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
// Head 1 is the copy carriage; it already worked and must keep working.
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
}
// IMEX supplements is_extruder_used for the secondary carriages a parallel mode drives.
// `primary` drives exactly one tool, so the supplement must not run: routing every region
// to filament 6 puts the initial tool on physical head 2, while the mode's only declared
// head is 0, which the unguarded supplement resolved back to filament slot 0.
TEST_CASE("Primary-mode IMEX prints mark only the filament slot they print with",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6); // filament 6 => logical slot 5 => physical head 2
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED0:0") != std::string::npos);
}
// Guard rail for the fix above: the modes the supplement exists for must keep marking their
// secondaries. Printed on filament 1 (slot 0), which pem routes to head 0 -- the head `copy`
// declares Primary -- so the plate is well-formed and validate() lets it through. Head 0 is
// the initial tool's head and is skipped (tool_ordering already marked slot 0); head 1 is the
// secondary and resolves to slot 4.
//
// This case previously printed on filament 6, which routes to head 2 while `copy` declares
// head 0 Primary. Print::validate() now refuses that plate outright (no filament on it can
// feed the Primary tool), so asserting it slices correctly would contradict
// "An IMEX plate whose filament never routes to the primary carriage is blocked" below.
// slice() does not surface validate()'s return, so the contradiction would have gone unnoticed.
TEST_CASE("Copy-mode IMEX prints still mark every secondary carriage's filament slot",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find(";USED0:1") != std::string::npos); // the filament actually printed
CHECK(gcode.find(";USED4:1") != std::string::npos); // head 1, the secondary carriage
CHECK(gcode.find(";USED5:0") != std::string::npos);
}
// The two IMEX changes are coupled: get_imex_active_tools() now returns an empty roster in
// primary mode, so if the temperature branch ever stopped excluding primary it would enter,
// emit nothing, skip the standard path, and silently restore the bug the copy-mode case above
// covers -- with every other test still green.
TEST_CASE("Primary-mode IMEX prints still transition to the second-layer temperature",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(gcode.find("M104 S240") != std::string::npos);
}
// A plate carries its IMEX mode as a name, matched against the printer's imex_mode_names at
// slice time, so a mode renamed or deleted underneath the plate -- or a project opened against a
// preset that names its modes differently -- leaves the plate pointing at nothing. That used to
// take every "not Primary" branch in the exporter while every name-keyed lookup came back empty,
// which is worse than either interpretation on its own:
// * the 1st->2nd layer temperature branch is mutually exclusive with the standard one, so an
// empty active-tool roster meant NO head was transitioned and every one of them held
// nozzle_temperature_initial_layer for the whole print; and
// * the initial T<n> was suppressed on the assumption that the mode's setup script would
// select the tool, while that script -- resolved by the same name -- did not exist.
// Print::validate() does not catch it either: the unresolved name yields an empty tools string,
// so there is no declared primary and its IMEX routing guard is skipped.
//
// Falling back to Primary is what makes the file coherent again. Asserted on the two emissions
// that were actually broken rather than on the mode string, which the placeholder test in
// test_imex_mode_gcode.cpp covers.
TEST_CASE("An IMEX plate set to a mode the printer no longer defines slices as Primary",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
config.set_deserialize_strict({
// imex_mode_names is "primary;copy" -- this is `copy` after a rename.
{ "imex_parallel_mode", "copy-renamed" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
});
const std::string gcode = slice({ cube(20) }, config);
// The head the toolpaths run on steps 200 -> 240 at the second layer, via the standard
// per-extruder path a Primary-mode print uses.
CHECK(gcode.find("M104 S240") != std::string::npos);
// ...and only that head, addressed the way a single-head print addresses it: nothing drives
// a copy carriage here, so the fallback goes through the standard per-extruder path, where
// only filament slot 0 prints. GCodeWriter::set_temperature therefore sees
// multiple_extruders == false and emits no tool qualifier at all. Excluding the whole
// " T" suffix rather than " T1" is what makes that the assertion: a regression that routed
// the transition to T2 or T3 instead would satisfy an exclusion of T1, and the unqualified
// find above is itself prefix-satisfied by any "M104 S240 T<n>".
CHECK(gcode.find("M104 S240 T") == std::string::npos);
// The initial tool selection is emitted. Matched as a line-leading token rather than a whole
// line so the assertion does not depend on the trailing "; change extruder" comment, which
// is switched off by a global unrelated to IMEX.
bool selects_initial_tool = false;
std::istringstream tool_lines(gcode);
std::string line;
while (std::getline(tool_lines, line))
if (line.rfind("T0", 0) == 0) {
selects_initial_tool = true;
break;
}
CHECK(selects_initial_tool);
}
// IQEX: when the second gantry is active the mode drives all four carriages, so every one of
// them needs its own filament resolved -- for the first layer via is_extruder_used (consumed by
// machine_start_gcode) and for the second via the per-tool transition. pem routes filament 1 to
// head 0, and heads 1/2/3 to filament slots 4/5/6, so all four slots must appear.
TEST_CASE("IQEX modes emit first- and second-layer temperatures for every active carriage",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({
{ "imex_mode_names", "primary;copy;iq-copy" },
{ "imex_mode_active_tools", "0:P;0:P,1:C;0:P,1:C,2:C,3:C" },
{ "imex_parallel_mode", "iq-copy" },
{ "nozzle_temperature_initial_layer", "200" },
{ "nozzle_temperature", "240" },
{ "machine_start_gcode",
";USED0:{if is_extruder_used[0]}1{else}0{endif}\n"
";USED4:{if is_extruder_used[4]}1{else}0{endif}\n"
";USED5:{if is_extruder_used[5]}1{else}0{endif}\n"
";USED6:{if is_extruder_used[6]}1{else}0{endif}\n" },
});
const std::string gcode = slice({ cube(20) }, config);
// First layer: every active carriage's filament is declared to machine_start_gcode.
CHECK(gcode.find(";USED0:1") != std::string::npos);
CHECK(gcode.find(";USED4:1") != std::string::npos);
CHECK(gcode.find(";USED5:1") != std::string::npos);
CHECK(gcode.find(";USED6:1") != std::string::npos);
// Second layer: every active carriage gets its own transition.
CHECK(gcode.find("M104 S240 T0") != std::string::npos);
CHECK(gcode.find("M104 S240 T1") != std::string::npos);
CHECK(gcode.find("M104 S240 T2") != std::string::npos);
CHECK(gcode.find("M104 S240 T3") != std::string::npos);
}
// M104/M109 name a physical heater, but every caller of the instance set_temperature overload
// addresses filaments by logical id. pem routes filament 5 (logical 4) to head 1, so a
// toolchange between filaments 1 and 5 must cool and wait on T1 -- never T4, which on this
// machine is an AFC lane index and names no heater at all.
//
// Regression: the same-physical short-circuit in set_extruder hides this for lane swaps within
// one head (filaments 1-4 all map to head 0, so no cool-down is emitted), so only a toolchange
// that CROSSES heads reaches the emission. Ooze prevention must be on for pre/post_toolchange
// to run at all.
TEST_CASE("IMEX heater commands name the physical head, not the logical filament",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
config.set_deserialize_strict({
{ "imex_parallel_mode", "primary" },
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" },
{ "single_extruder_multi_material", "0" },
{ "nozzle_temperature_initial_layer", "200,200,200,200,200,200,200" },
{ "nozzle_temperature", "240,240,240,240,240,240,240" },
});
// Two objects on filaments 1 and 5: logical 0 -> head 0, logical 4 -> head 1.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "5" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// The bare toolchange stays LOGICAL -- it is an AFC lane selector, not a heater.
CHECK(gcode.find("\nT4") != std::string::npos);
// Every heater command carrying a tool must name a configured head (0-3 here), never a
// logical slot above the head count. Scanning beats a fixed-string check: it fails on any
// stray unmapped emission, not just the two sites this test was written for.
std::istringstream ss(gcode);
std::string line;
std::vector<std::string> offenders;
while (std::getline(ss, line)) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t t = line.find(" T");
if (t == std::string::npos || t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
if (std::stoi(line.substr(t + 2)) > 3)
offenders.push_back(line);
}
INFO("heater commands naming a non-existent head: " << offenders.size()
<< (offenders.empty() ? "" : " e.g. " + offenders.front()));
CHECK(offenders.empty());
}
// The other half of that translation, and the half nothing else covers: GCodeWriter::set_temperature
// passes `this->config.is_imex.value` as the guard, NOT a constant, so a printer that is not an
// IMEX printer keeps addressing heaters by logical filament id exactly as upstream does.
//
// physical_extruder_map is not an IMEX-only key. Shipping dual-nozzle BBL profiles author it --
// fdm_bbl_3dp_002_common ships {1, 0} -- for the inherited BBL reading of the key, and PrintApply
// deliberately leaves a non-IMEX printer's map exactly as it arrives (IMEXHelpers.hpp spells out
// the two readings). Hardcode `true` at that call site and this whole suite still passes, because
// every other case here runs either on an IMEX printer or on one with no authored map -- while
// those profiles start sending filament 0's M104/M109 to heater 1 and filament 1's to heater 0.
//
// The two filaments carry DIFFERENT idle temperatures, so the S value and the T index cross-check
// each other: a remap moves both onto the other tool and fails both halves, and no assertion can
// be satisfied by a prefix.
//
// Idle temperature rather than nozzle_temperature, for a reason specific to THIS HARNESS. Keys in
// filament_options_with_variant are rewritten at apply time by
// update_values_to_printer_extruders_for_multiple_filaments, which sets each filament's value to
// `opt->get_at(variant_index[f])` -- it RE-INDEXES per filament by that filament's extruder
// variant, it does not flatten. Per-filament nozzle temperature is a real, working feature and
// survives that pass on a printer whose filaments resolve to different variant slots.
// multifilament_config pins nozzle_diameter to a single 0.4, so every filament here resolves to
// the SAME variant slot and therefore ends up with the same value -- which is why the IMEX cases
// above tell heads apart by tool qualifier rather than by temperature. idle_temperature is not in
// that key set, so "151,173" reaches the emitter intact and the two filaments stay distinguishable.
// Ooze prevention is what puts the idle temperatures into the file at all.
TEST_CASE("Heater commands keep the logical filament id on a non-IMEX printer",
"[MultiFilament][IMEX][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "nozzle_diameter", "0.4,0.4" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard,Direct Drive Standard" },
{ "extruder_printable_height", "0,0" },
// The shipping two-nozzle profile: not an IMEX printer, but it authors the swap map.
{ "is_imex", "0" },
{ "physical_extruder_map", "1,0" },
{ "single_extruder_multi_material", "0" },
// Ooze prevention drops the outgoing filament to its idle temperature on every tool
// change, through the instance set_temperature overload that carries the guard.
{ "ooze_prevention", "1" },
{ "standby_temperature_delta", "-50" }, // unused while idle_temperature is set
// Per filament and distinct: these are the values the assertions pair with a heater.
{ "idle_temperature", "151,173" },
{ "nozzle_temperature_initial_layer", "215,215" },
{ "nozzle_temperature", "240,240" },
// GCodeProcessor's preheat pass rewrites the tool-change temperature commands and drops
// the ";cooldown" M104s outright, applying physical_extruder_map itself as it does. That
// pass is not the code under test, so switch it off and let the writer's emissions stand.
{ "preheat_time", "0" },
{ "enable_prime_tower", "0" },
// The assertions spell "M104 S<t> T<n>"; RepRapFirmware emits "G10 S<t> P<n>" from the
// same code, so the flavor is pinned rather than defaulted.
{ "gcode_flavor", "klipper" },
});
// One filament per object, so both heaters are addressed and a tool change happens in both
// directions. Assigned at the object level: a region-level filament id would not raise the
// used-filament count the tool ordering works from.
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "1" } },
{ { "extruder", "2" } },
};
const std::string gcode = slice_with_object_overrides({ cube(20), cube(20) }, config, overrides);
// Every heater command that names a tool, collected as temperature -> the tools it was
// addressed to. Scanning beats fixed-string finds: "M104 S151" on its own is prefix-satisfied
// by "M104 S151 T1", and excluding just " T1" would let a command routed to some third tool
// through. Mirrors the offender scan in the IMEX case above.
std::map<int, std::set<int>> tools_by_temp;
std::istringstream ss(gcode);
for (std::string line; std::getline(ss, line);) {
if (line.rfind("M104", 0) != 0 && line.rfind("M109", 0) != 0)
continue;
const size_t s = line.find('S');
const size_t t = line.find(" T");
if (s == std::string::npos || t == std::string::npos)
continue;
if (s + 1 >= line.size() || !std::isdigit((unsigned char) line[s + 1]))
continue;
if (t + 2 >= line.size() || !std::isdigit((unsigned char) line[t + 2]))
continue;
tools_by_temp[std::stoi(line.substr(s + 1))].insert(std::stoi(line.substr(t + 2)));
}
std::string seen;
for (const auto& [temperature, tools] : tools_by_temp) {
seen += " S" + std::to_string(temperature) + "->";
for (int tool : tools)
seen += "T" + std::to_string(tool);
}
INFO("heater commands naming a tool:" << seen);
// Both idle temperatures have to be in the file, or the pairing below would prove nothing.
REQUIRE(tools_by_temp.count(151) == 1);
REQUIRE(tools_by_temp.count(173) == 1);
// Filament 0's idle temperature goes to heater 0 and nowhere else, filament 1's to heater 1.
// Applying physical_extruder_map {1, 0} here would swap both.
CHECK(tools_by_temp[151] == std::set<int>{ 0 });
CHECK(tools_by_temp[173] == std::set<int>{ 1 });
}
// The IMEX Primary tool prints the sliced paths directly, so it can only use a filament the
// printer's physical_extruder_map routes to it. The ghost filament picker enforces that for
// the secondary tools; the primary's filament comes from the ordinary object selector, which
// has no IMEX awareness. `copy` declares T0 Primary, but every filament this plate uses --
// filament 6, slot 5 -- routes to head 2, so nothing can feed T0 and validate() must refuse.
//
// The object's own extruder is pinned too: ModelVolume::get_extruders() reports the volume's
// extruder_id (1 by default), which would put slot 0 on the plate. Slot 0 routes to head 0,
// the declared primary, so the plate would be well-formed and correctly NOT blocked.
TEST_CASE("An IMEX plate whose filament never routes to the Primary tool is blocked",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 6);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "6" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("T0") != std::string::npos); // the declared primary
CHECK(err.string.find("T2") != std::string::npos); // where the filament actually lives
}
// A mixed filament is blended at the nozzle by its component toolheads, which a parallel
// mode is already using to print copies. Unsupported regardless of where the components
// route, so this must refuse even though component filament 1 sits on the declared primary
// T0 -- and it must refuse with the mixed message, not the routing one. Mixed slots normally
// sit past the end of physical_extruder_map, so the routing rule would call them unrouted.
TEST_CASE("An IMEX plate using a mixed filament is blocked", "[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{ { { "extruder", "8" } } };
Slic3r::Model model;
Slic3r::Print print;
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Mixed filaments") != std::string::npos);
}
// Filament 8 (slot 7) is the blend, filament 1 (slot 0) is ordinary, so used_filaments is > 1
// and the multi-color rule's gate opens too. This pins that the mixed rule still wins: with the
// two the other way round the user is told the mode's active tools all sit on one gantry --
// a lecture about a multi-color print they never asked for -- and never learns the blend is
// the problem.
//
// The second half is what keeps this honest. The multi-color rule only fires here because this
// fixture's `copy` mode is degenerate (imex_tools_per_gantry defaults to 2, so "0:P,1:C" puts
// both tools on gantry 0). Give the mode a Span tool and it returns nothing, and this test would
// pass under EITHER ordering while appearing to guard it. So assert the rule is actually armed.
TEST_CASE("A mixed filament outranks the multi-color rule on the same plate",
"[MultiFilament][IMEX][Regression]")
{
// Two cubes, offset: make_cube() is corner-at-origin, so identical meshes would be exactly
// coincident. validate() returns from the IMEX block before any geometry check today, but a
// future check landing earlier would fail this test for a reason it is not about.
const auto build = [](bool blend_slot_8) {
DynamicPrintConfig config = multifilament_config(8);
imex_7x4_printer(config);
all_regions_on_filament(config, 8);
config.set_deserialize_strict({
{ "imex_parallel_mode", "copy" },
{ "filament_is_mixed", "0,0,0,0,0,0,0,1" },
{ "filament_mixed_components", ";;;;;;;1,5" },
// The mixed arrays run parallel to filament_colour and must be sized to the filament
// count (see test_mixed_filament.cpp). validate() returns before the other five are
// read, but that is a property of where the rule sits, not something to rely on.
{ "filament_mixed_sublayer_ratios", ";;;;;;;" },
{ "filament_mixed_gradient", "0,0,0,0,0,0,0,0" },
{ "filament_mixed_gradient_range", ";;;;;;;" },
{ "filament_mixed_gradient_curve", ";;;;;;;" },
{ "filament_mixed_gradient_per_part", "0,0,0,0,0,0,0,0" },
});
if (!blend_slot_8)
config.set_deserialize_strict({ { "filament_is_mixed", "0,0,0,0,0,0,0,0" } });
return config;
};
const std::vector<std::vector<ConfigBase::SetDeserializeItem>> overrides{
{ { "extruder", "8" } }, { { "extruder", "1" } },
};
const auto validate_plate = [&](const DynamicPrintConfig& config, Slic3r::Print& print,
Slic3r::Model& model) {
std::vector<TriangleMesh> meshes;
meshes.push_back(cube(20));
TriangleMesh second = cube(20);
second.translate(30.0, 0.0, 0.0);
meshes.push_back(second);
init_print(std::move(meshes), print, model, config, &overrides, false);
std::vector<StringObjectException> warnings;
return print.validate(&warnings);
};
// The multi-color rule IS armed for this plate -- without that, the check below proves nothing.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(false), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("Multi-color") != std::string::npos);
}
// With the blend present the mixed rule takes precedence over it.
{
Slic3r::Model model;
Slic3r::Print print;
const StringObjectException err = validate_plate(build(true), print, model);
REQUIRE_FALSE(err.string.empty());
CHECK(err.string.find("not supported in IDEX/IQEX parallel modes") != std::string::npos);
CHECK(err.string.find("Multi-color") == std::string::npos);
// The two rules differ in more than wording: the mixed path attaches an object (for the
// notification's "Jump to" link), the multi-color path returns none. Pins which fired
// independently of the message text.
CHECK(err.object == print.objects().front());
}
}
// A tool change added from the layer slider switches heads mid-print just as a painted color
// does, so a parallel mode has to judge it with the plate's other filaments. Before it counted,
// a one-filament plate with a slider change to a copying head's filament passed validate().
TEST_CASE("A layer slider tool change counts as a color in a parallel mode", "[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1); // filament 1 => logical slot 0 => physical head 0
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
Slic3r::Model model;
Slic3r::Print print;
init_print({ cube(20) }, print, model, config);
std::vector<StringObjectException> warnings;
REQUIRE(print.validate(&warnings).string.empty());
// Filament 5 routes to head 1, which copies the primary in this mode.
model.plates_custom_gcodes[model.curr_plate_index].gcodes.push_back(
{ 5.0, CustomGCode::Type::ToolChange, 5, "", "" });
print.apply(model, print.full_print_config());
REQUIRE_FALSE(print.validate(&warnings).string.empty());
}
// Guard rail: the block must not fire on a well-formed plate. Filament 1 (slot 0) routes to
// head 0, which `copy` declares Primary, so the Primary tool has something to print with.
TEST_CASE("An IMEX plate whose filament routes to the Primary tool validates",
"[MultiFilament][IMEX]")
{
DynamicPrintConfig config = multifilament_config(7);
imex_7x4_printer(config);
all_regions_on_filament(config, 1);
config.set_deserialize_strict({ { "imex_parallel_mode", "copy" } });
Slic3r::Model model;
Slic3r::Print print;
init_print({ cube(20) }, print, model, config);
std::vector<StringObjectException> warnings;
const StringObjectException err = print.validate(&warnings);
CHECK(err.string.empty());
}
// A filament can define several variants (Standard, High Flow). Each filament prints with its
// variant of the extruder's variant string, or with its own first variant when it defines none, on a
// printer listing a single variant as on one listing several.
@@ -947,6 +1531,44 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant on a
}
}
// Without a dynamic nozzle map the hotend placeholders carry no hotend index (-1) on a BBL printer: Bambu
// firmware reads an explicit index as a request for the Filament Track Switch and rejects the job. Any other
// printer gets the extruder index of the filament.
TEST_CASE("Hotend placeholders resolve to -1 on a BBL printer and to the extruder index elsewhere", "[MultiFilament]")
{
// filament 1 prints the walls on extruder 2, filament 2 the infill on extruder 1
auto [is_bbl, start, changes] = GENERATE(table<bool, std::string, std::set<std::string>>({
{ true, "; hotend start filament 0: -1 -1 -1",
{ "; hotend change filament 0: -1 -1", "; hotend change filament 1: -1 -1" } },
// the first change loads filament 1 with no filament before it, so there is no outgoing extruder
{ false, "; hotend start filament 0: 1 1 1",
{ "; hotend change filament -1: -1 1", "; hotend change filament 0: 1 0", "; hotend change filament 1: 0 1" } },
}));
DynamicPrintConfig config = two_extruder_pressure_advance_config("2,1", "0,0,0,0", 1, 2);
config.set_key_value("machine_start_gcode", new ConfigOptionString(
"; hotend start filament [initial_no_support_extruder]: [initial_no_support_hotend] [current_hotend] {first_non_support_hotend[0]}"));
config.set_key_value("change_filament_gcode", new ConfigOptionString(
"; hotend change filament [current_filament_id]: [current_hotend] [next_hotend]"));
Print print;
print.is_BBL_printer() = is_bbl;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gcode = Slic3r::Test::gcode(print);
INFO("BBL printer: " << is_bbl);
std::vector<std::string> starts;
std::set<std::string> found;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
if (line.rfind("; hotend start ", 0) == 0)
starts.push_back(line);
else if (line.rfind("; hotend change ", 0) == 0)
found.insert(line);
}
CHECK(starts == std::vector<std::string>{ start });
CHECK(found == changes);
}
// Filament 1 prints the walls on extruder 1 (variant index 0), filament 2 the infill on extruder 2 (variant index 3).
TEST_CASE("Adaptive pressure advance on one extruder leaves the other extruder's pressure advance alone", "[MultiFilament]")
{
+43
View File
@@ -23,9 +23,11 @@
#include "libslic3r/GCode/SeamPlacer.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/ExPolygon.hpp"
#include <cstddef>
#include <string>
#include <string_view>
#include <cstdlib>
using namespace Slic3r;
@@ -1427,3 +1429,44 @@ TEST_CASE("Full containment warns for Blocked but not for Enforced", "[PreciseSe
else
CHECK(fixture.warning().empty());
}
TEST_CASE("Belt printers slice Precise Seam modifiers in the frame the object was sliced in", "[PreciseSeam][belt]")
{
Model model;
Print print;
Test::init_print({Test::cube(20)}, print, model, {
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
// Keep the first layer's islands the raw slice, like the modifier's.
{ "elefant_foot_compensation", 0 },
});
print.process();
const PrintObject *object = print.get_object(0);
REQUIRE(object->layer_count() > 0);
// A modifier with the object's own mesh and placement must slice to the object's own islands
// on every layer. Sliced without the belt rotation it would give 20 mm squares on the lower
// layers and nothing above 20 mm, while the tilted cube reaches about 28 mm.
Model modifiers;
ModelVolume *modifier = modifiers.add_object()->add_volume(*model.objects.front()->volumes.front());
modifier->set_type(ModelVolumeType::PRECISE_SEAM_BLOCKED);
const std::vector<ExPolygons> slices = object->slice_single_volume_regions(modifier);
REQUIRE(slices.size() == object->layer_count());
const double tolerance = scale_(0.05) * scale_(20.);
for (size_t i = 0; i < slices.size(); ++i) {
CAPTURE(i, object->get_layer(int(i))->slice_z);
const ExPolygons &islands = object->get_layer(int(i))->lslices;
double object_area = 0., modifier_area = 0.;
for (const ExPolygon &island : islands)
object_area += island.area();
for (const ExPolygon &region : slices[i])
modifier_area += region.area();
CHECK(std::abs(modifier_area - object_area) < tolerance);
CHECK(get_extents(slices[i]).inflated(scale_(0.05)).contains(get_extents(islands)));
}
}
+834
View File
@@ -14,6 +14,7 @@
#include <catch2/generators/catch_generators_range.hpp>
#include <catch2/catch_message.hpp>
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include <cstddef>
#include "libslic3r/Surface.hpp"
#include "libslic3r/Config.hpp"
@@ -26,6 +27,17 @@
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "libslic3r/Support/TreeModelVolumes.hpp"
#include "libslic3r/Support/TreeSupportCommon.hpp"
#include "libslic3r/Support/BeltFloorContext.hpp"
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/Polyline.hpp"
#include <limits>
#include <cmath>
#include <map>
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
@@ -36,6 +48,10 @@
#include "test_utils.hpp"
#include <algorithm>
#include <boost/algorithm/string/predicate.hpp>
#include <cstdlib>
#include <sstream>
#include <limits>
#include <fstream>
#include <iterator>
#include <memory>
@@ -451,6 +467,88 @@ TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate
CHECK(err.string.empty());
}
TEST_CASE("Purge tower selection keeps ordinary printers on the classic path", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 0 },
{ "enable_prime_tower", 1 },
{ "enable_belt_purge_tower", 1 }
});
config.set_key_value("timelapse_type", new ConfigOptionEnum<TimelapseType>(TimelapseType::tlSmooth));
Model model;
Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
CHECK(print.has_wipe_tower());
CHECK_FALSE(print.has_belt_purge_tower());
}
TEST_CASE("Belt purge planning requires its managed purge object", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 }
});
Model model;
Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
CHECK_FALSE(print.has_belt_purge_tower());
model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK(print.has_belt_purge_tower());
CHECK_FALSE(print.has_wipe_tower());
}
// The GUI creates the purge tower object; a project sliced without one (the CLI) must say
// that its filament changes go unpurged.
TEST_CASE("Belt purge tower enabled without a tower object warns", "[Print][PurgeTower][belt]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 },
{ "layer_change_gcode", "G92 E0\n" }
});
auto purge_warnings = [](Print &print) {
std::vector<StringObjectException> warnings;
print.validate(&warnings);
return std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) {
return w.opt_key == "enable_belt_purge_tower";
});
};
Model model;
Print print;
build_cubes(model, print, config, /*n=*/2, /*overlap=*/false);
model.objects[1]->config.set_key_value("extruder", new ConfigOptionInt(2));
print.apply(model, config);
REQUIRE(print.extruders().size() > 1);
CHECK(purge_warnings(print) == 1);
model.objects.front()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK(purge_warnings(print) == 0);
}
TEST_CASE("Belt purge rejects multiple managed purge objects", "[Print][PurgeTower][Regression]")
{
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "enable_belt_purge_tower", 1 }
});
Model model;
Print print;
build_cubes(model, print, config, /*n=*/2, /*overlap=*/false);
for (ModelObject *object : model.objects)
object->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
CHECK_FALSE(print.validate().string.empty());
}
TEST_CASE("A default slice emits perimeter, infill, and skirt", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
@@ -661,6 +759,199 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult
}
}
// A scarf joint starts one layer height below the layer and ramps up along the
// wall. On a tilted belt that start is a step backwards along the belt axis, into
// the previous layer's wall at the seam: 0.283 mm per 0.2 mm layer at 45 degrees.
// With an aligned seam the nozzle rams the same spot on every layer (field report
// from a BabyBelt Pro: the belt "jumped backwards" and knocked the part loose).
// Belt printers therefore never get a scarf, whatever the process preset says.
TEST_CASE("Belt printers never start a scarf seam below the layer", "[Print][belt][Seam]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "wall_loops", 2 },
{ "seam_position", "back" },
{ "seam_slope_type", "external" },
{ "seam_slope_inner_walls", 1 },
{ "seam_slope_start_height", 0 },
// No z-hop: on a belt a lift is a move along the belt axis (0.4 mm / sin 45 = 0.57 mm)
// and its return would read as a back-step. The shipped belt profiles print without one.
{ "z_hop", 0 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
// The belt axis is machine Z. Within a layer it only drifts by the frame
// coupling (well under 0.1 mm across a 20 mm cube); a scarf start is a full
// layer pitch (0.283 mm) backwards.
double last_z = std::numeric_limits<double>::lowest();
double worst_backstep = 0.;
GCodeReader parser;
parser.parse_buffer(gcode, [&](GCodeReader &, const GCodeReader::GCodeLine &line) {
if (! line.cmd_is("G1") || ! line.has_z())
return;
const double z = line.z();
if (last_z != std::numeric_limits<double>::lowest())
worst_backstep = std::max(worst_backstep, last_z - z);
last_z = z;
});
CHECK(worst_backstep < 0.2);
}
// printable_height on a belt printer is the clearance under the gantry, so an object taller
// than that is refused whatever the machine-frame transform does to the emitted coordinates.
TEST_CASE("Belt printers refuse an object taller than the gantry clearance", "[Print][belt]")
{
auto belt_config = [](double printable_height) {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "printable_height", printable_height },
{ "skirt_loops", 0 },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
};
SECTION("a 20 mm cube fits under 50 mm of clearance") {
Print print;
Model model;
init_print({ cube(20) }, print, model, belt_config(50));
CHECK(print.validate().string.empty());
}
SECTION("a 60 mm cube does not") {
Print print;
Model model;
init_print({ cube(60) }, print, model, belt_config(50));
CHECK(print.validate().string.find("height") != std::string::npos);
}
}
// On a belt every tilted layer starts on the belt, so "the first layers" the fan stays off
// for are a band along the belt, not the first slicing layers. The generator marks where
// each extrusion segment enters and leaves that band and the cooling buffer keeps the fan
// off inside it, on every layer.
TEST_CASE("Belt printers keep the part fan off within the band above the belt", "[Print][belt][Cooling]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
// Three layers, 0.6 mm: the lowest wall of each tilted layer is centred about 0.3 mm
// above the belt (half a line width in from the contact edge).
{ "close_fan_the_first_x_layers", 3 },
{ "full_fan_speed_layer", 0 },
{ "fan_min_speed", 100 },
{ "fan_max_speed", 100 },
{ "slow_down_layer_time", 1000 },
{ "fan_cooling_layer_time", 1001 },
{ "reduce_fan_stop_start_freq", 0 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
// The markers are consumed by the cooling buffer and never reach the file.
CHECK(gcode.find(";_BELT_BAND") == std::string::npos);
// With this axis mapping machine Y is the height above the belt along the gantry. Walk
// the moves with the fan state: extrusions that stay within 0.45 mm of the belt are well
// inside the band and must print with the fan off; extrusions that stay 5 mm clear of it
// must print with it on. The first three slicing layers have the fan off altogether.
size_t in_band = 0, in_band_fan_on = 0, clear = 0, clear_fan_off = 0;
int layer = -1;
bool fan_on = false;
double y = 0.;
std::istringstream lines(gcode);
for (std::string line; std::getline(lines, line); ) {
if (boost::starts_with(line, ";LAYER_CHANGE")) {
++ layer;
} else if (boost::starts_with(line, "M107")) {
fan_on = false;
} else if (boost::starts_with(line, "M106")) {
const size_t s = line.find('S');
fan_on = s != std::string::npos && std::atof(line.c_str() + s + 1) > 0.;
} else if (boost::starts_with(line, "G1 ")) {
const size_t comment = line.find(';');
const std::string cmd = line.substr(0, comment);
const size_t ypos = cmd.find(" Y"), epos = cmd.find(" E");
if (ypos == std::string::npos)
continue;
const double y_new = std::atof(cmd.c_str() + ypos + 2);
const bool extruding = epos != std::string::npos && std::atof(cmd.c_str() + epos + 2) > 0.;
if (extruding && layer >= 3) {
if (std::max(y, y_new) < 0.45) {
++ in_band;
in_band_fan_on += fan_on;
} else if (std::min(y, y_new) > 5.) {
++ clear;
clear_fan_off += ! fan_on;
}
}
y = y_new;
}
}
CHECK(in_band > 20);
CHECK(in_band_fan_on == 0);
CHECK(clear > 20);
CHECK(clear_fan_off == 0);
}
// Organic supports under an overhang on a belt printer reach below the object's first layer,
// where the virtual belt raft layers sit at negative Z. The lowest of them used to get a
// negative height and abort slicing with a negative flow error.
TEST_CASE("Belt printers slice organic tree supports that reach the belt", "[Print][belt][Support]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
std::string gcode;
REQUIRE_NOTHROW(gcode = slice({ TestMesh::overhang }, config));
CHECK(! gcode.empty());
}
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
{
Print print;
@@ -686,3 +977,546 @@ TEST_CASE("Slicing errors are reported per object with the object's name", "[Pri
CHECK(message.rfind("floating cube: ", 0) == 0);
CHECK(message.find("empty first layer") != std::string::npos);
}
// ---------------------------------------------------------------------------
// Belt mode must be invisible when it is off, and must not leave traces behind.
// ---------------------------------------------------------------------------
// Everything the slicer decided, without the lines that legitimately differ between
// two exports of the same print: comments (the config block lists every key, the
// header carries the export time) and the thumbnail blocks.
static std::string gcode_body(const std::string &gcode)
{
std::string body;
std::istringstream in(gcode);
for (std::string line; std::getline(in, line); ) {
line.erase(std::min(line.size(), line.find(';')));
while (! line.empty() && line.back() == ' ')
line.pop_back();
if (! line.empty())
body += line + '\n';
}
return body;
}
static DynamicPrintConfig belt_test_config()
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
}
TEST_CASE("Belt-only keys at non-default values leave non-belt G-code unchanged", "[Print][belt][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "sparse_infill_pattern", "adaptivecubic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
const std::string reference = gcode_body(slice({ TestMesh::overhang }, config));
REQUIRE(! reference.empty());
// Every belt key a profile can carry, at a value that would change a belt print.
// belt_printer stays off, so none of them may reach the G-code: the axis remaps are
// gated on belt mode, the rest is only read on belt printers. build_plate_tilt_x/y
// is a feature of its own on a flat bed and is left alone here; "leading_edge_only"
// prints as an outer brim by design.
config.set_deserialize_strict({
{ "belt_printer", 0 },
{ "belt_printer_infinite_y", 0 },
{ "belt_slice_rotation", "y" },
{ "belt_slice_rotation_angle", 30 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "belt_frame_tilt_decouple", 1 },
{ "belt_frame_tilt_angle", 30 },
{ "belt_support_floor_offset", -5 },
{ "enable_belt_purge_tower", 1 },
{ "belt_purge_tower_width", 10 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 5 },
});
CHECK(gcode_body(slice({ TestMesh::overhang }, config)) == reference);
}
TEST_CASE("Switching a sliced project from belt to non-belt matches a fresh slice", "[Print][belt][Regression]")
{
// The organic support layers and the adaptive infill octree are placed with the
// belt global Z offset, and the mesh with the belt min-Z lift. Both are only
// written while belt mode slices, so they used to survive a switch away from it.
DynamicPrintConfig flat = DynamicPrintConfig::full_print_config();
flat.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "sparse_infill_pattern", "adaptivecubic" },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
DynamicPrintConfig belt = belt_test_config();
belt.set_deserialize_strict({
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "sparse_infill_pattern", "adaptivecubic" },
});
// Both prints are placed with the belt config, so only the slicing history differs.
auto fresh_slice = [&](const DynamicPrintConfig &target) {
Print print;
Model model;
init_print({ TestMesh::overhang }, print, model, belt);
print.apply(model, target);
const std::string out = gcode(print);
return gcode_body(out);
};
auto resliced = [&](const DynamicPrintConfig &target) {
Print print;
Model model;
init_print({ TestMesh::overhang }, print, model, belt);
REQUIRE(! gcode(print).empty());
print.apply(model, target);
const std::string out = gcode(print);
return gcode_body(out);
};
SECTION("belt printer to a flat-bed printer") {
CHECK(resliced(flat) == fresh_slice(flat));
}
SECTION("belt tilt axis set to None") {
DynamicPrintConfig untilted = belt;
untilted.set_deserialize_strict({ { "belt_slice_rotation", "none" } });
CHECK(resliced(untilted) == fresh_slice(untilted));
}
}
TEST_CASE("A support-only change on a belt purge print matches a fresh slice", "[Print][belt][PurgeTower][Regression]")
{
// Snapping the purge prism onto the parts' layer grid shifts every object's layers by
// up to half a layer. A support-only change reruns support generation without
// reslicing, so the cached belt floor and the global Z offset have to carry the
// snap too, or the supports land on the pre-snap grid.
auto make_config = [](bool support) {
DynamicPrintConfig config = multifilament_config(2, {
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "enable_belt_purge_tower", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
config.set_deserialize_strict({
{ "enable_support", support ? 1 : 0 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
});
return config;
};
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
auto build = [&](Print &print, Model &model, const DynamicPrintConfig &config) {
init_print(std::vector<TriangleMesh>{ mesh(TestMesh::overhang), cube(20) }, print, model, config, &overrides);
model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
REQUIRE(print.has_belt_purge_tower());
};
std::string fresh;
{
Print print;
Model model;
build(print, model, make_config(true));
fresh = gcode_body(gcode(print));
}
REQUIRE(! fresh.empty());
Print print;
Model model;
build(print, model, make_config(false));
REQUIRE(! gcode(print).empty());
// Support only: posSlice stays valid, posSupportMaterial reruns.
print.apply(model, make_config(true));
CHECK(gcode_body(gcode(print)) == fresh);
}
TEST_CASE("Organic tree supports place a support blocker at its own height above a raft", "[Print][Support][Regression]")
{
// TreeModelVolumes consumes the support blockers in the same index space as the
// layer outlines, where object layer i sits at num_raft_layers + i, but
// slice_support_blockers() returns them in object-layer space. Without the shift
// every blocker lands num_raft_layers too low, so branches are kept out of the
// wrong layers and may pass through the blocked ones.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "raft_layers", 3 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
// A blocker floating beside the cube, 8 mm to 12 mm above the bed, so a collision at
// its centre can only come from the blocker itself (the part keeps its mesh
// coordinates in object space, hence the offset relative to the part).
ModelObject *object = model.objects.front();
ModelVolume *blocker = object->add_volume(TriangleMesh(its_make_cube(6., 6., 4.)));
blocker->set_type(ModelVolumeType::SUPPORT_BLOCKER);
const Vec3d part_offset = object->volumes.front()->get_offset();
blocker->set_offset(Vec3d(part_offset.x() + 20., part_offset.y(), 10.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &print_object = *print.objects().front();
const std::vector<Vec2d> bed = { { 0., 0. }, { 200., 0. }, { 200., 200. }, { 0., 200. } };
const BuildVolume build_volume{ bed, print.config().printable_height.value, {}, {} };
TreeSupport3D::TreeModelVolumes volumes{ print_object, build_volume, scaled<coord_t>(1.), scaled<coord_t>(0.5), 0, {} };
// The generator's raft layer count: the raft itself plus the gap layers up to the object.
const size_t num_raft = TreeSupport3D::TreeSupportSettings(TreeSupport3D::TreeSupportMeshGroupSettings(print_object),
print_object.slicing_parameters()).raft_layers.size();
REQUIRE(num_raft >= 3);
// Object layers the blocker was sliced into (object-layer space, as the generator
// receives them).
const std::vector<Polygons> blockers = print_object.slice_support_blockers();
size_t first = 0, last = 0;
bool found = false;
for (size_t i = 0; i < blockers.size(); ++ i)
if (! blockers[i].empty()) {
if (! found) { first = i; found = true; }
last = i;
}
REQUIRE(found);
REQUIRE(last - first > num_raft);
// The blocker's centre in the slicing frame (add_volume centred its mesh on its offset).
const Vec3d centre3 = print_object.trafo_sliced() * blocker->get_offset();
const Point centre = Point::new_scale(centre3.x(), centre3.y());
auto collides = [&](size_t tree_layer) {
for (const Slic3r::Polygon &poly : volumes.getCollision(0, TreeSupport3D::LayerIndex(tree_layer), false))
if (poly.contains(centre))
return true;
return false;
};
// In TreeModelVolumes' index space the blocker lives at num_raft + object layer.
CHECK(collides(num_raft + first));
CHECK(collides(num_raft + last));
// The layers just below it, where an unshifted blocker would land, are free; the
// layers just above the unshifted range, which the blocker does occupy, are not.
CHECK_FALSE(collides(first));
CHECK_FALSE(collides(first + num_raft - 1));
CHECK(collides(last + 1));
CHECK(collides(last + num_raft));
}
// organic_draw_branches() trims every branch slice against the collision volume (the
// part grown by the support XY distance), the bed and, on a belt, the belt plane before
// it becomes support, so a branch never runs into the part it supports. Not a belt
// feature: this is the generator every printer uses.
TEST_CASE("Organic tree supports keep their distance from the part", "[Print][Support]")
{
// A 20 mm cube carrying a 60 x 60 mm plate: a 20 mm wide ceiling all around the
// cube, 16 mm above the bed, with the cube's four corners in the way of the branches
// that drop from it. The plate reaches into the cube so the two shells overlap
// instead of sharing a face.
indexed_triangle_set its = its_make_cube(20., 20., 20.);
indexed_triangle_set plate = its_make_cube(60., 60., 4.);
its_translate(its, Vec3f(20.f, 20.f, 0.f));
its_translate(plate, Vec3f(0.f, 0.f, 16.f));
its_merge(its, plate);
TriangleMesh mesh(std::move(its));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "enable_support", 1 },
{ "support_type", "tree(auto)" },
{ "support_style", "organic" },
{ "support_threshold_angle", 30 },
});
Print print;
Model model;
init_print({ mesh }, print, model, config);
// On the bed, not at its corner (the fixture leaves the object at the origin).
model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &object = *print.objects().front();
INFO("object layers " << object.layers().size() << ", support layers " << object.support_layers().size());
REQUIRE(! object.support_layers().empty());
// Support exists under the plate at all.
size_t support_layers_with_fills = 0;
for (const SupportLayer *layer : object.support_layers())
if (! layer->support_fills.empty())
++ support_layers_with_fills;
INFO("support layers with extrusions " << support_layers_with_fills);
CHECK(support_layers_with_fills > 20);
// Object layers by print_z, to look up the part's slice at a support layer's height.
std::map<coord_t, const Layer *> object_layers;
for (const Layer *layer : object.layers())
object_layers[scaled<coord_t>(layer->print_z)] = layer;
auto contains = [](const ExPolygons &expolys, const Point &pt) {
for (const ExPolygon &ex : expolys)
if (ex.contains(pt))
return true;
return false;
};
// No support extrusion may run closer to the part's slice than half a line width:
// the generator keeps the support XY distance (0.35 mm by default) plus the line's
// own half width away from it.
const float min_gap = scaled<float>(0.2);
size_t too_close = 0, points = 0, layers_checked = 0, layers_unmatched = 0;
for (const SupportLayer *layer : object.support_layers()) {
if (layer->support_fills.empty())
continue;
// The object layer whose slab spans this support layer's height.
auto it = object_layers.lower_bound(scaled<coord_t>(layer->print_z - EPSILON));
if (it == object_layers.end()) {
++ layers_unmatched;
continue;
}
++ layers_checked;
const ExPolygons grown = offset_ex(it->second->lslices, min_gap);
for (const ExtrusionEntity *entity : layer->support_fills.flatten().entities)
for (const Slic3r::Polyline &pl : entity->as_polylines())
for (size_t i = 0; i < pl.points.size(); ++ i) {
// The vertices and the midpoints of the segments between them.
++ points;
if (contains(grown, pl.points[i]))
++ too_close;
if (i + 1 < pl.points.size() && contains(grown, (pl.points[i] + pl.points[i + 1]) / 2))
++ too_close;
}
}
INFO("support layers checked " << layers_checked << " (unmatched " << layers_unmatched << "), support points " << points
<< ", within 0.2 mm of the part " << too_close);
CHECK(layers_checked > 20);
CHECK(layers_unmatched == 0);
REQUIRE(points > 0);
CHECK(too_close == 0);
}
// Two parts along the belt: the second part's slicing frame starts at the belt
// below its leading end, so its first layers are empty and interleave with the
// first part's printing layers. Those must not reach the G-code as layer changes
// that print nothing: the preview numbers its layers from the moves it sees, and
// a gap folded every later layer into the one before it.
TEST_CASE("Belt G-code has no layer that prints nothing", "[Print][belt][GCode][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
// Both export paths drop the empty layers and count the layers the same way.
SECTION("by layer") { config.set_deserialize_strict({{ "print_sequence", "by layer" }}); }
SECTION("by object") { config.set_deserialize_strict({{ "print_sequence", "by object" }}); }
Print print;
Model model;
TriangleMesh cube_a(its_make_cube(20., 20., 20.));
TriangleMesh cube_b(its_make_cube(20., 20., 20.));
init_print({ cube_a, cube_b }, print, model, config);
// 60 mm apart along the belt: the second cube's lead-in layers fall among the
// first cube's layers.
model.objects[0]->instances.front()->set_offset(Vec3d(50., 40., 0.));
model.objects[1]->instances.front()->set_offset(Vec3d(50., 100., 0.));
print.apply(model, config);
print.set_status_silent();
const std::string gc = gcode(print);
REQUIRE(! gc.empty());
size_t layers = 0, empty = 0, total_header = 0, total_count = 0;
bool extruded = true; // before the first layer change
auto close_layer = [&]() { if (! extruded) ++ empty; };
GCodeReader reader;
reader.apply_config(config);
reader.parse_buffer(gc, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string &raw = line.raw();
if (raw.rfind(";LAYER_CHANGE", 0) == 0) {
close_layer();
++ layers;
extruded = false;
} else if (raw.rfind("; total layer number: ", 0) == 0) {
// Counted by the G-code processor from the layer changes it saw.
total_header = size_t(std::atoi(raw.c_str() + 22));
} else if (raw.rfind("; total layers count = ", 0) == 0) {
// GCode::m_layer_count, counted up front from the objects' layers; it also
// drives the M73 progress and the total_layer_count placeholder.
total_count = size_t(std::atoi(raw.c_str() + 23));
} else if (! extruded && line.extruding(self) && line.dist_XY(self) > EPSILON) {
// Material laid down along a move: a wipe or an unretraction does not count.
extruded = true;
}
});
close_layer();
INFO("layers " << layers << ", header " << total_header << ", count " << total_count
<< ", layers without extrusion " << empty);
CHECK(layers > 150); // both cubes, 141 layers each, overlapping along the belt
CHECK(empty == 0);
CHECK(total_header == layers);
CHECK(total_count == layers);
}
// A part with an overhang on its LEADING side (the end that prints first) needs
// supports below the object's own lowest slicing layer: the belt under that overhang
// is reached before the object's first contact with it, so the support layers sit at
// a lower slicing Z than any object layer. A generator that stops at the object's
// first layer, or at global Z = 0, leaves those supports floating above the belt.
TEST_CASE("Belt supports reach the belt under a leading overhang", "[Print][belt][Support][Regression]")
{
// default resolves to organic for tree support; tree_hybrid is the classic tree.
const char *support_type = GENERATE("normal(auto)", "tree(auto)");
const char *support_style = GENERATE("default", "organic", "tree_hybrid");
if (std::string(support_type) == "normal(auto)" && std::string(support_style) != "default")
return; // organic and tree_hybrid are tree styles
DYNAMIC_SECTION(support_type << " / " << support_style) {
// A 20 mm cube with a 2 mm thick fin that leaves its top edge and reaches
// 20 mm toward -Y, the end of the part that prints first, climbing at 45 deg
// as it goes (from z = 18 at the cube to z = 38 at the tip). With the layers
// leaning toward -Y at 45 deg the fin's underside is parallel to the layers:
// a ceiling 20 x 28 mm in one layer, with nothing but air between it and the
// belt, which lies up to 41 mm (of slicing Z) below the object's own lowest
// point. Support has to span all of it.
indexed_triangle_set its = its_make_cube(20., 20., 20.);
indexed_triangle_set fin = its_make_cube(20., 20., 2.);
Transform3d shear = Transform3d::Identity();
shear.matrix() << 1., 0., 0., 0.,
0., 1., 0., -20.,
0., -1., 1., 38.,
0., 0., 0., 1.;
its_transform(fin, shear);
its_merge(its, fin);
TriangleMesh mesh(std::move(its));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "z_hop", 0 },
{ "enable_support", 1 },
{ "support_type", support_type },
{ "support_style", support_style },
{ "support_threshold_angle", 30 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
Print print;
Model model;
init_print({ mesh }, print, model, config);
// On the bed, not at its corner: organic tree support clips its branches to
// the bed outline, and the fixture leaves the object at the origin.
model.objects.front()->instances.front()->set_offset(Vec3d(100., 100., 0.));
print.apply(model, config);
print.set_status_silent();
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(! object.layers().empty());
// The whole part is sliced: the layers lean at 45 deg, so the part spans
// (y + z) / sqrt(2) of slicing Z, and every layer in that span has geometry.
{
double lo = std::numeric_limits<double>::max(), hi = std::numeric_limits<double>::lowest();
for (const stl_vertex &v : mesh.its.vertices) {
lo = std::min<double>(lo, v.y() + v.z());
hi = std::max<double>(hi, v.y() + v.z());
}
const double span = (hi - lo) / std::sqrt(2.);
size_t nonempty = 0;
for (const Layer *layer : object.layers())
if (! layer->lslices.empty())
++ nonempty;
INFO("non-empty object layers " << nonempty << ", slicing span " << span << " mm");
CHECK(double(nonempty) * 0.2 > span - 0.6);
}
BeltFloorContext floor;
REQUIRE(floor.init(object.slicing_parameters(), print.config()));
// The lowest support layer that prints anything, and the belt floor beneath it.
const SupportLayer *lowest = nullptr;
for (const SupportLayer *layer : object.support_layers())
if (! layer->support_fills.empty() && (lowest == nullptr || layer->print_z < lowest->print_z))
lowest = layer;
REQUIRE(lowest != nullptr);
double floor_under_lowest = std::numeric_limits<double>::max();
for (const ExtrusionEntity *entity : lowest->support_fills.flatten().entities)
for (const Slic3r::Polyline &pl : entity->as_polylines())
for (const Point &pt : pl.points)
floor_under_lowest = std::min(floor_under_lowest, floor.floor_print_z(pt));
// The object's lowest geometry. The slicing frame starts at the lowest
// belt-floor point under the footprint, so the layers below the leading
// tip of the overhang are empty.
double first_object_z = std::numeric_limits<double>::max();
for (const Layer *layer : object.layers())
if (! layer->lslices.empty()) { first_object_z = layer->print_z; break; }
REQUIRE(first_object_z < std::numeric_limits<double>::max());
INFO("lowest support z " << lowest->print_z << ", floor under it " << floor_under_lowest
<< ", first object layer " << first_object_z);
// Well below the object's own lowest layer (the belt under the tip of the fin
// is ~41 mm of slicing Z below the cube's leading edge, which rests on it)...
CHECK(lowest->print_z < first_object_z - 5.);
// ...and resting on the belt: within a few layers of the floor beneath its own lines.
CHECK(lowest->print_z - floor_under_lowest < 4. * 0.2 + EPSILON);
CHECK(lowest->print_z - floor_under_lowest > -0.2 - EPSILON);
}
}
+6 -4
View File
@@ -512,12 +512,13 @@ TEST_CASE("Separated infill keeps fragmented and nested bodies independent", "[P
print.process();
const PrintObject &object = *print.objects().front();
REQUIRE(object.layer_count() > 1);
CHECK(object.separated_body_bboxes().size() == grid_size * grid_size + 2);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices.size() == grid_size * grid_size + 2);
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
REQUIRE(layer->lslices_separated_component_ids.size() == layer->lslices.size());
size_t holes = 0;
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &body = layer->lslices_separated_component_bboxes[i];
const BoundingBox &body = object.separated_body_bboxes()[layer->lslices_separated_component_ids[i]];
const BoundingBox &island = layer->lslices_bboxes[i];
CHECK(body.min == island.min);
CHECK(body.max == island.max);
@@ -574,6 +575,7 @@ TEST_CASE("Body centering survives islands merging and splitting between layers"
REQUIRE(object.get_layer(1)->lslices.size() == 3);
REQUIRE(object.get_layer(2)->lslices.size() == 3);
REQUIRE(object.get_layer(4)->lslices.size() == 5);
CHECK(object.separated_body_bboxes().size() == 2);
BoundingBox isolated_bbox = object.get_layer(0)->lslices_bboxes.front();
for (const BoundingBox &bbox : object.get_layer(0)->lslices_bboxes)
@@ -585,10 +587,10 @@ TEST_CASE("Body centering survives islands merging and splitting between layers"
if (bbox.min.x() < isolated_bbox.min.x())
connected_bbox.merge(bbox);
for (const Layer *layer : object.layers()) {
REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size());
REQUIRE(layer->lslices_separated_component_ids.size() == layer->lslices.size());
for (size_t i = 0; i < layer->lslices.size(); ++ i) {
const BoundingBox &expected = layer->lslices_bboxes[i].min.x() < isolated_bbox.min.x() ? connected_bbox : isolated_bbox;
const BoundingBox &actual = layer->lslices_separated_component_bboxes[i];
const BoundingBox &actual = object.separated_body_bboxes()[layer->lslices_separated_component_ids[i]];
CHECK(actual.min == expected.min);
CHECK(actual.max == expected.max);
}
+798 -1
View File
@@ -6,13 +6,15 @@
#include <catch2/catch_message.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/ClipperUtils.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Geometry/ConvexHull.hpp"
#include "libslic3r/Layer.hpp"
#include <boost/algorithm/string.hpp>
#include <cctype>
#include <cmath>
#include <cstddef>
#include "libslic3r/Polygon.hpp"
@@ -24,6 +26,13 @@
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include <string_view>
#include <limits>
#include <map>
#include <set>
#include <sstream>
#include <boost/algorithm/string/predicate.hpp>
#include <initializer_list>
#include <utility>
#include "test_helpers.hpp" // get access to init_print, etc
#include "libslic3r/BoundingBox.hpp"
@@ -32,6 +41,10 @@
#include "libslic3r/ExtrusionEntityCollection.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/BeltBrim.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/TriangleMesh.hpp"
using namespace Slic3r::Test;
using namespace Slic3r;
@@ -612,3 +625,787 @@ SCENARIO("Skirt and brim generation", "[SkirtBrim]") {
}
}
}
// Belt printers ---------------------------------------------------------------
//
// On a tilted belt the brim is laid onto the belt PLANE rather than into the Z=0
// bed plane, so it is spread across many layers instead of living on the first
// one. The discriminating measurement is the number of contiguous brim runs in
// the G-code: a flat plate brim gives a single run, a belt brim gives one per
// layer that carries a band. Distinct Z values are useless here, because the
// machine-frame transform couples Y into Z so every belt move has its own Z.
static DynamicPrintConfig belt_brim_config()
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
return config;
}
// Same belt as belt_brim_config(), but with `filaments` distinct filaments so the brim's
// tool selection can be observed. Kept separate from belt_brim_config() so the existing
// single-filament belt tests are untouched.
static DynamicPrintConfig belt_brim_multifilament_config(unsigned int filaments,
std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra = {})
{
DynamicPrintConfig config = multifilament_config(filaments);
config.set_deserialize_strict({
{ "belt_printer", 1 },
{ "belt_slice_rotation", "x" },
{ "belt_slice_rotation_angle", 45 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "top_shell_layers", 0 },
{ "bottom_shell_layers", 1 },
{ "machine_start_gcode", "T[initial_tool]\n" },
{ "layer_change_gcode", "G92 E0\n" },
});
if (extra.size() > 0)
config.set_deserialize_strict(extra);
return config;
}
// 0-based tool indices used by extrusions whose role comment contains `role` (needs
// gcode_comments). Mirrors tools_for_role in test_multifilament.cpp; statics do not cross
// translation units, so it is repeated here.
static std::set<int> belt_tools_for_role(const std::string &gcode, const std::string &role)
{
std::set<int> tools;
int current_tool = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1]))
current_tool = std::stoi(cmd.substr(1));
else if (line.extruding(self) && std::string(line.comment()).find(role) != std::string::npos)
tools.insert(current_tool);
});
return tools;
}
// Machine Z of the first extruding move whose role comment contains `role`, in file order;
// numeric_limits<double>::max() when the role never extrudes.
static double first_role_z(const std::string &gcode, const std::string &role)
{
double z = std::numeric_limits<double>::max();
GCodeReader parser;
parser.parse_buffer(gcode, [&z, &role](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find(role) != std::string_view::npos) {
z = self.z();
self.quit_parsing();
}
});
return z;
}
// Number of object layers that carry a belt brim band. Every band prints at its own
// layer Z, so this equals role_layers(gcode, "brim") (plus any apron bands below the
// first object layer). It is not a pass count: the bands on the empty lead-in layers
// ahead of the object's first contact print back to back, so they fold into one pass.
static int nonempty_belt_brim_layers(const PrintObject &object)
{
int n = 0;
for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer())
if (! band.empty())
++ n;
return n;
}
// Number of distinct Z heights at which `role` extrudes: one per layer that prints it.
static int role_layers(const std::string &gcode, const std::string &role)
{
std::set<long> zs;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (! line.extruding(self) || line.dist_XY(self) <= EPSILON)
return;
if (line.comment().find(role) != std::string_view::npos)
zs.insert(std::lround(self.z() * 1000.));
});
return int(zs.size());
}
// Apron bands below the object's first layer that print something.
static int belt_brim_apron_bands(const PrintObject &object)
{
int n = 0;
for (const BeltBrimBand &band : object.belt_brim_prologue())
if (! band.fills.empty())
++ n;
return n;
}
// For each active tool, the ordinal (1-based, over extruding moves) of the FIRST move whose
// role comment contains `role`. Lets a per-object ordering check key off the object's
// unique wall filament.
static std::map<int, long> first_move_by_tool(const std::string &gcode, const std::string &role)
{
std::map<int, long> first;
int tool = 0;
long idx = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
const std::string cmd(line.cmd());
if (cmd.size() >= 2 && cmd[0] == 'T' && std::isdigit((unsigned char) cmd[1])) {
tool = std::stoi(cmd.substr(1));
return;
}
if (! line.extruding(self))
return;
++ idx;
if (std::string(line.comment()).find(role) != std::string::npos && ! first.count(tool))
first[tool] = idx;
});
return first;
}
// C - the band coincident with the object's FIRST contact with the belt must not be dropped:
// a belt brim has to appear at or below the object's first perimeter. On the unfixed feature
// the first-contact band is dropped and the first brim then appears only at a later (higher)
// layer. Machine Z is meaningful and shared between roles under the belt remap, so the first
// brim's Z must not exceed the first perimeter's. Both with and without support.
TEST_CASE("Belt brim is laid at the object's first belt contact", "[SkirtBrim][belt]")
{
const bool support = GENERATE(false, true);
DYNAMIC_SECTION("enable_support=" << support) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 0 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
{ "enable_support", support ? 1 : 0 },
});
const std::string gcode = slice({ cube(20) }, config);
const double brim_z = first_role_z(gcode, "brim");
const double peri_z = first_role_z(gcode, "perimeter");
REQUIRE(brim_z < std::numeric_limits<double>::max());
REQUIRE(peri_z < std::numeric_limits<double>::max());
CHECK(brim_z <= peri_z + EPSILON);
}
}
// C control - when the band's own object layer has extrusion (any interior layer of a solid
// cube), the band takes the ordinary process_layer() path and must be drawn immediately
// before that layer's perimeters, and exactly once: never dropped, never double-emitted.
TEST_CASE("Belt brim on an object layer precedes its perimeters, once", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
// Ordering: the first thing extruded is brim, then perimeter.
const std::vector<std::string> seq = role_sequence(gc, { "brim", "perimeter" });
REQUIRE(seq.size() >= 2);
CHECK(seq[0] == "brim");
CHECK(seq[1] == "perimeter");
// Exactly once: every band prints at its own layer Z, so the number of Z heights with
// brim equals the number of bands - not fewer, which a dropped band would give. (A
// double emission would print twice at one Z: the pass count below catches that for
// the bands that sit on layers with perimeters.)
const PrintObject &object = *print.objects().front();
const int bands = nonempty_belt_brim_layers(object);
REQUIRE(bands > 0);
CHECK(role_layers(gc, "brim") == bands + belt_brim_apron_bands(object));
CHECK(role_passes(gc, "brim") <= bands);
}
// B - single extruder (filament id 1). Every band must survive the 1-based -> 0-based
// filament-id conversion the apron path performs: a wrong conversion drops all single-extruder
// bands, so the pass count would collapse. The expected count is derived from the sliced
// layers, not a ratio.
TEST_CASE("Belt brim on a single extruder emits every band once", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
const PrintObject &object = *print.objects().front();
const int expected = nonempty_belt_brim_layers(object) + belt_brim_apron_bands(object);
REQUIRE(expected > 0);
CHECK(role_layers(gc, "brim") == expected);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0 }); // filament 1 -> tool 0
}
// Number of brim segments the belt brim generator produced for `object`: the lattice
// lines of every per-layer band plus the apron prologue. Each segment is written as one
// extruding move, so this is what a G-code count has to match. A pass count cannot see a
// band emitted twice back to back (two copies of the same band merge into one pass).
static long belt_brim_segments(const PrintObject &object)
{
auto segments = [](const ExtrusionEntityCollection &fills) {
long n = 0;
for (const ExtrusionEntity *entity : fills.flatten().entities)
for (const Polyline &pl : entity->as_polylines())
n += long(pl.size()) - 1;
return n;
};
long n = 0;
for (const ExtrusionEntityCollection &band : object.belt_brim_by_layer())
n += segments(band);
for (const BeltBrimBand &band : object.belt_brim_prologue())
n += segments(band.fills);
return n;
}
// Number of extruding moves in the G-code whose role is `role`.
static long role_segments(const std::string &gcode, const std::string &role)
{
long n = 0;
GCodeReader reader;
reader.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.dist_XY(self) > EPSILON && line.comment().find(role) != std::string_view::npos)
++ n;
});
return n;
}
TEST_CASE("Belt brim writes every generated segment exactly once", "[SkirtBrim][belt]")
{
// The pass count above cannot tell one band from the same band twice in a row; the
// segment count can, so a brim band emitted twice back to back fails here.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "leading_brim_length", 6 },
});
Print print;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gc = gcode(print);
const long expected = belt_brim_segments(*print.objects().front());
REQUIRE(expected > 100);
CHECK(role_segments(gc, "brim") == expected);
}
// B - multi extruder (wall filament id 2). Every belt-brim line must print on the object's
// wall filament (index 2 -> tool 1), and the total number of passes must equal the
// single-extruder baseline: no per-filament doubling.
TEST_CASE("Belt brim on a multi-extruder object uses the wall filament, no doubling", "[SkirtBrim][belt]")
{
// Single-extruder baseline built the same way (same nozzle/flow), so the band geometry -
// and thus the band count - is identical and only the filament assignment differs.
DynamicPrintConfig base = belt_brim_multifilament_config(1, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
const int baseline = role_passes(slice({ cube(20) }, base), "brim");
REQUIRE(baseline > 0);
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
});
const std::string gc = slice({ cube(20) }, config);
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 1 }); // filament 2 -> tool 1
CHECK(role_passes(gc, "brim") == baseline);
}
// B - two objects offset ALONG the belt (Y, since the tilt is about X), each with its own
// wall filament. Each object's brim/apron must print on that object's filament AND before
// that object's own perimeters. The object is identified by its unique tool.
TEST_CASE("Belt brim of each object precedes its perimeters on its own filament", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
std::vector<TriangleMesh> meshes;
meshes.emplace_back(cube(20));
TriangleMesh second = cube(20);
second.translate(0.f, 40.f, 0.f); // offset along the belt so it lands well after the first
meshes.emplace_back(std::move(second));
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "outer_wall_filament_id", 1 }, { "inner_wall_filament_id", 1 } },
{ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 } },
};
Print print;
Model model;
init_print(std::move(meshes), print, model, config, &overrides, /*arrange=*/false);
print.process();
const std::string gc = gcode(print);
// Both brims appear, each on its object's wall filament (1 -> T0, 2 -> T1).
CHECK(belt_tools_for_role(gc, "brim") == std::set<int>{ 0, 1 });
const std::map<int, long> brim_first = first_move_by_tool(gc, "brim");
const std::map<int, long> peri_first = first_move_by_tool(gc, "perimeter");
for (int tool : { 0, 1 }) {
REQUIRE(brim_first.count(tool) == 1);
REQUIRE(peri_first.count(tool) == 1);
CHECK(brim_first.at(tool) < peri_first.at(tool));
}
}
// D - the belt-brim predicate must not fire on a request that produces no belt brim.
// leading_brim_length / extra_brim_width only feed the OUTER ring, so inner_only with zero
// brim_width yields nothing and must not claim the layers the prime tower / spiral vase need.
TEST_CASE("Belt inner-only leading brim does not reject the prime tower or spiral vase", "[SkirtBrim][belt]")
{
auto inner_leading = [](std::initializer_list<Slic3r::ConfigBase::SetDeserializeItem> extra) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "inner_only" },
{ "brim_width", 0 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
});
config.set_deserialize_strict(extra);
return config;
};
auto init_inner_leading_with_prime_tower = [](Print &print, Model &model, double brim_width) {
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "inner_only" },
{ "brim_width", brim_width },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
{ "enable_prime_tower", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
};
SECTION("prime tower is left alone") {
Print print;
Model model;
init_inner_leading_with_prime_tower(print, model, 0);
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
SECTION("spiral vase is left alone") {
Print print;
Model model;
init_print({ cube(20) }, print, model, inner_leading({ { "spiral_mode", 1 } }));
CHECK_FALSE(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
}
// enable_prime_tower stays on for any multi-filament project, but a belt printer never
// prints the classic tower, so the setting alone must not cost the print its brim.
SECTION("a real inner brim is accepted with the prime tower setting on") {
Print print;
Model model;
init_inner_leading_with_prime_tower(print, model, 4);
CHECK(print.objects().front()->has_belt_brim());
CHECK(print.validate().string.empty());
CHECK_FALSE(gcode(print).empty());
}
// A purge tower object is accepted too: the purge plan moves every object, apron
// bands included, onto one layer grid.
SECTION("a brim is accepted next to a belt purge tower object") {
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "enable_belt_purge_tower", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
Print print;
Model model;
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
model.objects.back()->config.set_key_value("belt_purge_tower_object", new ConfigOptionBool(true));
print.apply(model, config);
REQUIRE(print.has_belt_purge_tower());
CHECK(print.validate().string.empty());
CHECK_FALSE(gcode(print).empty());
}
}
TEST_CASE("Belt brim spans many layers instead of one", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
// A plate-brim implementation would score 1 here.
CHECK(role_passes(gcode, "brim") > 10);
}
TEST_CASE("Belt brim is absent when both widths are zero", "[SkirtBrim][belt]")
{
// The "no effect when disabled" guard: brim_type Auto is the shipped default and
// reports has_brim() even at width 0, so this also pins the gate that keeps the
// flat plate brim from running on a tilted belt.
const char *brim_type = GENERATE("auto_brim", "outer_only", "no_brim");
DYNAMIC_SECTION("brim_type " << brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 0 },
{ "leading_brim_length", 0 },
{ "extra_brim_width", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") == 0);
}
}
TEST_CASE("Leading brim length alone produces a belt brim", "[SkirtBrim][belt]")
{
// Exercises the leading_brim_length-only enablement path and the downhill sweep.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 0 },
{ "leading_brim_length", 5 },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") > 0);
}
TEST_CASE("Leading brim length reaches further ahead of the object", "[SkirtBrim][belt]")
{
// Compared between two runs rather than against an absolute coordinate, so the
// assertion survives any change of origin or axis remap.
auto brim_extent = [](double extra) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 3 },
{ "leading_brim_length", extra },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
// The apron prints before the object reaches the belt, so it shows up as brim
// extrusion at the lowest machine Z of any brim move.
double min_z = std::numeric_limits<double>::max();
GCodeReader parser;
parser.parse_buffer(gcode, [&min_z](GCodeReader &self, const GCodeReader::GCodeLine &line) {
if (line.extruding(self) && line.comment().find("brim") != std::string_view::npos)
min_z = std::min(min_z, static_cast<double>(self.z()));
});
return min_z;
};
const double without = brim_extent(0.);
const double with = brim_extent(10.);
REQUIRE(without < std::numeric_limits<double>::max());
REQUIRE(with < std::numeric_limits<double>::max());
CHECK(with < without);
}
TEST_CASE("Every brim type slices on a belt printer", "[SkirtBrim][belt]")
{
// Auto / Mouse ear / Painted collapse to outer-only rather than crashing or
// silently producing nothing.
const char *brim_type = GENERATE("auto_brim", "brim_ears", "painted", "outer_only",
"inner_only", "outer_and_inner", "leading_edge_only", "no_brim");
DYNAMIC_SECTION("brim_type " << brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
REQUIRE(! gcode.empty());
if (std::string(brim_type) == "no_brim")
CHECK(role_passes(gcode, "brim") == 0);
else if (std::string(brim_type) != "inner_only")
// A solid cube has no holes, so inner_only legitimately yields nothing.
CHECK(role_passes(gcode, "brim") > 0);
}
}
// The leading-edge-only brim is the outer brim cut down to the part's first contact
// with the belt. The cut has to be taken at the first layer that touches the belt: the
// slicing frame starts at the belt below the footprint, so layers().front() is an empty
// lead-in layer whose contact lies ahead of the part, and a cut taken there left no brim
// at all.
TEST_CASE("Leading-edge-only brim is laid at the first contact and nowhere else", "[SkirtBrim][belt][Regression]")
{
auto brim_gcode = [](const char *brim_type) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", brim_type },
{ "brim_width", 5 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
});
return slice({ cube(20) }, config);
};
const std::string leading = brim_gcode("leading_edge_only");
const std::string outer = brim_gcode("outer_only");
const double brim_z = first_role_z(leading, "brim");
const double peri_z = first_role_z(leading, "perimeter");
REQUIRE(brim_z < std::numeric_limits<double>::max());
REQUIRE(peri_z < std::numeric_limits<double>::max());
// At the first contact: the brim starts no later than the part does...
CHECK(brim_z <= peri_z + EPSILON);
// ...and stops there, while the outer brim keeps following the footprint.
const int leading_layers = role_layers(leading, "brim");
const int outer_layers = role_layers(outer, "brim");
INFO("brim layers: leading-edge " << leading_layers << ", outer " << outer_layers);
CHECK(leading_layers > 0);
CHECK(leading_layers < outer_layers);
}
// An overhang on the leading side is sliced before the part reaches the belt, so the
// first layer with geometry is the overhang's tip, above the belt. A leading-edge cut
// taken there lies ahead of the part: the brim shrank to a sliver well ahead of it, or
// vanished once the overhang reached further forward than the brim. The overhang does
// not touch the belt, so it must not change the brim at all.
TEST_CASE("Leading-edge-only brim ignores an overhang ahead of the part", "[SkirtBrim][belt][Regression]")
{
const double fin_length = GENERATE(30., 40.);
CAPTURE(fin_length);
// A 20 mm cube, with or without a 2 mm thick fin leaving its top edge and reaching
// `fin` toward -Y, the end of the part that prints first. The fin overlaps the cube
// by 1 mm so the two shells merge instead of sharing a face.
auto brim_layers = [](double fin) {
indexed_triangle_set its = its_make_cube(20., 20., 20.);
if (fin > 0.) {
indexed_triangle_set fin_its = its_make_cube(20., fin + 1., 2.);
its_translate(fin_its, Vec3f(0.f, float(-fin), 18.f));
its_merge(its, fin_its);
}
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "leading_edge_only" },
{ "brim_width", 5 },
{ "leading_brim_length", 10 },
{ "extra_brim_width", 0 },
{ "brim_object_gap", 0 },
});
return role_layers(slice({ TriangleMesh(std::move(its)) }, config), "brim");
};
const int plain = brim_layers(0.);
const int with_fin = brim_layers(fin_length);
INFO("leading-edge brim layers: plain cube " << plain << ", with the fin " << with_fin);
REQUIRE(plain > 0);
// One layer of slack: the fin widens the part's footprint on the plate, which can
// move the layer grid by a fraction of a layer.
CHECK(std::abs(with_fin - plain) <= 1);
}
TEST_CASE("An untilted belt printer gets no brim", "[SkirtBrim][belt]")
{
// Belt brim needs a tilt to have a belt plane to lie on, and the flat plate brim
// cannot reach the G-code on any belt printer: it is emitted out of
// skirt_brim_groups(), which _make_skirt() builds, and that returns early for every
// belt printer. So an untilted belt printer gets nothing - unchanged by this
// feature. Making the flat brim work here would mean reopening the belt skirt gate,
// which is a separate change; Print::validate() warns instead.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "belt_slice_rotation", "none" },
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "brim") == 0);
}
TEST_CASE("Belt brim does not resurrect the skirt", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "skirt_loops", 2 },
});
const std::string gcode = slice({ cube(20) }, config);
CHECK(role_passes(gcode, "skirt") == 0);
}
TEST_CASE("Belt brim lines all have the same width", "[SkirtBrim][belt]")
{
// Each brim line's extrusion volume comes from its nozzle-to-belt clearance. Anchoring
// every line to a fixed fraction of its own band gives them all the same clearance, so
// they all come out the same width. The nominal-spacing lattice this replaced let each
// line land wherever it fell inside its band, so the clearance - and the width with it -
// varied by 2x, which showed up as visibly ragged brim.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 5 },
{ "brim_object_gap", 0 },
});
Print print;
init_and_process_print({ cube(20) }, print, config);
const PrintObject *obj = print.objects().front();
std::vector<float> widths;
auto collect = [&widths](const ExtrusionEntityCollection &coll) {
for (const ExtrusionEntity *ee : coll.entities)
if (const auto *path = dynamic_cast<const ExtrusionPath *>(ee))
widths.push_back(path->width);
};
for (const ExtrusionEntityCollection &band : obj->belt_brim_by_layer())
collect(band);
for (const BeltBrimBand &band : obj->belt_brim_prologue())
collect(band.fills);
REQUIRE(widths.size() > 10);
const float lo = *std::min_element(widths.begin(), widths.end());
const float hi = *std::max_element(widths.begin(), widths.end());
CHECK_THAT(hi, Catch::Matchers::WithinRel(lo, 1e-4f));
}
TEST_CASE("Belt apron survives another object printing at the same Z", "[SkirtBrim][belt]")
{
// An apron band prints below its OWN object's first layer, but with two objects on the
// belt the second one is already printing at that print_z. The layer then has an
// object layer and takes the ordinary process_layer() path rather than the brim-only
// branch, so the band must be emitted from both or it is silently dropped. A
// single-object print cannot exercise this.
auto brim_passes = [](int object_count) {
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 3 },
{ "leading_brim_length", 8 },
{ "brim_object_gap", 0 },
});
std::vector<TriangleMesh> meshes;
for (int i = 0; i < object_count; ++ i) {
TriangleMesh m = cube(20);
// Offset along the belt so the second object starts well after the first.
m.translate(0.f, float(40 * i), 0.f);
meshes.emplace_back(std::move(m));
}
Print print;
Model model;
init_print(std::move(meshes), print, model, config);
print.process();
return role_passes(gcode(print), "brim");
};
const int one = brim_passes(1);
const int two = brim_passes(2);
REQUIRE(one > 0);
// Two identical objects should carry twice the brim. Merely asserting `two > one`
// would not be decisive: the FIRST object's apron survives the bug, because nothing
// else is printing that early, so only the second object's apron goes missing.
// Requiring close to 2x is what actually detects the dropped bands.
CHECK(two >= 1.8 * one);
}
TEST_CASE("Belt brim coexists with support material", "[SkirtBrim][belt]")
{
// Supports put extra layers into the same z stream as the apron bands, which is what
// the three-way merge in collect_layers_to_print() exists to handle: a band sharing a
// print_z with a support layer of the SAME object used to overwrite it in the
// print-wide merge. A smoke test - it cannot prove the collision occurred - but it
// does exercise the merge with all three streams populated.
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "leading_brim_length", 6 },
{ "brim_object_gap", 0 },
{ "enable_support", 1 },
});
const std::string gc = slice({ TestMesh::overhang }, config);
REQUIRE(! gc.empty());
CHECK(role_passes(gc, "brim") > 0);
}
// With a 0.3 mm first layer at 45 degrees the brim band on the belt is wider than one bead,
// so its lines go on the nominal lattice instead of at a fixed fraction of the band. A
// lattice line can then land where the belt is almost at the band's print_z; it must be
// moved uphill to the same 0.75 fraction the single-line case uses, not laid scraping the
// belt with its flow clamped to half a layer.
TEST_CASE("Belt brim lattice lines keep their clearance above the belt", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_config();
config.set_deserialize_strict({
{ "layer_height", 0.3 },
{ "initial_layer_print_height", 0.3 },
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
});
const std::string gcode = slice({ cube(20) }, config);
// Heights of the brim extrusions, from the ;HEIGHT: tags inside ;TYPE:Brim sections.
std::vector<double> brim_heights;
bool in_brim = false;
std::istringstream lines(gcode);
for (std::string line; std::getline(lines, line); ) {
if (boost::starts_with(line, ";TYPE:"))
in_brim = boost::starts_with(line, ";TYPE:Brim");
else if (in_brim && boost::starts_with(line, ";HEIGHT:"))
brim_heights.push_back(std::stod(line.substr(8)));
}
REQUIRE(! brim_heights.empty());
for (const double h : brim_heights) {
CHECK(h >= 0.75 * 0.3 - 1e-3);
CHECK(h <= 0.3 + 1e-3);
}
}
// The brim prints in the object's outer wall filament even when every extrusion of the object
// is offered to purging (flush_into_objects): the tool ordering registers the brim filament
// itself, so the writer always knows it.
TEST_CASE("Belt brim slices when every object is a flush target", "[SkirtBrim][belt]")
{
DynamicPrintConfig config = belt_brim_multifilament_config(2, {
{ "brim_type", "outer_only" },
{ "brim_width", 4 },
{ "brim_object_gap", 0 },
{ "flush_into_objects", 1 },
{ "flush_into_infill", 1 },
});
const std::vector<std::vector<Slic3r::ConfigBase::SetDeserializeItem>> overrides {
{ { "extruder", 1 } }, { { "extruder", 2 } },
};
Print print;
Model model;
init_print({ cube(20), cube(20) }, print, model, config, &overrides);
REQUIRE(print.validate().string.empty());
const std::string out = gcode(print);
CHECK(out.find(";TYPE:Brim") != std::string::npos);
}