mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-05 14:51:06 +00:00
The cooling buffer's band pass rebuilt positions from the layer's G-code and tested them against the first-layer plane. The G-code is in machine coordinates and the plane is in slicing coordinates, so on the shipped profiles the nearest move was over 100 mm from a 0.2 mm band and the pass never changed the fan. GCode::_extrude() already knows each path's height above the belt, so it now tags the band changes and the buffer applies and strips the tags. The pass also took the S of every M106 as the part fan, whatever its P index, and stored that 0..255 value where a percentage was expected (an auxiliary fan line came back as M106 S651); it now uses FanMover's parser, which ignores other fans, and converts to percent. It no longer overwrites the layer's intended speed, only the fan's actual state. Raised in Hanif Koh's review of #14394.
654 lines
28 KiB
C++
654 lines
28 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_all.hpp>
|
|
|
|
#include "libslic3r/libslic3r.h"
|
|
#include "libslic3r/Print.hpp"
|
|
#include "libslic3r/Layer.hpp"
|
|
#include "libslic3r/Model.hpp"
|
|
#include "libslic3r/GCodeReader.hpp"
|
|
|
|
#include "test_helpers.hpp"
|
|
#include "test_utils.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <limits>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
|
|
using namespace Slic3r;
|
|
using namespace Slic3r::Test;
|
|
|
|
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);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
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());
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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("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 },
|
|
});
|
|
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_FALSE(print.validate().string.empty());
|
|
}
|
|
}
|
|
|
|
// On a belt every tilted layer starts on the belt, so "first layer" cooling is a band along
|
|
// the belt, not the first slicing layers: the part fan goes off for the paths that start
|
|
// within a layer height of the belt and back on above it, on every layer. The G-code is in
|
|
// machine coordinates, so the generator tags the band changes and the cooling buffer
|
|
// applies them; before that the buffer compared machine-frame moves with a slicing-frame
|
|
// plane and never switched the fan at all.
|
|
TEST_CASE("Belt printers switch the part fan by height 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 },
|
|
{ "close_fan_the_first_x_layers", 1 },
|
|
{ "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 tags are consumed by the cooling buffer and never reach the file.
|
|
CHECK(gcode.find(";_BELT_BAND") == std::string::npos);
|
|
|
|
size_t fan_off = 0, fan_on = 0;
|
|
GCodeReader parser;
|
|
parser.parse_buffer(gcode, [&](GCodeReader &, const GCodeReader::GCodeLine &line) {
|
|
if (line.cmd_is("M107"))
|
|
++ fan_off;
|
|
else if (line.cmd_is("M106")) {
|
|
float s = 0.f;
|
|
if (line.has_value('S', s) && s <= 0.f)
|
|
++ fan_off;
|
|
else
|
|
++ fan_on;
|
|
}
|
|
});
|
|
// A flat-bed print turns the fan on once. Here it cycles with the layers.
|
|
CHECK(fan_off > 10);
|
|
CHECK(fan_on > 10);
|
|
}
|