Initial Commit for Publish Settings workflow

This commit is contained in:
Lam Wei Lun
2026-08-14 10:49:49 +08:00
parent 6dbdb1d07e
commit b280ab555c
20 changed files with 2897 additions and 243 deletions
+177
View File
@@ -11,6 +11,8 @@
#include "test_utils.hpp"
#include <nlohmann/json.hpp>
#include <boost/filesystem/operations.hpp>
#include <catch2/catch_tostring.hpp>
@@ -497,3 +499,178 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") {
delete plate;
}
}
// The "Publish" feature stores a published flag plus a JSON array of author-selected setting keys
// in model.model_info->metadata_items. This locks the serialization contract: both keys must survive
// a store_bbs_3mf -> load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior —
// keeping the user's currently-selected presets and overlaying only the published keys onto them —
// is exercised headlessly in "Published 3MF overlays only the author-selected process keys onto the
// edited preset" in test_preset_bundle_loading.cpp.)
SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") {
GIVEN("a model carrying published metadata") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items["published"] = "1";
model.model_info->metadata_items["published_keys"] = R"(["layer_height","wall_thickness"])";
// store_bbs_3mf stages Metadata/project_settings.config through the model's backup path;
// point it at a writable temp dir (the default lives under a read-only root in CI).
ScopedTemporaryDir backup_dir("orca_pub");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the published metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items["published"] == "1");
REQUIRE(dst_model.model_info->metadata_items["published_keys"] == R"(["layer_height","wall_thickness"])");
// The published_keys value is a JSON array of setting keys; it must parse back to
// the same keys that were selected.
nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items["published_keys"]);
REQUIRE(keys.is_array());
REQUIRE(keys.size() == 2);
REQUIRE(keys[0] == "layer_height");
REQUIRE(keys[1] == "wall_thickness");
}
release_PlateData_list(dst_plates);
}
}
}
// A project saved without the Publish metadata (i.e. a normal 3MF) must load identically: the
// loader must not fabricate a "published" flag or published_keys for files that never carried them.
SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") {
GIVEN("a model without any published metadata") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
ScopedTemporaryDir backup_dir("orca_legacy");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("no published key is fabricated") {
REQUIRE(loaded);
if (dst_model.model_info != nullptr) {
REQUIRE(dst_model.model_info->metadata_items.count("published") == 0);
REQUIRE(dst_model.model_info->metadata_items.count("published_keys") == 0);
}
}
release_PlateData_list(dst_plates);
}
}
}
// The "Publish" feature can also store material-qualified setting keys, one entry per material
// the author uses, in model.model_info->metadata_items. This locks the serialization contract
// for that entry list: the JSON must survive a store_bbs_3mf -> load_bbs_3mf round-trip
// verbatim, exactly like the plain published_keys array.
SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") {
GIVEN("a model carrying published material keys metadata") {
Model model;
std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl";
REQUIRE(load_stl(src_file.c_str(), &model));
model.add_default_instances();
const std::string material_keys_json =
R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])";
model.model_info = std::make_shared<ModelInfo>();
model.model_info->metadata_items["published_material_keys"] = material_keys_json;
ScopedTemporaryDir backup_dir("orca_pub_mat");
model.set_backup_path(backup_dir.string());
WHEN("stored to and reloaded from a .3mf") {
ScopedTemporaryFile temp(".3mf");
const std::string test_file = temp.string();
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = test_file.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
Model dst_model;
DynamicPrintConfig dst_config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs dst_plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false, is_orca_3mf = false;
Semver file_version;
bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
THEN("the published material keys metadata round-trips unchanged") {
REQUIRE(loaded);
REQUIRE(dst_model.model_info != nullptr);
REQUIRE(dst_model.model_info->metadata_items["published_material_keys"] == material_keys_json);
// The value must parse back to one material entry carrying the nested identity
// object, the author slot ordinal and the key list, so the loader can match it
// to the receiver's filaments.
nlohmann::json entries = nlohmann::json::parse(material_keys_json);
REQUIRE(entries.is_array());
REQUIRE(entries.size() == 1);
REQUIRE(entries[0]["material"]["filament_type"] == "PLA");
REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic");
REQUIRE(entries[0]["material"]["filament_id"] == "GFL99");
REQUIRE(entries[0]["slot"] == 0);
REQUIRE(entries[0]["keys"].is_array());
REQUIRE(entries[0]["keys"].size() == 2);
REQUIRE(entries[0]["keys"][0] == "filament_retraction_length");
}
release_PlateData_list(dst_plates);
}
}
}
@@ -7,12 +7,20 @@
#include "test_utils.hpp"
#include <algorithm>
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 write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
{
DynamicPrintConfig config(default_config);
@@ -540,3 +548,530 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
}
// 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
// (src/slic3r/GUI/Plater.cpp): Preset::normalize before load_config_model, then the
// published overlay in PresetBundle::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 ("Invalid configuration file"); a 3mf always carries it.
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
// Process scalar key.
config.opt_float("layer_height") = 0.28;
// Process vector key, size 2 to match the edited preset's resized vector.
config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 140., 150. };
// Process vector key, size 2: deliberately mismatched against the edited preset.
config.opt<ConfigOptionStrings>("post_process")->values = { "script-a", "script-b" };
// A filament key: published files may still carry legacy filament keys.
config.opt<ConfigOptionInts>("nozzle_temperature")->values = { 220 };
// A structural (denylisted) key: must be silently ignored even if a hand-crafted
// file lists it as published. full_print_config() omits the *_settings_id keys (they
// have no static counterpart), while a real 3mf project config carries it, so create
// it explicitly.
config.opt_string("print_settings_id", true) = "file process";
// Project-level filament/purge data: must NOT cross over in published mode.
config.opt<ConfigOptionFloats>("flush_multiplier")->values = { 2., 2. };
// A project-level option, to pin the project_config.apply_only() invariant.
config.opt<ConfigOptionFloats>("wipe_tower_x")->values = { 100. };
// The author's bed type must NOT cross over either: the receiver keeps its own.
config.option("curr_bed_type")->setInt(BedType::btPC);
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; the assertions below check that the
// published 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; do the same so only the production path is exercised.
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) The process scalar is overlaid onto the edited preset.
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
// b) A matching-size process vector is applied; a size-mismatched one is neither applied
// nor reported as skipped by accident — it lands in skipped_keys.
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values == std::vector<double>{ 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"));
// c) A filament key is never applied anywhere and is reported as skipped (warning).
CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr);
CHECK(contains_key(pub.skipped_keys, "nozzle_temperature"));
// d) 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"));
// Applied keys are not reported as skipped.
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
// e) project_config.apply_only() still runs, but in published mode only the plate/bed
// geometry crosses: the file's filament/purge data must NOT port to the receiver.
// Filament colors do not port; project_config keeps its ctor-seeded values.
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values != std::vector<std::string>{ "#FF0000" });
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == seed_filament_colour);
// Purge data does not port either, and update_multi_material_filament_presets() cannot
// resurrect it (flush_multiplier stays at the ctor seed).
CHECK(bundle.project_config.opt<ConfigOptionFloats>("flush_multiplier")->values == seed_flush_multiplier);
// The author's bed type does not cross over: the receiver keeps its own.
CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type);
// Plate/bed geometry still crosses.
CHECK(bundle.project_config.opt<ConfigOptionFloats>("wipe_tower_x")->values == std::vector<double>{ 100. });
// The published path keeps the user's currently-selected presets: the edited process
// preset is the same preset as before the load.
CHECK(bundle.prints.get_edited_preset().name == pre_load_name);
CHECK(bundle.prints.size() == pre_load_size);
// f) Non-published control: with published=false the overlay is disabled. The file's
// presets are loaded and selected instead (the user's preset is not kept) 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 through the normal preset import,
// not through the published 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
// (publishable_printer_keys). Matching-size retraction vectors apply; mismatched vectors are
// reported as skipped; any other printer-class key (e.g. machine_start_gcode) is
// contract-excluded: never applied and 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);
// Matching size (the receiver's default printer has one extruder).
config.opt<ConfigOptionFloats>("retraction_length")->values = { 1.4 };
// Size 2: mismatched against the single-extruder receiver.
config.opt<ConfigOptionFloats>("retraction_speed")->values = { 45., 55. };
// Printer-class but outside the allowlist: must be silently contract-excluded.
config.opt_string("machine_start_gcode") = "G28 ; from file";
PresetBundle bundle;
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
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 to the edited printer preset.
CHECK(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values == std::vector<double>{ 1.4 });
// Mismatched vector not applied and reported as skipped.
CHECK(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values == std::vector<double>{ 30. });
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 can carry material-qualified keys; on load they are applied to the
// receiver's filament presets whose material identity matches the author's (filament_id when
// both sides have one, filament_type + vendor fallback otherwise).
TEST_CASE("Published 3MF applies material retraction keys onto the receiver's matching filament 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" };
// Author per-slot material identity (filament_ids feeds the loader's local copy).
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;
// Receiver materials with matching stable ids.
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";
// In-memory preset configs carry the per-filament retraction keys as nullable options
// (the type real filament presets hold), so access them through the nullable type.
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 };
// The z-hop key must exist on the receiver preset for the overlay to apply into it.
petg.config.opt<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.1 };
bundle.filament_presets = { "My PLA", "My PETG" };
PublishedMaterialEntry pla_entry;
pla_entry.filament_type = "PLA";
pla_entry.filament_vendor = "Generic";
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_type = "PETG";
petg_entry.filament_vendor = "Generic";
petg_entry.filament_id = "GFT99";
petg_entry.slot = 1; // the author's PETG slot
petg_entry.keys = { "filament_retraction_length", "filament_z_hop" };
// A material that does not exist on the author's side: whole entry skipped, no reporting.
PublishedMaterialEntry abs_entry;
abs_entry.filament_type = "ABS";
abs_entry.filament_id = "GFX99";
abs_entry.keys = { "filament_retraction_length" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { pla_entry, petg_entry, abs_entry };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
// Per-slot scalar copy: the author's slot value lands in the matching receiver preset.
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
CHECK(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 1.2 });
CHECK(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values == std::vector<double>{ 0.3 });
// Structural keys inside a material entry are silently ignored: the receiver's own
// filament_settings_id is left 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 unknown material entry produced no skipped entry.
CHECK(pub.skipped_keys.empty());
}
// Material-qualified keys whose receiver-side material match is missing or ambiguous must be
// reported as skipped (material-qualified) and never applied; a single unqualified type
// fallback still applies.
TEST_CASE("Published 3MF reports material keys with no unique receiver match as skipped", "[Preset][Bundle][Published]")
{
auto make_file_config = [] {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
// Three author slots: PLA, PETG, ABS.
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", "#00FF00", "#0000FF" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "ABS" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99", "GFA99" };
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2, 1.5 };
return config;
};
PresetBundle bundle;
// Two receiver presets of the SAME type with no filament_id: the type fallback is ambiguous.
Preset &pla_a = add_inmemory_preset(bundle.filaments, "My PLA A");
pla_a.config.opt_string("filament_type", 0u) = "PLA";
pla_a.config.opt_string("filament_vendor", 0u) = "Generic";
pla_a.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
Preset &pla_b = add_inmemory_preset(bundle.filaments, "My PLA B");
pla_b.config.opt_string("filament_type", 0u) = "PLA";
pla_b.config.opt_string("filament_vendor", 0u) = "Generic";
pla_b.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
// A unique PETG receiver preset: the single type fallback is unambiguous.
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
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 };
bundle.filament_presets = { "My PLA A", "My PLA B", "My PETG" };
auto make_entry = [](const std::string &type, const std::string &key) {
PublishedMaterialEntry entry;
entry.filament_type = type;
entry.filament_vendor = "Generic";
entry.keys = { key };
return entry;
};
PublishedMaterialEntry pla_entry = make_entry("PLA", "filament_retraction_length");
pla_entry.slot = 0; // the author's PLA slot
PublishedMaterialEntry petg_entry = make_entry("PETG", "filament_retraction_length");
petg_entry.slot = 1; // the author's PETG slot
PublishedMaterialEntry abs_entry = make_entry("ABS", "filament_retraction_length"); // author slot exists, no receiver match
abs_entry.slot = 2;
PublishedConfig pub;
pub.published = true;
pub.material_keys = { pla_entry, petg_entry, abs_entry };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
// Ambiguous type fallback: neither PLA preset is touched, reported as skipped.
CHECK(bundle.filaments.find_preset("My PLA A")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(bundle.filaments.find_preset("My PLA B")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(contains_key(pub.skipped_keys, "material:PLA (filament_retraction_length)"));
// Unambiguous single fallback: applied.
CHECK(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 1.2 });
CHECK_FALSE(contains_key(pub.skipped_keys, "material:PETG (filament_retraction_length)"));
// Receiver-side miss: the author slot exists but no receiver preset matches.
CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)"));
}
// A slotted material entry carries the author's per-slot overrides: on load it applies to the
// receiver's matching preset at the author's slot ordinal (first matching author slot -> first
// matching receiver preset, second -> second, ...). Legacy entries without a slot keep applying
// to every matching receiver preset.
TEST_CASE("Published material keys apply to the receiver's matching filament preset by author slot ordinal", "[Preset][Bundle][Published]")
{
auto make_file_config = [] {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
// Three author slots of the SAME material (PETG) with distinct per-slot retraction.
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", "#00FF00", "#0000FF" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PETG", "PETG", "PETG" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFT99", "GFT99", "GFT99" };
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.7, 0.8, 0.9 };
return config;
};
auto make_slotted_entry = [](int slot) {
PublishedMaterialEntry entry;
entry.filament_type = "PETG";
entry.filament_vendor = "Generic";
entry.filament_id = "GFT99";
entry.slot = slot;
entry.keys = { "filament_retraction_length" };
return entry;
};
auto add_petg_preset = [](PresetBundle &bundle, const std::string &name) {
Preset &preset = add_inmemory_preset(bundle.filaments, name);
preset.filament_id = "GFT99";
preset.config.opt_string("filament_type", 0u) = "PETG";
preset.config.opt_string("filament_vendor", 0u) = "Generic";
preset.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
return &preset;
};
// Three receiver presets, one per author slot: each gets its ordinal's value.
{
PresetBundle bundle;
add_petg_preset(bundle, "My PETG 1");
add_petg_preset(bundle, "My PETG 2");
add_petg_preset(bundle, "My PETG 3");
bundle.filament_presets = { "My PETG 1", "My PETG 2", "My PETG 3" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
// Each author slot's value lands in the receiver preset at the same ordinal.
CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.7 });
CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.8 });
CHECK(bundle.filaments.find_preset("My PETG 3")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.9 });
CHECK(pub.skipped_keys.empty());
}
// A single receiver preset: only the first ordinal fits; the later slots are reported
// with a slot-qualified label.
{
PresetBundle bundle;
add_petg_preset(bundle, "My PETG 1");
bundle.filament_presets = { "My PETG 1" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_slotted_entry(0), make_slotted_entry(1), make_slotted_entry(2) };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.7 });
CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 1 (filament_retraction_length)"));
CHECK(contains_key(pub.skipped_keys, "material:GFT99 slot 2 (filament_retraction_length)"));
CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFT99 slot 0 (filament_retraction_length)"));
}
// A legacy entry (no slot) applies to every matching receiver preset, from the first
// author slot.
{
PresetBundle bundle;
add_petg_preset(bundle, "My PETG 1");
add_petg_preset(bundle, "My PETG 2");
bundle.filament_presets = { "My PETG 1", "My PETG 2" };
PublishedMaterialEntry legacy = make_slotted_entry(0);
legacy.slot = -1; // legacy: no slot field
PublishedConfig pub;
pub.published = true;
pub.material_keys = { legacy };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.7 });
CHECK(bundle.filaments.find_preset("My PETG 2")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.7 });
CHECK(pub.skipped_keys.empty());
}
// An out-of-range author slot is silently skipped: nothing applied, nothing reported.
{
PresetBundle bundle;
add_petg_preset(bundle, "My PETG 1");
bundle.filament_presets = { "My PETG 1" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_slotted_entry(5) };
DynamicPrintConfig config = make_file_config();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
CHECK(bundle.filaments.find_preset("My PETG 1")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
CHECK(pub.skipped_keys.empty());
}
}
// The published overlay must validate '#' variant indices: an out-of-range index must be
// reported as skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix
// on a scalar key must be 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 so the
// applied element is 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", "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(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values == std::vector<double>{ 10., 150. });
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values.size() == 2);
// Out-of-range variant and variant-suffixed scalar are reported as skipped.
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5"));
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));
}
// A receiver filament preset whose material identity fields are missing (hand-edited preset
// file) must not crash the material pass: the entry simply cannot match and is reported skipped.
TEST_CASE("Published 3MF survives a receiver filament 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<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.keys = { "filament_retraction_length" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { entry };
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
// No match possible without the identity fields: reported skipped, preset untouched.
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)"));
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values == std::vector<double>{ 0.5 });
}
// The printer publishable allowlist is the union of the printer tab's "Retraction" and
// "Z-Hop" optgroup option lists; lock the exact contents and order (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);
}