From 3c37bf9ca48957152816bb8dc407cbafaf3765d1 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Fri, 14 Aug 2026 11:52:47 -0300 Subject: [PATCH] Import project --- src/libslic3r/PresetBundle.cpp | 109 +++++++++++----- src/libslic3r/PresetBundle.hpp | 3 + src/slic3r/GUI/GUI_App.cpp | 6 +- src/slic3r/GUI/Tab.cpp | 7 +- tests/libslic3r/test_3mf.cpp | 95 ++++++++++++++ .../libslic3r/test_preset_bundle_loading.cpp | 117 ++++++++++++++++-- 6 files changed, 293 insertions(+), 44 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index a71b5a9d18..3e23107102 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -2715,38 +2715,76 @@ void PresetBundle::load_installed_sla_materials(AppConfig &config) preset.set_visible_from_appconfig(config); } -// Restore the mixed-color filament metadata written by export_selections(). Every array is -// resized to the filament count so a project saved with a different filament count, or one -// predating these keys, still yields well-formed parallel arrays. -static void load_mixed_filament_settings(DynamicPrintConfig &project_config, AppConfig &config, - const std::string &printer_name, size_t n_filaments) +// Mixed-color filament metadata is project state, carried in the 3mf's project_settings.config. +// As in BambuStudio it also gets a single GLOBAL app-config snapshot, restored once at startup so +// the last session's mixes are there before any project is opened; a project load then overwrites +// them through s_project_options. It is deliberately not a per-printer snapshot: the component ids +// in filament_mixed_components are 1-based indices into the project's filament list, so re-applying +// a printer's copy on every printer change would silently replace a loaded project's mixes. +// Mirrors PresetBundle::load_selections in BambuStudio. +static void load_mixed_filament_settings(DynamicPrintConfig &project_config, const AppConfig &config, size_t n_filaments) { std::vector parts; - auto load_bools = [&](const char *key, const char *opt_key) { - auto &vals = project_config.option(opt_key)->values; - if (config.has_printer_setting(printer_name, key)) { - boost::algorithm::split(parts, config.get_printer_setting(printer_name, key), boost::algorithm::is_any_of(",")); + auto load_bools = [&](const char *key) { + auto &vals = project_config.option(key)->values; + if (config.has("presets", key)) { + boost::algorithm::split(parts, config.get("presets", key), boost::algorithm::is_any_of(",")); vals.clear(); for (const auto &p : parts) vals.push_back(p == "1"); } vals.resize(n_filaments, false); }; - auto load_strings = [&](const char *key, const char *opt_key) { - auto &vals = project_config.option(opt_key)->values; - if (config.has_printer_setting(printer_name, key)) { - boost::algorithm::split(parts, config.get_printer_setting(printer_name, key), boost::algorithm::is_any_of("|")); + auto load_strings = [&](const char *key) { + auto &vals = project_config.option(key)->values; + if (config.has("presets", key)) { + boost::algorithm::split(parts, config.get("presets", key), boost::algorithm::is_any_of("|")); vals = parts; } vals.resize(n_filaments, std::string{}); }; - load_bools("filament_is_mixed", "filament_is_mixed"); - load_strings("filament_mixed_components", "filament_mixed_components"); - load_strings("filament_mixed_sublayer_ratios", "filament_mixed_sublayer_ratios"); - load_bools("filament_mixed_gradient", "filament_mixed_gradient"); - load_strings("filament_mixed_gradient_range", "filament_mixed_gradient_range"); - load_strings("filament_mixed_gradient_curve", "filament_mixed_gradient_curve"); - load_bools("filament_mixed_gradient_per_part", "filament_mixed_gradient_per_part"); + load_bools("filament_is_mixed"); + load_strings("filament_mixed_components"); + load_strings("filament_mixed_sublayer_ratios"); + load_bools("filament_mixed_gradient"); + load_strings("filament_mixed_gradient_range"); + load_bools("filament_mixed_gradient_per_part"); + + // The gradient curve is the one array whose values contain '|' themselves (it separates the + // control points), so it is stored C-style escaped rather than '|'-joined. + { + auto &vals = project_config.option("filament_mixed_gradient_curve")->values; + if (config.has("presets", "filament_mixed_gradient_curve")) { + std::vector curves; + if (unescape_strings_cstyle(config.get("presets", "filament_mixed_gradient_curve"), curves)) + vals = std::move(curves); + } + vals.resize(n_filaments, std::string{}); + } +} + +// Orca's per-printer preset memory (update_selections, which BambuStudio has no equivalent of) +// rebuilds the filament list wholesale from that printer's snapshot, presets and colours included. +// Any existing mix then describes filaments that are no longer there, so clear the arrays and size +// them to the new filament count rather than carrying stale component indices across. +static void reset_mixed_filament_settings(DynamicPrintConfig &project_config, size_t n_filaments) +{ + auto reset_bools = [&](const char *opt_key) { + auto &vals = project_config.option(opt_key)->values; + vals.assign(n_filaments, false); + }; + auto reset_strings = [&](const char *opt_key) { + auto &vals = project_config.option(opt_key)->values; + vals.assign(n_filaments, std::string{}); + }; + + reset_bools("filament_is_mixed"); + reset_strings("filament_mixed_components"); + reset_strings("filament_mixed_sublayer_ratios"); + reset_bools("filament_mixed_gradient"); + reset_strings("filament_mixed_gradient_range"); + reset_strings("filament_mixed_gradient_curve"); + reset_bools("filament_mixed_gradient_per_part"); } void PresetBundle::update_selections(AppConfig &config) @@ -2829,7 +2867,7 @@ void PresetBundle::update_selections(AppConfig &config) auto flush_multipliers = matrix | boost::adaptors::transformed(boost::lexical_cast); project_config.option("flush_multiplier")->values = std::vector(flush_multipliers.begin(), flush_multipliers.end()); } - load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size()); + reset_mixed_filament_settings(project_config, filament_presets.size()); // Update visibility of presets based on their compatibility with the active printer. // Always try to select a compatible print and filament preset to the current printer preset, @@ -2980,7 +3018,7 @@ void PresetBundle::load_selections(AppConfig &config, const PresetPreferences& p auto flush_multipliers = matrix | boost::adaptors::transformed(boost::lexical_cast); project_config.option("flush_multiplier")->values = std::vector(flush_multipliers.begin(), flush_multipliers.end()); } - load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size()); + load_mixed_filament_settings(project_config, config, filament_presets.size()); // Update visibility of presets based on their compatibility with the active printer. // Always try to select a compatible print and filament preset to the current printer preset, @@ -3115,8 +3153,11 @@ void PresetBundle::export_selections(AppConfig &config) "|"); config.set_printer_setting(printer_name, "flush_multiplier", flush_multiplier_str); - // Mixed-color filament metadata. Bools are joined with ',' and strings with '|' because - // the component/ratio/curve strings themselves contain commas. + // Mixed-color filament metadata: a single global snapshot, restored by load_selections at + // startup (see the comment there). Written to the shared "presets" section rather than to this + // printer's settings on purpose — a per-printer copy is re-applied on every printer change and + // replaces a loaded project's mixes. Bools are ','-joined; the component/ratio/range strings + // are '|'-joined; the gradient curve is escaped instead, because its values contain '|'. auto join_bools = [](const std::vector &vals) { std::string s; for (size_t i = 0; i < vals.size(); ++i) { @@ -3126,19 +3167,19 @@ void PresetBundle::export_selections(AppConfig &config) return s; }; if (auto *opt = project_config.option("filament_is_mixed")) - config.set_printer_setting(printer_name, "filament_is_mixed", join_bools(opt->values)); + config.set("presets", "filament_is_mixed", join_bools(opt->values)); if (auto *opt = project_config.option("filament_mixed_components")) - config.set_printer_setting(printer_name, "filament_mixed_components", boost::algorithm::join(opt->values, "|")); + config.set("presets", "filament_mixed_components", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_sublayer_ratios")) - config.set_printer_setting(printer_name, "filament_mixed_sublayer_ratios", boost::algorithm::join(opt->values, "|")); + config.set("presets", "filament_mixed_sublayer_ratios", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_gradient")) - config.set_printer_setting(printer_name, "filament_mixed_gradient", join_bools(opt->values)); + config.set("presets", "filament_mixed_gradient", join_bools(opt->values)); if (auto *opt = project_config.option("filament_mixed_gradient_range")) - config.set_printer_setting(printer_name, "filament_mixed_gradient_range", boost::algorithm::join(opt->values, "|")); + config.set("presets", "filament_mixed_gradient_range", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_gradient_curve")) - config.set_printer_setting(printer_name, "filament_mixed_gradient_curve", boost::algorithm::join(opt->values, "|")); + config.set("presets", "filament_mixed_gradient_curve", escape_strings_cstyle(opt->values)); if (auto *opt = project_config.option("filament_mixed_gradient_per_part")) - config.set_printer_setting(printer_name, "filament_mixed_gradient_per_part", join_bools(opt->values)); + config.set("presets", "filament_mixed_gradient_per_part", join_bools(opt->values)); // BBS //config.set("presets", "sla_print", sla_prints.get_selected_preset_name()); @@ -3361,6 +3402,12 @@ bool PresetBundle::is_mixed_filament(size_t idx) const return opt && idx < opt->values.size() && opt->values[idx]; } +size_t PresetBundle::num_mixed_filaments() const +{ + auto *opt = project_config.option("filament_is_mixed"); + return opt == nullptr ? 0 : size_t(std::count(opt->values.begin(), opt->values.end(), true)); +} + std::vector PresetBundle::physical_filament_config_indices() const { std::vector indices; diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 7640d1ff8d..c3e7dd4441 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -500,6 +500,9 @@ public: // Mixed-color filament slots: virtual slots realized from 2-3 physical filaments. bool is_mixed_filament(size_t idx) const; std::vector physical_filament_config_indices() const; + // 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. + size_t num_mixed_filaments() const; void on_extruders_count_changed(int extruder_count); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 12e8500dbc..d14943c94a 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -8905,7 +8905,11 @@ void GUI_App::load_current_presets(bool active_preset_combox/*= false*/, bool ch if (printer_technology == ptFFF && !edited_printer_preset.config.opt_bool("single_extruder_multi_material")) { auto* nozzle_diameter = edited_printer_preset.config.option("nozzle_diameter"); if (nozzle_diameter) { - preset_bundle->set_num_filaments(nozzle_diameter->values.size()); + // 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 truncates them away — and this + // runs right after a project is loaded, so it would silently drop the project's mixes + // and then let update_extruder_count() strip every painted facet above the new count. + preset_bundle->set_num_filaments(nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments()); } } this->plater()->set_printer_technology(printer_technology); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 045ce9c43c..dc7ccb7284 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2182,8 +2182,11 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); new_colors.push_back(new_color); } - wxGetApp().preset_bundle->set_num_filaments(num_extruder, new_colors); - wxGetApp().plater()->on_filament_count_change(num_extruder); + // 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); } diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index c839149f5f..e09f664b7e 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1,5 +1,6 @@ #include "libslic3r/Model.hpp" +#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" @@ -497,3 +498,97 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { delete plate; } } + + +// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an +// ordinary extruder state — a project saved by BambuStudio encodes filament 5 of a 5-slot setup +// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays. This +// pins both halves of that contract at the .3mf layer: the project keys and the painted states +// must come back exactly as written. +SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") { + GIVEN("a painted model whose project config describes a mixed filament in the last slot") { + 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(); + + // Both the exporter and the importer stage Metadata/project_settings.config through the + // model's backup path; point them at writable temp dirs. + ScopedTemporaryDir backup_dir("orca_mixed_src"); + model.set_backup_path(backup_dir.string()); + + ModelVolume* mv = model.objects.front()->volumes.front(); + { + TriangleSelector selector(mv->mesh()); + selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot + selector.set_facet(1, EnforcerBlockerType::Extruder2); + REQUIRE(mv->mmu_segmentation_facets.set(selector)); + } + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_key_value("filament_colour", new ConfigOptionStrings( + { "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" })); + config.set_key_value("filament_is_mixed", new ConfigOptionBools( + { false, false, false, false, true })); + config.set_key_value("filament_mixed_components", new ConfigOptionStrings( + { "", "", "", "", "3,2" })); + config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings( + { "", "", "", "", "0.4200,0.5800" })); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + PlateData* plate = new PlateData(); + plate->plate_index = 0; + + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + store_params.plate_data_list.push_back(plate); + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + ScopedTemporaryDir dst_backup_dir("orca_mixed_dst"); + dst_model.set_backup_path(dst_backup_dir.string()); + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + + THEN("the mixed-filament project keys survive") { + auto* is_mixed = dst_config.option("filament_is_mixed"); + REQUIRE(is_mixed != nullptr); + REQUIRE(is_mixed->values == std::vector({ 0, 0, 0, 0, 1 })); + + auto* components = dst_config.option("filament_mixed_components"); + REQUIRE(components != nullptr); + REQUIRE(components->values.size() == 5); + REQUIRE(components->values[4] == "3,2"); + + auto* ratios = dst_config.option("filament_mixed_sublayer_ratios"); + REQUIRE(ratios != nullptr); + REQUIRE(ratios->values.size() == 5); + REQUIRE(ratios->values[4] == "0.4200,0.5800"); + } + + THEN("the painted facets survive, including the one painted with the mixed slot") { + REQUIRE(dst_model.objects.size() == 1); + ModelVolume* dst_mv = dst_model.objects.front()->volumes.front(); + REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty()); + REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2)); + REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5)); + } + + release_PlateData_list(dst_plates); + delete plate; // store_bbs_3mf does not take ownership of the source plate + } + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 5d9e60d6ef..47cf7d5c43 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -567,21 +567,25 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w } +namespace { + +const char *kMixedKeys[] = { + "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", +}; + +} // namespace + // Mixed-color filament metadata lives in project_config as parallel per-filament arrays. // set_num_filaments() is the single place that grows them alongside filament_colour; if it // misses them, creating a mixed slot writes past the end of the short arrays. TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]") { - static const char *kMixedKeys[] = { - "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", - }; - auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t { if (const auto *b = cfg.option(key)) return b->values.size(); @@ -609,3 +613,96 @@ TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament CHECK(mixed_array_size(bundle.project_config, key) == 1); } } + +// A mix is described by 1-based indices into the project's filament list, so it is only meaningful +// alongside that list. As in BambuStudio the app-config snapshot is global — one "last session" +// copy under the shared "presets" section, restored at startup only. A PER-PRINTER copy would be +// re-applied on every printer change and would replace a loaded project's mixes with whatever +// snapshot that printer last held, which also shrinks the filament count and makes reload_scene +// strip painted facets above it. +TEST_CASE("Mixed-color filament metadata is snapshotted globally, never per printer", "[Preset][Bundle][FilamentMixer]") +{ + PresetBundle bundle; + // export_selections skips the built-in "Default Printer" placeholder entirely. + add_inmemory_preset(bundle.printers, "Test Printer"); + bundle.printers.select_preset_by_name("Test Printer", true); + bundle.set_num_filaments(2u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = { false, true }; + bundle.project_config.option("filament_mixed_components")->values = { "", "1,2" }; + bundle.project_config.option("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" }; + + AppConfig app_config; + bundle.export_selections(app_config); + + const std::string printer_name = bundle.printers.get_selected_preset_name(); + for (const char *key : kMixedKeys) { + DYNAMIC_SECTION("global, not per printer: " << key) { + CHECK(app_config.has("presets", key)); + CHECK_FALSE(app_config.has_printer_setting(printer_name, key)); + } + } + + SECTION("with the encoding load_selections reads back") { + CHECK(app_config.get("presets", "filament_is_mixed") == "0,1"); + CHECK(app_config.get("presets", "filament_mixed_components") == "|1,2"); + CHECK(app_config.get("presets", "filament_mixed_sublayer_ratios") == "|0.5,0.5"); + } +} + +// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the +// control points — so it cannot be '|'-joined into the app config like its siblings without a +// multi-point curve being split across filament slots on the way back in. +TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]") +{ + const std::vector curves = { "", "", "0,0|0.5,0.3|1,1" }; + + PresetBundle bundle; + add_inmemory_preset(bundle.printers, "Test Printer"); + bundle.printers.select_preset_by_name("Test Printer", true); + bundle.set_num_filaments(3u, std::string("#FF0000")); + bundle.project_config.option("filament_mixed_gradient_curve")->values = curves; + + AppConfig app_config; + bundle.export_selections(app_config); + + // Decoding the stored form returns the three slots intact, curve delimiters and all. A plain + // '|' join would decode as five slots here instead of three. + std::vector decoded; + REQUIRE(unescape_strings_cstyle(app_config.get("presets", "filament_mixed_gradient_curve"), decoded)); + CHECK(decoded == curves); +} + +// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra +// virtual filaments at the tail of that list with no nozzle of their own, so the sync has to add +// them on top. Sizing to the nozzle count alone truncates them — and because that sync runs right +// after a project is loaded, it silently drops the project's mixes and then lets the filament-count +// change strip every painted facet above the new count. +TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]") +{ + // The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3. + const size_t nozzle_count = 4; + PresetBundle bundle; + bundle.set_num_filaments(5u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("filament_mixed_components")->values = + { "", "", "", "", "2,3" }; + + REQUIRE(bundle.num_mixed_filaments() == 1); + + SECTION("nozzle count plus the mixed slots preserves the mix") { + bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00")); + + CHECK(bundle.filament_presets.size() == 5); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(4)); + CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); + } + + SECTION("the nozzle count alone is what truncated it away") { + bundle.set_num_filaments(nozzle_count, std::string("#00FF00")); + + CHECK(bundle.filament_presets.size() == nozzle_count); + CHECK(bundle.num_mixed_filaments() == 0); + } +}