Fixes issue with mixed filament being loaded into a real slot

This commit is contained in:
Lam Wei Lun
2026-09-04 14:41:20 +08:00
parent bbb4681b32
commit c6ab725584
2 changed files with 354 additions and 4 deletions

View File

@@ -4759,6 +4759,98 @@ static void apply_mixed_config_relocations(DynamicPrintConfig&
}
}
// Relocate the per-slot cells of the receiver's OWN mixed-filament definitions (the ones that
// pre-existed in project_config) onto fresh tail slots, clearing each vacated source cell so an
// incoming published real filament can claim it. Unlike apply_mixed_config_relocations - which
// moves the incoming file's config and leaves sources alone - a displaced receiver mix must not
// keep its mixed flag in the physical region: the source slot becomes a physical slot, so its
// mixed flag and definition are reset, while its swatch colour and mapping travel with the
// definition to the tail slot. Reads come from a frozen snapshot so an earlier move's
// destination never overwrites a later move's still-unread source (sources sit in the physical
// region and destinations past it, so they cannot overlap, but the snapshot keeps the helper
// safe for any future reordering).
static void apply_receiver_mix_relocations(DynamicPrintConfig& config,
std::vector<std::vector<std::string>>& ams_multi_color_filment,
const std::vector<std::pair<size_t, size_t>>& moves)
{
if (moves.empty())
return;
auto move_bools = [&](const char* key, bool clear_source) {
ConfigOption* opt = config.optptr(key);
if (opt == nullptr)
return;
auto* live = static_cast<ConfigOptionBools*>(opt);
std::unique_ptr<ConfigOption> snapshot(opt->clone());
const auto* frozen = static_cast<const ConfigOptionBools*>(snapshot.get());
for (const auto [from, to] : moves) {
const bool cell = from < frozen->values.size() ? frozen->values[from] : false;
if (live->values.size() <= to)
live->values.resize(to + 1, false);
live->values[to] = cell;
if (clear_source && from < live->values.size())
live->values[from] = false;
}
};
auto move_strings = [&](const char* key, bool clear_source) {
ConfigOption* opt = config.optptr(key);
if (opt == nullptr)
return;
auto* live = static_cast<ConfigOptionStrings*>(opt);
std::unique_ptr<ConfigOption> snapshot(opt->clone());
const auto* frozen = static_cast<const ConfigOptionStrings*>(snapshot.get());
for (const auto [from, to] : moves) {
const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string();
if (live->values.size() <= to)
live->values.resize(to + 1, std::string{});
live->values[to] = cell;
if (clear_source && from < live->values.size())
live->values[from] = std::string{};
}
};
auto move_ints = [&](const char* key) {
ConfigOption* opt = config.optptr(key);
if (opt == nullptr)
return;
auto* live = static_cast<ConfigOptionInts*>(opt);
std::unique_ptr<ConfigOption> snapshot(opt->clone());
const auto* frozen = static_cast<const ConfigOptionInts*>(snapshot.get());
for (const auto [from, to] : moves) {
const int cell = from < frozen->values.size() ? frozen->values[from] : 0;
if (live->values.size() <= to)
live->values.resize(to + 1, 0);
live->values[to] = cell;
}
};
// Mixed-definition cells: move to the tail and clear the source - the vacated physical slot
// no longer holds a mix.
move_bools("filament_is_mixed", true);
move_strings("filament_mixed_components", true);
move_strings("filament_mixed_sublayer_ratios", true);
move_bools("filament_mixed_gradient", true);
move_strings("filament_mixed_gradient_range", true);
move_strings("filament_mixed_gradient_curve", true);
move_bools("filament_mixed_gradient_per_part", true);
// Swatch colour and mapping travel with the definition; the source colour is left for the
// incoming real's publish_color (or the slot's resolved preset) to fill in.
move_strings("filament_colour", false);
move_strings("filament_multi_colour", false);
move_strings("filament_colour_type", false);
move_ints("filament_map");
move_ints("filament_nozzle_map");
move_ints("filament_volume_map");
{
const std::vector<std::vector<std::string>> frozen = ams_multi_color_filment;
for (const auto [from, to] : moves) {
const std::vector<std::string> cell = from < frozen.size() ? frozen[from] : std::vector<std::string>();
if (ams_multi_color_filment.size() <= to)
ams_multi_color_filment.resize(to + 1, std::vector<std::string>{});
ams_multi_color_filment[to] = cell;
}
}
}
//convert the old filament preset to new one after split
static void convert_filament_preset_name(std::string& machine_name, std::string& filament_name)
@@ -5287,7 +5379,24 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
// counter preserving author order (dest = max(authored, next_free)). Appends past
// the extruder limit are dropped and reported. Physical entries past capacity join
// the same counter as mixed_placeholder empties the GUI flags for assignment.
size_t next_free_slot = this->filament_presets.size();
//
// The finished project keeps the physical-first invariant (see the sidebar's
// add_custom_filament: mixed slots always sit at the tail, physical slots packed
// first). That invariant must survive an import, so every receiver mixed slot that
// would end up inside (or ahead of) the incoming physical region is displaced to a
// fresh tail slot, and the tail allocator starts at the physical boundary rather
// than the receiver's slot count - otherwise a relocated mix can collide with a
// keep-placed new real slot.
size_t physical_boundary = this->num_physical_filaments();
for (const PublishedMaterialEntry& entry : published_config->material_keys)
// Mirror the payload-real keep-place decision below: a real keeps its authored
// slot when that slot already exists on the receiver, or lies within the
// printer's physical capacity. Both end up as physical slots at index
// entry.slot, so they bound the physical region even past the capacity.
if (entry.slot >= 0 && !is_mixed_definition(entry) &&
(size_t(entry.slot) < this->filament_presets.size() || size_t(entry.slot) < physical_capacity))
physical_boundary = std::max(physical_boundary, size_t(entry.slot) + 1);
size_t next_free_slot = std::max(physical_boundary, 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
@@ -5295,6 +5404,33 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
// onto consecutive slots, so incremental in-place shifts would overwrite a
// definition that has not been moved yet).
std::vector<std::pair<size_t, size_t>> mixed_moves;
// Receiver-mix relocations land in project_config, not the incoming config, so they
// get their own move list, applied below after the arrays grow. The swept set lets
// the payload loop below treat a displaced receiver mix as the physical slot it will
// become (a payload mix like-for-like overriding such a slot must itself relocate).
std::vector<std::pair<size_t, size_t>> receiver_mix_moves;
std::set<size_t> swept_mix_slots;
for (size_t slot = 0; slot < this->filament_presets.size(); ++slot) {
if (!this->is_mixed_filament(slot))
continue;
// Slot 0 is the receiver's base filament and is never a virtual mix; the
// sidebar's physical-first layout guarantees it, so never displace it.
if (slot == 0)
continue;
if (slot >= physical_boundary)
continue; // already lives in the tail region
const size_t dest = next_free_slot++;
swept_mix_slots.insert(slot);
receiver_mix_moves.emplace_back(slot, dest);
any_mixed_relocated = true;
// A payload mix authored at the same slot (a mix inside the physical region) is
// processed after this sweep and overrides its own destination below.
published_config->mixed_slot_relocations.insert_or_assign(int(slot), int(dest));
published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + " -> slot " +
std::to_string(dest) + ": mixed filament");
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated receiver mixed filament slot " << slot
<< " -> " << dest << " (physical-first rebalance)";
}
for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) {
PublishedMaterialEntry& entry = *entry_it;
if (entry.slot < 0) {
@@ -5310,7 +5446,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
// 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));
keep_place = this->is_mixed_filament(size_t(entry.slot)) && swept_mix_slots.count(size_t(entry.slot)) == 0;
else
keep_place = size_t(entry.slot) < this->filament_presets.size() ||
size_t(entry.slot) < physical_capacity;
@@ -5357,7 +5493,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
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->mixed_slot_relocations.insert_or_assign(authored_slot, entry.slot);
published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " +
std::to_string(entry.slot) + ": mixed filament");
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot
@@ -5374,7 +5510,7 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
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->mixed_slot_relocations.insert_or_assign(authored_slot, dest_slot);
}
published_config->material_replacements.emplace_back(
(dest_slot != authored_slot ?
@@ -5725,6 +5861,11 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool
// slots wrote to it; that compound case is not chased.)
const bool edited_survives_load = this->filament_presets.empty() ||
this->filament_presets.front() == this->filaments.get_edited_preset().name;
// Displaced receiver mixes (see the physical-first rebalance above): move their
// per-slot cells onto the grown tail slots and reset the vacated physical slots,
// so the incoming real filaments can claim them. Runs before mixed_final_slots is
// built, so the rebalanced layout is what the mix validation sees.
apply_receiver_mix_relocations(this->project_config, this->ams_multi_color_filment, receiver_mix_moves);
// Final layout for mix-definition validation: every slot that will hold a
// mixed definition once this load completes - the receiver's own virtual
// slots, each published mixed entry's final (possibly relocated) slot, and

View File

@@ -2980,6 +2980,215 @@ TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a phy
}
}
// A receiver that already owns a MIXED filament must keep the physical-first invariant after a
// published-3MF import: when incoming physical filaments would land on (or ahead of) the
// receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left
// interleaved with them (the R,M,R bug).
TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]")
{
// Build the receiver's tool-changer with three slots, the third being the receiver's own
// mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by
// hand, so a lower slot count than the printer's nozzle count is preserved on load - a
// non-SEMM tool-changer would top the preset list up to the nozzle count and shift the
// expected sizes (the rebalance logic under test is the same either way).
auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) {
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
pla.config.opt_string("filament_type", 0u) = "PLA";
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
bundle.set_num_filaments(3, "#123456");
bundle.printers.get_edited_preset().config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = true;
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = components;
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = ratios;
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[2] = "#800080";
bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values[2] = "#800080";
};
auto make_real_entry = [](int slot, const char *color) {
PublishedMaterialEntry entry;
entry.slot = slot;
entry.filament_type = "PLA";
entry.filament_vendor = "Generic";
entry.publish_color = true;
entry.color = color;
return entry;
};
// A four-physical author project with no mixed slots (colour publish only), as in the
// reported Ferrari reference file.
auto make_config_4_real = [] {
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
};
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
return config;
};
// [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M].
{
PresetBundle bundle;
make_receiver(bundle, "1,2", "0.5,0.5");
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"),
make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") };
DynamicPrintConfig config = make_config_4_real();
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
// The receiver grows by one extra virtual slot; the mix lands at the tail.
REQUIRE(bundle.filament_presets.size() == 5);
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
REQUIRE(is_mixed.size() == 5);
for (size_t i = 0; i < 4; ++i)
CHECK_FALSE(is_mixed[i]);
CHECK(is_mixed[4]);
// The definition travelled with its swatch colour; the vacated slot 2 became physical.
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 5);
CHECK(components[4] == "1,2");
CHECK(components[2].empty());
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
REQUIRE(colour.size() == 5);
CHECK(colour[4] == "#800080");
CHECK(colour[2] == "#FFFFFF");
CHECK(pub.mixed_slot_relocations.at(2) == 4);
bool relocated_reported = false;
for (const std::string &message : pub.material_replacements)
if (message.find("slot 2 -> slot 4") != std::string::npos &&
message.find("mixed filament") != std::string::npos)
relocated_reported = true;
CHECK(relocated_reported);
CHECK(pub.skipped_keys.empty());
}
// [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits
// ahead of the appended payload mix (slot 4), preserving physical-first tail ordering.
{
PresetBundle bundle;
make_receiver(bundle, "1,2", "0.5,0.5");
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
};
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#000000", "#0000FF", "#800080" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
// Authored slot 3 is a payload mixed definition blending slots 1 and 3.
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 0, 1 };
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "", "1,3" };
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" };
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 0, 1 };
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "", "0.9,0.1" };
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "", "0,0.1|1,0.9" };
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 1 };
PublishedMaterialEntry mix;
mix.slot = 3;
mix.filament_type = "PLA";
mix.publish_color = true;
mix.color = "#800080";
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios",
"filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve",
"filament_mixed_gradient_per_part" };
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"),
make_real_entry(2, "#0000FF"), mix };
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
REQUIRE(bundle.filament_presets.size() == 5);
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
REQUIRE(is_mixed.size() == 5);
CHECK_FALSE(is_mixed[0]);
CHECK_FALSE(is_mixed[1]);
CHECK_FALSE(is_mixed[2]);
CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first
CHECK(is_mixed[4]); // payload's mix, appended after
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 5);
CHECK(components[3] == "1,2");
CHECK(components[4] == "1,3");
REQUIRE(pub.mixed_slot_relocations.size() == 2);
CHECK(pub.mixed_slot_relocations.at(2) == 3);
CHECK(pub.mixed_slot_relocations.at(3) == 4);
CHECK(pub.skipped_keys.empty());
}
}
// Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance
// onto consecutive tail slots in index order (no cascade/overlap).
TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[Preset][Bundle][Published]")
{
PresetBundle bundle;
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
pla.config.opt_string("filament_type", 0u) = "PLA";
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
bundle.set_num_filaments(3, "#123456");
bundle.printers.get_edited_preset().config.opt<ConfigOptionBool>("single_extruder_multi_material", true)->value = true;
// Receiver: slot 1 and slot 2 are mixed.
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[1] = 1;
bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values[2] = 1;
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[1] = "1,2";
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values[2] = "1,3";
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5";
bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6";
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
};
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF" };
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA" };
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99" };
auto make_real_entry = [](int slot, const char *color) {
PublishedMaterialEntry entry;
entry.slot = slot;
entry.filament_type = "PLA";
entry.filament_vendor = "Generic";
entry.publish_color = true;
entry.color = color;
return entry;
};
PublishedConfig pub;
pub.published = true;
pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") };
Preset::normalize(config);
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
REQUIRE(bundle.filament_presets.size() == 5);
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
REQUIRE(is_mixed.size() == 5);
for (size_t i = 0; i < 3; ++i)
CHECK_FALSE(is_mixed[i]);
CHECK(is_mixed[3]);
CHECK(is_mixed[4]);
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
REQUIRE(components.size() == 5);
CHECK(components[3] == "1,2");
CHECK(components[4] == "1,3");
REQUIRE(pub.mixed_slot_relocations.size() == 2);
CHECK(pub.mixed_slot_relocations.at(1) == 3);
CHECK(pub.mixed_slot_relocations.at(2) == 4);
CHECK(pub.skipped_keys.empty());
}
// The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a
// non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle
// count cannot become a physical filament. It becomes an empty mixed-filament placeholder