Fixes mixed filament growth bug. Fixes unit test

This commit is contained in:
Lam Wei Lun
2026-09-02 14:29:15 +08:00
parent 4654d24f6f
commit f1719b5580
3 changed files with 329 additions and 69 deletions

View File

@@ -5595,6 +5595,12 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
for (const PublishedMaterialEntry& entry : published_config->material_keys)
if (entry.slot >= 0)
published_slots.insert(entry.slot);
// Grown slots the receiver creates that carry no published content of their own
// (e.g. an unpublished mixed slot left as a gap by a published mix's authored
// position) at or beyond the receiver's physical capacity. They must not become
// physical filaments (that would overflow the nozzle count): finalized as empty
// mixed placeholders below, like the surplus-material placeholders.
std::set<int> virtual_gap_slots;
// Exact-name resolution of each published slot's preset through the collection's
// own name machinery: find_preset2 follows renamed_from (vendor profile renames)
// and canonical bundle names, and auto-matches removed vendor-generic profiles
@@ -5665,6 +5671,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
while (this->filament_presets.size() < target_slots) {
const size_t new_slot_idx = this->filament_presets.size();
std::string initial_preset;
if (new_slot_idx >= physical_capacity && published_slots.count(static_cast<int>(new_slot_idx)) == 0)
// An unpublished grown slot past the printer's physical capacity cannot
// host a real filament: finalize it as a virtual placeholder below.
virtual_gap_slots.insert(static_cast<int>(new_slot_idx));
if (published_slots.count(static_cast<int>(new_slot_idx)) != 0) {
// Grow the slot the way the sidebar "add filament" does: seed it with
// the receiver's last preset.
@@ -5927,6 +5937,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
for (size_t i = 0; i < this->filament_presets.size(); ++i)
if (this->is_mixed_filament(i))
mixed_final_slots.insert(int(i));
// Unpublished gap slots past the capacity are virtual too: count them in the
// final layout so a published mix whose components collide with one is caught.
for (int gap_slot : virtual_gap_slots)
mixed_final_slots.insert(gap_slot);
const size_t mixed_final_slot_count = this->filament_presets.size();
// Finalize a slot as an empty mixed-filament placeholder: mark it virtual with
// an intentionally empty definition, and add it to the final-layout set so a
@@ -6320,6 +6334,17 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
apply_slot_keys(write_config, preset_keys, entry.slot, material_label);
}
}
// Unpublished gap slots past the printer's physical capacity: the receiver grew
// them only to reach a published definition, so they must not become physical
// filaments (that would overflow the nozzle count). Finalize each as an empty
// mixed placeholder, exactly like the surplus-material placeholders above.
for (int gap_slot : virtual_gap_slots)
if (!this->is_mixed_filament(size_t(gap_slot))) {
finalize_mixed_placeholder(size_t(gap_slot));
published_config->material_replacements.emplace_back(
"slot " + std::to_string(gap_slot) +
": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)");
}
}
}

View File

@@ -866,10 +866,12 @@ SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer ta
}
}
// A "full publish" entry carries the whole slot's key list: its vector options keep only the
// author's slot value, the other slots are masked to their defaults so a slot-1 full publish
// does not leak slot 0's data into the file.
SCENARIO("Full-publish entries filter the whole slot and mask the other slots", "[3mf]") {
// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot
// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a
// per-slot key) go through the same masking path in filter_published_config (keys and full_keys
// are filtered identically), so the two forms are exercised together.
SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") {
const bool full = GENERATE(true, false);
GIVEN("a full print configuration with two filament slots") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
@@ -878,45 +880,18 @@ SCENARIO("Full-publish entries filter the whole slot and mask the other slots",
// mask can restore it on the non-published slot.
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
PublishedMaterialEntry full_entry;
full_entry.slot = 1;
full_entry.full = true;
full_entry.full_keys = { "filament_flow_ratio" };
WHEN("filtering with a full entry for slot 1") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { full_entry });
THEN("the full key list is present with the author's slot value") {
REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr);
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
WHEN("filtering with a published entry for slot 1") {
PublishedMaterialEntry entry;
entry.slot = 1;
if (full) {
entry.full = true;
entry.full_keys = { "filament_flow_ratio" };
} else {
entry.keys = { "filament_flow_ratio" };
}
THEN("the non-published slot is masked to its default") {
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6));
}
THEN("the identity keys stay present") {
REQUIRE(filtered_cfg.option("filament_colour") != nullptr);
}
}
}
}
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry });
// A partial-publish entry (per-slot keys) is masked to the author's slot exactly like a full
// entry, so publishing one slot's retraction does not ship the other slots' values in the file.
SCENARIO("Partial-publish entries mask the other slots like full entries", "[3mf]") {
GIVEN("a full print configuration with two filament slots") {
DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config();
full_cfg.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
full_cfg.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222" };
full_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1.02, 0.98 };
PublishedMaterialEntry partial_entry;
partial_entry.slot = 1;
partial_entry.keys = { "filament_flow_ratio" };
WHEN("filtering with a partial entry for slot 1") {
DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry });
THEN("the partial key is present with the author's slot value") {
THEN("the selected key is present with the author's slot value") {
REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr);
REQUIRE_THAT(filtered_cfg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6));
}
@@ -1075,3 +1050,87 @@ SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]
}
}
// The published flag is gated on the exact string "1": any other serialized value means "not
// published", so a receiver never treats a file as published on a loose truthiness check.
TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") {
CHECK(is_published_3mf_flag("1"));
CHECK_FALSE(is_published_3mf_flag("0"));
CHECK_FALSE(is_published_3mf_flag("false"));
CHECK_FALSE(is_published_3mf_flag("true"));
CHECK_FALSE(is_published_3mf_flag(""));
CHECK_FALSE(is_published_3mf_flag("YES"));
}
// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was
// produced by the publish feature (GUI "recently published" tracking). It must return true only
// for a file whose metadata carries the flag set to "1", and false for legacy files and for a
// file whose flag is present but not "1" (which loads as a normal, non-published 3MF).
SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") {
auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) {
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>();
// An empty flag_value means "don't write the flag at all" (a legacy file).
if (!flag_value.empty())
model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value;
model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value;
ScopedTemporaryDir backup_dir("orca_is_pub");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
StoreParams store_params;
store_params.path = path.c_str();
store_params.model = &model;
store_params.config = &config;
store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
REQUIRE(store_bbs_3mf(store_params));
};
GIVEN("a minimal published 3MF whose flag is \"1\"") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "1", R"(["layer_height"])");
WHEN("probed by bbs_3mf_is_published") {
THEN("it is recognized as published") {
CHECK(bbs_3mf_is_published(temp.string()));
}
}
}
GIVEN("a legacy 3MF without any published flag") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "", R"(["layer_height"])");
WHEN("probed by bbs_3mf_is_published") {
THEN("it is not recognized as published") {
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
}
}
}
GIVEN("a 3MF carrying the flag set to \"0\"") {
ScopedTemporaryFile temp(".3mf");
store_model(temp.string(), "0", R"(["layer_height"])");
WHEN("probed and loaded") {
THEN("it is not recognized as published") {
CHECK_FALSE(bbs_3mf_is_published(temp.string()));
}
THEN("it loads as a normal, non-published 3MF") {
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;
REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates,
&project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig));
REQUIRE(dst_model.model_info != nullptr);
// The key is present but not "1", so nothing treats the file as published; the
// stored keys still round-trip verbatim.
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0");
REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])");
release_PlateData_list(dst_plates);
}
}
}
}

View File

@@ -1212,13 +1212,14 @@ TEST_CASE("Published 3MF imports a full material under the author's stripped nam
REQUIRE(bundle.filament_presets.size() == 2);
CHECK(bundle.filament_presets[0] == "My PETG");
// The grown slot lands on a freshly created copy carrying the author's slot-1 value;
// the library preset that seeded it stays untouched.
// 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: Generic PLA @System -> Generic PLA");
CHECK(pub.material_replacements[0] == "slot 1: My PETG -> Generic PLA");
CHECK(pub.skipped_keys.empty());
}
}
@@ -1922,7 +1923,7 @@ TEST_CASE("Published 3MF grows the receiver's slots only as far as the published
// 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 seeds published slots from unused presets and mutates them in place", "[Preset][Bundle][Published]")
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();
@@ -1937,8 +1938,9 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t
};
// Receiver with its own material plus one more library preset; author publishes only slot 4
// (Red). The grown slot is seeded from the unused library preset, so the published red
// recolors that preset in place and never the receiver's own material.
// (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";
@@ -1960,14 +1962,14 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
REQUIRE(bundle.filament_presets.size() == 4);
// Unpublished filler slots repeat the receiver's last preset ("Add one filament").
// 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 slot was seeded from the unused library preset; the published colour was
// written onto it in place, never onto the receiver's own material.
CHECK(bundle.filament_presets[3] == "Other PLA");
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
// 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");
@@ -1977,10 +1979,11 @@ TEST_CASE("Published 3MF seeds published slots from unused presets and mutates t
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 4);
}
// Without a checked Type row, a grown published slot is still seeded from the published
// material's identity - an exact filament_id outranks any arbitrary unused preset, and an
// entry carrying only a family constrains the pick to that family.
TEST_CASE("Published 3MF seeds a grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]")
// 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();
@@ -1998,6 +2001,7 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with
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");
@@ -2022,8 +2026,9 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with
REQUIRE(bundle.filament_presets.size() == 3);
CHECK(bundle.filament_presets[1] == "My PLA");
CHECK(bundle.filament_presets[2] == "Zzz PLA");
// An exact identity match is not a substitute, so nothing is reported.
CHECK(pub.material_replacements.empty());
// 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") {
@@ -2039,7 +2044,11 @@ TEST_CASE("Published 3MF seeds a grown slot by published identity or family with
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");
}
}
@@ -3797,6 +3806,103 @@ TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder
}));
}
// 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.
@@ -3859,10 +3965,12 @@ TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.begin()));
}
// A grown slot's material is chosen by identity tiers: exact preset name, then the bare
// name/alias form, then exact setting_id, then exact filament_id, then vendor+type, then type
// only. Each section pits two adjacent tiers against each other.
TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Preset][Bundle][Published]")
// 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();
@@ -3894,6 +4002,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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 };
@@ -3903,8 +4012,11 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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");
CHECK(pub.material_replacements.empty());
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")
@@ -3921,6 +4033,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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 };
@@ -3931,6 +4044,8 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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")
@@ -3949,6 +4064,7 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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 };
@@ -3959,22 +4075,26 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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 PLA");
mine.config.opt_string("filament_type", 0u) = "PLA";
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 PLA" };
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 };
@@ -3983,12 +4103,12 @@ TEST_CASE("Published 3MF scores a grown slot's material by identity tiers", "[Pr
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[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: Aaa PLA (substitute)");
CHECK(pub.material_replacements[0] == "slot 2: My PETG -> Aaa PLA (substitute)");
}
}
@@ -4203,3 +4323,59 @@ TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Pr
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);
}