Files
OrcaSlicer/tests/fff_print/test_print.cpp
T
harrierpigeonandClaude Fable 5.1 61a0db4a87 Belt printer: address the review on #14394
Code review items (raistlin7447):

1. PrintObject::slice() zeroes m_belt_min_z, m_belt_global_z_offset and
   m_belt_global_xy_correction before slicing. They were only written in belt
   mode, so a project switched to a normal printer, or whose tilt axis was set
   to None, kept the old offsets and shifted the adaptive infill octree and the
   organic support layers by them.
2. TreeModelVolumes shifts the support blockers into the raft-offset index
   space; a test now pins the index the blocker lands on.
3. The final-alignment clamp in libnest2d is opt-in (NfpPConfig::clamp_to_bin)
   and arrange sets it for belt printers only. Printers with an off-centre
   best_object_pos keep their alignment; a flat-bed test pins that.
4. The preview's belt view follows the loaded G-code, not the selected printer:
   GCodeProcessor carries the file's belt keys (and, for a belt file, its bed)
   into export_config_for_render(), and GCodeViewer enables the belt view from
   the header tilt.
5. belt_shift_layer_grid() also shifts the cached belt floor and the global Z
   offset, so a support-only or brim-only change after the purge-prism snap
   matches a fresh slice.
6. update_print_fff_config() resets raft_layers and draft_shield on a belt
   printer instead of only greying out the fields Print::validate() rejects.
7. GCodeWriter takes a first-layer point test instead of the FirstLayerPlane;
   GCode installs one that measures from the belt surface, like its
   extrusions, so the first-layer travel speed and the second-layer
   temperature change no longer depend on the gcode_remap_* convention.
8. belt_brim_clip_leading_edge() is exported and called by both the generator
   and the test.
9. Both phong.vs shaders use slope.up_direction for the overhang highlight.

The pre-slice and G-code axis remaps are gated on belt_printer through
BeltTransformPipeline::axis_remap_enabled(), so belt keys left in a profile
cannot change a non-belt print.

Tests requested in the review: belt-only keys at non-default values leave
non-belt G-code unchanged; switching a sliced project from belt to non-belt
(and tilt axis None) matches a fresh slice; a support-only change on a belt
purge print matches a fresh slice; non-belt start G-code moves keep the
first-layer Z in the processor; the belt brim's segment count catches a band
emitted twice.

The belt-to-non-belt test exposed an unrelated gap: invalidate_step(posSlice)
re-invalidated posSupportMaterial but not posSimplifySupportPath, so after
any re-slice the regenerated support paths were exported unsimplified.
posSimplifySupportPath is now in that list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 23:06:01 -05:00

1258 lines
54 KiB
C++

#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <string>
#include <catch2/generators/catch_generators.hpp>
#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"
#include "libslic3r/PrintBase.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/catch_all.hpp>
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/BuildVolume.hpp"
#include "libslic3r/Support/TreeModelVolumes.hpp"
#include "libslic3r/Support/TreeSupportCommon.hpp"
#include "libslic3r/Polygon.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <boost/algorithm/string/predicate.hpp>
#include <cstdlib>
#include <sstream>
#include <limits>
#include <fstream>
#include <iterator>
#include <memory>
#include <string_view>
#include <utility>
#include <vector>
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/SurfaceCollection.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
TEST_CASE("Timelapse g-code is emitted once per layer for Bambu and non-Bambu printers", "[Print][Regression]")
{
struct PrinterCase {
std::string name;
std::string structure;
bool is_bbl;
};
const PrinterCase printer = GENERATE(from_range(std::vector<PrinterCase>{
{ "non-BBL undefined", "undefine", false },
{ "non-BBL CoreXY", "corexy", false },
{ "non-BBL i3", "i3", false },
{ "non-BBL H-Bot", "hbot", false },
{ "non-BBL Delta", "delta", false },
{ "Bambu CoreXY", "corexy", true },
{ "Bambu i3", "i3", true },
}));
INFO("printer: " << printer.name);
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "initial_layer_print_height", 0.2 },
{ "layer_change_gcode", ";TEST_LAYER_CHANGE" },
{ "layer_height", 0.2 },
{ "printer_structure", printer.structure },
{ "spiral_mode", false },
{ "time_lapse_gcode", "TIMELAPSE_TAKE_FRAME" },
});
Print print;
print.is_BBL_printer() = printer.is_bbl;
Model model;
init_print({ cube(20) }, print, model, config);
const std::string gcode = Slic3r::Test::gcode(print);
const auto count = [&gcode](std::string_view token) {
size_t occurrences = 0;
size_t pos = 0;
while ((pos = gcode.find(token, pos)) != std::string::npos) {
++occurrences;
pos += token.size();
}
return occurrences;
};
const size_t layer_changes = count("\n;TEST_LAYER_CHANGE\n");
REQUIRE(layer_changes > 0);
CHECK(count("\nTIMELAPSE_TAKE_FRAME\n") == layer_changes);
}
SCENARIO("Changing the number of solid shell layers does not make all surfaces internal", "[Print]") {
GIVEN("sliced 20mm cube and config with top_shell_layers = 2 and bottom_shell_layers = 1") {
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
{ "initial_layer_print_height", 0.25 }
});
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
// and one solid bottom layer (0).
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
// iterate over all of the regions in the layer
for (const LayerRegion *region : layer.regions()) {
// for each region, iterate over the fill surfaces
for (const Surface &surface : region->fill_surfaces.surfaces)
CHECK(surface.is_solid());
}
};
print.process();
test_is_solid_infill(0, 0); // should be solid
test_is_solid_infill(0, 79); // should be solid
test_is_solid_infill(0, 78); // should be solid
WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3);
print.apply(model, config);
print.process();
THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0);
}
AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79);
test_is_solid_infill(0, 78);
test_is_solid_infill(0, 77);
}
}
}
}
// ---------------------------------------------------------------------------
// Print::validate() warning collection
//
// validate() returns its warnings in a vector. The warning paths deliberately
// differ in how many entries they produce; these tests pin down each behaviour:
// * independent checks -> stack (one entry each)
// * motion-ability -> coalesce into one (mutually exclusive, gated)
// * clumping detection -> one independent warning
// * layered clearance -> many collisions concatenated into one entry
// * null warnings pointer -> no-op, no crash, no blocking error
// ---------------------------------------------------------------------------
namespace {
// Build `n` 20mm cubes (spread apart, or stacked at the origin when `overlap`) into
// `model`/`print` and apply `config`, leaving the print ready to validate(). No slicing needed.
void build_cubes(Slic3r::Model& model, Slic3r::Print& print,
DynamicPrintConfig config, int n, bool overlap)
{
config.set_key_value("layer_change_gcode", new ConfigOptionString("G92 E0\n")); // validate() relative-E reset
for (int i = 0; i < n; ++i) {
ModelObject* object = model.add_object();
object->add_volume(cube(20));
ModelInstance* inst = object->add_instance();
inst->set_offset(Vec3d(overlap ? 0.0 : i * 60.0, 0.0, 0.0));
}
for (ModelObject* mo : model.objects) {
mo->ensure_on_bed();
print.auto_assign_extruders(mo);
}
print.apply(model, config);
}
// Build cubes and run validate(), collecting warnings; returns the blocking error.
StringObjectException validate_cubes(const DynamicPrintConfig& config,
std::vector<StringObjectException>& warnings,
int n = 1, bool overlap = false)
{
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, n, overlap);
return print.validate(&warnings);
}
size_t count_opt_key(const std::vector<StringObjectException>& warnings, const std::string& key)
{
return std::count_if(warnings.begin(), warnings.end(),
[&](const StringObjectException& w) { return w.opt_key == key; });
}
// Make `default_acceleration` exceed the machine's extruding-acceleration limit.
void trigger_acceleration_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats{ 100. });
c.set_key_value("default_acceleration", new ConfigOptionFloatsNullable{ 100000. });
}
// Make `default_jerk` exceed the machine's jerk limit (junction deviation off so
// the jerk check is not skipped).
void trigger_jerk_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats{ 0. });
c.set_key_value("machine_max_jerk_x", new ConfigOptionFloats{ 1. });
c.set_key_value("machine_max_jerk_y", new ConfigOptionFloats{ 1. });
c.set_key_value("default_jerk", new ConfigOptionFloatsNullable{ 9999. });
}
// Precise outer wall is ignored unless the wall sequence is inner-outer.
void trigger_precise_wall_warning(DynamicPrintConfig& c)
{
c.set_key_value("precise_outer_wall", new ConfigOptionBool(true));
c.set_key_value("wall_sequence", new ConfigOptionEnum<WallSequence>(WallSequence::OuterInner));
}
} // namespace
// ---------------------------------------------------------------------------
// {first_object_name} filename placeholder
// ---------------------------------------------------------------------------
namespace {
// Add a printable 20mm cube named `name` to `model`; returns it so the caller can tweak it.
ModelObject* add_named_cube(Model& model, const std::string& name)
{
ModelObject* obj = model.add_object();
obj->name = name;
obj->add_volume(make_cube(20.0, 20.0, 20.0));
obj->add_instance();
obj->ensure_on_bed();
return obj;
}
// Resolve `format` to an output file name for a print of `model`. `filename_base`, when set,
// is the saved-project name passed to output_filename().
std::string resolved_output_name(Model& model, const std::string& format, const std::string& filename_base = {})
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString(format));
Print print;
for (ModelObject* obj : model.objects)
print.auto_assign_extruders(obj);
print.apply(model, config);
return print.output_filename(filename_base);
}
struct ScopedLifecycleHook
{
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
};
} // namespace
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
{
struct ObservedEvent {
LifecycleEvent event;
std::string id;
std::string name;
};
std::vector<ObservedEvent> events;
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back({ event, ctx.id, ctx.name });
});
Print print;
Model model;
ModelInfo info;
info.model_name = "Lifecycle test model";
model.model_info = std::make_shared<ModelInfo>(std::move(info));
init_print({cube(20)}, print, model);
print.process();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
GCodeProcessorResult result;
print.export_gcode_from_previous_file(temp.string(), &result);
const std::string expected_id = std::to_string(print.model().id().id);
const std::vector<LifecycleEvent> expected_events = {
LifecycleEvent::SliceStarted,
LifecycleEvent::SliceGeometryFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
};
REQUIRE(events.size() == expected_events.size());
for (size_t i = 0; i < expected_events.size(); ++i) {
CHECK(events[i].event == expected_events[i]);
CHECK(events[i].id == expected_id);
CHECK(events[i].name == "Lifecycle test model");
}
}
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
{
std::string event_id;
std::string event_name = "unset";
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
if (event == LifecycleEvent::SliceStarted) {
event_id = ctx.id;
event_name = ctx.name;
}
});
Print print;
Model model;
init_print({cube(20)}, print, model);
print.process();
CHECK(event_id == std::to_string(print.model().id().id));
CHECK(event_name.empty());
}
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
}
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{
Model model;
SECTION("uses the object's name") {
add_named_cube(model, "WidgetPart");
CHECK(resolved_output_name(model, "{first_object_name}") == "WidgetPart.gcode");
}
SECTION("picks the first when several objects are printable") {
add_named_cube(model, "FirstPart");
add_named_cube(model, "SecondPart");
CHECK(resolved_output_name(model, "{first_object_name}") == "FirstPart.gcode");
}
SECTION("skips objects outside the print volume (e.g. on another plate)") {
// First in model order, but not on the current plate, so is_printable() is false.
add_named_cube(model, "OtherPlatePart")->instances.front()->print_volume_state = ModelInstancePVS_Fully_Outside;
add_named_cube(model, "OnPlatePart");
CHECK(resolved_output_name(model, "{first_object_name}") == "OnPlatePart.gcode");
}
SECTION("is empty when the object has no name") {
add_named_cube(model, "");
CHECK(resolved_output_name(model, "part_{first_object_name}") == "part_.gcode");
}
}
TEST_CASE("Print: {first_object_name} is not replaced by the saved-project file name", "[Print]")
{
// Passing a saved-project file name as the filename_base must not change {first_object_name}.
Model model;
add_named_cube(model, "WidgetPart");
CHECK(resolved_output_name(model, "{first_object_name}", "SavedProject") == "WidgetPart.gcode");
}
TEST_CASE("Print::validate stacks independent warnings", "[Print][validate]")
{
// Two unrelated checks (region precise-wall + machine acceleration) must each
// contribute their own entry.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(warnings.size() >= 2);
CHECK(count_opt_key(warnings, "precise_outer_wall") == 1); // jump-to key is preserved
for (const auto& w : warnings)
CHECK(w.is_warning); // every collected entry is a warning
}
TEST_CASE("Print::validate coalesces motion-ability warnings into one", "[Print][validate]")
{
// The jerk/junction/acceleration checks are mutually exclusive (gated on a shared
// key), so adding a second motion trigger must NOT add a second warning.
DynamicPrintConfig accel_only = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_only);
std::vector<StringObjectException> w_accel;
CHECK(validate_cubes(accel_only, w_accel).string.empty());
DynamicPrintConfig accel_and_jerk = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_and_jerk);
trigger_jerk_warning(accel_and_jerk);
std::vector<StringObjectException> w_both;
CHECK(validate_cubes(accel_and_jerk, w_both).string.empty());
CHECK(w_accel.size() >= 1);
CHECK(w_both.size() == w_accel.size()); // the extra motion trigger collapses into the same warning
}
TEST_CASE("Print::validate reports the clumping-detection warning", "[Print][validate]")
{
// A distinct single-shot path: clumping/wrapping detection without a prime tower warns
// (and carries the enable_prime_tower jump-to key). enable_prime_tower must be off, as
// the warning lives in the no-prime-tower branch.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(false));
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true));
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(count_opt_key(warnings, "enable_prime_tower") == 1);
}
TEST_CASE("Print::validate concatenates layered-clearance collisions into one warning", "[Print][validate]")
{
// In by-layer mode, layered_print_cleareance_valid folds every too-close pair into a
// single warning entry (newline-joined), unlike the per-check stacking above. Isolate
// that entry by type so unrelated default-config warnings don't affect the assertion.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings, /*n=*/3, /*overlap=*/true);
CHECK(err.string.empty());
auto is_layered = [](const StringObjectException& w) {
return w.type == STRING_EXCEPT_OBJECT_COLLISION_IN_LAYER_PRINT; };
REQUIRE(std::count_if(warnings.begin(), warnings.end(), is_layered) == 1); // 3 objects, 2 collisions, 1 entry
auto it = std::find_if(warnings.begin(), warnings.end(), is_layered);
CHECK(it->string.find('\n') != std::string::npos); // the collisions were concatenated
}
TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate]")
{
// Callers may pass no warnings sink: a warning-producing config must not crash
// and must still return without a blocking error.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
StringObjectException err = print.validate(); // warnings == nullptr
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) }, {
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 } // keep recorded Z at the printed height
});
CHECK(role_passes(gcode, "perimeter") > 0);
CHECK(role_passes(gcode, "infill") > 0);
CHECK(role_passes(gcode, "skirt") > 0);
CHECK_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20.0, 1e-4));
}
// The G-code carries a config-comment block describing the resolved settings. The
// per-region width lines are always present; the support and first-layer lines appear
// only when those features are configured.
TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
{
const std::string gcode = slice({ cube(20) }, { { "initial_layer_line_width", 0 } });
CHECK(gcode.find("; external perimeters extrusion width") != std::string::npos);
CHECK(gcode.find("; perimeters extrusion width") != std::string::npos);
CHECK(gcode.find("; infill extrusion width") != std::string::npos);
CHECK(gcode.find("; solid infill extrusion width") != std::string::npos);
CHECK(gcode.find("; top infill extrusion width") != std::string::npos);
CHECK(gcode.find("; support material extrusion width") == std::string::npos);
CHECK(gcode.find("; first layer extrusion width") == std::string::npos);
CHECK(gcode.find("; layer_height") != std::string::npos);
CHECK(gcode.find("; sparse_infill_density") != std::string::npos);
const std::string with_support = slice({ cube(20) }, {
{ "initial_layer_line_width", 0 }, { "enable_support", true }, { "raft_layers", 3 },
});
CHECK(with_support.find("; support material extrusion width") != std::string::npos);
const std::string with_first_layer = slice({ cube(20) }, { { "initial_layer_line_width", "0.5" } });
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
}
// gcode_skip_config_block suppresses the resolved-settings block while leaving the
// header and executable blocks intact.
TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
{ "gcode_skip_config_block", true },
{ "gcode_comments", true },
});
CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos);
CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos);
CHECK(gcode.find("; layer_height =") == std::string::npos);
CHECK(gcode.find("; fill_density =") == std::string::npos);
CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos);
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
}
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
// must stay close to EOF regardless of the resolved-settings config block's size.
TEST_CASE("The estimated printing time comment stays near the end of the file", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {});
const size_t config_block_end = gcode.find("; CONFIG_BLOCK_END");
const size_t filament_stats = gcode.find("; filament used [mm]");
const size_t time_comment = gcode.find("estimated printing time");
REQUIRE(config_block_end != std::string::npos);
REQUIRE(filament_stats != std::string::npos);
REQUIRE(time_comment != std::string::npos);
CHECK(filament_stats > config_block_end);
CHECK(time_comment > filament_stats);
const size_t line_start = gcode.rfind('\n', time_comment) + 1;
const size_t trailing_lines = std::count(gcode.begin() + line_start, gcode.end(), '\n');
CHECK(trailing_lines <= 5);
}
// Custom G-code templates substitute placeholders during export.
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
{
// [current_extruder] in the start G-code.
CHECK(slice({ cube(20) }, { { "machine_start_gcode", "; Extruder [current_extruder]" } })
.find("; Extruder 0") != std::string::npos);
// [layer_num] / [layer_z] in the end G-code (a 20mm cube at 0.1mm is 200 layers).
const std::string end_gcode = slice({ cube(20) }, {
{ "machine_end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 },
});
CHECK(end_gcode.find("; Layer_num 199") != std::string::npos);
CHECK(end_gcode.find("; Layer_z 20") != std::string::npos);
// printing_by_object_gcode is emitted between sequentially printed objects.
CHECK(slice_two_cubes_arranged({
{ "print_sequence", "by object" },
{ "printing_by_object_gcode", "; between-object-gcode" },
})
.find("; between-object-gcode") != std::string::npos);
// [layer_num] keeps counting across sequentially printed objects (199 then 399).
const std::string per_layer = slice_two_cubes_arranged({
{ "print_sequence", "by object" },
{ "layer_change_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 },
});
CHECK(per_layer.find(";Layer:199 ") != std::string::npos);
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
Model model;
Slic3r::Test::init_print({cube(20)}, print, model);
print.process();
SECTION("non-BBL printer") {}
SECTION("BBL printer") { print.is_BBL_printer() = true; }
ScopedTemporaryFile temp(".gcode");
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
std::ifstream in(temp.string());
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(gcode.empty());
}
TEST_CASE("Exporting a sliced print again gives the same G-code", "[Print][export_gcode][Regression]")
{
const int instances = GENERATE(1, 3);
CAPTURE(instances);
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
TestMesh mesh = TestMesh::ipadstand;
SECTION("infill reversed by chaining") { config.set_deserialize_strict({{"sparse_infill_pattern", "gyroid"}}); }
SECTION("support reversed by chaining") {
mesh = TestMesh::overhang;
config.set_deserialize_strict({{"enable_support", true}, {"support_interface_pattern", "concentric"}});
}
Print print;
Model model;
Slic3r::Test::init_print({Slic3r::Test::mesh(mesh)}, print, model, config, nullptr, true, instances);
const auto export_without_timestamp = [&print]() {
std::string gcode = Slic3r::Test::gcode(print);
const size_t line = gcode.find("; generated by ");
REQUIRE(line != std::string::npos);
gcode.erase(line, gcode.find('\n', line) - line);
return gcode;
};
const std::string first = export_without_timestamp();
const std::string second = export_without_timestamp();
// Shows the first differing line on failure.
const size_t diff = std::mismatch(first.begin(), first.end(), second.begin(), second.end()).first - first.begin();
const size_t line_start = diff == 0 ? 0 : first.rfind('\n', diff - 1) + 1;
INFO("first export: " << first.substr(line_start, first.find('\n', diff) - line_start));
INFO("second export: " << second.substr(line_start, second.find('\n', diff) - line_start));
CHECK(diff == first.size());
CHECK(first.size() == second.size());
}
TEST_CASE("Sequential printing follows model order", "[Print]")
{
// Two objects of different heights, taller one added first. Orca prints
// sequential objects in model order, so the taller one is printed first.
const std::string gcode = Slic3r::Test::slice({ cube(20), Slic3r::make_cube(20, 20, 10) }, {
{ "print_sequence", "by object" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 }
});
// The first object's height is the peak Z reached before Z drops back to the
// first layer (the object change). With by-object printing only an object
// change returns Z to the bottom.
double first_object_peak_z = 0.0;
double running_peak = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (first_object_peak_z != 0.0 || !line.extruding(self)) return; // ignore travels (e.g. start-gcode Z lift)
if (running_peak > 1.0 && self.z() < 1.0)
first_object_peak_z = running_peak;
else
running_peak = std::max(running_peak, static_cast<double>(self.z()));
});
REQUIRE_THAT(first_object_peak_z, Catch::Matchers::WithinAbs(20.0, 0.3));
}
// A sequential (by-object) print must publish the print-level nozzle group result just
// like a by-layer print, so custom g-code can index the per-nozzle placeholder tables
// (e.g. nozzle_diameter_at_nozzle_id[]) instead of failing on an empty vector.
TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][MultiNozzle]")
{
SECTION("process() publishes the result") {
Print print;
Model model;
place_two_cubes_apart(60.0, { { "print_sequence", "by object" } }, print, model);
print.process();
REQUIRE(print.get_layered_nozzle_group_result() != nullptr);
}
SECTION("start g-code can index the per-nozzle diameter table") {
const std::string gcode = slice_two_cubes_arranged({
{ "print_sequence", "by object" },
{ "machine_start_gcode", "{if nozzle_diameter_at_nozzle_id[0] > 0}; SEQ-ND-OK\n{endif}" },
});
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
}
}
// 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 },
{ "belt_slice_rotation_global", 1 },
{ "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 },
{ "belt_slice_rotation_global", 1 },
{ "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 },
{ "belt_slice_rotation_global", 1 },
{ "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 },
{ "belt_slice_rotation_global", 1 },
{ "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;
Model model;
init_print({Slic3r::Test::cube(20.)}, print, model);
// Lift the cube off the bed: its first layer is empty, which G-code export reports per object.
ModelObject *object = model.objects.front();
object->name = "floating cube";
object->instances.front()->set_offset(object->instances.front()->get_offset() + Vec3d(0., 0., 2.));
print.apply(model, DynamicPrintConfig::full_print_config());
print.set_status_silent();
ScopedTemporaryFile temp(".gcode");
std::string message;
try {
print.process();
print.export_gcode(temp.string(), nullptr, nullptr);
FAIL("slicing did not report the empty first layer");
} catch (const SlicingErrors &errors) {
REQUIRE(errors.errors_.size() == 1);
message = print.slicing_errors_message(errors);
}
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 },
{ "belt_slice_rotation_global", 1 },
{ "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
// and an explicit first_layer_plane are features of their own on a flat bed and are
// 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 },
{ "belt_slice_rotation_global", 0 },
{ "belt_preslice_global", 0 },
{ "preslice_remap_x", "pos_x" },
{ "preslice_remap_y", "pos_z" },
{ "preslice_remap_z", "neg_y" },
{ "preslice_remap_global", 1 },
{ "gcode_remap_x", "rev_x" },
{ "gcode_remap_y", "pos_z" },
{ "gcode_remap_z", "pos_y" },
{ "gcode_back_transform", 0 },
{ "belt_frame_tilt_decouple", 1 },
{ "belt_frame_tilt_angle", 30 },
{ "first_layer_plane_offset", 1 },
{ "first_layer_plane_thickness", 1 },
{ "belt_support_floor_offset", -5 },
{ "belt_support_floor_mode", "none" },
{ "belt_support_z_offset_mode", "raft_only" },
{ "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 },
{ "belt_slice_rotation_global", 1 },
{ "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 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));
}