Fixes issue where user imports a 3MF file where filament slots exceeds the maximum number of slots that user has on its printer

This commit is contained in:
Lam Wei Lun
2026-08-31 10:46:23 +08:00
parent a62db72e02
commit 06517e623f
3 changed files with 446 additions and 40 deletions

View File

@@ -5407,6 +5407,11 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
// preset no other slot references wins on equal scores; with no replacement
// available the receiver's material is kept and the keys are reported as skipped;
// - colour: applied to the slot regardless of the type gate.
// - capacity: on a non-SEMM receiver whose printer has fewer nozzles than the
// authored slot needs, the entry becomes an empty mixed-filament placeholder
// appended at the tail (the GUI flags it; the user assigns components from their
// own filaments); on a single-physical-slot receiver it is dropped and reported
// instead, since an empty mix could never be edited there.
// Applied partial values land on the collection's edited layer when the slot references
// it and that layer survives the load (visible as a modification, revertible, the user's
// unsaved edits preserved), otherwise on the stored preset in place.
@@ -5417,11 +5422,38 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
// Grow the receiver's slots only as far as the highest published slot (never
// shrink, never pull filler materials for unpublished slots).
bool has_published_entries = false;
size_t grow_target = 0;
// Physical filament capacity of the receiver's printer: a non-SEMM tool-changer
// feeds filament N from nozzle N, so the nozzle count is the hard limit; a SEMM
// printer (single_extruder_multi_material) sizes its slot list by hand, so only
// the global slot limit applies (same condition as GUI_App::load_current_presets).
// Published entries that would need a NEW physical slot past this capacity are
// appended as empty mixed-filament placeholders instead of growing the list.
size_t physical_capacity = size_t(EnforcerBlockerType::ExtruderMax);
{
const Preset& receiver_printer = this->printers.get_edited_preset();
if (receiver_printer.printer_technology() == ptFFF &&
!receiver_printer.config.opt_bool("single_extruder_multi_material")) {
if (const auto* nozzle_diameter = receiver_printer.config.option<ConfigOptionFloats>("nozzle_diameter");
nozzle_diameter != nullptr && !nozzle_diameter->values.empty())
physical_capacity = nozzle_diameter->values.size();
}
}
const std::set<std::string>& mixed_definitions = publish_mixed_keys();
auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) {
return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) {
return mixed_definitions.count(publish_base_key(key)) != 0;
});
};
size_t grow_target = 0;
for (const PublishedMaterialEntry& entry : published_config->material_keys) {
has_published_entries = true;
if (entry.slot >= 0)
grow_target = std::max(grow_target, size_t(entry.slot) + 1);
if (entry.slot < 0)
continue;
// A physical entry past the capacity becomes a tail placeholder below; its
// growth is covered by the append counter, not the positional target.
if (!is_mixed_definition(entry) && size_t(entry.slot) >= physical_capacity)
continue;
grow_target = std::max(grow_target, size_t(entry.slot) + 1);
}
// Mixed-filament definitions live in project-level virtual slots, so applying one
// positionally onto a receiver slot that holds a real, physical filament would
@@ -5439,13 +5471,12 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
// from the new index. No existing slot changes meaning.
// - destinations are also capped: appends past the extruder limit are dropped
// and reported instead of being forced onto a physical filament.
const std::set<std::string>& mixed_definitions = publish_mixed_keys();
auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) {
return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) {
return mixed_definitions.count(publish_base_key(key)) != 0;
});
};
size_t next_free_slot = this->filament_presets.size();
// Physical entries past the printer's capacity join the same append counter
// (dest = next_free, packed consecutively at the tail - never max(authored,
// next_free), which would grow filler physical slots past the capacity) and are
// flagged mixed_placeholder: they become empty mixed-filament placeholders the
// GUI flags for the user to assign components to.
size_t next_free_slot = this->filament_presets.size();
bool any_mixed_relocated = false;
// All authored-slot -> destination moves decided by this pass, applied to the
// incoming config in one batched snapshot step below (an earlier move's
@@ -5455,42 +5486,95 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
std::vector<std::pair<size_t, size_t>> mixed_moves;
for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) {
PublishedMaterialEntry& entry = *entry_it;
if (entry.slot < 0 || !is_mixed_definition(entry) ||
// Like-for-like override of a virtual receiver slot (bounds-checked).
this->is_mixed_filament(size_t(entry.slot))) {
if (entry.slot < 0) {
++entry_it;
continue;
}
const bool is_payload_mix = is_mixed_definition(entry);
// Does this entry need a tail slot at all? A payload mixed definition does,
// except when it like-for-like overrides a receiver slot that is already a
// mix (bounds-checked). A physical entry only becomes a placeholder when it
// would need a NEW physical slot: an authored position the receiver's list
// already covers is applied positionally as before, even when that list sits
// above the printer's capacity (pre-existing state is never shrunk).
bool keep_place = false;
if (is_payload_mix)
keep_place = this->is_mixed_filament(size_t(entry.slot));
else
keep_place = size_t(entry.slot) < this->filament_presets.size() ||
size_t(entry.slot) < physical_capacity;
if (keep_place) {
++entry_it;
continue;
}
const std::string material_label = !entry.filament_id.empty() ? entry.filament_id :
!entry.publish_type_value.empty() ? entry.publish_type_value :
entry.filament_type;
if (std::max(size_t(entry.slot), next_free_slot) >= size_t(EnforcerBlockerType::ExtruderMax)) {
// No free virtual slot left: report instead of destroying a real filament.
const std::string material_label = !entry.filament_id.empty() ? entry.filament_id :
!entry.publish_type_value.empty() ? entry.publish_type_value :
entry.filament_type;
// The local skipped_keys is published wholesale at the end of the pass;
// writing published_config->skipped_keys here would be clobbered by it.
skipped_keys.emplace_back("material:" + material_label +
" (mixed filament definition: filament slot limit reached)");
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF mixed filament from slot " << entry.slot
<< " could not be placed: all " << next_free_slot << " slots exhausted";
skipped_keys.emplace_back("material:" + material_label + (is_payload_mix ?
" (mixed filament definition: filament slot limit reached)" :
" (filament slot limit reached)"));
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF " << (is_payload_mix ? "mixed filament definition" : "material")
<< " from slot " << entry.slot << " could not be placed: all " << next_free_slot
<< " slots exhausted";
entry_it = published_config->material_keys.erase(entry_it);
continue;
}
if (!is_payload_mix && physical_capacity < 2) {
// A single physical slot can never host a mixed-filament editor (the
// sidebar's mixed section needs two physical filaments to mix), so a
// placeholder would be invisible and unfixable: drop the entry and
// report it like any other unappliable input.
skipped_keys.emplace_back("material:" + material_label + " (printer supports only " +
std::to_string(physical_capacity) + " filament)");
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF material from slot " << entry.slot
<< " dropped: printer supports only " << physical_capacity << " filament";
entry_it = published_config->material_keys.erase(entry_it);
continue;
}
const int authored_slot = entry.slot;
const int dest_slot = int(std::max(size_t(authored_slot), next_free_slot));
const int dest_slot = is_payload_mix ? int(std::max(size_t(authored_slot), next_free_slot)) : int(next_free_slot);
next_free_slot = size_t(dest_slot) + 1;
if (dest_slot == authored_slot)
// Uncontended fresh tail slot: the definition is already readable there.
++entry_it;
else {
entry.slot = dest_slot;
if (is_payload_mix) {
if (dest_slot == authored_slot)
// Uncontended fresh tail slot: the definition is already readable there.
++entry_it;
else {
entry.slot = dest_slot;
any_mixed_relocated = true;
mixed_moves.emplace_back(size_t(authored_slot), size_t(entry.slot));
published_config->mixed_slot_relocations.emplace(authored_slot, entry.slot);
published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " +
std::to_string(entry.slot) +
": mixed filament relocated (would have replaced a physical filament)");
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot
<< " -> " << entry.slot;
++entry_it;
}
} else {
// Surplus published material beyond the printer's capacity: become an
// empty mixed-filament placeholder at the next free tail slot. The flag
// routes the entry to the placeholder finalize below; the slot's
// definition stays empty until the user assigns components.
entry.mixed_placeholder = true;
any_mixed_relocated = true;
mixed_moves.emplace_back(size_t(authored_slot), size_t(entry.slot));
published_config->mixed_slot_relocations.emplace(authored_slot, entry.slot);
published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " +
std::to_string(entry.slot) +
": mixed filament relocated (would have replaced a physical filament)");
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot
<< " -> " << entry.slot;
if (dest_slot != authored_slot) {
entry.slot = dest_slot;
mixed_moves.emplace_back(size_t(authored_slot), size_t(dest_slot));
published_config->mixed_slot_relocations.emplace(authored_slot, dest_slot);
}
published_config->material_replacements.emplace_back(
(dest_slot != authored_slot ?
"slot " + std::to_string(authored_slot) + " -> slot " + std::to_string(dest_slot) :
"slot " + std::to_string(dest_slot)) +
": " + material_label + " placed as an unassigned mixed filament (printer supports only " +
std::to_string(physical_capacity) + " filaments)");
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF material from slot " << authored_slot
<< " placed as an unassigned mixed filament at slot " << dest_slot
<< " (printer supports only " << physical_capacity << " filaments)";
++entry_it;
}
}
@@ -5501,9 +5585,10 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
// Defensive cap: growth never exceeds the file's own filament count. The
// receiver's current slot count is a floor: neither the preset list nor the
// project vectors are ever shrunk, even when the file carries fewer filaments
// than the receiver has slots. Relocated mixed entries legitimately land past
// the file's own slot count (virtual slots consume no nozzle or tray), so
// their destinations lift the ceiling explicitly.
// than the receiver has slots. Relocated mixed entries and capacity
// placeholders legitimately land past the file's own slot count (virtual
// slots consume no nozzle or tray), so their append destinations lift the
// ceiling explicitly.
const size_t target_slots = std::max({this->filament_presets.size(), std::min(grow_target, num_filaments),
any_mixed_relocated ? next_free_slot : size_t(0)});
// Slots carrying published content, steering the initial preset selection of
@@ -5884,12 +5969,14 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
this->filament_presets.front() == this->filaments.get_edited_preset().name;
// Final layout for mix-definition validation: every slot that will hold a
// mixed definition once this load completes - the receiver's own virtual
// slots plus each published mixed entry's final (possibly relocated) slot.
// Mix components are 1-based slot numbers, so a component is valid only
// when the slot it names exists and does not itself hold a mixed filament.
// slots, each published mixed entry's final (possibly relocated) slot, and
// each capacity placeholder (they become mixes in the finalize below, before
// this loop's is_mixed_filament scan would see them). Mix components are
// 1-based slot numbers, so a component is valid only when the slot it names
// exists and does not itself hold a mixed filament.
std::set<int> mixed_final_slots;
for (const PublishedMaterialEntry& mix_entry : published_config->material_keys)
if (mix_entry.slot >= 0 && is_mixed_definition(mix_entry))
if (mix_entry.slot >= 0 && (is_mixed_definition(mix_entry) || mix_entry.mixed_placeholder))
mixed_final_slots.insert(mix_entry.slot);
for (size_t i = 0; i < this->filament_presets.size(); ++i)
if (this->is_mixed_filament(i))
@@ -5903,6 +5990,22 @@ void PresetBundle::load_config_file_config(const std::string& name_or_path,
if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size())
continue; // out of range: nothing to do for this slot
const size_t slot = size_t(entry.slot);
// Surplus published material beyond the printer's capacity: finalize the
// tail placeholder - mark the slot virtual with an intentionally empty
// definition. The GUI flags the empty mix (check_mixed_filament_integrity)
// and blocks slicing until the user assigns components from their own
// filaments. The entry's keys are deliberately not applied and no preset
// is detached: the placeholder carries no material of its own. The colour
// was already seeded into the project arrays by the growth pass above.
if (entry.mixed_placeholder) {
if (ConfigOptionBools* is_mixed_opt = this->project_config.opt<ConfigOptionBools>("filament_is_mixed");
is_mixed_opt != nullptr && slot < is_mixed_opt->values.size())
is_mixed_opt->values[slot] = true;
material_applied = true;
continue;
}
// Resolve the stored preset itself (real=true), never the edited snapshot:
// find_preset would return &m_edited_preset for the selected slot. The
// overlay target below decides between the edited layer and this preset.

View File

@@ -80,6 +80,13 @@ struct PublishedMaterialEntry {
// Required filament colour, applied on load regardless of the type match.
bool publish_color{false};
std::string color;
// Import-side only, never serialized: the entry's authored slot sits past the receiver
// printer's physical filament capacity, so instead of growing a physical slot the entry
// is appended as an empty mixed-filament placeholder (virtual tail slot; the GUI flags
// it and the user assigns components from their own filaments). The flag also keeps the
// entry out of the payload mixed-definition validation and the value-apply passes,
// which only make sense for a slot that carries a real material.
bool mixed_placeholder{false};
};
// "PLA High Speed" -> "PLA" (strip a space modifier); dash types like "PA-CF" are kept intact.

View File

@@ -2932,6 +2932,302 @@ TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a phy
}
}
// 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.