Keep mixed-color filaments intact when the extruder count changes (#15385)

* Keep mixed-color filaments intact when the extruder count changes

The extruder-count spinner resized the filament arrays in bulk at the tail,
which is where mixed-color slots live, so a new filament landed behind the
mix and the sidebar skipped a slot number. It now adds and removes one slot
at a time through the same calls the sidebar's +/- buttons use, so a new
slot opens ahead of the mixed tail and a removal renumbers object filament
ids, painted facets, custom g-code and mixed components rather than
clamping them away.

Drops the vector overload of set_num_filaments(), which this leaves without
callers.
This commit is contained in:
SoftFever
2026-08-26 19:06:33 +08:00
committed by GitHub
parent 9dc9b42475
commit 5552ed6cf1
6 changed files with 246 additions and 88 deletions
+20 -64
View File
@@ -3190,63 +3190,6 @@ void PresetBundle::export_selections(AppConfig &config)
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": printer %1%, print %2%, filaments[0] %3% ")%printers.get_selected_preset_name() % prints.get_selected_preset_name() %filament_presets[0]; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": printer %1%, print %2%, filaments[0] %3% ")%printers.get_selected_preset_name() % prints.get_selected_preset_name() %filament_presets[0];
} }
// BBS
void PresetBundle::set_num_filaments(unsigned int n, std::vector<std::string> new_colors) {
int old_filament_count = this->filament_presets.size();
if (n > old_filament_count && old_filament_count != 0)
filament_presets.resize(n, filament_presets.back());
else {
filament_presets.resize(n);
}
ConfigOptionStrings* filament_color = project_config.option<ConfigOptionStrings>("filament_colour");
ConfigOptionStrings *filament_multi_color = project_config.option<ConfigOptionStrings>("filament_multi_colour");
ConfigOptionStrings* filament_color_type = project_config.option<ConfigOptionStrings>("filament_colour_type");
ConfigOptionInts* filament_map = project_config.option<ConfigOptionInts>("filament_map");
ConfigOptionInts* filament_nozzle_map = project_config.option<ConfigOptionInts>("filament_nozzle_map");
ConfigOptionInts* filament_volume_map = project_config.option<ConfigOptionInts>("filament_volume_map");
filament_color->resize(n);
// Sync filament multi colour
filament_multi_color->values.resize(n);
for (size_t i = 0; i < n; i++) {
filament_multi_color->values[i] = filament_color->values[i];
}
filament_color_type->resize(n);
filament_map->values.resize(n, 1);
filament_nozzle_map->values.resize(n, 0);
filament_volume_map->values.resize(n, static_cast<int>(NozzleVolumeType::nvtStandard));
ams_multi_color_filment.resize(n);
// Mixed-color metadata is a parallel per-filament array set, so it has to grow and shrink
// with the filament count exactly like filament_colour above.
if (auto* opt = project_config.option<ConfigOptionBools>("filament_is_mixed"))
opt->values.resize(n, false);
if (auto* opt = project_config.option<ConfigOptionStrings>("filament_mixed_components"))
opt->values.resize(n, std::string{});
if (auto* opt = project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios"))
opt->values.resize(n, std::string{});
if (auto* opt = project_config.option<ConfigOptionBools>("filament_mixed_gradient"))
opt->values.resize(n, false);
if (auto* opt = project_config.option<ConfigOptionStrings>("filament_mixed_gradient_range"))
opt->values.resize(n, std::string{});
if (auto* opt = project_config.option<ConfigOptionStrings>("filament_mixed_gradient_curve"))
opt->values.resize(n, std::string{});
if (auto* opt = project_config.option<ConfigOptionBools>("filament_mixed_gradient_per_part"))
opt->values.resize(n, false);
// BBS set new filament color to new_color
if (old_filament_count < n) {
if (!new_colors.empty()) {
for (int i = old_filament_count; i < n; i++) {
filament_color->values[i] = new_colors[i - old_filament_count];
filament_multi_color->values[i] = new_colors[i - old_filament_count];
filament_color_type->values[i] = "1"; // default color type
}
}
}
update_multi_material_filament_presets();
}
void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) void PresetBundle::set_num_filaments(unsigned int n, std::string new_color)
{ {
unsigned old_filament_count = this->filament_presets.size(); unsigned old_filament_count = this->filament_presets.size();
@@ -3262,6 +3205,11 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color)
ConfigOptionInts* filament_nozzle_map = project_config.option<ConfigOptionInts>("filament_nozzle_map"); ConfigOptionInts* filament_nozzle_map = project_config.option<ConfigOptionInts>("filament_nozzle_map");
ConfigOptionInts* filament_volume_map = project_config.option<ConfigOptionInts>("filament_volume_map"); ConfigOptionInts* filament_volume_map = project_config.option<ConfigOptionInts>("filament_volume_map");
// Which slots are new is a fact about the arrays below, not about filament_presets:
// update_multi_material_filament_presets() tops that list up to the nozzle count on its own,
// so it can already sit at the new size while every array below is still at the old one.
const size_t old_slot_count = filament_color->values.size();
filament_color->resize(n); filament_color->resize(n);
// Sync filament multi colour // Sync filament multi colour
filament_multi_color->values.resize(n); filament_multi_color->values.resize(n);
@@ -3292,13 +3240,11 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color)
opt->values.resize(n, false); opt->values.resize(n, false);
//BBS set new filament color to new_color //BBS set new filament color to new_color
if (old_filament_count < n) { if (!new_color.empty()) {
if (!new_color.empty()) { for (size_t i = old_slot_count; i < n; i++) {
for (unsigned i = old_filament_count; i < n; i++) { filament_color->values[i] = new_color;
filament_color->values[i] = new_color; filament_multi_color->values[i] = new_color;
filament_multi_color->values[i] = new_color; filament_color_type->values[i] = "1"; // default color type
filament_color_type->values[i] = "1"; // default color type
}
} }
} }
@@ -3407,6 +3353,16 @@ size_t PresetBundle::num_mixed_filaments() const
return opt == nullptr ? 0 : size_t(std::count(opt->values.begin(), opt->values.end(), true)); return opt == nullptr ? 0 : size_t(std::count(opt->values.begin(), opt->values.end(), true));
} }
// Counted off the mixed flags, not filament_presets: that list is topped up to the nozzle count on
// its own, so it can sit a slot ahead of the arrays that describe slots. Unlike the sibling
// physical_filament_config_indices(), which bounds by filament_presets, this ignores that top-up.
size_t PresetBundle::num_physical_filaments() const
{
const auto *opt = project_config.option<ConfigOptionBools>("filament_is_mixed");
return opt == nullptr ? filament_presets.size()
: size_t(std::count(opt->values.begin(), opt->values.end(), false));
}
std::vector<size_t> PresetBundle::physical_filament_config_indices() const std::vector<size_t> PresetBundle::physical_filament_config_indices() const
{ {
std::vector<size_t> indices; std::vector<size_t> indices;
+5 -2
View File
@@ -326,8 +326,9 @@ public:
// Export selections (current print, current filaments, current printer) into config.ini // Export selections (current print, current filaments, current printer) into config.ini
void export_selections(AppConfig &config); void export_selections(AppConfig &config);
// BBS // n is the total slot count, and growth appends at the raw tail - which is where the mixed
void set_num_filaments(unsigned int n, std::vector<std::string> new_colors); // slots live. A caller adding physical filaments has to add num_mixed_filaments() on top and
// then move the new slots ahead of the mixed tail, as Sidebar::add_custom_filament does.
void set_num_filaments(unsigned int n, std::string new_col = ""); void set_num_filaments(unsigned int n, std::string new_col = "");
void update_num_filaments(unsigned int to_del_flament_id); void update_num_filaments(unsigned int to_del_flament_id);
@@ -503,6 +504,8 @@ public:
// How many slots are mixed. They sit at the tail of the filament list and have no nozzle of // How many slots are mixed. They sit at the tail of the filament list and have no nozzle of
// their own, so any resize driven by the printer's extruder count has to add this on top. // their own, so any resize driven by the printer's extruder count has to add this on top.
size_t num_mixed_filaments() const; size_t num_mixed_filaments() const;
// How many slots hold a real filament, i.e. everything ahead of the mixed tail.
size_t num_physical_filaments() const;
void on_extruders_count_changed(int extruder_count); void on_extruders_count_changed(int extruder_count);
+10 -4
View File
@@ -8906,10 +8906,16 @@ void GUI_App::load_current_presets(bool active_preset_combox/*= false*/, bool ch
auto* nozzle_diameter = edited_printer_preset.config.option<ConfigOptionFloats>("nozzle_diameter"); auto* nozzle_diameter = edited_printer_preset.config.option<ConfigOptionFloats>("nozzle_diameter");
if (nozzle_diameter) { if (nozzle_diameter) {
// Mixed-color slots are virtual filaments kept at the tail of the list, so they have no // Mixed-color slots are virtual filaments kept at the tail of the list, so they have no
// nozzle of their own. Sizing to the nozzle count alone would silently drop the mixes of // nozzle of their own and the count has to allow for them. Only ever grow: this sizes
// a just-loaded project, and update_extruder_count() would then strip the facets painted // the list so the combo boxes have something to bind to, and set_num_filaments() trims
// with them. // at the raw tail, so shrinking here would eat the mixes rather than the surplus
preset_bundle->set_num_filaments(nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments()); // physical slots. A list longer than the nozzle count is a state the app reaches
// legitimately - raising the extruder count and not saving the printer preset leaves
// exactly that on the next start - and losing the project's mixes to it is worse than
// carrying a filament the printer has no nozzle for until the count is next changed.
const size_t target = nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments();
if (target > preset_bundle->filament_presets.size())
preset_bundle->set_num_filaments(target);
} }
} }
this->plater()->set_printer_technology(printer_technology); this->plater()->set_printer_technology(printer_technology);
+7 -4
View File
@@ -5506,12 +5506,15 @@ void Sidebar::add_custom_filament(wxColour new_col, const std::string& preset_na
// Mixed-color slots are kept at the tail of the filament arrays, so a new physical // Mixed-color slots are kept at the tail of the filament arrays, so a new physical
// filament has to be inserted just after the last physical one rather than appended. // filament has to be inserted just after the last physical one rather than appended.
// total == every slot (physical + mixed); insert_pos == the physical slot count. // Count off filament_is_mixed, not filament_presets or the combos: the extruder-count spinner
size_t total = wxGetApp().preset_bundle->filament_presets.size(); // reaches this before the sidebar has rebuilt, and update_multi_material_filament_presets()
size_t insert_pos = p->combos_filament.size(); // can have grown filament_presets alone.
auto *bundle = wxGetApp().preset_bundle;
size_t insert_pos = bundle->num_physical_filaments();
size_t total = insert_pos + bundle->num_mixed_filaments();
int filament_count = (int)(total + 1); int filament_count = (int)(total + 1);
std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
wxGetApp().preset_bundle->set_num_filaments(filament_count, new_color); bundle->set_num_filaments(filament_count, new_color);
// Maintain physical-first ordering: rotate the new slot from end to insert_pos. // Maintain physical-first ordering: rotate the new slot from end to insert_pos.
// No mixed slots -> insert_pos == total -> every rotate below is a no-op. // No mixed slots -> insert_pos == total -> every rotate below is a no-op.
+18 -14
View File
@@ -2174,21 +2174,25 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
//Orca: sync filament num if it's a multi tool printer //Orca: sync filament num if it's a multi tool printer
if (opt_key == "extruders_count" && !m_config->opt_bool("single_extruder_multi_material")){ if (opt_key == "extruders_count" && !m_config->opt_bool("single_extruder_multi_material")){
auto num_extruder = boost::any_cast<size_t>(value); const size_t num_extruder = boost::any_cast<size_t>(value);
int old_filament_size = wxGetApp().preset_bundle->filament_presets.size(); auto *bundle = wxGetApp().preset_bundle;
std::vector<std::string> new_colors; Sidebar &sidebar = wxGetApp().plater()->sidebar();
for (int i = old_filament_size; i < num_extruder; ++i) { // A tool changer feeds filament N from nozzle N, so the extruder count sizes the physical
wxColour new_col = Plater::get_next_color_for_filament(); // run only; mixed slots are virtual and keep the tail. Go one slot at a time through the
std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); // sidebar's own +/- calls: they insert ahead of the mixed tail and renumber filament ids,
new_colors.push_back(new_color); // painted facets, custom g-code and mixed components, which a bulk resize clamps away.
// Both also refresh the print tab and export the selections, so nothing to do afterwards.
size_t physical = bundle->num_physical_filaments();
while (physical != num_extruder) {
if (physical < num_extruder)
sidebar.add_custom_filament(Plater::get_next_color_for_filament());
else
sidebar.delete_filament(physical - 1); // physical > num_extruder >= 1
const size_t updated = bundle->num_physical_filaments();
if (updated == physical)
break; // the call declined, e.g. the total slot limit - do not spin
physical = updated;
} }
// Mixed-color slots are virtual filaments at the tail of the list with no nozzle of their
// own, so they are carried on top of the new extruder count instead of being truncated.
const size_t total_filaments = num_extruder + wxGetApp().preset_bundle->num_mixed_filaments();
wxGetApp().preset_bundle->set_num_filaments(total_filaments, new_colors);
wxGetApp().plater()->on_filament_count_change(total_filaments);
wxGetApp().get_tab(Preset::TYPE_PRINT)->update();
wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config);
} }
//Orca: disable purge_in_prime_tower if single_extruder_multi_material is disabled //Orca: disable purge_in_prime_tower if single_extruder_multi_material is disabled
@@ -704,3 +704,189 @@ TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slo
CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_mixed_filaments() == 0);
} }
} }
// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on
// its own, so a physical count derived from that list reports a slot no per-filament array has
// yet. That is what made the extruder-count handler conclude there was nothing to add and leave
// the new sidebar combo with no colour to draw.
TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]")
{
PresetBundle bundle;
SECTION("no mixed slots") {
bundle.set_num_filaments(4u, std::string("#FF0000"));
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
CHECK(bundle.num_physical_filaments() == 4);
}
SECTION("behind a mixed tail") {
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 6);
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 5);
CHECK(bundle.num_physical_filaments() == 4);
CHECK(bundle.num_mixed_filaments() == 1);
}
}
// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the
// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour.
TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]")
{
PresetBundle bundle;
bundle.set_num_filaments(4u, std::string("#FF0000"));
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
bundle.update_multi_material_filament_presets();
REQUIRE(bundle.filament_presets.size() == 5);
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
// The call Sidebar::add_custom_filament makes once the extruder count opens a slot.
bundle.set_num_filaments(5u, std::string("#00FF00"));
const auto &colours = bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values;
REQUIRE(colours.size() == 5);
CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with
}
// The mixed-slot flags are written into the app config on exit and read back on the next start.
// If the read side loses them the slots survive as filaments but stop being mixes, so the project
// comes back with the mix showing as an ordinary physical filament.
TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]")
{
AppConfig app_config;
// Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail.
{
PresetBundle bundle;
add_inmemory_preset(bundle.printers, "Test Printer");
bundle.printers.select_preset_by_name("Test Printer", true);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.filaments.select_preset_by_name("Test Filament", true);
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.filament_presets.assign(5, "Test Filament");
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "2,3" };
bundle.export_selections(app_config);
REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1");
}
// This session.
PresetBundle bundle;
add_inmemory_preset(bundle.printers, "Test Printer");
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.load_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
}
// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose
// saved filament list is one longer than its nozzle count, because the extra slot is the mix.
TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]")
{
auto make_toolchanger = [](PresetBundle &bundle) -> Preset & {
Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer");
p.config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
p.config.option<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
return p;
};
AppConfig app_config;
{
PresetBundle bundle;
make_toolchanger(bundle);
bundle.printers.select_preset_by_name("Tool Changer", true);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.filaments.select_preset_by_name("Test Filament", true);
bundle.set_num_filaments(5u, std::string("#FF0000"));
bundle.filament_presets.assign(5, "Test Filament");
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "1,2" };
bundle.export_selections(app_config);
REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1");
}
PresetBundle bundle;
make_toolchanger(bundle);
add_inmemory_preset(bundle.filaments, "Test Filament");
bundle.load_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
SECTION("and through the GUI startup calls that follow it") {
// GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only.
const size_t target = 4u + bundle.num_mixed_filaments();
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.num_mixed_filaments() == 1);
// TabPrinter::extruders_count_changed.
bundle.on_extruders_count_changed(4);
CHECK(bundle.num_mixed_filaments() == 1);
// Tab::select_preset re-reads the snapshot when remember_printer_config is on.
bundle.update_selections(app_config);
CHECK(bundle.filament_presets.size() == 5);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(4));
}
}
// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes.
// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly
// where the mixes live, so applying the target to a longer list deletes them. A list longer than
// the target is reachable - raising the extruder count without saving the printer preset leaves
// the extra physical slot behind on the next start - so the startup sizing must only ever grow.
TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]")
{
// 5 physical + 1 mix, on a printer preset still reporting 4 nozzles.
const size_t nozzle_count = 4;
PresetBundle bundle;
bundle.set_num_filaments(6u, std::string("#FF0000"));
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
{ "", "", "", "", "", "1,2" };
REQUIRE(bundle.num_physical_filaments() == 5);
const size_t target = nozzle_count + bundle.num_mixed_filaments();
REQUIRE(target < bundle.filament_presets.size());
SECTION("applied as written, the mix is gone and every slot reads physical") {
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == target);
CHECK(bundle.num_mixed_filaments() == 0);
CHECK(bundle.num_physical_filaments() == target);
}
SECTION("applied as a floor, the mix is left alone") {
if (target > bundle.filament_presets.size())
bundle.set_num_filaments(target);
CHECK(bundle.filament_presets.size() == 6);
CHECK(bundle.num_mixed_filaments() == 1);
CHECK(bundle.is_mixed_filament(5));
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
}