mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 19:01:21 +00:00
Merge main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ®ion)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(), [¢er](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(), [¢er](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(), [¢er](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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;"));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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]")
|
||||
{
|
||||
|
||||
@@ -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 ®ion : 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user