Merge branch 'main' into feat/printer-agent-infra

This commit is contained in:
Ian Chua
2026-09-15 16:47:15 +08:00
committed by GitHub
31 changed files with 944 additions and 303 deletions
+68
View File
@@ -15,6 +15,7 @@
#include "libslic3r/Geometry.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/SVG.hpp"
#include "libslic3r/libslic3r.h"
@@ -676,6 +677,73 @@ TEST_CASE("Ironing follows the solid infill rotation template", "[Fill]")
REQUIRE(compared > int(ironing.size()) / 2);
}
namespace {
PrintRegionConfig ironing_config(IroningType type,
int top_surface_filament_id = 1,
int top_shell_layers = 3,
int bottom_shell_layers = 1)
{
PrintRegionConfig cfg;
cfg.ironing_type.value = type;
cfg.top_surface_filament_id.value = top_surface_filament_id;
cfg.top_shell_layers.value = top_shell_layers;
cfg.bottom_shell_layers.value = bottom_shell_layers;
cfg.outer_wall_filament_id.value = 1;
cfg.wall_loops.value = 2;
return cfg;
}
} // namespace
TEST_CASE("Ironing an all-solid region uses the top surface filament on every layer", "[Fill]")
{
const PrintRegionConfig cfg = ironing_config(IroningType::AllSolid, /*top_surface_filament_id=*/2);
const bool is_topmost_layer = GENERATE(false, true);
CAPTURE(is_topmost_layer);
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, is_topmost_layer) == 2);
}
TEST_CASE("Ironing top surfaces uses the top surface filament when the region has top shells", "[Fill]")
{
const PrintRegionConfig cfg = ironing_config(IroningType::TopSurfaces,
/*top_surface_filament_id=*/3,
/*top_shell_layers=*/2);
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == 3);
}
TEST_CASE("Ironing top surfaces without top shells needs spiral mode and more than one bottom shell", "[Fill]")
{
const PrintRegionConfig one_bottom_shell = ironing_config(IroningType::TopSurfaces,
/*top_surface_filament_id=*/1,
/*top_shell_layers=*/0,
/*bottom_shell_layers=*/1);
const PrintRegionConfig two_bottom_shells = ironing_config(IroningType::TopSurfaces,
/*top_surface_filament_id=*/1,
/*top_shell_layers=*/0,
/*bottom_shell_layers=*/2);
REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == 1);
REQUIRE(Layer::choose_ironing_extruder(one_bottom_shell, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == -1);
REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1);
}
TEST_CASE("Ironing the topmost surface only applies to the topmost layer", "[Fill]")
{
const PrintRegionConfig cfg = ironing_config(IroningType::TopmostOnly, /*top_surface_filament_id=*/4);
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/true) == 4);
REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1);
}
TEST_CASE("A region with ironing turned off is never ironed", "[Fill]")
{
const PrintRegionConfig cfg = ironing_config(IroningType::NoIroning);
const bool spiral_mode = GENERATE(false, true);
CAPTURE(spiral_mode);
REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1);
}
TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]")
{
auto angles_for = [](int direction) {
+55
View File
@@ -15,6 +15,8 @@
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
#include <sstream>
using namespace Slic3r;
SCENARIO("Generic config validation performs as expected.", "[Config]") {
@@ -488,6 +490,59 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
config.set_key_value("wall_loops", new ConfigOptionInt(3));
config.set_key_value("filament_type", new ConfigOptionStrings({ "PLA", "PETG" }));
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28\nG1 Z5"));
ScopedTemporaryFile tmp(".json");
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
std::string file_contents;
{
boost::nowide::ifstream ifs(tmp.string());
file_contents.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
// The file format: one tab per nesting level and a trailing newline.
REQUIRE_FALSE(file_contents.empty());
CHECK(file_contents.rfind("{\n\t\"", 0) == 0);
CHECK(file_contents.back() == '\n');
std::ostringstream strict, replaced;
config.save_to_json(strict, "test_preset", "User", "1.0.0.0");
config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true);
CHECK(strict.str() == file_contents);
CHECK(replaced.str() == file_contents);
CHECK(nlohmann::json::parse(strict.str())["machine_start_gcode"] == "G28\nG1 Z5");
}
TEST_CASE("save_to_json replaces invalid UTF-8 in a stream only when asked", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
std::ostringstream strict, replaced;
CHECK_THROWS_AS(config.save_to_json(strict, "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
REQUIRE_NOTHROW(config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true));
CHECK(nlohmann::json::parse(replaced.str())["machine_start_gcode"] == "G28 ; \xEF\xBF\xBD");
}
TEST_CASE("save_to_json leaves an existing file untouched when the config cannot be serialized", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
ScopedTemporaryFile tmp(".json");
{
boost::nowide::ofstream ofs(tmp.string());
ofs << "previous";
}
CHECK_THROWS_AS(config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
boost::nowide::ifstream ifs(tmp.string());
const std::string contents((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
CHECK(contents == "previous");
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
const std::vector<std::string> refs = {
"master_plugin;;header-stamp",
@@ -484,6 +484,34 @@ TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves pe
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
REQUIRE(config.option<ConfigOptionInts>("filament_self_index")->values == std::vector<int>({1, 2}));
}
SECTION("a variant option shorter than the filament slots keeps its first value instead of zero") {
DynamicPrintConfig config;
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = {etDirectDrive, etDirectDrive};
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow};
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow",
"Direct Drive Standard,Direct Drive High Flow"};
make_filament_arrays(config);
config.option<ConfigOptionInts>("filament_map", true)->values = {1, 2};
// no loaded preset carries the key, so only its single registered default is present
config.option<ConfigOptionFloatsNullable>("filament_cooling_before_tower", true)->values = {10.};
// only the first filament's two variant columns were loaded
config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed", true)->values = {-1., -2.};
std::vector<std::vector<NozzleVolumeType>> nozzle_volume_types;
int extruder_count = 2;
int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types);
config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys,
"filament_self_index", "filament_extruder_variant");
// filament 2 resolves to column 3 (its extruder's High Flow column), past the end of both vectors
REQUIRE_THAT(config.option<ConfigOptionFloatsNullable>("filament_cooling_before_tower")->values,
Catch::Matchers::Approx(std::vector<double>({10., 10.})));
REQUIRE_THAT(config.option<ConfigOptionFloatsNullable>("filament_ramming_volumetric_speed")->values,
Catch::Matchers::Approx(std::vector<double>({-1., -1.})));
REQUIRE(config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>({12., 21.}));
}
}
// update_values_from_multi_to_multi_2 walks the DESTINATION PRINTER's variant list while writing
@@ -987,6 +987,193 @@ TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bund
CHECK(error == "Preset was not found in the loaded bundle");
}
TEST_CASE("Manifest-backed resolution reuses the vendor tree it already loaded", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir dir;
const fs::path process_dir = dir.path() / "Acme" / "process";
fs::create_directories(process_dir);
std::ofstream((dir.path() / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
<< R"({"name":"fdm_process_common","sub_path":"process/base.json"},)"
<< R"({"name":"Acme First","sub_path":"process/first.json"},)"
<< R"({"name":"Acme Second","sub_path":"process/second.json"}]})";
auto write_base = [&](double travel_speed) {
std::ofstream((process_dir / "base.json").string())
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
<< R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})";
};
auto write_child = [&](const std::string &file, const std::string &name) {
std::ofstream((process_dir / file).string())
<< R"({"type":"process","name":")" << name << R"(","from":"system",)"
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
};
write_base(111.0);
write_child("first.json", "Acme First");
write_child("second.json", "Acme Second");
auto travel_speed = [&](PresetBundle &bundle, const std::string &file) {
DynamicPrintConfig raw;
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
std::string error;
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, (process_dir / file).string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
return raw.option<ConfigOptionFloats>("travel_speed")->values.front();
};
PresetBundle bundle;
CHECK_THAT(travel_speed(bundle, "first.json"), Catch::Matchers::WithinAbs(111.0, 1e-6));
// Only a reload would see this change.
write_base(222.0);
CHECK_THAT(travel_speed(bundle, "second.json"), Catch::Matchers::WithinAbs(111.0, 1e-6));
PresetBundle fresh;
CHECK_THAT(travel_speed(fresh, "second.json"), Catch::Matchers::WithinAbs(222.0, 1e-6));
}
TEST_CASE("Manifest-backed resolution does not keep a vendor tree that failed to load", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir dir;
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
auto write_manifest = [&](const std::string &leading_entry) {
std::ofstream((dir.path() / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","process_list":[)" << leading_entry
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
};
write_manifest("123,");
fs::create_directories(child_file.parent_path());
std::ofstream(child_file.string())
<< R"({"type":"process","name":"Acme Process","from":"system",)"
<< R"("instantiation":"true","layer_height":"0.2"})";
PresetBundle bundle;
auto resolve = [&](std::string &error) {
DynamicPrintConfig raw;
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
return bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error);
};
std::string error;
CHECK_FALSE(resolve(error));
CHECK_FALSE(error.empty());
write_manifest("");
error.clear();
CHECK(resolve(error));
CHECK(error.empty());
}
TEST_CASE("Manifest-backed resolution reuses the library base for type-probed files", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir dir;
const fs::path library_pet = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament" / "pet.json";
const fs::path filament_dir = dir.path() / "Acme" / "filament";
std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string())
<< R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)"
<< R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})";
fs::create_directories(library_pet.parent_path());
auto write_library_pet = [&](double density) {
std::ofstream(library_pet.string())
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
<< R"("filament_id":"GFL99","instantiation":"false",)"
<< R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})";
};
write_library_pet(1.27);
std::ofstream((dir.path() / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","filament_list":[)"
<< R"({"name":"Acme PETG","sub_path":"filament/petg.json","filament_id":"GFA00"},)"
<< R"({"name":"Acme PETG Matte","sub_path":"filament/petg_matte.json","filament_id":"GFA01"}]})";
fs::create_directories(filament_dir);
auto write_child = [&](const std::string &file, const std::string &name, const std::string &filament_id) {
std::ofstream((filament_dir / file).string())
<< R"({"type":"filament","name":")" << name << R"(","from":"system",)"
<< R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})";
};
write_child("petg.json", "Acme PETG", "GFA00");
write_child("petg_matte.json", "Acme PETG Matte", "GFA01");
auto density = [](const DynamicPrintConfig &config) {
return config.option<ConfigOptionFloats>("filament_density")->values.front();
};
PresetBundle bundle;
DynamicPrintConfig first;
first.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
std::string error;
REQUIRE(bundle.resolve_preset_config(first, Preset::TYPE_FILAMENT, (filament_dir / "petg.json").string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_THAT(density(first), Catch::Matchers::WithinAbs(1.27, 1e-6));
// Only a reload would see this change.
write_library_pet(1.5);
DynamicPrintConfig second;
Preset::Type type = Preset::TYPE_INVALID;
REQUIRE(bundle.resolve_preset_config_type(second, type, (filament_dir / "petg_matte.json").string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK(type == Preset::TYPE_FILAMENT);
CHECK_THAT(density(second), Catch::Matchers::WithinAbs(1.27, 1e-6));
}
TEST_CASE("Manifest-backed resolution shares the library between vendors under one root", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir dir;
const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament";
std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string())
<< R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)"
<< R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"},)"
<< R"({"name":"Generic PETG","sub_path":"filament/generic_petg.json","filament_id":"GFL98"}]})";
fs::create_directories(library_dir);
auto write_library_pet = [&](double density) {
std::ofstream((library_dir / "pet.json").string())
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
<< R"("filament_id":"GFL99","instantiation":"false",)"
<< R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})";
};
write_library_pet(1.27);
std::ofstream((library_dir / "generic_petg.json").string())
<< R"({"type":"filament","name":"Generic PETG","from":"system",)"
<< R"("filament_id":"GFL98","instantiation":"true","inherits":"fdm_filament_pet"})";
auto write_vendor = [&](const std::string &vendor, const std::string &filament_id) {
const fs::path filament_dir = dir.path() / vendor / "filament";
fs::create_directories(filament_dir);
std::ofstream((dir.path() / (vendor + ".json")).string())
<< R"({"version":"1.0.0","name":")" << vendor << R"(","filament_list":[)"
<< R"({"name":")" << vendor << R"( PETG","sub_path":"filament/petg.json","filament_id":")" << filament_id << R"("}]})";
std::ofstream((filament_dir / "petg.json").string())
<< R"({"type":"filament","name":")" << vendor << R"( PETG","from":"system",)"
<< R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})";
return filament_dir / "petg.json";
};
const fs::path acme_petg = write_vendor("Acme", "GFA00");
const fs::path beta_petg = write_vendor("Beta", "GFB00");
auto density = [&](PresetBundle &bundle, const fs::path &file) {
DynamicPrintConfig raw;
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
std::string error;
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, file.string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
return raw.option<ConfigOptionFloats>("filament_density")->values.front();
};
PresetBundle bundle;
CHECK_THAT(density(bundle, acme_petg), Catch::Matchers::WithinAbs(1.27, 1e-6));
// Only a reload would see this change.
write_library_pet(1.5);
CHECK_THAT(density(bundle, beta_petg), Catch::Matchers::WithinAbs(1.27, 1e-6));
CHECK_THAT(density(bundle, library_dir / "generic_petg.json"), Catch::Matchers::WithinAbs(1.27, 1e-6));
PresetBundle fresh;
CHECK_THAT(density(fresh, beta_petg), Catch::Matchers::WithinAbs(1.5, 1e-6));
}
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
// with that printer and the plater combo box lists the shared alias twice.
+64
View File
@@ -4,6 +4,8 @@
#include "test_utils.hpp"
#include <boost/filesystem.hpp>
#include <algorithm>
#include <cctype>
#include <fstream>
@@ -88,3 +90,65 @@ TEST_CASE("copy_file reports the OS error when the destination cannot be written
REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; }));
#endif // _WIN32
}
TEST_CASE("A resolved input path still names the same file after the working directory changes", "[utils]") {
ScopedTemporaryFile model(".3mf");
{ std::ofstream out(model.string()); out << "3mf"; }
const std::string name = model.path().filename().string();
// Resolve the bare name from the directory holding the file, then move away from it. The guard
// restores the directory the test started in, wherever this leaves it.
ScopedWorkingDirectory cwd(model.path().parent_path());
const std::string resolved = resolve_cli_input_path(name);
boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR));
REQUIRE(boost::filesystem::exists(resolved));
REQUIRE(boost::filesystem::equivalent(resolved, model.path()));
// Control: the bare name finds nothing from here, so resolving it this late would have failed.
REQUIRE_FALSE(boost::filesystem::exists(name));
}
TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[utils]") {
ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path());
// Read back rather than reusing temp_directory_path(): changing to it resolves any symlink.
const boost::filesystem::path here = boost::filesystem::current_path();
SECTION("a bare name") {
REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string());
}
SECTION("a ./ prefix is dropped") {
REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string());
}
SECTION("a ../ traversal is collapsed") {
REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string());
}
}
TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[utils]") {
SECTION("an absolute path") {
const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred();
REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string());
}
#ifdef _WIN32
// Every absolute form Windows accepts opens today, so each must come back byte for byte:
// normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes.
SECTION("an absolute Windows path of any form") {
for (const std::string absolute : {R"(C:\models\model.3mf)",
R"(C:/models/model.3mf)",
R"(\\server\share\model.3mf)",
R"(\\?\C:\models\model.3mf)"})
REQUIRE(resolve_cli_input_path(absolute) == absolute);
}
#endif
// These are downloaded rather than opened, and completing one would produce a path, not a URL.
SECTION("a custom open protocol URL") {
for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf",
"prusaslicer://open/?file=https://example.com/model.3mf",
"bambustudio://open/?file=https://example.com/model.3mf",
"cura://open/?file=https://example.com/model.3mf"})
REQUIRE(resolve_cli_input_path(url) == url);
}
SECTION("an empty argument") {
REQUIRE(resolve_cli_input_path("").empty());
}
}
+18
View File
@@ -176,4 +176,22 @@ inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe
#endif
}
// Changes the working directory and restores the previous one on scope exit, including when an
// assertion throws. It is process wide state shared with every other test.
class ScopedWorkingDirectory
{
public:
explicit ScopedWorkingDirectory(const boost::filesystem::path &dir)
: m_previous(boost::filesystem::current_path())
{
boost::filesystem::current_path(dir);
}
~ScopedWorkingDirectory() { boost::system::error_code ec; boost::filesystem::current_path(m_previous, ec); }
ScopedWorkingDirectory(const ScopedWorkingDirectory &) = delete;
ScopedWorkingDirectory &operator=(const ScopedWorkingDirectory &) = delete;
private:
boost::filesystem::path m_previous;
};
#endif // SLIC3R_TEST_UTILS