mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-06 07:11:10 +00:00
Brings belt-printer up to main4b4a261787. Resolutions: - G-code header (#15897, #15915): main moved the header, config and thumbnail block later in _do_export; write_belt_header() moves with it, still after the thumbnails and outside the BTT_TFT gate. - _extrude: first-layer acceleration keeps the per-path first-layer plane test with main's cached nozzle index (#16028); main's set_speed out-param form (#16108) everywhere else. - GCodeWriter (#16108): the arc-to-polyline fallback for machine mappings that cannot express G2/G3 now runs in the out-param extrude_arc_to_xy, which is the overload GCode calls, and appends to the caller's string. - GCodeProcessorResult: the belt fields join main's forwarding assign. - Clipper2 (#15969): belt arrange helpers take Slic3r::Point; the tree support join types lose their ClipperLib qualifier. - CLI arrange (#15837): belt printers still reserve no wipe tower. - Wipe tower options (#15841): the two new sparse-layer toggles are hidden for belt printers like the rest of the tower options. - Keyboard shortcuts (#15706): main's registry replaces the old key switch; the belt view toggle is re-registered in the next commit. - Print::process: the belt purge-plan undo runs before main's SliceStarted event. - scripts/filament_id_snapshot.json: deleted on main (a77209af8f). - Includes and appended tests: union of both sides.
970 lines
41 KiB
C++
970 lines
41 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 <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/libslic3r.h"
|
|
#include "libslic3r/Print.hpp"
|
|
#include "libslic3r/Layer.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>
|
|
|
|
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);
|
|
}
|