Files
OrcaSlicer/tests/fff_print/test_print.cpp
SoftFever 780b2f1ebe fix(engine): publish the nozzle group result for sequential prints
The Print-level LayeredNozzleGroupResult had a single producer, the
by-layer branch of ToolOrdering, which is gated to non-sequential prints.
The by-object branch in Print::process computed a grouping only in auto
map modes and never stored it, so a sequential slice exported with a null
group result: the per-nozzle placeholder tables came up empty and any
start g-code indexing nozzle_diameter_at_nozzle_id[] aborted with
"Indexing an empty vector variable". A prior by-layer slice masked the
bug by leaving its (never cleared) result on the Print.

Now the by-object branch runs get_recommended_filament_maps in every
static map mode (in manual modes the result mirrors the user's
assignment, deviations throw as in by-layer) and publishes it
print-wide. The config write-back stays gated to auto modes: in manual
modes it would only re-store the pre-slice values.

Regression test: a two-object by-object print must publish a non-null
group result and resolve nozzle_diameter_at_nozzle_id[] in start g-code
(both fail without the fix). Suites green (libslic3r 48929/162,
fff_print 633/60); 18-fixture byte gate identical; the by-object repro
project goes from the export error to valid g-code, determinism x2.
2026-07-12 00:46:15 +08:00

443 lines
19 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 <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("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);
}
// 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);
}
}