Files
OrcaSlicer/tests/fff_print/test_gcode.cpp
T
xNoVoSx 5e94368863 Write the first layer temperatures of the printed filaments into the G-code header (#16422)
The `; first_layer_bed_temperature` and `; first_layer_temperature` compatibility lines were always taken from filament 1. Anycubic's GoKlipper reads the bed line to heat the bed, so a plate printed only with another filament got the wrong temperature.

The bed line now follows `bed_temperature_formula` (highest used filament or first printing filament). The nozzle line uses the first printing filament. Both values are computed before the by-object loop.
2026-10-14 16:11:57 -05:00

213 lines
10 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/catch_message.hpp>
#include "libslic3r/BoundingBox.hpp"
#include "libslic3r/Config.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/ModelArrange.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Point.hpp"
#include <cstddef>
#include <fstream>
#include <ios>
#include <string>
#include <vector>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
using namespace Slic3r;
TEST_CASE("Klipper object labels name each copy without the characters Klipper cannot parse", "[GCode]")
{
const auto [name, label] = GENERATE(table<std::string, std::string>({
{"my part (2)", "my_part_2"},
{"(cube)", "cube"},
}));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"gcode_flavor", "klipper"}, {"exclude_object", "1"}});
Print print;
Model model;
Test::init_print(std::vector<TriangleMesh>{Test::cube(20.)}, print, model, config, nullptr, false, 2);
model.objects.front()->name = name;
arrange_objects(model, BoundingBox{Point::new_scale(0., 0.), Point::new_scale(500., 500.)},
ArrangeParams{scaled(min_object_distance(config))});
print.apply(model, config);
const std::string gcode = Test::gcode(print);
for (const char *copy : {"0", "1"}) {
const std::string instance_label = label + "_id_0_copy_" + copy;
INFO(instance_label);
CHECK(gcode.find("EXCLUDE_OBJECT_DEFINE NAME=" + instance_label + " ") != std::string::npos);
CHECK(gcode.find("EXCLUDE_OBJECT_START NAME=" + instance_label + "\n") != std::string::npos);
}
}
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
{
const std::string formula = GENERATE("by_first_filament", "by_highest_temp");
DYNAMIC_SECTION("bed_temperature_formula " << formula) {
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Everything prints with filament 2.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"bed_temperature_formula", formula},
// Authored 1..n as PresetBundle writes it; the all-1s default collapses every per-filament value
// to filament 1.
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"sparse_infill_filament_id", 2},
{"internal_solid_filament_id", 2},
{"top_surface_filament_id", 2},
{"bottom_surface_filament_id", 2},
{"outer_wall_filament_id", 2},
{"inner_wall_filament_id", 2},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice({Test::cube(20)}, config);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
}
// Printing by object reassigns the initial filament to each object's first filament while it emits the objects, so
// the header must still name the filament the print starts with: the firmware heats the bed for the first object.
TEST_CASE("The G-code header names the first object's filament when printing by object", "[GCode]")
{
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Object 1 prints with filament 2, object 2
// with filament 1.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"print_sequence", "by object"},
{"bed_temperature_formula", "by_first_filament"},
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice_with_object_overrides({Test::cube(20), Test::cube(20)}, config,
{{{"extruder", "2"}}, {{"extruder", "1"}}});
// Filament 2 starts and filament 1 follows, so the last object's filament differs from the first one's.
const size_t first_t1 = gcode.find("\nT1");
const size_t first_t0 = gcode.find("\nT0");
REQUIRE(first_t1 != std::string::npos);
REQUIRE(first_t0 != std::string::npos);
REQUIRE(first_t1 < first_t0);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
TEST_CASE("Object label comments number each object and copy with or without exclude object", "[GCode]")
{
// repetier has no exclude-object commands, so set_object_info() writes nothing for it.
// BBL printers write M624/M625 skip blocks whatever exclude_object is set to.
const auto [flavor, exclude_object, bbl] = GENERATE(table<std::string, std::string, bool>({
{"marlin2", "0", false},
{"klipper", "0", false},
{"klipper", "1", false},
{"repetier", "1", false},
{"marlin2", "0", true},
}));
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({{"gcode_flavor", flavor}, {"exclude_object", exclude_object}, {"gcode_label_objects", "1"}});
Print print;
print.is_BBL_printer() = bbl;
Model model;
Test::init_print(std::vector<TriangleMesh>{Test::cube(20.), Test::cube(20.)}, print, model, config, nullptr, false, 2);
arrange_objects(model, BoundingBox{Point::new_scale(0., 0.), Point::new_scale(500., 500.)},
ArrangeParams{scaled(min_object_distance(config))});
print.apply(model, config);
const std::string gcode = Test::gcode(print);
INFO(flavor << ", exclude_object " << exclude_object << ", BBL " << bbl);
for (const char *label : {"id:0 copy 0", "id:0 copy 1", "id:1 copy 0", "id:1 copy 1"}) {
INFO(label);
CHECK(gcode.find("; printing object object.stl " + std::string(label) + "\n") != std::string::npos);
}
if (bbl)
for (const PrintObject *object : print.objects())
for (const PrintInstance &instance : object->instances()) {
const std::string label_id = std::to_string(instance.model_instance->get_labeled_id());
INFO("label id " << label_id);
CHECK(gcode.find("; start printing object, unique label id: " + label_id + "\nM624 ") != std::string::npos);
CHECK(gcode.find("; stop printing object, unique label id: " + label_id + "\nM625\n") != std::string::npos);
}
}
TEST_CASE("An exported G-code's settings load back as the settings it was sliced with", "[GCode]")
{
// Two filaments, so the config block carries the flush matrix already multiplied by flush_multiplier.
const DynamicPrintConfig config = Test::multifilament_config(2, {
{ "flush_multiplier", "0.5" },
{ "printable_area", "0x0,250x0,250x210,0x210" },
{ "printable_height", "180" },
});
Print print;
Model model;
Test::init_print(std::vector<TriangleMesh>{ Test::cube(20.) }, print, model, config, nullptr, false);
ScopedTemporaryFile file(".gcode");
{
std::ofstream os(file.string(), std::ios::binary);
os << Test::gcode(print);
REQUIRE(os.good());
}
PresetBundle bundle;
// The config block leaves prime_volume_mode out, so the receiver's choice has to survive the load.
bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value = PrimeVolumeMode::pvmFast;
ConfigSubstitutions substitutions;
bundle.load_config_model(file.string(), bundle.config_from_gcode(file.string(), ForwardCompatibilitySubstitutionRule::Enable, substitutions));
const DynamicPrintConfig &printer = bundle.printers.get_edited_preset().config;
CHECK(printer.opt_serialize("printable_area") == config.opt_serialize("printable_area"));
CHECK_THAT(printer.opt_float("printable_height"), Catch::Matchers::WithinAbs(180., 1e-6));
// The purge between the two filaments is what the slice used: 280 at a 0.5 multiplier.
const std::vector<double> &matrix = bundle.project_config.option<ConfigOptionFloats>("flush_volumes_matrix")->values;
const std::vector<double> &multiplier = bundle.project_config.option<ConfigOptionFloats>("flush_multiplier")->values;
REQUIRE(matrix.size() == 4);
REQUIRE(multiplier.size() == 1);
CHECK_THAT(matrix[1] * multiplier[0], Catch::Matchers::WithinAbs(140., 1e-6));
CHECK(bundle.project_config.option<ConfigOptionEnum<PrimeVolumeMode>>("prime_volume_mode")->value == PrimeVolumeMode::pvmFast);
}