mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 13:32:44 +00:00
Resolving a system preset through its vendor manifest loaded the whole vendor tree and the filament library from JSON, and the CLI did that separately for every --load-settings and --load-filaments file. A run with machine, process and filament presets parsed BBL's 2,879 profile files and the library's 512 three times over, about a second each. Keep the library and vendor bundles loaded by the manifest path on the PresetBundle that resolved them, keyed by source root, vendor and substitution rule, and have the CLI resolve every system preset through one bundle for the whole run. A failed load is not kept, so errors are reported as before. On a cube slice with X1C machine, process and PLA presets: 2.42 s -> 0.93 s, BBL.json opened once instead of three times, identical G-code.
5241 lines
284 KiB
C++
5241 lines
284 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <boost/filesystem.hpp>
|
|
#include <fstream>
|
|
|
|
#include "libslic3r/PresetBundle.hpp"
|
|
#include "libslic3r/AppConfig.hpp"
|
|
#include "libslic3r/Model.hpp"
|
|
#include "libslic3r/TriangleMesh.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include "libslic3r/miniz_extension.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <iostream>
|
|
#include <initializer_list>
|
|
|
|
using namespace Slic3r;
|
|
|
|
namespace {
|
|
|
|
namespace fs = boost::filesystem;
|
|
|
|
// Whether a key is listed in a vector of keys (published_keys / skipped_keys).
|
|
bool contains_key(const std::vector<std::string> &keys, const std::string &key)
|
|
{
|
|
return std::find(keys.begin(), keys.end(), key) != keys.end();
|
|
}
|
|
|
|
void check_double_vector(const std::vector<double> &actual, std::initializer_list<double> expected)
|
|
{
|
|
REQUIRE(actual.size() == expected.size());
|
|
size_t index = 0;
|
|
for (double value : expected)
|
|
REQUIRE_THAT(actual[index++], Catch::Matchers::WithinAbs(value, 1e-6));
|
|
}
|
|
|
|
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
|
|
{
|
|
DynamicPrintConfig config(default_config);
|
|
config.option<ConfigOptionString>("print_settings_id", true)->value = name;
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
|
|
fs::create_directories(file.parent_path());
|
|
config.save_to_json(file.string(), name, "User", "1.0.0");
|
|
}
|
|
|
|
// Write a preset json carrying a name and an "inherits" value, using the given collection's
|
|
// default config so it loads back into that collection. Works for any preset type.
|
|
void write_preset_with_inherits(const DynamicPrintConfig &default_config, const fs::path &file,
|
|
const std::string &name, const std::string &inherits)
|
|
{
|
|
DynamicPrintConfig config(default_config);
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
|
|
fs::create_directories(file.parent_path());
|
|
config.save_to_json(file.string(), name, "User", "1.0.0");
|
|
}
|
|
|
|
// Add an in-memory preset (no file) with the given inherits value (empty => root preset).
|
|
Preset &add_inmemory_preset(PresetCollection &coll, const std::string &name, const std::string &inherits = {})
|
|
{
|
|
DynamicPrintConfig config(coll.default_preset().config);
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
return coll.load_preset(std::string(), name, config, /*select=*/false);
|
|
}
|
|
|
|
// Mark an already-loaded preset as renamed from one or more former names.
|
|
void set_renamed_from(PresetCollection &coll, const std::string &preset_name, std::vector<std::string> old_names)
|
|
{
|
|
for (auto it = coll.begin(); it != coll.end(); ++it)
|
|
if (it->name == preset_name)
|
|
it->renamed_from = std::move(old_names);
|
|
}
|
|
|
|
// A single-slot PLA file config for the published-material load tests (mirrors what the GUI
|
|
// builds from a 3mf's project settings before load_config_model).
|
|
DynamicPrintConfig published_pla_file_config()
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
}
|
|
|
|
// A standalone print preset collection that exposes the protected rename-map builder, so a
|
|
// renamed_from scenario can be set up without the full system-profile load pipeline.
|
|
// (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with
|
|
// the same type/keys/defaults PresetBundle uses for its print collection.)
|
|
struct RenameTestCollection : public PresetCollection
|
|
{
|
|
RenameTestCollection()
|
|
: PresetCollection(Preset::TYPE_PRINT, Preset::print_options(),
|
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
|
|
{}
|
|
using PresetCollection::update_map_system_profile_renamed;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
PresetBundle bundle;
|
|
PresetsConfigSubstitutions substitutions;
|
|
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
|
|
|
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
|
|
const Preset *root_user = bundle.prints.find_preset("User");
|
|
REQUIRE(root_user != nullptr);
|
|
CHECK(root_user->name == "User");
|
|
CHECK_FALSE(root_user->is_from_bundle());
|
|
|
|
const Preset *local_bundle = bundle.prints.find_preset("_local/bundle-1/LocalBundle");
|
|
REQUIRE(local_bundle != nullptr);
|
|
CHECK(local_bundle->name == "_local/bundle-1/LocalBundle");
|
|
CHECK(local_bundle->is_from_bundle());
|
|
|
|
const Preset *subscribed = bundle.prints.find_preset("_subscribed/remote-1/Subscribed");
|
|
REQUIRE(subscribed != nullptr);
|
|
CHECK(subscribed->name == "_subscribed/remote-1/Subscribed");
|
|
CHECK(subscribed->is_from_bundle());
|
|
}
|
|
|
|
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
PresetBundle bundle;
|
|
|
|
PresetsConfigSubstitutions substitutions;
|
|
std::vector<std::string> result;
|
|
int overwrite = 0;
|
|
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
|
|
const fs::path user_root = temp_dir.path() / "user";
|
|
|
|
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
|
|
fs::create_directories(user_root);
|
|
bundle.prints.update_user_presets_directory(user_root.string(), PRESET_PRINT_NAME);
|
|
|
|
REQUIRE(bundle.import_json_presets(
|
|
substitutions,
|
|
file,
|
|
[](std::string const &) { return 1; },
|
|
ForwardCompatibilitySubstitutionRule::Disable,
|
|
overwrite,
|
|
result));
|
|
|
|
const Preset *imported = bundle.prints.find_preset("Imported");
|
|
REQUIRE(imported != nullptr);
|
|
CHECK(imported->name == "Imported");
|
|
CHECK(imported->bundle_id.empty());
|
|
CHECK_FALSE(imported->is_from_bundle());
|
|
// Detached user presets (no inherits) are saved in the "base" subfolder of the user preset root.
|
|
CHECK(fs::equivalent(fs::path(imported->file).parent_path().parent_path(), user_root / PRESET_PRINT_NAME));
|
|
}
|
|
|
|
TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
VendorProfile orca_vendor; orca_vendor.id = "ORCA";
|
|
VendorProfile::PrinterModel model;
|
|
model.name = "Orca Test";
|
|
orca_vendor.models.emplace_back(model);
|
|
bundle.vendors.emplace("ORCA", std::move(orca_vendor));
|
|
|
|
bundle.printers.get_edited_preset().config.erase("printer_model");
|
|
|
|
CHECK(bundle.get_current_vendor_type() == VendorType::Unknown);
|
|
}
|
|
|
|
TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
|
|
// A bare number where the list wants an object. An array element has no key,
|
|
// so reporting one as if it did throws nlohmann's invalid_iterator - which is
|
|
// not a parse_error, and escapes the catch around the vendor profile parse.
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
|
|
<< R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})";
|
|
fs::create_directories(dir.path() / "Acme" / "process");
|
|
std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string())
|
|
<< R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
PresetBundle bundle;
|
|
size_t loaded = 0;
|
|
REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json(
|
|
dir.path().string(), "Acme", PresetBundle::LoadSystem,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent).second);
|
|
|
|
CHECK(bundle.error_count() > 0); // the malformed element was counted
|
|
CHECK(loaded == 1); // the well-formed one beside it still loaded
|
|
}
|
|
|
|
TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
DynamicPrintConfig& config = bundle.printers.get_edited_preset().config;
|
|
|
|
config.erase("nozzle_diameter");
|
|
CHECK(bundle.get_printer_extruder_count() == 1);
|
|
|
|
config.set_key_value("nozzle_diameter", new ConfigOptionFloats());
|
|
CHECK(bundle.get_printer_extruder_count() == 1);
|
|
|
|
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.4, 0.6 }));
|
|
CHECK(bundle.get_printer_extruder_count() == 2);
|
|
}
|
|
|
|
TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset& printer = add_inmemory_preset(bundle.printers, "Test Printer");
|
|
printer.is_system = true;
|
|
printer.config.option<ConfigOptionString>("printer_model")->value = "TEST-MODEL";
|
|
printer.config.option<ConfigOptionString>("printer_variant")->value = "0.4";
|
|
printer.config.option<ConfigOptionString>("default_bed_type")->value = "Engineering Plate";
|
|
|
|
AppConfig app_config;
|
|
app_config.set("curr_bed_type", std::to_string(static_cast<int>(btPTE)));
|
|
PresetBundle::PresetPreferences preferred_selection;
|
|
BedType expected_bed_type;
|
|
|
|
SECTION("New printer uses its symbolic default") {
|
|
expected_bed_type = btEP;
|
|
preferred_selection = {"TEST-MODEL", "0.4"};
|
|
}
|
|
SECTION("Re-enabled printer uses its saved selection") {
|
|
expected_bed_type = btPC;
|
|
preferred_selection = {"TEST-MODEL", "0.4"};
|
|
app_config.set_printer_setting("Test Printer", "curr_bed_type",
|
|
std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
SECTION("Existing printer keeps its saved selection after presets reload") {
|
|
expected_bed_type = btPCT;
|
|
app_config.set("presets", PRESET_PRINTER_NAME, "Test Printer");
|
|
app_config.set_printer_setting("Test Printer", "curr_bed_type",
|
|
std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
|
|
bundle.load_selections(app_config, preferred_selection);
|
|
bundle.export_selections(app_config);
|
|
|
|
CHECK(bundle.project_config.opt_enum<BedType>("curr_bed_type") == expected_bed_type);
|
|
CHECK(app_config.get_printer_setting("Test Printer", "curr_bed_type") == std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
|
|
TEST_CASE("find_preset resolves a system preset's renamed_from", "[Preset][Rename]")
|
|
{
|
|
RenameTestCollection coll;
|
|
|
|
// "New Process" is the current preset; it was renamed from "Old Process".
|
|
add_inmemory_preset(coll, "New Process");
|
|
set_renamed_from(coll, "New Process", { "Old Process" });
|
|
coll.update_map_system_profile_renamed();
|
|
|
|
// The rename map knows the old name...
|
|
const std::string *renamed = coll.get_preset_name_renamed("Old Process");
|
|
REQUIRE(renamed != nullptr);
|
|
CHECK(*renamed == "New Process");
|
|
|
|
// ...and plain find_preset() now follows it (the core of this PR; previously this
|
|
// resolution lived only in find_preset2 and a few call sites).
|
|
const Preset *resolved = coll.find_preset("Old Process");
|
|
REQUIRE(resolved != nullptr);
|
|
CHECK(resolved->name == "New Process");
|
|
|
|
// A genuinely unknown name still returns null (no spurious match).
|
|
CHECK(coll.find_preset("Totally Unknown") == nullptr);
|
|
|
|
// A child that still inherits the OLD name resolves through the runtime walker,
|
|
// which uses plain find_preset().
|
|
Preset &child = add_inmemory_preset(coll, "Child Process", "Old Process");
|
|
const Preset *parent = coll.get_preset_parent(child);
|
|
REQUIRE(parent != nullptr);
|
|
CHECK(parent->name == "New Process");
|
|
}
|
|
|
|
TEST_CASE("find_preset resolves a preset renamed more than once", "[Preset][Rename]")
|
|
{
|
|
RenameTestCollection coll;
|
|
|
|
// "New Process" was renamed twice, so it carries both former names in renamed_from.
|
|
add_inmemory_preset(coll, "New Process");
|
|
set_renamed_from(coll, "New Process", { "Original Process", "Old Process" });
|
|
coll.update_map_system_profile_renamed();
|
|
|
|
// Each historical name resolves to the current preset.
|
|
for (const char *old_name : { "Original Process", "Old Process" }) {
|
|
INFO("resolving old name: " << old_name);
|
|
const std::string *renamed = coll.get_preset_name_renamed(old_name);
|
|
REQUIRE(renamed != nullptr);
|
|
CHECK(*renamed == "New Process");
|
|
|
|
const Preset *resolved = coll.find_preset(old_name);
|
|
REQUIRE(resolved != nullptr);
|
|
CHECK(resolved->name == "New Process");
|
|
}
|
|
|
|
// A child inheriting either former name resolves through the runtime walker.
|
|
Preset &child = add_inmemory_preset(coll, "Child Process", "Original Process");
|
|
REQUIRE(coll.get_preset_parent(child) != nullptr);
|
|
CHECK(coll.get_preset_parent(child)->name == "New Process");
|
|
}
|
|
|
|
TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the library", "[Preset][Rename]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
// The OrcaFilamentLibrary replacement that removed empty "<vendor> Generic" profiles map to.
|
|
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
|
|
|
// Plain lookups do NOT fuzzy-match a removed vendor profile.
|
|
CHECK(bundle.filaments.find_preset("Voron Generic PLA") == nullptr);
|
|
CHECK(bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/false) == nullptr);
|
|
|
|
// With auto_match, the removed "Voron Generic PLA" resolves to "Generic PLA @System".
|
|
const Preset *matched = bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/true);
|
|
REQUIRE(matched != nullptr);
|
|
CHECK(matched->name == "Generic PLA @System");
|
|
|
|
// No library preset exists for an unrelated material => still no match.
|
|
CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr);
|
|
}
|
|
|
|
TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
RenameTestCollection coll;
|
|
|
|
// Current parent, renamed from "Old Process".
|
|
add_inmemory_preset(coll, "New Process");
|
|
set_renamed_from(coll, "New Process", { "Old Process" });
|
|
coll.update_map_system_profile_renamed();
|
|
|
|
// A user preset on disk that still inherits the OLD name.
|
|
write_preset_with_inherits(coll.default_preset().config,
|
|
temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process");
|
|
|
|
PresetsConfigSubstitutions substitutions;
|
|
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::Disable);
|
|
|
|
const Preset *child = coll.find_preset("Child");
|
|
REQUIRE(child != nullptr);
|
|
// The dangling "Old Process" was rewritten to the resolved parent name at load time,
|
|
// so the runtime walker (plain find_preset) can resolve the chain.
|
|
CHECK(child->inherits() == "New Process");
|
|
REQUIRE(coll.get_preset_parent(*child) != nullptr);
|
|
CHECK(coll.get_preset_parent(*child)->name == "New Process");
|
|
}
|
|
|
|
TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
PresetBundle bundle;
|
|
|
|
add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
|
|
|
// A user filament that still inherits a removed "<vendor> Generic PLA" profile.
|
|
write_preset_with_inherits(bundle.filaments.default_preset().config,
|
|
temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA");
|
|
|
|
PresetsConfigSubstitutions substitutions;
|
|
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::Disable);
|
|
|
|
const Preset *child = bundle.filaments.find_preset("MyPLA");
|
|
REQUIRE(child != nullptr);
|
|
CHECK(child->inherits() == "Generic PLA @System");
|
|
REQUIRE(bundle.filaments.get_preset_parent(*child) != nullptr);
|
|
CHECK(bundle.filaments.get_preset_parent(*child)->name == "Generic PLA @System");
|
|
}
|
|
|
|
namespace {
|
|
|
|
// A live reference to a preset's compatible_printers / compatible_prints list. Fetches the *stored*
|
|
// preset (real=true) so writes and reads hit the same object; creates the option if absent.
|
|
std::vector<std::string> &compatible_list(PresetCollection &coll, const std::string &preset_name, const char *field_key)
|
|
{
|
|
Preset *preset = coll.find_preset(preset_name, /*first_visible_if_not_found=*/false, /*real=*/true);
|
|
REQUIRE(preset != nullptr);
|
|
return preset->config.option<ConfigOptionStrings>(field_key, true)->values;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Renamed printer/process names are normalized into compatible lists on load", "[Preset][Rename]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
// Current printer + process, each renamed from an older name.
|
|
add_inmemory_preset(bundle.printers, "New Printer");
|
|
set_renamed_from(bundle.printers, "New Printer", { "Old Printer" });
|
|
add_inmemory_preset(bundle.prints, "New Process");
|
|
set_renamed_from(bundle.prints, "New Process", { "Old Process" });
|
|
|
|
// A user process still compatible with the OLD printer name.
|
|
add_inmemory_preset(bundle.prints, "My Process");
|
|
compatible_list(bundle.prints, "My Process", "compatible_printers") = { "Old Printer" };
|
|
|
|
// A user filament referencing the OLD printer AND OLD process names, plus an unknown printer.
|
|
add_inmemory_preset(bundle.filaments, "My Filament");
|
|
compatible_list(bundle.filaments, "My Filament", "compatible_printers") = { "Old Printer", "Unknown Printer" };
|
|
compatible_list(bundle.filaments, "My Filament", "compatible_prints") = { "Old Process" };
|
|
|
|
// Build the rename maps (done during system load in the real pipeline), then normalize.
|
|
AppConfig app_config;
|
|
bundle.load_installed_printers(app_config); // rebuilds every collection's rename map
|
|
bundle.normalize_compatible_presets();
|
|
|
|
// The stale printer name in a process' compatible_printers is rewritten to the current name.
|
|
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
|
|
|
// The stale process name in a filament's compatible_prints is rewritten (this field has no
|
|
// runtime rename fallback, so load-time normalization is the only fix).
|
|
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_prints") == std::vector<std::string>{ "New Process" });
|
|
|
|
// The renamed printer is rewritten while the unknown/deleted name is preserved as-is.
|
|
CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") ==
|
|
(std::vector<std::string>{ "New Printer", "Unknown Printer" }));
|
|
|
|
// Normalizing rewrites config in place without flagging the preset dirty.
|
|
CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty);
|
|
|
|
// A system preset that already references the current name is left untouched (idempotent no-op).
|
|
bundle.normalize_compatible_presets();
|
|
CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector<std::string>{ "New Printer" });
|
|
}
|
|
|
|
TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
// Current printer + process, each renamed from an older name.
|
|
add_inmemory_preset(bundle.printers, "New Printer");
|
|
set_renamed_from(bundle.printers, "New Printer", { "Old Printer" });
|
|
add_inmemory_preset(bundle.prints, "New Process");
|
|
set_renamed_from(bundle.prints, "New Process", { "Old Process" });
|
|
|
|
// A *system* (vendor) filament whose own compatible lists still reference the OLD names. A vendor
|
|
// profile can point at a sibling preset that was later renamed, so system presets must be
|
|
// normalized too (they are skipped by neither collection walk).
|
|
add_inmemory_preset(bundle.filaments, "System Filament").is_system = true;
|
|
compatible_list(bundle.filaments, "System Filament", "compatible_printers") = { "Old Printer" };
|
|
compatible_list(bundle.filaments, "System Filament", "compatible_prints") = { "Old Process" };
|
|
|
|
AppConfig app_config;
|
|
bundle.load_installed_printers(app_config); // build the rename maps
|
|
bundle.normalize_compatible_presets();
|
|
|
|
// The stale references in the system preset are rewritten to the current names.
|
|
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
|
std::vector<std::string>{ "New Printer" });
|
|
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
|
|
std::vector<std::string>{ "New Process" });
|
|
|
|
// The rewrite does not flag the system preset dirty, and is idempotent.
|
|
CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty);
|
|
bundle.normalize_compatible_presets();
|
|
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") ==
|
|
std::vector<std::string>{ "New Printer" });
|
|
}
|
|
|
|
TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
// A renamed SLA process, and a same-named FFF process that must NOT be picked up: resolving the
|
|
// SLA material's compatible_prints against `prints` would wrongly rewrite to "Wrong FFF Process".
|
|
add_inmemory_preset(bundle.sla_prints, "New SLA Process");
|
|
set_renamed_from(bundle.sla_prints, "New SLA Process", { "Old SLA Process" });
|
|
add_inmemory_preset(bundle.prints, "Wrong FFF Process");
|
|
set_renamed_from(bundle.prints, "Wrong FFF Process", { "Old SLA Process" });
|
|
|
|
add_inmemory_preset(bundle.sla_materials, "My SLA Material");
|
|
compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") = { "Old SLA Process" };
|
|
|
|
AppConfig app_config;
|
|
bundle.load_installed_printers(app_config);
|
|
bundle.normalize_compatible_presets();
|
|
|
|
CHECK(compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") ==
|
|
std::vector<std::string>{ "New SLA Process" });
|
|
}
|
|
|
|
TEST_CASE("Profile validator flags dangling and renamed preset references", "[Preset][Validate]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
// Current printers: a real one, and a renamed one (its old name resolves via renamed_from).
|
|
add_inmemory_preset(bundle.printers, "Real Printer");
|
|
add_inmemory_preset(bundle.printers, "New Printer");
|
|
set_renamed_from(bundle.printers, "New Printer", { "Old Printer" });
|
|
|
|
// A real process, referenced from a filament's compatible_prints.
|
|
add_inmemory_preset(bundle.prints, "Real Process").is_system = true;
|
|
|
|
// A fully valid system filament: references only current names.
|
|
add_inmemory_preset(bundle.filaments, "Good Filament").is_system = true;
|
|
compatible_list(bundle.filaments, "Good Filament", "compatible_printers") = { "Real Printer" };
|
|
compatible_list(bundle.filaments, "Good Filament", "compatible_prints") = { "Real Process" };
|
|
|
|
AppConfig app_config;
|
|
bundle.load_installed_printers(app_config); // build the rename maps
|
|
|
|
// With only valid references, the validator is clean.
|
|
CHECK_FALSE(bundle.check_preset_references());
|
|
|
|
SECTION("deleted compatible_printers is flagged") {
|
|
add_inmemory_preset(bundle.filaments, "Ghost Ref Filament").is_system = true;
|
|
compatible_list(bundle.filaments, "Ghost Ref Filament", "compatible_printers") = { "Ghost Printer" };
|
|
CHECK(bundle.check_preset_references());
|
|
}
|
|
|
|
SECTION("renamed compatible_printers (old name) is flagged") {
|
|
add_inmemory_preset(bundle.filaments, "Old Ref Filament").is_system = true;
|
|
compatible_list(bundle.filaments, "Old Ref Filament", "compatible_printers") = { "Old Printer" };
|
|
CHECK(bundle.check_preset_references());
|
|
}
|
|
|
|
SECTION("deleted compatible_prints is flagged") {
|
|
add_inmemory_preset(bundle.filaments, "Bad Process Ref").is_system = true;
|
|
compatible_list(bundle.filaments, "Bad Process Ref", "compatible_prints") = { "Ghost Process" };
|
|
CHECK(bundle.check_preset_references());
|
|
}
|
|
|
|
SECTION("deleted inherits parent is flagged") {
|
|
add_inmemory_preset(bundle.filaments, "Orphan Filament", "Ghost Parent").is_system = true;
|
|
CHECK(bundle.check_preset_references());
|
|
}
|
|
|
|
SECTION("non-system preset with a dangling reference is ignored") {
|
|
add_inmemory_preset(bundle.filaments, "User Filament"); // is_system stays false
|
|
compatible_list(bundle.filaments, "User Filament", "compatible_printers") = { "Ghost Printer" };
|
|
CHECK_FALSE(bundle.check_preset_references());
|
|
}
|
|
}
|
|
|
|
// Under a shared override key, the last preset merged into the full config overwrote the others', so an
|
|
// edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never
|
|
// re-sliced. Per-type keys make that collision impossible; guard the scoping here.
|
|
TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]")
|
|
{
|
|
// Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change.
|
|
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides"));
|
|
CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides"));
|
|
CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides"));
|
|
|
|
// ...and each key lives on exactly its own preset type's option list, so no two ever share a slot.
|
|
const std::pair<Preset::Type, const std::vector<std::string>*> scopes[] = {
|
|
{Preset::TYPE_PRINT, &Preset::print_options()},
|
|
{Preset::TYPE_PRINTER, &Preset::printer_options()},
|
|
{Preset::TYPE_FILAMENT, &Preset::filament_options()},
|
|
};
|
|
for (const auto &owner : scopes)
|
|
for (const auto &scoped : scopes) {
|
|
const std::string key = Preset::plugin_overrides_key(scoped.first);
|
|
CAPTURE(owner.first, key);
|
|
CHECK(contains(*owner.second, key) == (owner.first == scoped.first));
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
// A standalone filament collection that exposes the protected library masking builder, so the Orca
|
|
// Filament Library scenario can be set up without the full system-profile load pipeline.
|
|
struct LibraryFilamentTestCollection : public PresetCollection
|
|
{
|
|
LibraryFilamentTestCollection()
|
|
: PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(),
|
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
|
|
{}
|
|
using PresetCollection::update_library_profile_excluded_from;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Missing app config is accepted as default CLI state", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
AppConfig app_config;
|
|
app_config.set_loading_path((dir.path() / "missing.conf").string());
|
|
CHECK(app_config.load_if_exists().empty());
|
|
}
|
|
|
|
TEST_CASE("Read-only user preset loading does not create or delete files", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
PresetBundle bundle;
|
|
PresetsConfigSubstitutions substitutions;
|
|
|
|
const fs::path missing_root = dir.path() / "missing-user";
|
|
bundle.prints.load_presets(missing_root.string(), PRESET_PRINT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr,
|
|
PresetOrigin(), true);
|
|
CHECK_FALSE(fs::exists(missing_root / PRESET_PRINT_NAME));
|
|
|
|
const fs::path malformed = dir.path() / "existing-user" / PRESET_PRINT_NAME / "malformed.json";
|
|
fs::create_directories(malformed.parent_path());
|
|
std::ofstream(malformed.string()) << "{not-json";
|
|
bundle.prints.load_presets((dir.path() / "existing-user").string(), PRESET_PRINT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr,
|
|
PresetOrigin(), true);
|
|
CHECK(fs::exists(malformed));
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution probes loaded FFF collections", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "typeless-process.json";
|
|
std::ofstream(source_file.string()) << R"({"name":"Typeless Process","from":"User"})";
|
|
|
|
PresetBundle bundle;
|
|
Preset &process = add_inmemory_preset(bundle.prints, "Typeless Process");
|
|
process.file = source_file.string();
|
|
process.config.option<ConfigOptionFloats>("travel_speed", true)->values = {321.0};
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error.empty());
|
|
CHECK(resolved_type == Preset::TYPE_PRINT);
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution preserves duplicate identity ambiguity", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "duplicate-process.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.prints, "First Process Identity").file = source_file.string();
|
|
add_inmemory_preset(bundle.prints, "Second Process Identity").file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset identity is ambiguous");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution rejects cross-type ambiguity", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "ambiguous.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.prints, "Process Identity").file = source_file.string();
|
|
add_inmemory_preset(bundle.filaments, "Filament Identity").file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset type is ambiguous");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution rejects a missing type candidate", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "unknown.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset type could not be resolved");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Exact file resolution rejects multiple preset identities", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "duplicate.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
Preset &first = add_inmemory_preset(bundle.prints, "First Identity");
|
|
first.file = source_file.string();
|
|
Preset &second = add_inmemory_preset(bundle.prints, "Second Identity");
|
|
second.file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Parent";
|
|
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset identity is ambiguous");
|
|
}
|
|
|
|
TEST_CASE("System preset resolution returns the canonical vendor configuration", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir source_dir;
|
|
PresetBundle bundle;
|
|
|
|
VendorProfile vendor("VendorB");
|
|
vendor.name = "Vendor B";
|
|
auto [vendor_it, inserted] = bundle.vendors.emplace(vendor.id, std::move(vendor));
|
|
REQUIRE(inserted);
|
|
|
|
Preset &resolved = add_inmemory_preset(bundle.prints, "Vendor B Process", "fdm_process_common");
|
|
resolved.is_system = true;
|
|
resolved.vendor = &vendor_it->second;
|
|
resolved.file = (source_dir.path() / "vendor-b-process.json").string();
|
|
std::ofstream(resolved.file) << "{}";
|
|
resolved.config.option<ConfigOptionFloats>("travel_speed", true)->values = {321.0};
|
|
resolved.config.option<ConfigOptionInt>("wall_loops", true)->value = 2;
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
raw.option<ConfigOptionInt>("wall_loops", true)->value = 5;
|
|
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, resolved.file,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
CHECK(raw.option<ConfigOptionInt>("wall_loops")->value == 2);
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed preset resolution loads the source vendor tree", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path vendor_dir = dir.path() / "Acme";
|
|
const fs::path child_file = vendor_dir / "process" / "nested" / "child.json";
|
|
|
|
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 Process","sub_path":"process/nested/child.json"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream((vendor_dir / "process" / "base.json").string())
|
|
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
|
|
<< R"("instantiation":"false","travel_speed":["321"]})";
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common","wall_loops":"5"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
raw.option<ConfigOptionInt>("wall_loops", true)->value = 5;
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
CHECK(raw.option<ConfigOptionInt>("wall_loops")->value == 5);
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution is scoped to the explicit source root", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
auto write_vendor = [&](const std::string &root_name, double travel_speed) {
|
|
const fs::path root = dir.path() / root_name;
|
|
const fs::path child_file = root / "Acme" / "process" / "child.json";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream((root / "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 Process","sub_path":"process/child.json"}]})";
|
|
std::ofstream((root / "Acme" / "process" / "base.json").string())
|
|
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
|
|
<< R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})";
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
|
|
return child_file;
|
|
};
|
|
|
|
const fs::path source_a = write_vendor("root-a", 111.0);
|
|
const fs::path source_b = write_vendor("root-b", 222.0);
|
|
REQUIRE(fs::exists(source_a));
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "synthetic-parent-marker";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_b.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(222.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Exact-only resolution rejects an unconfigured manifest-backed file", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "Acme" / "process" / "child.json";
|
|
fs::create_directories(source_file.parent_path());
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
std::ofstream(source_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Some Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset was not found in the loaded bundle");
|
|
}
|
|
|
|
TEST_CASE("Vendor filament resolution uses the shared Orca library base", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
const fs::path vendor_dir = dir.path() / "Acme";
|
|
const fs::path child_file = vendor_dir / "filament" / "nested" / "petg.json";
|
|
|
|
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_dir / "filament");
|
|
std::ofstream((library_dir / "filament" / "pet.json").string())
|
|
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
|
|
<< R"("filament_id":"GFL99","instantiation":"false",)"
|
|
<< R"("filament_type":["PETG"],"filament_density":["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/nested/petg.json","filament_id":"GFA00"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"filament","name":"Acme PETG","from":"system",)"
|
|
<< R"("filament_id":"GFA00","instantiation":"true","inherits":"fdm_filament_pet"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
CHECK(raw.opt_string("filament_type", 0u) == "PETG");
|
|
REQUIRE(raw.option<ConfigOptionFloats>("filament_density")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("filament_density")->values.front(), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a missing parent", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"Missing Parent","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK_FALSE(error.empty());
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a vendor load with malformed entries", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
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"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK_FALSE(error.empty());
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects files absent from the vendor manifest", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path listed_file = dir.path() / "Acme" / "process" / "listed.json";
|
|
const fs::path unlisted_file = dir.path() / "Acme" / "process" / "unlisted.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Listed Process","sub_path":"process/listed.json"}]})";
|
|
fs::create_directories(listed_file.parent_path());
|
|
std::ofstream(listed_file.string())
|
|
<< R"({"type":"process","name":"Listed Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
std::ofstream(unlisted_file.string())
|
|
<< R"({"type":"process","name":"Unlisted Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, unlisted_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error == "Source file is not an instantiated preset in its vendor manifest");
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a mismatched preset type", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path process_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
fs::create_directories(process_file.parent_path());
|
|
std::ofstream(process_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, process_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error == "Source file is not an instantiated preset in its vendor manifest");
|
|
}
|
|
|
|
TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path detached_file = dir.path() / "detached.json";
|
|
std::ofstream(detached_file.string()) << "{}";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, detached_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
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));
|
|
}
|
|
|
|
// 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.
|
|
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
|
|
{
|
|
LibraryFilamentTestCollection filaments;
|
|
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
|
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
|
|
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
|
VendorProfile vendor("Vendor");
|
|
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
vendor.name = "Vendor";
|
|
|
|
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
|
|
Preset &preset = add_inmemory_preset(filaments, name);
|
|
preset.alias = "Generic ABS";
|
|
preset.vendor = &owner;
|
|
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
|
|
};
|
|
|
|
add_filament(library, "Generic ABS @System", {});
|
|
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
|
|
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
|
|
|
|
filaments.update_library_profile_excluded_from();
|
|
|
|
const Preset *generic = filaments.find_preset("Generic ABS @System");
|
|
REQUIRE(generic != nullptr);
|
|
CHECK(generic->m_excluded_from.count("Printer A") == 1);
|
|
CHECK(generic->m_excluded_from.count("Printer B") == 1);
|
|
CHECK(generic->m_excluded_from.size() == 2);
|
|
|
|
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
|
|
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
|
|
REQUIRE(specific != nullptr);
|
|
CHECK(specific->m_excluded_from.empty());
|
|
|
|
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
|
|
add_inmemory_preset(printers, "Printer A");
|
|
add_inmemory_preset(printers, "Printer C");
|
|
const Preset *printer_a = printers.find_preset("Printer A");
|
|
const Preset *printer_c = printers.find_preset("Printer C");
|
|
REQUIRE(printer_a != nullptr);
|
|
REQUIRE(printer_c != nullptr);
|
|
|
|
const PresetWithVendorProfile generic_lib(*generic, &library);
|
|
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
|
|
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
|
}
|
|
|
|
namespace {
|
|
|
|
// One system printer plus the filament presets a machine facing dialog has to choose between:
|
|
// an Orca Filament Library generic with no compatible_printers, a same alias vendor filament
|
|
// that names the printer, a library filament with no vendor twin, and a vendor filament that
|
|
// belongs to a different printer.
|
|
struct MachineFilaments
|
|
{
|
|
PresetBundle bundle;
|
|
VendorProfile library{PresetBundle::ORCA_FILAMENT_LIBRARY};
|
|
VendorProfile vendor{"Vendor"};
|
|
|
|
MachineFilaments()
|
|
{
|
|
// VendorProfile's constructor takes an id; the library rule keys off the name.
|
|
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
vendor.name = "Vendor";
|
|
|
|
Preset &printer = add_inmemory_preset(bundle.printers, "Printer A 0.4 nozzle");
|
|
printer.is_system = true;
|
|
printer.vendor = &vendor;
|
|
printer.config.option<ConfigOptionString>("printer_model", true)->value = "Printer A";
|
|
|
|
add_filament(library, "Generic ABS @System", "Generic ABS", {});
|
|
add_filament(vendor, "Generic ABS @Printer A", "Generic ABS", { "Printer A 0.4 nozzle" });
|
|
add_filament(library, "FilAr ABS @System", "FilAr ABS", {});
|
|
add_filament(vendor, "Vendor PLA @Printer B", "Vendor PLA", { "Printer B 0.4 nozzle" });
|
|
|
|
// update_library_profile_excluded_from() is protected and has its own test above; record
|
|
// the exclusion it derives from the same alias vendor filament.
|
|
Preset *shadowed = bundle.filaments.find_preset("Generic ABS @System");
|
|
REQUIRE(shadowed != nullptr);
|
|
shadowed->m_excluded_from.insert("Printer A 0.4 nozzle");
|
|
}
|
|
|
|
void add_filament(const VendorProfile &owner, const std::string &name, const std::string &alias,
|
|
std::vector<std::string> compatible_printers)
|
|
{
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, name);
|
|
preset.is_system = true;
|
|
preset.alias = alias;
|
|
preset.vendor = &owner;
|
|
compatible_list(bundle.filaments, name, "compatible_printers") = std::move(compatible_printers);
|
|
}
|
|
|
|
bool offers(const std::string &preset_name, bool include_user_presets = false)
|
|
{
|
|
const std::vector<Preset *> offered =
|
|
bundle.get_filament_presets_for_machine("Printer A", "0.4", include_user_presets);
|
|
return std::any_of(offered.begin(), offered.end(),
|
|
[&preset_name](const Preset *p) { return p->name == preset_name; });
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Filaments offered for a machine follow the app's compatibility rule", "[Preset][Bundle]")
|
|
{
|
|
MachineFilaments f;
|
|
|
|
SECTION("a library filament with no compatible_printers is offered") {
|
|
CHECK(f.offers("FilAr ABS @System"));
|
|
}
|
|
|
|
SECTION("a same alias vendor filament shadows the library generic") {
|
|
CHECK(f.offers("Generic ABS @Printer A"));
|
|
CHECK_FALSE(f.offers("Generic ABS @System"));
|
|
}
|
|
|
|
SECTION("a filament naming a different printer is not offered") {
|
|
CHECK_FALSE(f.offers("Vendor PLA @Printer B"));
|
|
}
|
|
|
|
SECTION("a user filament is offered only when the printer supports user presets") {
|
|
add_inmemory_preset(f.bundle.filaments, "My PLA");
|
|
|
|
CHECK_FALSE(f.offers("My PLA", /*include_user_presets=*/false));
|
|
CHECK(f.offers("My PLA", /*include_user_presets=*/true));
|
|
}
|
|
}
|
|
|
|
|
|
namespace {
|
|
|
|
const char *kMixedKeys[] = {
|
|
"filament_is_mixed",
|
|
"filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios",
|
|
"filament_mixed_gradient",
|
|
"filament_mixed_gradient_range",
|
|
"filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part",
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// Mixed-color filament metadata lives in project_config as parallel per-filament arrays.
|
|
// set_num_filaments() is the single place that grows them alongside filament_colour; if it
|
|
// misses them, creating a mixed slot writes past the end of the short arrays.
|
|
TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t {
|
|
if (const auto *b = cfg.option<ConfigOptionBools>(key))
|
|
return b->values.size();
|
|
if (const auto *s = cfg.option<ConfigOptionStrings>(key))
|
|
return s->values.size();
|
|
return size_t(-1); // key missing entirely
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
|
|
const unsigned int n = GENERATE(2u, 4u, 8u);
|
|
bundle.set_num_filaments(n, std::string("#FF0000"));
|
|
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == n);
|
|
for (const char *key : kMixedKeys) {
|
|
DYNAMIC_SECTION("grown: " << key) {
|
|
CHECK(mixed_array_size(bundle.project_config, key) == n);
|
|
}
|
|
}
|
|
|
|
SECTION("shrinking keeps them in step too") {
|
|
bundle.set_num_filaments(1, std::string("#00FF00"));
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 1);
|
|
for (const char *key : kMixedKeys)
|
|
CHECK(mixed_array_size(bundle.project_config, key) == 1);
|
|
}
|
|
}
|
|
|
|
// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds
|
|
// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every
|
|
// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up
|
|
// indexing a filament list they were never saved against.
|
|
TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
// export_selections skips the built-in "Default Printer" placeholder entirely.
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
bundle.set_num_filaments(2u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values = { false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values = { "", "1,2" };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" };
|
|
|
|
AppConfig app_config;
|
|
bundle.export_selections(app_config);
|
|
|
|
const std::string printer_name = bundle.printers.get_selected_preset_name();
|
|
for (const char *key : kMixedKeys) {
|
|
DYNAMIC_SECTION("per printer, not global: " << key) {
|
|
CHECK(app_config.has_printer_setting(printer_name, key));
|
|
CHECK_FALSE(app_config.has("presets", key));
|
|
}
|
|
}
|
|
|
|
SECTION("with the encoding load_selections reads back") {
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1");
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2");
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5");
|
|
}
|
|
}
|
|
|
|
// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the
|
|
// control points — so it cannot be '|'-joined into the app config like its siblings without a
|
|
// multi-point curve being split across filament slots on the way back in.
|
|
TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
const std::vector<std::string> curves = { "", "", "0,0|0.5,0.3|1,1" };
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
bundle.set_num_filaments(3u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = curves;
|
|
|
|
AppConfig app_config;
|
|
bundle.export_selections(app_config);
|
|
|
|
// Decoding the stored form returns the three slots intact, curve delimiters and all. A plain
|
|
// '|' join would decode as five slots here instead of three.
|
|
std::vector<std::string> decoded;
|
|
REQUIRE(unescape_strings_cstyle(
|
|
app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded));
|
|
CHECK(decoded == curves);
|
|
}
|
|
|
|
// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra
|
|
// virtual filaments at the tail of that list with no nozzle of their own, so the count has to
|
|
// allow for them: sizing to the nozzle count alone drops the project's mixes and strips every
|
|
// painted facet above the new count.
|
|
TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
// The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3.
|
|
const size_t nozzle_count = 4;
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "2,3" };
|
|
|
|
REQUIRE(bundle.num_mixed_filaments() == 1);
|
|
|
|
SECTION("nozzle count plus the mixed slots preserves the mix") {
|
|
bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00"));
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
|
}
|
|
|
|
SECTION("the nozzle count alone is what truncated it away") {
|
|
bundle.set_num_filaments(nozzle_count, std::string("#00FF00"));
|
|
|
|
CHECK(bundle.filament_presets.size() == nozzle_count);
|
|
CHECK(bundle.num_mixed_filaments() == 0);
|
|
}
|
|
}
|
|
|
|
// A "published" 3MF keeps the user's currently-selected presets and overlays only the
|
|
// author-selected process keys onto the edited preset (mirrors the GUI load path: normalize
|
|
// before load_config_model, then the overlay in load_config_file_config).
|
|
TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]")
|
|
{
|
|
// The file config the GUI builds from a .3mf's project settings.
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// The loader derives the filament count from filament_colour and throws when it is
|
|
// empty; a 3mf always carries it.
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt_float("layer_height") = 0.28; // process scalar
|
|
config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 140., 150. }; // matching-size vector
|
|
config.opt<ConfigOptionStrings>("post_process")->values = { "script-a", "script-b" }; // mismatched vector
|
|
config.opt<ConfigOptionInts>("nozzle_temperature")->values = { 220 }; // filament key (not applied anywhere)
|
|
// Structural (denylisted) key: must be silently ignored. full_print_config() omits the
|
|
// *_settings_id keys, so create one explicitly.
|
|
config.opt_string("print_settings_id", true) = "file process";
|
|
config.opt<ConfigOptionFloats>("flush_multiplier")->values = { 2., 2. }; // must NOT cross over
|
|
config.opt<ConfigOptionFloats>("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over
|
|
config.opt<ConfigOptionFloat>("wipe_tower_rotation_angle")->value = 45.; // published-only plate geometry, crosses over
|
|
config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over
|
|
return config;
|
|
};
|
|
|
|
const std::vector<std::string> published_keys = {
|
|
"layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id"
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
const std::string pre_load_name = bundle.prints.get_edited_preset().name;
|
|
const size_t pre_load_size = bundle.prints.size();
|
|
|
|
// The edited presets are the overlay targets: recognizable pre-load values.
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 10., 20. };
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionStrings>("post_process")->values = { "existing-script" };
|
|
bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process";
|
|
// Capture the ctor-seeded project_config values so the assertions below check the load
|
|
// leaves them untouched rather than hardcoding the defaults.
|
|
const std::vector<std::string> seed_filament_colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
const std::vector<double> seed_flush_multiplier = bundle.project_config.opt<ConfigOptionFloats>("flush_multiplier")->values;
|
|
const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt();
|
|
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The GUI normalizes the config before load; mirror that so only the production path runs.
|
|
Preset::normalize(config);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = published_keys;
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// a) Process scalar overlaid; matching-size vector applied, mismatched one lands in
|
|
// skipped_keys; applied keys are not reported.
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 140., 150. });
|
|
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionStrings>("post_process")->values == std::vector<std::string>{ "existing-script" });
|
|
CHECK(contains_key(pub.skipped_keys, "post_process"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
|
|
|
|
// b) A filament key is never applied anywhere and is reported as skipped.
|
|
CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr);
|
|
CHECK(contains_key(pub.skipped_keys, "nozzle_temperature"));
|
|
|
|
// c) A structural key is silently ignored: neither applied nor reported as skipped.
|
|
CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process");
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id"));
|
|
|
|
// d) Only plate/bed geometry crosses in published mode: filament/purge data and bed type
|
|
// stay at the ctor seeds.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == seed_filament_colour);
|
|
CHECK(bundle.project_config.opt<ConfigOptionFloats>("flush_multiplier")->values == seed_flush_multiplier);
|
|
CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type);
|
|
check_double_vector(bundle.project_config.opt<ConfigOptionFloats>("wipe_tower_x")->values, { 100. });
|
|
CHECK_THAT(bundle.project_config.opt<ConfigOptionFloat>("wipe_tower_rotation_angle")->value, Catch::Matchers::WithinAbs(45., 0.000001));
|
|
|
|
// e) The published path keeps the user's currently-selected presets: same preset, same size.
|
|
CHECK(bundle.prints.get_edited_preset().name == pre_load_name);
|
|
CHECK(bundle.prints.size() == pre_load_size);
|
|
|
|
// f) Non-published control: the overlay is disabled, the file's presets are imported
|
|
// instead, and no skipped_keys are produced.
|
|
PresetBundle control_bundle;
|
|
const size_t control_pre_size = control_bundle.prints.size();
|
|
PublishedConfig control_pub;
|
|
control_pub.published = false;
|
|
control_pub.published_keys = published_keys;
|
|
DynamicPrintConfig control_config = make_file_config();
|
|
Preset::normalize(control_config);
|
|
control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub);
|
|
|
|
CHECK(control_pub.skipped_keys.empty());
|
|
CHECK(control_bundle.prints.size() > control_pre_size);
|
|
// The file's layer_height reached the edited preset via the normal import, not the overlay.
|
|
CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
}
|
|
|
|
// The published printer overlay is restricted to the publishable retraction/z-hop allowlist:
|
|
// matching-size vectors apply, mismatched vectors are reported as skipped, and any other
|
|
// printer-class key (e.g. machine_start_gcode) is contract-excluded (never applied, never
|
|
// reported).
|
|
TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
Preset::normalize(config);
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 1.4 }; // matching size (1 extruder)
|
|
config.opt<ConfigOptionFloats>("retraction_speed")->values = { 45., 55. }; // size 2: mismatched
|
|
config.opt_string("machine_start_gcode") = "G28 ; from file"; // outside the allowlist
|
|
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
// A recognizable non-default value: the skipped mismatch below must leave it untouched
|
|
// (asserting the default instead would silently test PrintConfig's retraction_speed).
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values = { 33. };
|
|
bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Matching-size retraction vector applied; mismatched vector reported as skipped and the
|
|
// receiver's own value survives.
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 1.4 });
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values, { 33. });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_speed"));
|
|
// Contract-excluded printer key: silently ignored, absent from skipped_keys.
|
|
CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user");
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode"));
|
|
}
|
|
|
|
// A published 3MF carries per-slot material keys; on load they are applied positionally to the
|
|
// receiver's slot N (a key-only entry has no type gate), written onto the slot's stored preset
|
|
// in place.
|
|
TEST_CASE("Published 3MF applies positional material keys onto the receiver's material presets", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Two filament slots; filament_diameter drives the normalized per-slot vector sizes.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
// Keep the multi-extruder consistency validation happy for a 2-slot config.
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
// Author per-slot retraction values. These are per-filament override keys that are not
|
|
// members of the static PrintRegionConfig, so full_print_config() omits them and they
|
|
// must be created explicitly (as nullable, matching the real 3MF project config).
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
config.option<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.2, 0.3 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.filament_id = "GFL99";
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_settings_id")->values = { "receiver-pla" };
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.filament_id = "GFT99";
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.1 };
|
|
bundle.filament_presets = { "My PLA", "My PETG" };
|
|
|
|
PublishedMaterialEntry pla_entry;
|
|
pla_entry.filament_id = "GFL99";
|
|
pla_entry.slot = 0; // the author's PLA slot
|
|
pla_entry.keys = { "filament_retraction_length", "filament_settings_id" };
|
|
PublishedMaterialEntry petg_entry;
|
|
petg_entry.filament_id = "GFT99";
|
|
petg_entry.slot = 1; // the author's PETG slot
|
|
petg_entry.keys = { "filament_retraction_length", "filament_z_hop" };
|
|
// A slot-less entry (no slot field, only possible in hand-crafted files): silently skipped.
|
|
PublishedMaterialEntry noslot_entry;
|
|
noslot_entry.filament_type = "ABS";
|
|
noslot_entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { pla_entry, petg_entry, noslot_entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The author's slot values are written onto the receiver's stored presets in place.
|
|
check_double_vector(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 1.2 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.3 });
|
|
// Structural keys inside a material entry are silently ignored: the receiver's own
|
|
// filament_settings_id is untouched and nothing is reported for it.
|
|
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{ "receiver-pla" });
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)"));
|
|
// Everything applied; the slot-less entry produced no skipped entry.
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A "full publish" slot serializes the whole filament. On load the slot always receives a
|
|
// standalone detached copy of the author's material - created even when the receiver's own
|
|
// material matches the published type - and no receiver library preset is ever mutated.
|
|
TEST_CASE("Published 3MF full-published slots are imported as standalone detached copies", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Two author slots; filament_diameter drives the normalized per-slot vector sizes.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
// The full dump of slot 0, publishing the whole filament as type "ABS".
|
|
auto make_full_abs_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "ABS";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
SECTION("type match still creates a detached copy instead of mutating the receiver material") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA", "My PLA" };
|
|
|
|
PublishedMaterialEntry full = make_full_abs_entry();
|
|
full.publish_type_value = "PLA"; // author requires PLA, receiver slot is PLA
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { full };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The type matches, but the import still detaches: the slot lands on a fresh copy
|
|
// named from the published type (no identity fields in this entry), carrying the
|
|
// author's values; the receiver's own material is untouched.
|
|
CHECK(bundle.filament_presets[0] == "PLA");
|
|
Preset *copy = bundle.filaments.find_preset("PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> PLA");
|
|
}
|
|
|
|
SECTION("type mismatch also detaches: a fresh same-type copy replaces the slot") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS");
|
|
abs.config.opt_string("filament_type", 0u) = "ABS";
|
|
abs.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.3 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_full_abs_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 1);
|
|
// No substitution search runs: a brand-new copy named after the published type is
|
|
// created and both pre-existing presets stay exactly as they were.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
Preset *copy = bundle.filaments.find_preset("ABS", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My ABS", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.3 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS");
|
|
}
|
|
|
|
SECTION("the author's identity rides on the copy when the type has no library match") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA");
|
|
other.config.opt_string("filament_type", 0u) = "PLA";
|
|
other.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.7 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry full = make_full_abs_entry();
|
|
// The dump carries the identity too, so the created copy takes the author's type
|
|
// and vendor instead of the baseline clone's.
|
|
full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { full };
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The author's slot 0 really is ABS.
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "ABS", "PETG" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// No ABS preset needs to exist in the library: the copy carries the author's values,
|
|
// type and vendor included. Neither receiver preset was touched.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
Preset *copy = bundle.filaments.find_preset("ABS", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(copy->config.opt_string("filament_type", 0u) == "ABS");
|
|
CHECK(copy->config.opt_string("filament_vendor", 0u) == "Generic");
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Other PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.7 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS");
|
|
}
|
|
}
|
|
|
|
// The author's preset name travels in the file and names the created standalone copy (variant
|
|
// tail stripped), regardless of what the receiver's library holds: an exact-name library preset
|
|
// is never reused nor mutated.
|
|
TEST_CASE("Published 3MF imports a full material under the author's stripped name", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "OGFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
|
|
auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor, const char *setting_id) {
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, name);
|
|
preset.filament_id = id;
|
|
preset.setting_id = setting_id;
|
|
preset.config.opt_string("filament_type", 0u) = "PLA";
|
|
preset.config.opt_string("filament_vendor", 0u) = vendor;
|
|
preset.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
return &preset;
|
|
};
|
|
|
|
SECTION("an exact-name library preset exists: a detached copy is created beside it") {
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "OGFL99";
|
|
entry.filament_vendor = "Generic";
|
|
entry.setting_id = "RcBNzytWgwRrwXXz";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy is named after the stripped author name; the receiver's exact-name preset
|
|
// keeps its own values.
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("only the name is present (broken/stale ids): the copy is still created") {
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Bambu PLA Basic @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("grown slot (author slot 1) receives its own detached copy") {
|
|
auto two_slot_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "OGFL99", "OGFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 0.8 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = two_slot_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "My PETG");
|
|
// The grown slot seeds the receiver's last preset ("My PETG"), then the full material
|
|
// detaches onto a copy carrying the author's slot-1 value; "Generic PLA @System" is
|
|
// left untouched.
|
|
CHECK(bundle.filament_presets[1] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.8 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 1: My PETG -> Generic PLA");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// The stripped author name can collide with an existing library preset ("Generic PLA"): the
|
|
// created copy must uniquify with the "(Published)" suffix rather than overwrite, reuse or
|
|
// mutate any of the receiver's own presets.
|
|
TEST_CASE("Published 3MF uniquifies an imported full material name on collision", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
// A legacy bundle preset literally named "Generic PLA" - collides with the stripped name.
|
|
Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA");
|
|
bare.config.opt_string("filament_type", 0u) = "PLA";
|
|
bare.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
bare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// The author's exact preset.
|
|
Preset &qidi = add_inmemory_preset(bundle.filaments, "Generic PLA @Qidi Q2 0.4 nozzle");
|
|
qidi.config.opt_string("filament_type", 0u) = "PLA";
|
|
qidi.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
qidi.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// The Orca library preset.
|
|
Preset &sys = add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
|
sys.config.opt_string("filament_type", 0u) = "PLA";
|
|
sys.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
sys.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy lands beside the collision, suffixed; every pre-existing preset keeps its own
|
|
// values.
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA (Published)");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @Qidi Q2 0.4 nozzle", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The overlay writes onto the edited layer only when that layer survives the load (slot 0's
|
|
// preset still matches the edited preset). When the user views a non-first slot's material and
|
|
// the published entry targets that slot, the final re-select would destroy the edited layer -
|
|
// so the values must land on the stored preset instead and survive.
|
|
TEST_CASE("Published 3MF writes to the stored preset when the edited layer is re-selected away", "[Preset][Bundle][Published]")
|
|
{
|
|
// Two-slot author config: slot 1 carries the published retraction value.
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFT99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PETG", "My PLA" };
|
|
// The user is viewing slot 1's material.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The published values survived on the stored preset (the edited layer was re-selected to
|
|
// slot 0's material and must not have been the only copy).
|
|
Preset *stored = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(stored != nullptr);
|
|
CHECK(stored->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// The load re-selected slot 0's material, mirroring a normal project load.
|
|
CHECK(bundle.filaments.get_edited_preset().name == "My PETG");
|
|
// Selecting the slot's material afterwards surfaces the applied values.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
CHECK(bundle.filaments.get_edited_preset().config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(bundle.filaments.get_edited_preset().config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The created copy is named after the author's preset with the "@variant" tail stripped;
|
|
// trailing whitespace left behind by the truncation must be trimmed away, and names without
|
|
// a tail pass through unchanged.
|
|
TEST_CASE("publish_material_base_name strips the variant tail from a published preset name", "[Preset][Bundle][Published]")
|
|
{
|
|
CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA");
|
|
CHECK(publish_material_base_name("Generic PLA @Qidi Q2 0.4 nozzle") == "Generic PLA");
|
|
// Truncation at '@' leaves the space before the tail; it must not survive.
|
|
CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA");
|
|
CHECK(publish_material_base_name("Voron Generic PLA") == "Voron Generic PLA");
|
|
CHECK(publish_material_base_name("") == "");
|
|
// A tail-only name strips to nothing; the caller falls back to identity fields.
|
|
CHECK(publish_material_base_name("@System").empty());
|
|
}
|
|
|
|
// A full-published material arrives as a brand-new standalone preset: parentless, visible,
|
|
// project-embedded ("Preset Inside Project"), carrying the author's values and colour - and
|
|
// never touching any of the receiver's own presets.
|
|
TEST_CASE("Published 3MF imports a full material as a detached project-embedded preset", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &spare = add_inmemory_preset(bundle.filaments, "Spare PLA");
|
|
spare.config.opt_string("filament_type", 0u) = "PLA";
|
|
spare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.4 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot lands on the freshly created copy, named after the stripped author name.
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
Preset *copy = bundle.filaments.find_preset("Author PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
// Detached + project-embedded contract.
|
|
CHECK(copy->is_project_embedded);
|
|
CHECK(copy->inherits().empty());
|
|
CHECK(copy->setting_id.empty());
|
|
CHECK(copy->vendor == nullptr);
|
|
CHECK_FALSE(copy->is_system);
|
|
CHECK_FALSE(copy->is_default);
|
|
CHECK_FALSE(copy->is_external);
|
|
CHECK(copy->is_visible);
|
|
CHECK(copy->filament_id == "AFL01");
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{ "Author PLA" });
|
|
// The published values and colour live on the copy.
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
// Universally compatible: no printer/print restrictions survive the import.
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_printers")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_prints")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_printers_condition")->value.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_prints_condition")->value.empty());
|
|
// Nothing pre-existing was touched.
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.filaments.find_preset("Spare PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.4 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA");
|
|
}
|
|
|
|
// Compatibility restrictions riding on the receiver's baseline preset must not leak onto the
|
|
// imported copy: a detached full material is usable with every printer and print profile.
|
|
TEST_CASE("Published 3MF clears printer restrictions on the imported full material", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &restricted = add_inmemory_preset(bundle.filaments, "Restricted PLA");
|
|
restricted.config.opt_string("filament_type", 0u) = "PLA";
|
|
restricted.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
restricted.config.set_key_value("compatible_printers", new ConfigOptionStrings({ "Unrelated Printer" }));
|
|
restricted.config.set_key_value("compatible_prints", new ConfigOptionStrings({ "Unrelated Print" }));
|
|
restricted.config.option<ConfigOptionString>("compatible_printers_condition", true)->value = "printer_settings_id==\"Nope\"";
|
|
restricted.config.option<ConfigOptionString>("compatible_prints_condition", true)->value = "print_settings_id==\"Nope\"";
|
|
bundle.filament_presets = { "Restricted PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy (named from the published type; no identity fields) has no restrictions left.
|
|
CHECK(bundle.filament_presets[0] == "PLA");
|
|
Preset *copy = bundle.filaments.find_preset("PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_printers")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_prints")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_printers_condition")->value.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_prints_condition")->value.empty());
|
|
// The receiver's own restricted preset keeps its restrictions.
|
|
const Preset *original = bundle.filaments.find_preset("Restricted PLA", false, true);
|
|
REQUIRE(original != nullptr);
|
|
CHECK(original->config.opt<ConfigOptionStrings>("compatible_printers")->values == std::vector<std::string>{ "Unrelated Printer" });
|
|
CHECK(original->config.opt<ConfigOptionString>("compatible_printers_condition")->value == "printer_settings_id==\"Nope\"");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Identical Full materials (same setting_id + preset_name identity) share one created
|
|
// instance: an author who pointed several slots at one material gets one standalone copy,
|
|
// and the first entry's slot values win.
|
|
TEST_CASE("Published 3MF shares one imported copy between identical full slots", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "AFL01", "AFL01" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 0.8 };
|
|
return config;
|
|
};
|
|
auto make_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PETG");
|
|
other.config.opt_string("filament_type", 0u) = "PETG";
|
|
other.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.65 };
|
|
bundle.filament_presets = { "My PETG", "Other PETG" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry(0), make_entry(1) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Both slots point at the single shared copy; no second "(Published)" instance exists.
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
CHECK(bundle.filament_presets[1] == "Author PLA");
|
|
CHECK(bundle.filaments.find_preset("Author PLA", false, true) != nullptr);
|
|
CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr);
|
|
// The first entry's slot values won.
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// Both slots reported, same target; originals untouched.
|
|
REQUIRE(pub.material_replacements.size() == 2);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA");
|
|
CHECK(pub.material_replacements[1] == "slot 1: Other PETG -> Author PLA");
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.filaments.find_preset("Other PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.65 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Without a preset name the copy falls back to the stable material id; fully anonymous
|
|
// hand-crafted entries never share instances between slots (their dedup key is slot-scoped).
|
|
TEST_CASE("Published 3MF names unidentified full materials from their fallback fields", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
|
|
SECTION("empty preset_name falls back to the filament_id") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets[0] == "AFL01");
|
|
CHECK(bundle.filaments.find_preset("AFL01", false, true) != nullptr);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("fully anonymous entries get slot-scoped copies") {
|
|
PresetBundle bundle;
|
|
Preset &first = add_inmemory_preset(bundle.filaments, "First PLA");
|
|
first.config.opt_string("filament_type", 0u) = "PLA";
|
|
first.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &second = add_inmemory_preset(bundle.filaments, "Second PLA");
|
|
second.config.opt_string("filament_type", 0u) = "PLA";
|
|
second.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.55 };
|
|
bundle.filament_presets = { "First PLA", "Second PLA" };
|
|
|
|
PublishedMaterialEntry entry0;
|
|
entry0.slot = 0;
|
|
entry0.full = true;
|
|
entry0.publish_type = true;
|
|
entry0.publish_type_value = "ABS"; // the only naming field present
|
|
entry0.full_keys = { "filament_retraction_length" };
|
|
PublishedMaterialEntry entry1 = entry0;
|
|
entry1.slot = 1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry0, entry1 };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// No shared identity: each slot gets its own uniquified copy.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
CHECK(bundle.filament_presets[1] == "ABS (Published)");
|
|
CHECK(bundle.filaments.find_preset("ABS", false, true) != nullptr);
|
|
CHECK(bundle.filaments.find_preset("ABS (Published)", false, true) != nullptr);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// Within-load dedup does not span loads: importing the same published file again into the same
|
|
// session creates a second, uniquified copy instead of mutating or reusing the first.
|
|
TEST_CASE("Re-importing a published full material uniquifies the second copy", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
for (int round = 0; round < 2; ++round) {
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
if (round == 0) {
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr);
|
|
} else {
|
|
// The second import uniquifies beside the first instead of touching it.
|
|
CHECK(bundle.filament_presets[0] == "Author PLA (Published)");
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA (Published)", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: Author PLA -> Author PLA (Published)");
|
|
}
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A partially-published slot can carry a curated type and/or colour. The colour is applied
|
|
// regardless of the type match; a type mismatch with no same-type replacement keeps the
|
|
// receiver's material and reports the slot's keys as skipped. The receiver's slot count grows
|
|
// only as far as the highest slot with published content.
|
|
TEST_CASE("Published 3MF partial slots apply colour and gate keys by the published type", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
|
|
SECTION("matching type applies the keys and the colour") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Type matched: keys and colour applied onto the receiver's preset in place.
|
|
Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(pla_preset != nullptr);
|
|
check_double_vector(pla_preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(pla_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
SECTION("type mismatch without a replacement keeps the material and skips the keys") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "ABS"; // no ABS in the receiver library
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot keeps the receiver's material: the colour applies in place, the keys are
|
|
// skipped.
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(pla_preset != nullptr);
|
|
check_double_vector(pla_preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pla_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)"));
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
SECTION("receiver slot count grows to fit the highest published slot and assigns matching type preset") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.8 };
|
|
// The receiver has a single slot; the file carries two, only slot 1 is published.
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PETG";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot list was grown so author slot 1 has a material (the PETG preset), which
|
|
// then receives the author's values in place.
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[1] == "My PETG");
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 1.2 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
}
|
|
}
|
|
|
|
// The receiver's slot list grows only as far as the highest published slot: a file whose author
|
|
// published nothing (or only a low slot) must not pull filler materials into the receiver's
|
|
// setup, and the receiver never grows to the file's count.
|
|
TEST_CASE("Published 3MF grows the receiver's slots only as far as the published slots", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Four author slots (a 4-filament model).
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
auto add_pla_preset = [](PresetBundle &bundle) {
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
preset.config.opt_string("filament_type", 0u) = "PLA";
|
|
preset.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
return &preset;
|
|
};
|
|
auto make_color_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
return entry;
|
|
};
|
|
|
|
// A file whose author published nothing for any slot: the receiver's setup is untouched.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true; // no material entries at all
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 1);
|
|
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Only slot 0 published: a single-slot receiver keeps its single slot; the file's other
|
|
// three slots pull nothing in.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 1);
|
|
// The published colour lands on the slot's preset in place.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
|
|
// Slot 3 published: the receiver grows to 4 so the published slot exists. Unpublished
|
|
// filler slots repeat the receiver's last preset ("Add one filament" behaviour).
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(3) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "My PLA");
|
|
// Only "My PLA" exists in the library, so the published slot keeps the aliasing and the
|
|
// colour is written onto the shared preset (every slot references it).
|
|
CHECK(bundle.filament_presets[3] == "My PLA");
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
// The project-level per-slot vectors were grown and seeded: fillers take their preset's
|
|
// colour, the published slot its published colour.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[1] == "#123456");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[3] == "#ABCDEF");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour_type")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionFloats>("flush_volumes_matrix")->values.size() == 16);
|
|
}
|
|
|
|
// Slots 0 and 2 published: the receiver grows to 3, never to the file's 4.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0), make_color_entry(2) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
}
|
|
|
|
// A receiver with more slots than the file's filament count keeps its setup: neither the
|
|
// preset list nor the project-level vectors are shrunk to the file's smaller size.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
// Distinct project colours make a shrink observable.
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222", "#333333" };
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values = { "#111111", "#222222", "#333333" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0) }; // highest published slot: 0
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// A one-filament file: num_filaments (1) is below the receiver's slot count (3).
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
// No shrink: all three project entries survive, with slot 0 synced to the published
|
|
// colour at its unshifted index and slots 1-2 untouched.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF", "#222222", "#333333" });
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values == std::vector<std::string>{ "#ABCDEF", "#222222", "#333333" });
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 3);
|
|
// The published colour still reached slot 0's preset in place.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
}
|
|
|
|
// A published slot is seeded from an unused library preset and the values are written onto it
|
|
// in place, so the receiver's own material (slot 0) is never overwritten.
|
|
TEST_CASE("Published 3MF grows published slots to the receiver's last preset and recolors it in place", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
// Receiver with its own material plus one more library preset; author publishes only slot 4
|
|
// (Red). Growth always repeats the receiver's last filament ("Add one filament"), so the
|
|
// grown slot references the shared "My PLA" preset and the published red recolors it in
|
|
// place; the unused "Other PLA" preset is left untouched.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA");
|
|
other.config.opt_string("filament_type", 0u) = "PLA";
|
|
other.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#654321" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 3;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
// Every grown slot (published or filler) repeats the receiver's last preset.
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "My PLA");
|
|
// The published red recolors the shared "My PLA" preset in place; the unused "Other PLA"
|
|
// preset is left untouched.
|
|
CHECK(bundle.filament_presets[3] == "My PLA");
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#654321" });
|
|
// The project-level colours are sized and seeded for every grown slot.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[1] == "#123456");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[3] == "#ABCDEF");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour_type")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 4);
|
|
}
|
|
|
|
// Growth always repeats the receiver's last preset; a published slot only lands on its
|
|
// material identity when the aliased grown slot is re-pointed (de-alias fires on a preset
|
|
// key). Lock the identity priority there: an exact filament_id outranks an arbitrary unused
|
|
// preset, and an entry carrying only a family constrains the pick to that family.
|
|
TEST_CASE("Published 3MF re-points an aliased grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 2;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.keys = { "filament_retraction_length" };
|
|
// An unused preset sorting before everything else: an unconstrained pick would take it.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &arbitrary = add_inmemory_preset(bundle.filaments, "Aaa PLA");
|
|
arbitrary.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
SECTION("an exact filament_id outranks the first unused preset") {
|
|
Preset &authored = add_inmemory_preset(bundle.filaments, "Zzz PLA");
|
|
authored.config.opt_string("filament_type", 0u) = "PLA";
|
|
authored.filament_id = "GFA00";
|
|
entry.filament_id = "GFA00";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "Zzz PLA");
|
|
// The exact-id preset outranks the type-only "Aaa PLA"; the re-point is reported.
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Zzz PLA");
|
|
}
|
|
|
|
SECTION("a family-only entry picks an unused preset of that family") {
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "Bbb PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
entry.filament_type = "PETG";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
// The family pick lands on the only PETG preset (the PLA presets and the receiver's own
|
|
// material lose), and the re-point is reported.
|
|
CHECK(bundle.filament_presets[2] == "Bbb PETG");
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PETG");
|
|
}
|
|
}
|
|
|
|
// The GUI displays the edited preset, a snapshot of the selected collection preset taken at
|
|
// selection time. Since the overlay mutates the collection presets in place, the load must
|
|
// re-select the first slot's filament so the applied values - and slot replacements - surface
|
|
// in the GUI.
|
|
TEST_CASE("Published 3MF refreshes the edited preset so the applied material values surface", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
SECTION("the edited preset carries the applied colour and keys") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
// Mirror the GUI: the displayed preset is the collection's edited preset.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
// The slot references the edited preset, so the overlay lands on the edited layer:
|
|
// visible as a modification while the stored preset stays untouched.
|
|
CHECK(edited.name == "My PLA");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
// The overlay is a visible, revertible modification of the edited preset.
|
|
CHECK(bundle.filaments.current_is_dirty());
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("a slot replacement is reflected in the edited preset") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS");
|
|
abs.config.opt_string("filament_type", 0u) = "ABS";
|
|
abs.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.3 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedMaterialEntry entry = make_entry();
|
|
entry.publish_type_value = "ABS"; // mismatch: replaced by the library's ABS
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets[0] == "My ABS");
|
|
// The edited preset now displays the replacement with the author's values on top.
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
CHECK(edited.name == "My ABS");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
}
|
|
}
|
|
|
|
// The overlay lands on the edited layer when the slot references the collection's edited
|
|
// preset, so the user's unsaved in-memory edits on it survive a published load (only the
|
|
// published keys are touched) and the change shows as a visible, revertible modification.
|
|
TEST_CASE("Published 3MF preserves unsaved edits on the edited filament preset", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.1 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
// The user has unsaved in-memory edits on the preset being shown.
|
|
bundle.filaments.get_edited_preset().config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values = { 0.7 };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Published values land on the edited layer...
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
CHECK(edited.name == "My PLA");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// ...the user's unsaved edit on a non-published key survives...
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.7 });
|
|
// ...and the stored preset is untouched.
|
|
Preset *stored = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(stored != nullptr);
|
|
CHECK(stored->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.1 });
|
|
// The overlay is a visible, revertible modification of the edited preset.
|
|
CHECK(bundle.filaments.current_is_dirty());
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// The published overlay must validate '#' variant indices: an out-of-range index is reported as
|
|
// skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix on a scalar
|
|
// key is rejected instead of silently no-op'd.
|
|
TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Vector key, size 2 (matches the receiver's resized vector); distinct values make the
|
|
// applied element observable.
|
|
config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 140., 150. };
|
|
config.opt_float("layer_height") = 0.28;
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 10., 20. };
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "wiping_volumes_extruders#abc", "layer_height#0" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// In-range variant applied element-wise; the out-of-range one did not resize the vector.
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 10., 150. });
|
|
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values.size() == 2);
|
|
// Out-of-range variant, malformed variant and variant-suffixed scalar are reported as
|
|
// skipped; the malformed one must not fall back to element 0.
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5"));
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#abc"));
|
|
CHECK(contains_key(pub.skipped_keys, "layer_height#0"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1"));
|
|
// The scalar was never applied.
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001));
|
|
}
|
|
|
|
// The print/printer overlay guards option types like the material pass does: a published key
|
|
// whose file-side option kind differs from the receiver's is reported as skipped instead of
|
|
// throwing ConfigurationError out of load_config_model, which would abort the whole project
|
|
// load. (Nullable variants share the type() of their non-nullable base, so this covers
|
|
// genuinely different option kinds - e.g. a string where a float vector is expected.)
|
|
TEST_CASE("Published 3MF reports type-mismatched keys as skipped instead of aborting", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// The file carries the vector key as a string option (equal size to the receiver's)...
|
|
config.set_key_value("wiping_volumes_extruders", new ConfigOptionStrings({ "140", "150" }));
|
|
config.opt_float("layer_height") = 0.28;
|
|
|
|
PresetBundle bundle;
|
|
// ...while the receiver's edited print preset holds the float variant of the same key:
|
|
// without the type guard, ConfigOptionVector::set() throws ConfigurationError out of
|
|
// load_config_model.
|
|
bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20. }));
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "wiping_volumes_extruders", "layer_height" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The load completes; the type-mismatched key is reported as skipped and the receiver's
|
|
// value is untouched; the matching scalar key still applies.
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 10., 20. });
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
}
|
|
|
|
// A receiver filament preset missing its material identity (hand-edited file) must not crash
|
|
// the type gate: the gate reads it as a type mismatch, and the slot falls back to the
|
|
// "no replacement" path (keys skipped, colour still applied to the slot's preset).
|
|
TEST_CASE("Published 3MF survives a receiver preset missing its material identity", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
// Malformed receiver preset: the identity options are missing entirely.
|
|
pla.config.erase("filament_type");
|
|
pla.config.erase("filament_vendor");
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.filament_id = "GFL99";
|
|
entry.slot = 0;
|
|
entry.publish_type = true; // exercises the type gate against the missing identity
|
|
// Require ABS: the receiver library (PLA-typed default preset, typeless slot preset) has no
|
|
// ABS candidate, so the gate falls into the "no replacement" path (a PLA requirement would
|
|
// legitimately replace the slot with the visible PLA default).
|
|
entry.publish_type_value = "ABS";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The gate reads the missing identity as a type mismatch; no same-type replacement exists,
|
|
// so the keys are skipped while the colour applies to the slot's preset in place. No crash.
|
|
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)"));
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
Preset *mine_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(mine_preset != nullptr);
|
|
CHECK(mine_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
|
|
// Lock the exact contents and order of the printer allowlist (the union of the tab's
|
|
// "Retraction" and "Z-Hop" optgroup lists, Tab.cpp).
|
|
TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]")
|
|
{
|
|
auto keys_of = [](const std::vector<PublishablePrinterOption>& opts) {
|
|
std::vector<std::string> keys;
|
|
keys.reserve(opts.size());
|
|
for (const PublishablePrinterOption& opt : opts)
|
|
keys.emplace_back(opt.key);
|
|
return keys;
|
|
};
|
|
|
|
const std::vector<std::string> expected_retraction = {
|
|
"retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed",
|
|
"retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance",
|
|
"retract_before_wipe", "retract_after_wipe"
|
|
};
|
|
const std::vector<std::string> expected_z_hop = {
|
|
"retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above",
|
|
"retract_lift_below"
|
|
};
|
|
|
|
CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction);
|
|
CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop);
|
|
|
|
std::set<std::string> expected_union(expected_retraction.begin(), expected_retraction.end());
|
|
expected_union.insert(expected_z_hop.begin(), expected_z_hop.end());
|
|
CHECK(publishable_printer_keys() == expected_union);
|
|
}
|
|
|
|
// Loading the same published file twice must not compound values on the receiver's presets:
|
|
// each load re-applies the same absolute values, so the result is idempotent.
|
|
TEST_CASE("Published 3MF reloading does not compound values on the receiver's presets", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
auto load = [&] {
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
};
|
|
load();
|
|
// First load: the receiver's preset carries the published values (mutated in place).
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
load();
|
|
// Each load re-applies the same values onto the (already mutated) preset: no accumulation.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
}
|
|
|
|
// A receiver with several slots aliasing the same preset (multi-extruder profile with one
|
|
// filament) and an author publishing keys on several slots: each published slot is re-pointed
|
|
// at its own distinct preset so values never leak between slots.
|
|
TEST_CASE("Published 3MF gives each published slot its own preset on an aliased receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9, 1.2, 1.5 };
|
|
return config;
|
|
};
|
|
auto make_key_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
// A 4-extruder receiver with a single filament preset: the slots alias [A, A, A, A] before
|
|
// the published pass.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// Spare library presets for the re-pointing to fall back on.
|
|
for (const char *name : { "Extra PLA A", "Extra PLA B", "Extra PLA C" }) {
|
|
Preset &extra = add_inmemory_preset(bundle.filaments, name);
|
|
extra.config.opt_string("filament_type", 0u) = "PLA";
|
|
extra.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
}
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_key_entry(0), make_key_entry(1), make_key_entry(2), make_key_entry(3) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
// Every published slot references its own distinct preset: slot 0 keeps the receiver's
|
|
// material, slots 1-3 are re-pointed at the spare library presets.
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[1] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[2] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[2] != bundle.filament_presets[1]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[1]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[2]);
|
|
// Each slot's stored preset carries its own slot's retraction (mutated in place).
|
|
const std::vector<double> expected = { 0.6, 0.9, 1.2, 1.5 };
|
|
for (size_t slot = 0; slot < 4; ++slot) {
|
|
Preset *preset = bundle.filaments.find_preset(bundle.filament_presets[slot], false, true);
|
|
REQUIRE(preset != nullptr);
|
|
check_double_vector(preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { expected[slot] });
|
|
}
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// De-aliasing runs on the exported identity even without a checked Type row: the re-pointed
|
|
// slot lands on the exact published material (by filament_id) rather than an arbitrary spare,
|
|
// and the formerly silent re-point is surfaced through the replacements notification list.
|
|
TEST_CASE("Published 3MF de-aliases an aliased slot by published identity without a type requirement", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9 };
|
|
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// A spare sorting before the exact match: an unconstrained pick would take it.
|
|
Preset &spare = add_inmemory_preset(bundle.filaments, "Aaa PLA");
|
|
spare.config.opt_string("filament_type", 0u) = "PLA";
|
|
spare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &match = add_inmemory_preset(bundle.filaments, "Zzz PLA");
|
|
match.config.opt_string("filament_type", 0u) = "PLA";
|
|
match.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
match.filament_id = "GFA00";
|
|
bundle.filament_presets = { "My PLA", "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.filament_id = "GFA00";
|
|
entry.keys = { "filament_retraction_length" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[1] == "Zzz PLA");
|
|
// The published key was written onto the re-pointed slot's own preset.
|
|
Preset *target = bundle.filaments.find_preset("Zzz PLA", false, true);
|
|
REQUIRE(target != nullptr);
|
|
check_double_vector(target->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 1: My PLA -> Zzz PLA");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Printer retraction keys are published per-extruder ("#N"): a receiver with a different
|
|
// extruder count still receives the in-range elements; out-of-range variants are reported as
|
|
// skipped instead of corrupting the receiver's vector.
|
|
TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count mismatches", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Author has 4 extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9, 1.2, 1.5 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Receiver with a single extruder: only "#0" is in range; "#1..#3" are skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_length#2", "retraction_length#3" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6 });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#1"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#2"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#3"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0"));
|
|
}
|
|
|
|
// Receiver with four extruders and a 1-extruder author: only "#0" is published; the
|
|
// receiver's other extruders keep their own values.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8, 0.8, 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The author's file carries a single-extruder value.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.7 };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.7, 0.8, 0.8, 0.8 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A published mixed filament serializes its definition (components, ratios, gradient) into the
|
|
// receiver's project_config - the project-level parallel arrays, not a filament preset. The
|
|
// mix's own blended colour is carried as publish_color so the receiver renders the swatch.
|
|
TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2)
|
|
// blending slots 1 and 2 at 60/40 with a gradient.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99", "GFL99" };
|
|
// The mixed slot's definition. These keys are project-level arrays in the full config;
|
|
// on export they are masked so only the published slot's entry survives.
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 1 };
|
|
return config;
|
|
};
|
|
|
|
// A receiver that already carries the mix slot at index 2 as an actual mixed slot (e.g. a
|
|
// two-physical-plus-one-mix project with the same layout): the incoming definition is a
|
|
// like-for-like override of the virtual slot and applies in place without relocation.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
bundle.filament_presets = { "My PLA", "My PETG", "My PLA" };
|
|
|
|
// Grow the receiver's project arrays to 3 slots first, as set_num_filaments would,
|
|
// then mark the third slot as the receiver's own mixed filament.
|
|
bundle.set_num_filaments(3);
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = "1,1";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5";
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.filament_id = "GFL99";
|
|
mix.slot = 2;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The definition landed in project_config's parallel arrays at the author slot.
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 3);
|
|
CHECK(ratios[2] == "0.6,0.4");
|
|
const auto &gradient = bundle.project_config.opt<ConfigOptionBools>("filament_mixed_gradient")->values;
|
|
CHECK(gradient[2]);
|
|
const auto &range = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values;
|
|
CHECK(range[2] == "0.9,0.1");
|
|
const auto &curve = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values;
|
|
CHECK(curve[2] == "0,0.1|1,0.9");
|
|
const auto &per_part = bundle.project_config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values;
|
|
CHECK(per_part[2]);
|
|
// The mix's blended colour crossed into project_config for the swatch.
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 3);
|
|
CHECK(colour[2] == "#800080");
|
|
// The other slots were not overwritten by the mask.
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
// Nothing skipped: every serialized mixed key was applied.
|
|
CHECK(pub.skipped_keys.empty());
|
|
// Like-for-like override: no slot was relocated.
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// A receiver with fewer slots: the slot is grown and seeded before the definition applies.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 2;
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A published mixed filament whose definition cannot be applied is reported as skipped instead
|
|
// of aborting the load: the entry lists a mixed key that the file's payload does not carry.
|
|
TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
// Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the
|
|
// entry lists them, the file config does not.
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 1;
|
|
mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grows to two slots; the missing payload keys are reported as skipped rather
|
|
// than dropped silently (material_label is empty for this entry).
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)"));
|
|
CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)"));
|
|
}
|
|
|
|
// A published mixed filament must never convert one of the receiver's real, physical slots
|
|
// into a virtual mix: definitions that collide with a physical slot are relocated past every
|
|
// positional (real-filament) destination, while ones colliding with an existing mixed slot
|
|
// override it in place.
|
|
TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a physical slot", "[Preset][Bundle][Published]")
|
|
{
|
|
// An author project with <num_author_slots> slots whose last slot is a mixed filament.
|
|
auto make_file_config = [](size_t num_author_slots, size_t num_tail_mixes = 1) {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
std::vector<double> diameters(num_author_slots, 1.75);
|
|
std::vector<int> self_index;
|
|
std::vector<std::string> variants;
|
|
std::vector<std::string> types;
|
|
for (size_t i = 0; i < num_author_slots; ++i) {
|
|
self_index.push_back(int(i + 1));
|
|
variants.emplace_back("Direct Drive Standard");
|
|
types.push_back(i % 2 == 0 ? "PLA" : "PETG");
|
|
}
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = diameters;
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = self_index;
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = variants;
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF", "#FFFF00", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values.resize(num_author_slots, "#808080");
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = types;
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values.assign(num_author_slots, "Generic");
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values.resize(num_author_slots);
|
|
// The last <num_tail_mixes> author slots are mixed ones (components differ per slot so
|
|
// the definitions are distinguishable after relocation).
|
|
const size_t first_mix_slot = num_author_slots - num_tail_mixes;
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values.assign(num_author_slots, 0);
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values.assign(num_author_slots, "");
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values.assign(num_author_slots, "");
|
|
for (size_t i = first_mix_slot; i < num_author_slots; ++i) {
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values[i] = 1;
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values[i] =
|
|
i % 2 == 0 ? std::string("1,2") : std::string("1,3");
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[i] =
|
|
i % 2 == 0 ? std::string("0.6,0.4") : std::string("0.3,0.7");
|
|
}
|
|
return config;
|
|
};
|
|
|
|
// The reported bug: an author publishes with physical filaments on slots 1-2 and a mixed
|
|
// filament on slot 5; the receiver runs five real filaments of his own. Slot 5 must stay
|
|
// untouched and the mix lands as a newly appended virtual slot 6.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(5, "#123456");
|
|
const std::vector<std::string> receiver_colours =
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.slot = 4;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grew by exactly one extra virtual slot.
|
|
REQUIRE(bundle.filament_presets.size() == 6);
|
|
// All five physical slots kept their meaning: no mixed flag, untouched names/colours.
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 6);
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
CHECK_FALSE(is_mixed[2]);
|
|
CHECK_FALSE(is_mixed[3]);
|
|
CHECK_FALSE(is_mixed[4]);
|
|
CHECK(is_mixed[5]);
|
|
CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(),
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.begin()));
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[4] == "My PLA");
|
|
// The definition itself is readable at the new index.
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 6);
|
|
CHECK(components[5] == "1,2");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 6);
|
|
CHECK(ratios[5] == "0.6,0.4");
|
|
// The blended colour seeds the swatch of the new slot only.
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 6);
|
|
CHECK(colour[5] == "#800080");
|
|
// The relocation is surfaced to the user through the post-import notice (the de-alias
|
|
// pass may contribute further messages, so presence is asserted, not the count).
|
|
bool relocated_reported = false;
|
|
for (const std::string &message : pub.material_replacements)
|
|
if (message.find("slot 4 -> slot 5") != std::string::npos)
|
|
relocated_reported = true;
|
|
CHECK(relocated_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A definition colliding with the receiver's own mixed filament is overridden in place:
|
|
// nothing grows, nothing is reported as moved.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(3);
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = "1,1";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = "0.9,0.1";
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 2;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config(3);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 3);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 3);
|
|
CHECK(ratios[2] == "0.6,0.4");
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// Author publishes four physical filaments plus two mixed ones on slots 5 and 6; the
|
|
// receiver runs five real filaments. Both mixes relocate onto consecutive fresh slots,
|
|
// preserving their author order (slot 5 -> slot 6, slot 6 -> slot 7); no receiver slot is
|
|
// converted into a virtual mix.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(5, "#123456");
|
|
const std::vector<std::string> receiver_colours =
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
|
|
auto make_mix_entry = [](int authored_slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = authored_slot;
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
entry.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
return entry;
|
|
};
|
|
PublishedMaterialEntry mix_a = make_mix_entry(4, "#800080");
|
|
PublishedMaterialEntry mix_b = make_mix_entry(5, "#FF69B4");
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix_a, mix_b };
|
|
DynamicPrintConfig config = make_file_config(6, 2);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Two fresh virtual slots were appended.
|
|
REQUIRE(bundle.filament_presets.size() == 7);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 7);
|
|
for (size_t i = 0; i < 5; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[5]);
|
|
CHECK(is_mixed[6]);
|
|
// The definitions follow their author order.
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 7);
|
|
CHECK(components[5] == "1,2");
|
|
CHECK(components[6] == "1,3");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 7);
|
|
CHECK(ratios[5] == "0.6,0.4");
|
|
CHECK(ratios[6] == "0.3,0.7");
|
|
// The five real slots kept their colours; each mix's blended colour seeded its new slot.
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 7);
|
|
CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), colour.begin()));
|
|
CHECK(colour[5] == "#800080");
|
|
CHECK(colour[6] == "#FF69B4");
|
|
// Both relocations are reported with the correct mapping.
|
|
bool a_reported = false, b_reported = false;
|
|
for (const std::string &message : pub.material_replacements) {
|
|
if (message.find("slot 4 -> slot 5") != std::string::npos)
|
|
a_reported = true;
|
|
if (message.find("slot 5 -> slot 6") != std::string::npos)
|
|
b_reported = true;
|
|
}
|
|
CHECK(a_reported);
|
|
CHECK(b_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
// The relocation table is exposed for the model-reference remapping.
|
|
REQUIRE(pub.mixed_slot_relocations.size() == 2);
|
|
CHECK(pub.mixed_slot_relocations.at(4) == 5);
|
|
CHECK(pub.mixed_slot_relocations.at(5) == 6);
|
|
}
|
|
}
|
|
|
|
// A receiver that already owns a MIXED filament must keep the physical-first invariant after a
|
|
// published-3MF import: when incoming physical filaments would land on (or ahead of) the
|
|
// receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left
|
|
// interleaved with them (the R,M,R bug).
|
|
TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]")
|
|
{
|
|
// Build the receiver's tool-changer with three slots, the third being the receiver's own
|
|
// mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by
|
|
// hand, so a lower slot count than the printer's nozzle count is preserved on load - a
|
|
// non-SEMM tool-changer would top the preset list up to the nozzle count and shift the
|
|
// expected sizes (the rebalance logic under test is the same either way).
|
|
auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) {
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(3, "#123456");
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = true;
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = components;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = ratios;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[2] = "#800080";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values[2] = "#800080";
|
|
};
|
|
auto make_real_entry = [](int slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
return entry;
|
|
};
|
|
// A four-physical author project with no mixed slots (colour publish only), as in the
|
|
// reported Ferrari reference file.
|
|
auto make_config_4_real = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
// [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M].
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, "1,2", "0.5,0.5");
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"),
|
|
make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") };
|
|
DynamicPrintConfig config = make_config_4_real();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grows by one extra virtual slot; the mix lands at the tail.
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
for (size_t i = 0; i < 4; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[4]);
|
|
// The definition travelled with its swatch colour; the vacated slot 2 became physical.
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 5);
|
|
CHECK(components[4] == "1,2");
|
|
CHECK(components[2].empty());
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 5);
|
|
CHECK(colour[4] == "#800080");
|
|
CHECK(colour[2] == "#FFFFFF");
|
|
CHECK(pub.mixed_slot_relocations.at(2) == 4);
|
|
bool relocated_reported = false;
|
|
for (const std::string &message : pub.material_replacements)
|
|
if (message.find("slot 2 -> slot 4") != std::string::npos &&
|
|
message.find("mixed filament") != std::string::npos)
|
|
relocated_reported = true;
|
|
CHECK(relocated_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits
|
|
// ahead of the appended payload mix (slot 4), preserving physical-first tail ordering.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, "1,2", "0.5,0.5");
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#000000", "#0000FF", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
// Authored slot 3 is a payload mixed definition blending slots 1 and 3.
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "", "1,3" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "", "0.9,0.1" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "", "0,0.1|1,0.9" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 1 };
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 3;
|
|
mix.filament_type = "PLA";
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios",
|
|
"filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"),
|
|
make_real_entry(2, "#0000FF"), mix };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
CHECK_FALSE(is_mixed[2]);
|
|
CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first
|
|
CHECK(is_mixed[4]); // payload's mix, appended after
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 5);
|
|
CHECK(components[3] == "1,2");
|
|
CHECK(components[4] == "1,3");
|
|
REQUIRE(pub.mixed_slot_relocations.size() == 2);
|
|
CHECK(pub.mixed_slot_relocations.at(2) == 3);
|
|
CHECK(pub.mixed_slot_relocations.at(3) == 4);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance
|
|
// onto consecutive tail slots in index order (no cascade/overlap).
|
|
TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(3, "#123456");
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = true;
|
|
// Receiver: slot 1 and slot 2 are mixed.
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[1] = 1;
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[1] = "1,2";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = "1,3";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6";
|
|
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99" };
|
|
|
|
auto make_real_entry = [](int slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
return entry;
|
|
};
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
for (size_t i = 0; i < 3; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[3]);
|
|
CHECK(is_mixed[4]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 5);
|
|
CHECK(components[3] == "1,2");
|
|
CHECK(components[4] == "1,3");
|
|
REQUIRE(pub.mixed_slot_relocations.size() == 2);
|
|
CHECK(pub.mixed_slot_relocations.at(1) == 3);
|
|
CHECK(pub.mixed_slot_relocations.at(2) == 4);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a
|
|
// non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle
|
|
// count cannot become a physical filament. It becomes an empty mixed-filament placeholder
|
|
// instead - a virtual tail slot the GUI flags (broken mix) and the user fills with components
|
|
// from their own filaments. SEMM receivers keep the ungated behaviour.
|
|
TEST_CASE("Published 3MF turns a surplus slot past the printer's filament capacity into an empty mixed placeholder", "[Preset][Bundle][Published]")
|
|
{
|
|
// An author project with <num_author_slots> physical slots, no mixed ones.
|
|
auto make_file_config = [](size_t num_author_slots) {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
std::vector<double> diameters(num_author_slots, 1.75);
|
|
std::vector<int> self_index;
|
|
std::vector<std::string> variants;
|
|
for (size_t i = 0; i < num_author_slots; ++i) {
|
|
self_index.push_back(int(i + 1));
|
|
variants.emplace_back("Direct Drive Standard");
|
|
}
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = diameters;
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = self_index;
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = variants;
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values.resize(num_author_slots, "#808080");
|
|
config.opt<ConfigOptionStrings>("filament_type")->values.assign(num_author_slots, "PLA");
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values.assign(num_author_slots, "Generic");
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values.resize(num_author_slots);
|
|
return config;
|
|
};
|
|
// A non-SEMM receiver with <nozzles> nozzles running <slots> copies of one preset.
|
|
auto make_receiver = [](PresetBundle &bundle, size_t nozzles, size_t slots) {
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets.assign(slots, "My PLA");
|
|
bundle.set_num_filaments(slots, "#123456");
|
|
auto &printer_config = bundle.printers.get_edited_preset().config;
|
|
printer_config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
|
printer_config.opt<ConfigOptionFloats>("nozzle_diameter", true)->values.assign(nozzles, 0.4);
|
|
};
|
|
auto make_physical_entry = [](int slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
return entry;
|
|
};
|
|
|
|
// The reported case: an author publishes with a filament on slot 5; the receiver is a
|
|
// 4-filament tool-changer. The receiver keeps its four physical slots and the surplus
|
|
// material lands as an empty mixed placeholder at the tail.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 4, 4);
|
|
const std::vector<std::string> receiver_colours =
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(4, "#ABCDEF") };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grew by exactly one virtual slot, not a fifth physical one.
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
for (size_t i = 0; i < 4; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[4]);
|
|
// The placeholder carries no definition: the GUI's integrity check flags it and
|
|
// blocks slicing until the user assigns components.
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 5);
|
|
CHECK(components[4].empty());
|
|
// The four physical slots kept their meaning and colours.
|
|
CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(),
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.begin()));
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[3] == "My PLA");
|
|
// The published colour seeds the placeholder's swatch.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[4] == "#ABCDEF");
|
|
// The conversion is surfaced through the post-import notice.
|
|
bool placeholder_reported = false;
|
|
for (const std::string &message : pub.material_replacements)
|
|
if (message.find("unassigned mixed filament") != std::string::npos)
|
|
placeholder_reported = true;
|
|
CHECK(placeholder_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.mixed_slot_relocations.empty());
|
|
}
|
|
|
|
// Two surplus slots (5 and 6) become two consecutive empty placeholders.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 4, 4);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(4, "#ABCDEF"), make_physical_entry(5, "#F0F0F0") };
|
|
DynamicPrintConfig config = make_file_config(6);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 6);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 6);
|
|
for (size_t i = 0; i < 4; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[4]);
|
|
CHECK(is_mixed[5]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 6);
|
|
CHECK(components[4].empty());
|
|
CHECK(components[5].empty());
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 6);
|
|
CHECK(colour[4] == "#ABCDEF");
|
|
CHECK(colour[5] == "#F0F0F0");
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.mixed_slot_relocations.empty());
|
|
}
|
|
|
|
// A surplus slot past both the receiver's list and the capacity packs onto the next free
|
|
// tail slot (never max(authored, next_free), which would grow filler physical slots past
|
|
// the capacity), and the relocation is recorded for the model-reference remapping.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 2, 2);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(3, "#ABCDEF") };
|
|
DynamicPrintConfig config = make_file_config(4);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2].empty());
|
|
REQUIRE(pub.mixed_slot_relocations.size() == 1);
|
|
CHECK(pub.mixed_slot_relocations.at(3) == 2);
|
|
bool relocation_reported = false;
|
|
for (const std::string &message : pub.material_replacements)
|
|
if (message.find("slot 3 -> slot 2") != std::string::npos &&
|
|
message.find("unassigned mixed filament") != std::string::npos)
|
|
relocation_reported = true;
|
|
CHECK(relocation_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A Full Publish entry past the capacity becomes a placeholder too: no standalone
|
|
// detached copy is created for a material that got no physical slot.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 4, 4);
|
|
|
|
PublishedMaterialEntry entry = make_physical_entry(4, "#ABCDEF");
|
|
entry.full = true;
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.filament_id = "GFL99";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
CHECK(is_mixed[4]);
|
|
// No detached copy under the stripped name or its uniquified forms.
|
|
CHECK(bundle.filaments.find_preset("Generic PLA", false, true) == nullptr);
|
|
CHECK(bundle.filaments.find_preset("Generic PLA (Published)", false, true) == nullptr);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A SEMM receiver (the default printer preset) sizes its slot list by hand: the published
|
|
// slot past the nozzle count still grows physically, as before the capacity gate.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(4, "#123456");
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(4, "#ABCDEF") };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
for (size_t i = 0; i < 5; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[4] == "#ABCDEF");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A pre-existing oversized slot list is never shrunk: a published entry pointing at one
|
|
// of its slots is applied positionally even though the list exceeds the nozzle count.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 4, 5);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(4, "#ABCDEF") };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
for (size_t i = 0; i < 5; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
// The published colour reached the addressed slot's (shared) preset in place.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values ==
|
|
std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[4] == "#ABCDEF");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// On a single-physical-slot receiver an empty mix could never be edited (the sidebar's
|
|
// mixed section needs two physical filaments), so the surplus entry is dropped and
|
|
// reported instead of becoming an unfixable placeholder.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 1, 1);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_physical_entry(1, "#ABCDEF") };
|
|
DynamicPrintConfig config = make_file_config(2);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 1);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 1);
|
|
CHECK_FALSE(is_mixed[0]);
|
|
REQUIRE(pub.skipped_keys.size() == 1);
|
|
CHECK(pub.skipped_keys.front().find("printer supports only 1") != std::string::npos);
|
|
}
|
|
|
|
// A payload mixed definition is exempt from the capacity gate: mixes are virtual slots
|
|
// that consume no nozzle, so a published mix past the nozzle count still lands.
|
|
{
|
|
PresetBundle bundle;
|
|
make_receiver(bundle, 4, 4);
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 4;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config(5);
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values.assign(5, 0);
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values.assign(5, "");
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values.assign(5, "");
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values[4] = 1;
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values[4] = "1,2";
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[4] = "0.6,0.4";
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 5);
|
|
CHECK(is_mixed[4]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 5);
|
|
CHECK(components[4] == "1,2");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 5);
|
|
CHECK(ratios[4] == "0.6,0.4");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A single-extruder receiver collapses the author's per-extruder printer slots onto its single
|
|
// slot: the first serialized variant of a base key is applied, the remaining variants of that
|
|
// base key are reported as skipped.
|
|
TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Author has two extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9 };
|
|
config.opt<ConfigOptionFloats>("retraction_speed")->values = { 30.0, 40.0 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Both extruders published: the first serialized variant (#0, left) lands on the receiver's
|
|
// single slot; the second variant (#1) is reported as skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values = { 25.0 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values, { 30.0 });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#1"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_speed#1"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0"));
|
|
}
|
|
|
|
// Only the second extruder published: the single-extruder receiver still applies it (the
|
|
// author's "right" is the only serialized slot) and reports nothing skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise
|
|
// (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are
|
|
// reported as skipped.
|
|
TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
// Author has two extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Receiver with two extruders: both published slots override element-wise.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6, 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8, 0.7 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6, 0.9, 0.7 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on
|
|
// its own, so a physical count derived from that list reports a slot no per-filament array has
|
|
// yet. That is what made the extruder-count handler conclude there was nothing to add and leave
|
|
// the new sidebar combo with no colour to draw.
|
|
TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
SECTION("no mixed slots") {
|
|
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.num_physical_filaments() == 4);
|
|
}
|
|
|
|
SECTION("behind a mixed tail") {
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 6);
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 5);
|
|
CHECK(bundle.num_physical_filaments() == 4);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
}
|
|
}
|
|
|
|
// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the
|
|
// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour.
|
|
TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
|
|
// The call Sidebar::add_custom_filament makes once the extruder count opens a slot.
|
|
bundle.set_num_filaments(5u, std::string("#00FF00"));
|
|
|
|
const auto &colours = bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colours.size() == 5);
|
|
CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with
|
|
}
|
|
|
|
// The mixed-slot flags are written into the app config on exit and read back on the next start.
|
|
// If the read side loses them the slots survive as filaments but stop being mixes, so the project
|
|
// comes back with the mix showing as an ordinary physical filament.
|
|
TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
AppConfig app_config;
|
|
|
|
// Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail.
|
|
{
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.filaments.select_preset_by_name("Test Filament", true);
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.filament_presets.assign(5, "Test Filament");
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "2,3" };
|
|
bundle.export_selections(app_config);
|
|
|
|
REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1");
|
|
}
|
|
|
|
// This session.
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.load_selections(app_config);
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
|
}
|
|
|
|
// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose
|
|
// saved filament list is one longer than its nozzle count, because the extra slot is the mix.
|
|
TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
auto make_toolchanger = [](PresetBundle &bundle) -> Preset & {
|
|
Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer");
|
|
p.config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
|
|
p.config.option<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
|
return p;
|
|
};
|
|
|
|
AppConfig app_config;
|
|
{
|
|
PresetBundle bundle;
|
|
make_toolchanger(bundle);
|
|
bundle.printers.select_preset_by_name("Tool Changer", true);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.filaments.select_preset_by_name("Test Filament", true);
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.filament_presets.assign(5, "Test Filament");
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "1,2" };
|
|
bundle.export_selections(app_config);
|
|
REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1");
|
|
}
|
|
|
|
PresetBundle bundle;
|
|
make_toolchanger(bundle);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.load_selections(app_config);
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
|
|
SECTION("and through the GUI startup calls that follow it") {
|
|
// GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only.
|
|
const size_t target = 4u + bundle.num_mixed_filaments();
|
|
if (target > bundle.filament_presets.size())
|
|
bundle.set_num_filaments(target);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
|
|
// TabPrinter::extruders_count_changed.
|
|
bundle.on_extruders_count_changed(4);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
|
|
// Tab::select_preset re-reads the snapshot when remember_printer_config is on.
|
|
bundle.update_selections(app_config);
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
}
|
|
}
|
|
|
|
// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes.
|
|
// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly
|
|
// where the mixes live, so applying the target to a longer list deletes them. A list longer than
|
|
// the target is reachable - raising the extruder count without saving the printer preset leaves
|
|
// the extra physical slot behind on the next start - so the startup sizing must only ever grow.
|
|
TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
// 5 physical + 1 mix, on a printer preset still reporting 4 nozzles.
|
|
const size_t nozzle_count = 4;
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(6u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "", "1,2" };
|
|
REQUIRE(bundle.num_physical_filaments() == 5);
|
|
|
|
const size_t target = nozzle_count + bundle.num_mixed_filaments();
|
|
REQUIRE(target < bundle.filament_presets.size());
|
|
|
|
SECTION("applied as written, the mix is gone and every slot reads physical") {
|
|
bundle.set_num_filaments(target);
|
|
|
|
CHECK(bundle.filament_presets.size() == target);
|
|
CHECK(bundle.num_mixed_filaments() == 0);
|
|
CHECK(bundle.num_physical_filaments() == target);
|
|
}
|
|
|
|
SECTION("applied as a floor, the mix is left alone") {
|
|
if (target > bundle.filament_presets.size())
|
|
bundle.set_num_filaments(target);
|
|
|
|
CHECK(bundle.filament_presets.size() == 6);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(5));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
|
}
|
|
}
|
|
|
|
// After a published-3MF import relocated mixed-filament definitions, the freshly loaded
|
|
// model's slot references must follow: object/volume "extruder" configs and multi-material
|
|
// color-painting states (which store the one-based slot number) are re-pointed to where each
|
|
// definition landed; everything else keeps its state.
|
|
TEST_CASE("remap_model_filament_slots repoints extruder configs and color painting", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_model = [] {
|
|
Model model;
|
|
ModelObject *object_a = model.add_object();
|
|
object_a->name = "relocated mix";
|
|
ModelVolume *vol_a = object_a->add_volume(make_cube(10., 10., 10.));
|
|
vol_a->config.set_key_value("extruder", new ConfigOptionInt(5)); // author slot 5 (0-based 4)
|
|
// Author painted one facet with the mix (slot 5) and another with a physical (slot 2).
|
|
{
|
|
TriangleSelector selector(vol_a->mesh());
|
|
selector.set_facet(0, EnforcerBlockerType(5));
|
|
selector.set_facet(1, EnforcerBlockerType(2));
|
|
vol_a->mmu_segmentation_facets.set_data(selector.serialize());
|
|
}
|
|
// A second object that does not reference the relocated slot at all. Painted with a
|
|
// real, non-relocated state (NONE is never serialized: an unsplit triangle without a
|
|
// state is the unpainted default and is skipped by TriangleSelector::serialize()).
|
|
ModelObject *object_b = model.add_object();
|
|
object_b->name = "untouched";
|
|
object_b->config.set_key_value("extruder", new ConfigOptionInt(1));
|
|
ModelVolume *vol_b = object_b->add_volume(make_cube(5., 5., 5.));
|
|
vol_b->config.set_key_value("extruder", new ConfigOptionInt(2));
|
|
{
|
|
TriangleSelector selector(vol_b->mesh());
|
|
selector.set_facet(0, EnforcerBlockerType(2));
|
|
vol_b->mmu_segmentation_facets.set_data(selector.serialize());
|
|
}
|
|
return model;
|
|
};
|
|
|
|
const std::map<int, int> relocations = {{4, 5}};
|
|
|
|
Model model = make_model();
|
|
Slic3r::remap_model_filament_slots(model, relocations);
|
|
|
|
const ModelVolume *vol_a = model.objects[0]->volumes.front();
|
|
CHECK(vol_a->config.extruder() == 6); // author slot 5 -> final slot 6
|
|
// Painted states follow: the mix facet moved 5 -> 6, the physical one is untouched.
|
|
REQUIRE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(6)));
|
|
REQUIRE_FALSE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(5)));
|
|
CHECK(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2)));
|
|
|
|
const ModelVolume *vol_b = model.objects[1]->volumes.front();
|
|
CHECK(vol_b->config.extruder() == 2);
|
|
// The untouched volume's paint (a non-relocated state) survives as-is.
|
|
CHECK(TriangleSelector::has_facets(vol_b->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2)));
|
|
|
|
// The mapping is applied simultaneously: each entry reads the original slot number, so
|
|
// relocating onto another relocated-from slot number must not chase chains. With the
|
|
// 0-based relocations {3->4, 4->6} the 1-based config map is {4->5, 5->7}: a volume on
|
|
// 1-based slot 4 lands on 5 and does NOT continue to 7.
|
|
Model chained = make_model();
|
|
chained.objects[0]->volumes.front()->config.set_key_value("extruder", new ConfigOptionInt(4));
|
|
Slic3r::remap_model_filament_slots(chained, std::map<int, int>{{3, 4}, {4, 6}});
|
|
CHECK(chained.objects[0]->volumes.front()->config.extruder() == 5);
|
|
// The chained model's paint follows its own single-step mapping: painted state 5 -> 7,
|
|
// and nothing lands back on 5.
|
|
CHECK(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(),
|
|
EnforcerBlockerType(7)));
|
|
CHECK_FALSE(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(),
|
|
EnforcerBlockerType(5)));
|
|
}
|
|
|
|
// The slot ceiling (EnforcerBlockerType::ExtruderMax) is what the color-painting encoding can
|
|
// address, so a mixed filament that does not fit must be dropped and reported instead of being
|
|
// forced onto one of the receiver's physical filaments.
|
|
TEST_CASE("Published 3MF drops a mixed filament that does not fit the slot limit and reports it", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
// A receiver already at the format's slot ceiling.
|
|
bundle.set_num_filaments(unsigned(EnforcerBlockerType::ExtruderMax), "#123456");
|
|
const std::vector<std::string> receiver_colours =
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.slot = int(EnforcerBlockerType::ExtruderMax) + 6; // beyond the ceiling
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Nothing grew and no slot became a virtual mix.
|
|
REQUIRE(bundle.filament_presets.size() == size_t(EnforcerBlockerType::ExtruderMax));
|
|
for (size_t i = 0; i < bundle.filament_presets.size(); ++i)
|
|
CHECK_FALSE(bundle.is_mixed_filament(i));
|
|
// The receiver's colours were not touched.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == receiver_colours);
|
|
// The mix was reported instead of being applied.
|
|
REQUIRE(contains_key(pub.skipped_keys, "material:PLA (mixed filament definition: filament slot limit reached)"));
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// The publish dialog can only produce definitions whose components reference existing physical
|
|
// slots, so a payload whose components point at slots that do not exist (or at another mixed
|
|
// slot) or that carries fewer than two components is broken. The load reports it through the
|
|
// same channel as every other rejected input instead of shipping a mix the GUI integrity check
|
|
// would only flag later.
|
|
TEST_CASE("Published 3MF rejects a mixed filament definition with impossible components", "[Preset][Bundle][Published]")
|
|
{
|
|
// A two-physical-plus-one-mix author file; the definition under test sits on slot 2.
|
|
auto make_file_config = [](const std::string &components) {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", components };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
|
|
return config;
|
|
};
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.filament_id = "GFL99";
|
|
mix.slot = 2;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
for (const char *components : { "1,4", "1,3", "1" }) {
|
|
// The claimed components: "1,4" names a slot past the final count, "1,3" names the mix
|
|
// slot itself (1-based), "1" is not enough components to blend.
|
|
INFO("components = " << components);
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config(components);
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The definition was rejected, not applied: the grown slot is finalized as an empty
|
|
// mixed placeholder instead of keeping the seeded preset and masquerading as a real
|
|
// filament.
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.is_mixed_filament(2));
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2].empty());
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2].empty());
|
|
// Reported through the shared rejection channel.
|
|
if (std::string(components) == "1")
|
|
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)"));
|
|
else
|
|
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: components reference missing slots)"));
|
|
// The slot change was surfaced like the other slot adaptations.
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0].find("slot 2: mixed filament definition could not be imported") != std::string::npos);
|
|
// The blended colour was not written into the (shared) slot preset either.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values ==
|
|
std::vector<std::string>{ "#123456" });
|
|
}
|
|
}
|
|
|
|
// The reported scenario: an author publishes two mixed filaments whose components are
|
|
// full-published physical slots; the receiver is a smaller tool-changer, so some of those
|
|
// component slots become empty mixed placeholders. A mix whose component turned into a
|
|
// placeholder can never be valid (mixes cannot reference mixes): it is rejected, and its
|
|
// grown slot must be finalized as an empty mixed placeholder too - not keep the seeded
|
|
// preset and masquerade as a real filament carrying the mix identity.
|
|
TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder component as an empty placeholder", "[Preset][Bundle][Published]")
|
|
{
|
|
// An author project: six physical slots plus two tail mixes, the second referencing the
|
|
// sixth physical slot (H2C-style: slot 7 = 1+2, slot 8 = 2+6).
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = std::vector<double>(8, 1.75);
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = std::vector<std::string>(8, "Direct Drive Standard");
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00",
|
|
"#FF00FF", "#00FFFF", "#800080", "#804000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values.assign(8, "PLA");
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values.assign(8, "Generic");
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 1, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "", "", "", "", "1,2", "2,6" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "0.6,0.4", "0.5,0.5" };
|
|
// The export always serializes all seven masked mixed arrays, not just the ones in
|
|
// use; the unused gradient arrays ride along as defaults.
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 0, 0, 0, 0, 0, 0 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values.assign(8, "");
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values.assign(8, "");
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 0, 0, 0, 0, 0 };
|
|
return config;
|
|
};
|
|
// A non-SEMM receiver with four nozzles and four slots (tool-changer style).
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets.assign(4, "My PLA");
|
|
bundle.set_num_filaments(4, "#123456");
|
|
auto &printer_config = bundle.printers.get_edited_preset().config;
|
|
printer_config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
|
printer_config.opt<ConfigOptionFloats>("nozzle_diameter", true)->values.assign(4, 0.4);
|
|
|
|
auto make_full_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.full = true;
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
auto make_mix_entry = [](int slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
entry.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
return entry;
|
|
};
|
|
|
|
// The dialog's emit order: the full-published physical slots (1, 2, 5, 6) and both mixes.
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(4), make_full_entry(5),
|
|
make_mix_entry(6, "#800080"), make_mix_entry(7, "#804000") };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grew to the author's slot count, all virtual territory at the tail.
|
|
REQUIRE(bundle.filament_presets.size() == 8);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 8);
|
|
// Slots 0-3 stayed physical; authored slots 5 and 6 (0-based 4 and 5) became capacity
|
|
// placeholders.
|
|
for (size_t i = 0; i < 4; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[4]);
|
|
CHECK(is_mixed[5]);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[4].empty());
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[5].empty());
|
|
// The first mix applied onto its uncontended tail slot.
|
|
CHECK(is_mixed[6]);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[6] == "1,2");
|
|
// The second mix was rejected - its second component (authored slot 6) turned into a
|
|
// placeholder - and its slot was finalized as an empty mixed placeholder instead of
|
|
// keeping the seeded preset as a phantom real filament.
|
|
CHECK(is_mixed[7]);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[7].empty());
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[7].empty());
|
|
REQUIRE(pub.skipped_keys.size() == 1);
|
|
CHECK(pub.skipped_keys[0] == "material:PLA (mixed filament definition: components reference missing slots)");
|
|
// Two Full Publish detach lines (slots 0-1), two capacity placeholder lines (slots 4-5),
|
|
// and the rejected mix's finalization line (slot 7).
|
|
REQUIRE(pub.material_replacements.size() == 5);
|
|
CHECK(std::any_of(pub.material_replacements.begin(), pub.material_replacements.end(),
|
|
[](const std::string &line) {
|
|
return line.find("slot 7: mixed filament definition could not be imported") != std::string::npos;
|
|
}));
|
|
}
|
|
|
|
// The receiver must not overflow its physical capacity when it grows slots to reach a published
|
|
// mixed definition: an unpublished mixed slot that lands as a gap past the nozzle count becomes
|
|
// an empty mixed placeholder, not a physical filament. An author with six physical slots (0-5)
|
|
// and two tail mixes (slots 6 and 7) publishes only 0-5 and 7; the receiver has four nozzles.
|
|
// Slots 4 and 5 become surplus placeholders, slot 7 keeps its authored mix position, and the
|
|
// unpublished gap slot 6 is finalized as a virtual placeholder - never a fifth physical slot.
|
|
TEST_CASE("Published 3MF turns an unpublished gap slot past the printer's capacity into an empty mixed placeholder", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets.assign(4, "My PLA");
|
|
bundle.set_num_filaments(4, "#123456");
|
|
auto &printer_config = bundle.printers.get_edited_preset().config;
|
|
printer_config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
|
printer_config.opt<ConfigOptionFloats>("nozzle_diameter", true)->values.assign(4, 0.4);
|
|
|
|
// 8 authored slots: 0-5 physical, 6 unpublished mixed, 7 published mixed. The payload masks
|
|
// the unpublished slot's mixed flag (filter_published_config), so slot 6 reads as physical.
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = std::vector<double>(8, 1.75);
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = std::vector<std::string>(8, "Direct Drive Standard");
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00",
|
|
"#FF00FF", "#00FFFF", "#800080", "#804000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values.assign(8, "PLA");
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values.assign(8, "Generic");
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "", "", "", "", "", "1,2" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "", "0.5,0.5" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = std::vector<unsigned char>(8, 0);
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values.assign(8, "");
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values.assign(8, "");
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = std::vector<unsigned char>(8, 0);
|
|
|
|
auto make_full_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.full = true;
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
auto make_mix_entry = [](int slot, const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.filament_type = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
entry.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
return entry;
|
|
};
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(2), make_full_entry(3),
|
|
make_full_entry(4), make_full_entry(5), make_mix_entry(7, "#804000") };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 8);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 8);
|
|
// Slots 0-3 stay physical; 4 and 5 are surplus placeholders; 6 is the unpublished gap; 7 is
|
|
// the published mix. All four tail slots are virtual.
|
|
for (size_t i = 0; i < 4; ++i)
|
|
CHECK_FALSE(is_mixed[i]);
|
|
CHECK(is_mixed[4]);
|
|
CHECK(is_mixed[5]);
|
|
CHECK(is_mixed[6]);
|
|
CHECK(is_mixed[7]);
|
|
// Surplus physical slots (4,5) and the unpublished gap (6) carry no definition; the
|
|
// published mix on slot 7 keeps its own.
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 8);
|
|
CHECK(components[4].empty());
|
|
CHECK(components[5].empty());
|
|
CHECK(components[6].empty());
|
|
CHECK(components[7] == "1,2");
|
|
// Exactly four physical slots remain (never a fifth past the nozzle count).
|
|
size_t physical_count = 0;
|
|
for (bool mixed : is_mixed)
|
|
if (!mixed)
|
|
++physical_count;
|
|
CHECK(physical_count == 4);
|
|
// The unpublished gap's conversion is surfaced through the post-import notice.
|
|
bool gap_reported = false;
|
|
for (const std::string &message : pub.material_replacements)
|
|
if (message.find("slot 6: unassigned mixed filament") != std::string::npos)
|
|
gap_reported = true;
|
|
CHECK(gap_reported);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The relocation shifts cells inside the file's per-slot mixed arrays; a payload too short to
|
|
// actually carry the definition degrades to empty cells, which the definition validation then
|
|
// reports - an empty mix must not ship as a virtual slot.
|
|
TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells are missing", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(5, "#123456");
|
|
const std::vector<std::string> receiver_colours =
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.filament_id = "GFL99";
|
|
mix.slot = 3; // authored slot 3; the receiver's five real slots occupy 0-4
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
// The file's mixed arrays only cover its single physical slot: the definition data for
|
|
// slot 3 does not exist in the payload.
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The mix was relocated past the physical territory...
|
|
REQUIRE(pub.mixed_slot_relocations.size() == 1);
|
|
CHECK(pub.mixed_slot_relocations.at(3) == 5);
|
|
REQUIRE(pub.material_replacements.size() == 2);
|
|
CHECK(pub.material_replacements[0].find("slot 3 -> slot 5") != std::string::npos);
|
|
// ...and the receiver grew to hold the destination slot, but the definition itself was
|
|
// rejected: the relocated cells degraded to empty defaults, the slot was finalized as an
|
|
// empty mixed placeholder (not a real filament), and both facts were reported.
|
|
REQUIRE(bundle.filament_presets.size() == 6);
|
|
CHECK(bundle.is_mixed_filament(5));
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[5].empty());
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[5].empty());
|
|
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)"));
|
|
CHECK(pub.material_replacements[1].find("slot 5: mixed filament definition could not be imported") != std::string::npos);
|
|
// The five real slots kept their colours.
|
|
CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(),
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.begin()));
|
|
}
|
|
|
|
// A grown published slot always repeats the receiver's last preset; it can only move to the
|
|
// published material's identity when a replacement is warranted (an aliased slot that would
|
|
// otherwise leak keys, or a type mismatch). The tier priority candidate_score uses is locked
|
|
// here: exact preset name > bare name > exact setting_id > exact filament_id > vendor+type,
|
|
// with the lower tiers reported as a substitute.
|
|
TEST_CASE("Published 3MF re-points an aliased grown slot's material by identity tiers", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 2;
|
|
entry.filament_type = "PLA";
|
|
|
|
SECTION("an exact preset name outranks the bare-name form")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA");
|
|
bare.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &exact = add_inmemory_preset(bundle.filaments, "Authored PLA @Vendor");
|
|
exact.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
entry.preset_name = "Authored PLA @Vendor";
|
|
entry.keys = { "filament_retraction_length" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
// The aliased grown slot is re-pointed at the exact-name preset; the bare-name and the
|
|
// receiver's own preset lose.
|
|
CHECK(bundle.filament_presets[2] == "Authored PLA @Vendor");
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA @Vendor");
|
|
}
|
|
|
|
SECTION("a bare name outranks an exact setting_id")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA");
|
|
bare.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA");
|
|
sid.config.opt_string("filament_type", 0u) = "PLA";
|
|
sid.setting_id = "SID123";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name
|
|
entry.setting_id = "SID123";
|
|
entry.keys = { "filament_retraction_length" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "Authored PLA");
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA");
|
|
}
|
|
|
|
SECTION("an exact setting_id outranks an exact filament_id")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA");
|
|
sid.config.opt_string("filament_type", 0u) = "PLA";
|
|
sid.setting_id = "SID123";
|
|
Preset &fid = add_inmemory_preset(bundle.filaments, "Ccc PLA");
|
|
fid.config.opt_string("filament_type", 0u) = "PLA";
|
|
fid.filament_id = "GFA00";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name
|
|
entry.setting_id = "SID123";
|
|
entry.filament_id = "GFA00";
|
|
entry.keys = { "filament_retraction_length" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "Bbb PLA");
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PLA");
|
|
}
|
|
|
|
SECTION("a vendor+type match is reported as a substitute")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
mine.config.opt_string("filament_type", 0u) = "PETG";
|
|
Preset &exact_vendor = add_inmemory_preset(bundle.filaments, "Aaa PLA");
|
|
exact_vendor.config.opt_string("filament_type", 0u) = "PLA";
|
|
exact_vendor.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
Preset &other_vendor = add_inmemory_preset(bundle.filaments, "Zzz PLA");
|
|
other_vendor.config.opt_string("filament_type", 0u) = "PLA";
|
|
other_vendor.config.opt_string("filament_vendor", 0u) = "Other";
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
entry.filament_vendor = "Generic"; // no name or id identity: the family tiers decide
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA"; // the grown slot seeds "My PETG" -> the gate reads a mismatch
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PETG");
|
|
// The same-vendor PLA outranks the type-only candidate...
|
|
CHECK(bundle.filament_presets[2] == "Aaa PLA");
|
|
// ...and since it is not an exact material match, the load says so.
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 2: My PETG -> Aaa PLA (substitute)");
|
|
}
|
|
}
|
|
|
|
// Structural keys (identity links like filament_ids / inherits / printer_settings_id) are
|
|
// never applied onto the receiver and never reported: a hand-crafted file listing them must
|
|
// not trigger the "could not be applied" warning, while unknown keys still do.
|
|
TEST_CASE("Published 3MF silently ignores structural keys in published_keys", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt_float("layer_height") = 0.28;
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
const std::vector<std::string> ids_before =
|
|
bundle.filaments.get_edited_preset().config.opt<ConfigOptionStrings>("filament_settings_id")->values;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "filament_ids", "inherits", "printer_settings_id", "layer_height", "not_a_setting" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The real setting applied...
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
// ...the structural keys were neither applied nor reported...
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "filament_ids"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "inherits"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "printer_settings_id"));
|
|
CHECK(bundle.filaments.get_edited_preset().config.opt<ConfigOptionStrings>("filament_settings_id")->values == ids_before);
|
|
// ...while an unknown key still reports.
|
|
CHECK(contains_key(pub.skipped_keys, "not_a_setting"));
|
|
}
|
|
|
|
// A whole-vector key requires the receiver's vector to have the same number of elements as the
|
|
// author's: pasting a 3-extruder list into a 2-extruder machine would overwrite the wrong
|
|
// elements, so the key is reported as skipped and the receiver keeps its own values.
|
|
TEST_CASE("Published 3MF skips a whole-vector key whose size does not match the receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt_float("layer_height") = 0.28;
|
|
// Author's wiping matrix sized for three extruders.
|
|
config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20., 30. }));
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
// Receiver sized for two extruders.
|
|
bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 40., 50. }));
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "wiping_volumes_extruders", "layer_height" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values,
|
|
{ 40., 50. });
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
|
|
// The matching scalar key still applied.
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6));
|
|
}
|
|
|
|
// The uniquify chain continues past the first suffix: with both "X" and "X (Published)" already
|
|
// present, the next imported copy of "X" lands as "X (Published 2)" and leaves the others alone.
|
|
TEST_CASE("Published 3MF uniquifies a second imported full material as (Published 2)", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA");
|
|
bare.config.opt_string("filament_type", 0u) = "PLA";
|
|
bare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &pub1 = add_inmemory_preset(bundle.filaments, "Generic PLA (Published)");
|
|
pub1.config.opt_string("filament_type", 0u) = "PLA";
|
|
pub1.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA (Published 2)");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA (Published 2)", false, true)
|
|
->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values,
|
|
{ 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)
|
|
->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values,
|
|
{ 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true)
|
|
->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values,
|
|
{ 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published 2)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A mixed filament's blended colour is a swatch for the project's colour strip only: it must
|
|
// never be written into the slot's (possibly shared) preset config, or every slot referencing
|
|
// that preset would turn into the blend colour.
|
|
TEST_CASE("Published 3MF never writes a mixed filament's blended colour into the slot's preset", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
bundle.set_num_filaments(3);
|
|
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = "1,1";
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5";
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.filament_id = "GFL99";
|
|
mix.slot = 2;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios", "filament_mixed_gradient",
|
|
"filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 1 };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The definition applied like-for-like onto the virtual slot...
|
|
CHECK(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.is_mixed_filament(2));
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] == "1,2");
|
|
// ...the blend colour landed in the project strip only...
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[2] == "#800080");
|
|
// ...and the shared preset kept its own colour: slots 0 and 1 render unchanged.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values ==
|
|
std::vector<std::string>{ "#123456" });
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// Duplicate entries for the same authored slot only occur in hand-crafted files (the dialog
|
|
// emits one entry per slot); the load's contract under that input is deterministic last-wins,
|
|
// not corruption.
|
|
TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#000000" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
auto make_entry = [](const char *color) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_color = true;
|
|
entry.color = color;
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry("#AA0000"), make_entry("#BB0000") };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The second entry won both the project strip and the slot's preset.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[0] == "#BB0000");
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values ==
|
|
std::vector<std::string>{ "#BB0000" });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)
|
|
->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values,
|
|
{ 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// normalize_filament_type maps "PLA High Speed" onto the canonical family "PLA" (a space-
|
|
// separated modifier is dropped) but leaves dash-separated composite types like "PA-CF" intact,
|
|
// and passes through unknown types and the empty string unchanged.
|
|
TEST_CASE("normalize_filament_type strips a space modifier but keeps dash types", "[Preset][Bundle][Published]")
|
|
{
|
|
CHECK(normalize_filament_type("PLA High Speed") == "PLA");
|
|
CHECK(normalize_filament_type("PA-CF") == "PA-CF");
|
|
CHECK(normalize_filament_type("PETG-CF") == "PETG-CF");
|
|
CHECK(normalize_filament_type("PLA") == "PLA");
|
|
CHECK(normalize_filament_type("ABC") == "ABC");
|
|
CHECK(normalize_filament_type("") == "");
|
|
}
|
|
|
|
// collect_dirty_settings_keys feeds the Publish dialog's pre-check: it must be the set union of
|
|
// the dirty options across the edited print, printer and filament presets.
|
|
TEST_CASE("collect_dirty_settings_keys unions the dirty settings from all three presets", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
// The edited preset is initialised as a copy of the selected (default) preset, so a single
|
|
// edit makes exactly that option dirty. deep_diff reports scalar keys by name but per-element
|
|
// vector keys as "key#<index>", so a vector edit surfaces as "key#0".
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.28;
|
|
bundle.filaments.get_edited_preset().config.opt<ConfigOptionStrings>("filament_type", true)->values = { "ABS" };
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.6 };
|
|
|
|
const std::vector<std::string> dirty = collect_dirty_settings_keys(bundle);
|
|
for (const char *key : { "layer_height", "filament_type#0", "nozzle_diameter#0" })
|
|
CHECK(contains_key(dirty, key));
|
|
}
|
|
|
|
// The publish denylist and the mixed-key list are single sources of truth for the import path:
|
|
// lock their members so a silent edit to either cannot drift away from the contract the import
|
|
// and export masks rely on.
|
|
TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export contract", "[Preset][Bundle][Published]")
|
|
{
|
|
const std::set<std::string>& structural = publish_structural_keys();
|
|
// Structural / inheritance keys must never be applied onto a receiver's presets.
|
|
for (const char *key : { "printer_settings_id", "filament_settings_id", "print_settings_id",
|
|
"compatible_printers", "compatible_prints", "compatible_printers_condition",
|
|
"compatible_prints_condition", "default_filament_profile", "default_print_profile",
|
|
"inherits", "extruder_count", "printer_model", "filament_ids" })
|
|
CHECK(structural.count(key) == 1);
|
|
// ...but a per-slot publishable material key is not structural.
|
|
CHECK(structural.count("filament_retraction_length") == 0);
|
|
CHECK(structural.count("filament_colour") == 0);
|
|
|
|
const std::set<std::string>& mixed = publish_mixed_keys();
|
|
CHECK(mixed == std::set<std::string>{
|
|
"filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios",
|
|
"filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part" });
|
|
// Mixed keys are project-level arrays, not material-preset keys, so none is structural.
|
|
for (const std::string &key : mixed)
|
|
CHECK(structural.count(key) == 0);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// data_dir() is a process-wide global that import_presets extracts into; scope it to the test.
|
|
struct ScopedDataDir
|
|
{
|
|
std::string previous = data_dir();
|
|
explicit ScopedDataDir(const fs::path &dir) { set_data_dir(dir.string()); }
|
|
~ScopedDataDir() { set_data_dir(previous); }
|
|
};
|
|
|
|
std::string read_file(const fs::path &file)
|
|
{
|
|
std::ifstream in(file.string(), std::ios::binary);
|
|
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
void write_zip(const fs::path &zip_file, const std::vector<std::pair<std::string, std::string>> &entries)
|
|
{
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
REQUIRE(open_zip_writer(&zip, zip_file.string()));
|
|
for (const auto &[name, content] : entries)
|
|
REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION));
|
|
REQUIRE(mz_zip_writer_finalize_archive(&zip));
|
|
REQUIRE(close_zip_writer(&zip));
|
|
}
|
|
|
|
bool any_filename_contains(const fs::path &root, const std::string &needle)
|
|
{
|
|
for (fs::recursive_directory_iterator it(root), end; it != end; ++it)
|
|
if (it->path().filename().string().find(needle) != std::string::npos)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Config import confines zip entries, preset names and bundle ids to the preset directory", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
const fs::path data_root = temp_dir.path() / "datadir";
|
|
const fs::path src_dir = temp_dir.path() / "src";
|
|
fs::create_directories(src_dir);
|
|
ScopedDataDir scoped_data_dir(data_root);
|
|
|
|
PresetBundle bundle;
|
|
AppConfig app_config;
|
|
const auto confirm = [](std::string const &) { return 1; };
|
|
const auto import = [&](const fs::path &file) {
|
|
std::vector<std::string> files{file.string()};
|
|
bundle.import_presets(files, confirm, ForwardCompatibilitySubstitutionRule::Disable, app_config);
|
|
return files;
|
|
};
|
|
|
|
const fs::path good_file = src_dir / "Good.json";
|
|
write_print_preset(bundle.prints.default_preset().config, good_file, "Good");
|
|
const std::string good_json = read_file(good_file);
|
|
|
|
// Four levels up from where import_presets writes (<datadir>/user/default/temp) is temp_dir
|
|
// itself, so anything that escapes lands where the scan below can see it.
|
|
const std::string up = "../../../../";
|
|
const std::string up_win = "..\\..\\..\\..\\";
|
|
|
|
SECTION("zip entry names with either separator are reduced to a basename") {
|
|
const fs::path zip = src_dir / "bundle.zip";
|
|
write_zip(zip, {{up + "zip-escape.json", "{}"}, {up_win + "zip-escape.json", "{}"}, {"presets/Good.json", good_json}});
|
|
import(zip);
|
|
CHECK(bundle.prints.find_preset("Good") != nullptr);
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "zip-escape"));
|
|
}
|
|
|
|
SECTION("a preset name that walks out of the preset directory is rejected") {
|
|
for (const std::string &name : {up + "name-escape", up_win + "name-escape"}) {
|
|
const fs::path file = src_dir / "escape.json";
|
|
write_print_preset(bundle.prints.default_preset().config, file, name);
|
|
CHECK(import(file).empty());
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "name-escape"));
|
|
}
|
|
}
|
|
|
|
SECTION("a bundle id that walks out of the bundle directory is rejected") {
|
|
const fs::path zip = src_dir / "bundle.zip";
|
|
write_zip(zip, {{BUNDLE_STRUCTURE_JSON_NAME, "{\"id\": \"" + up + "bundle-escape\"}"}, {"Good.json", good_json}});
|
|
CHECK(import(zip).empty());
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape"));
|
|
}
|
|
}
|