From 790a01061545afee7009e0e0f8eaa3d28fc8b2bc Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 21 Aug 2026 14:40:35 +0800 Subject: [PATCH 1/4] feat: plater notification API for plugins --- src/slic3r/plugin/host/PluginHostUi.cpp | 103 ++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index c098ea3224..8fc3f12877 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -44,16 +46,20 @@ namespace { struct GilSafeCallable { py::object fn; + std::atomic_bool active{true}; explicit GilSafeCallable(py::object f) : fn(std::move(f)) {} + void disable() + { + active.store(false, std::memory_order_release); + PythonGILState gil; + if (gil) + fn = py::object(); + else + (void) fn.release(); + } ~GilSafeCallable() { - if (fn) { - PythonGILState gil; - if (gil) - fn = py::object(); - else - (void) fn.release(); - } + disable(); } }; using CallablePtr = std::shared_ptr; @@ -166,11 +172,34 @@ public: } return out; } + void bind_callback(const CallablePtr& callback, const std::string& plugin_key) + { + if (!callback) + return; + std::lock_guard lk(m_mtx); + m_callbacks[plugin_key].push_back(callback); + } + std::vector take_callbacks_for_plugin(const std::string& plugin_key) + { + std::lock_guard lk(m_mtx); + auto it = m_callbacks.find(plugin_key); + if (it == m_callbacks.end()) + return {}; + std::vector callbacks; + callbacks.reserve(it->second.size()); + for (const std::weak_ptr& weak_callback : it->second) { + if (auto callback = weak_callback.lock()) + callbacks.push_back(std::move(callback)); + } + m_callbacks.erase(it); + return callbacks; + } private: std::mutex m_mtx; std::unordered_map m_resources; std::unordered_map m_owners; + std::unordered_map>> m_callbacks; int m_next_id{1}; }; @@ -448,6 +477,46 @@ void progress_close(int id) }); } +void plater_notification(NotificationManager::NotificationLevel notification_level, const std::string& text, + const std::string& hypertext, py::object on_click) +{ + const std::string plugin_key = PluginAuditManager::instance().current_plugin(); + CallablePtr holder = make_holder(std::move(on_click)); + if (holder) + UiRegistry::instance().bind_callback(holder, plugin_key); + + std::function callback; + if (holder) { + callback = [holder](wxEvtHandler*) -> bool { + if (!holder->active.load(std::memory_order_acquire)) + return false; + + PythonGILState gil; + if (!gil) + return false; + try { + py::object result = holder->fn(); + return result.is_none() || result.cast(); + } catch (py::error_already_set& e) { + BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised: " << e.what(); + PyErr_Clear(); + return false; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised: " << e.what(); + return false; + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised an unknown exception"; + return false; + } + }; + } + + run_on_ui_blocking([notification_level, text, hypertext, callback = std::move(callback)]() mutable { + wxGetApp().plater()->get_notification_manager()->push_notification(NotificationType::CustomNotification, notification_level, text, + hypertext, std::move(callback)); + }); +} + } // namespace void PluginHostUi::RegisterBindings(pybind11::module_& host) @@ -530,6 +599,23 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host) ui.def("create_progress_dialog", &ui_create_progress_dialog, py::arg("title"), py::arg("message"), py::arg("maximum") = 100, py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE, "Create a native progress dialog and return a ProgressDialog handle."); + + py::enum_(ui, "NotificationLevel") + .value("ProgressBarNotificationLevel", NotificationManager::NotificationLevel::ProgressBarNotificationLevel) + .value("HintNotificationLevel", NotificationManager::NotificationLevel::HintNotificationLevel) + .value("RegularNotificationLevel", NotificationManager::NotificationLevel::RegularNotificationLevel) + .value("PrintInfoNotificationLevel", NotificationManager::NotificationLevel::PrintInfoNotificationLevel) + .value("PrintInfoShortNotificationLevel", NotificationManager::NotificationLevel::PrintInfoShortNotificationLevel) + .value("ImportantNotificationLevel", NotificationManager::NotificationLevel::ImportantNotificationLevel) + .value("WarningNotificationLevel", NotificationManager::NotificationLevel::WarningNotificationLevel) + .value("SeriousWarningNotificationLevel", NotificationManager::NotificationLevel::SeriousWarningNotificationLevel) + .value("ErrorNotificationLevel", NotificationManager::NotificationLevel::ErrorNotificationLevel) + .export_values(); + + ui.def("push_notification", &plater_notification, py::arg("notification_level"), py::arg("text"), + py::arg("hyper_text") = "", py::arg("on_click") = py::none(), + "Push a plater notification. hyper_text is an underlined label; on_click() is called when it is clicked " + "and may return True to close the notification."); } void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key) @@ -538,6 +624,9 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key) return; auto teardown = [plugin_key]() { + for (auto& callback : UiRegistry::instance().take_callbacks_for_plugin(plugin_key)) + callback->disable(); + // Destroy() bypasses wxEVT_CLOSE, so the plugin's on_close is not fired on // forced teardown (intended); the resource destructor still cleans the registry. for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) { From c5b152b722245f96d6baad88830b38f4c4a3e167 Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:37:05 +0200 Subject: [PATCH 2/4] CLI: record command-line overrides in different_settings_to_system (#15642) * CLI: record command-line overrides in different_settings_to_system Settings passed on the command line (--sparse-infill-density 25% ...) override the loaded presets when m_extra_config is applied to m_print_config, but nothing recorded them in different_settings_to_system. The exported project therefore carried the new value with no mark that it was modified, and re-opening it in the GUI reverted it to the system preset's value -- the same failure the preset-leaf diff fixes for user presets, via a different source of override. The key set comes from m_config, not m_extra_config. read_cli() puts only what the user typed into m_config and setup() adds nothing but CLI-own defaults (none of the keys run() materialises there is a preset option), whereas the CLI writes its own values into m_extra_config (has_filament_switcher, filament_colour, filament_map ...), which must not be reported as user overrides. Values are snapshotted just before the apply and only keys the override actually changed are recorded: a typed value equal to the loaded one modifies nothing, and listing it would read as a spurious difference against what the GUI writes. Each key lands in the column(s) whose preset type owns it -- process, every filament, printer -- and a key already present is not duplicated. Keys no preset owns (curr_bed_type, a project setting) land nowhere, as in the GUI. Follow-up to #15595, split out at review. * CLI: judge command-line overrides the way the value is read Review follow-ups on the override recording: - Lists were compared as whole serialized strings. read_cli() builds a fresh one-entry list, so --nozzle-temperature 245 against 245,245,245 on a three-filament project was recorded in every filament column although nothing changed. Lists are now compared entry by entry with a missing entry read as the first, as get_at() reads it (and as resize() pads). - The log line fired for every changed key, including ones no preset owns (curr_bed_type) and which therefore land in no column. It now fires only when a column took the key. - m_print_config.has(key) straight after apply(m_extra_config, true) was always true, both configs sharing print_config_def; removed. columns.size() >= 2 also always holds after the resize to filament_count + 2 -- different_settings_to_system is not a CLI option, so nothing in between can shrink it -- but that rests on code far away, so it stays a plain check rather than an assert: release builds compile asserts out, and a _GLIBCXX_ASSERTIONS build would abort on columns[0]. Deliberately NOT done: comparing a key the loaded config lacks against its built-in default. On reopen the GUI restores an unlisted key from the SYSTEM preset, not the default. A 3MF written before an option existed leaves it absent here, so --sparse-infill-density 20% (the default) against a Prusa system 15% would go unrecorded and be reverted to 15%. Absent keys stay always-recorded: over-recording is cosmetic, under-recording loses the value. Verified that such a key really is absent at this point, rather than filled from the system preset. Reported by HanifKoh and raistlin7447 in review of #15642. --- src/OrcaSlicer.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index d3e24437fb..499e73d073 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -3846,9 +3846,94 @@ int CLI::run(int argc, char **argv) } } + //ORCA: settings passed on the command line (--sparse-infill-density 25% ...) override the loaded + // presets right here, so they belong in different_settings_to_system just as a preset + // override does. Without them re-opening the exported project in the GUI shows nothing + // modified and reverts those values to the system presets'. + // + // The keys come from m_config, not m_extra_config: read_cli() puts only what the user typed + // into m_config (setup() adds nothing but CLI-own defaults), whereas the CLI writes its own + // values into m_extra_config. Only keys whose value the override actually changed are + // recorded -- a typed value equal to the loaded one modifies nothing -- and each lands in + // the column(s) whose preset type owns it: [0] process, [1..n-2] filaments, [n-1] printer. + // + // "Changed" is judged the way the value is read: a list is compared entry by entry with a + // missing entry read as the first, as get_at() does -- so --nozzle-temperature 245 against + // 245,245,245 is no change, although the two serialize differently. + // + // A key the loaded config does not carry at all is always recorded, even if the typed value + // equals the built-in default. On reopen the GUI restores an unlisted key from the SYSTEM + // preset, which need not match that default: a 3MF written before an option existed leaves + // it absent here, and --sparse-infill-density 20% (the default) against a Prusa system 15% + // would otherwise go unrecorded and be reverted. Over-recording is cosmetic; under-recording + // loses the value. + std::map> cli_override_before; + for (const std::string &key : m_config.keys()) { + if (!m_extra_config.has(key)) + continue; + const ConfigOption *loaded = m_print_config.option(key); + cli_override_before[key].reset(loaded != nullptr ? loaded->clone() : nullptr); // null: always recorded + } + // Apply command line options to a more specific DynamicPrintConfig which provides normalize() // (command line options override --load files) m_print_config.apply(m_extra_config, true); + + if (!cli_override_before.empty()) { + std::vector &columns = m_print_config.option("different_settings_to_system", true)->values; + auto owned_by = [](const std::vector &options, const std::string &key) { + return std::find(options.begin(), options.end(), key) != options.end(); + }; + auto add_to_column = [&columns](size_t index, const std::string &key) { + std::vector keys; + Slic3r::unescape_strings_cstyle(columns[index], keys); + if (std::find(keys.begin(), keys.end(), key) == keys.end()) { + keys.push_back(key); + columns[index] = Slic3r::escape_strings_cstyle(keys); + } + }; + auto same_value = [](const ConfigOption *a, const ConfigOption *b) { + if (a == nullptr || b == nullptr) + return false; + const auto *va = dynamic_cast(a); + const auto *vb = dynamic_cast(b); + if (va == nullptr || vb == nullptr) + return va == vb && a->serialize() == b->serialize(); + const std::vector ea = va->vserialize(), eb = vb->vserialize(); + if (ea.empty() || eb.empty()) + return ea.empty() && eb.empty(); + for (size_t i = 0; i < std::max(ea.size(), eb.size()); ++i) + if (ea[i < ea.size() ? i : 0] != eb[i < eb.size() ? i : 0]) + return false; + return true; + }; + //ORCA: always true after the resize to filament_count + 2 above, and nothing in between can + // shrink the column vector -- different_settings_to_system is not a CLI option. Kept as + // a check rather than an assert: release builds compile asserts out, so an assert would + // protect nothing, while a build with _GLIBCXX_ASSERTIONS would abort on columns[0]. + if (columns.size() >= 2) { + for (const auto &[key, before] : cli_override_before) { + if (same_value(before.get(), m_print_config.option(key))) + continue; + bool recorded = false; + if (owned_by(Preset::print_options(), key)) { + add_to_column(0, key); + recorded = true; + } + if (owned_by(Preset::filament_options(), key)) { + for (size_t i = 1; i + 1 < columns.size(); ++i) + add_to_column(i, key); + recorded = true; + } + if (owned_by(Preset::printer_options(), key)) { + add_to_column(columns.size() - 1, key); + recorded = true; + } + if (recorded) + BOOST_LOG_TRIVIAL(info) << boost::format("CLI: override %1% recorded in different_settings_to_system") % key; + } + } + } // Normalizing after importing the 3MFs / AMFs m_print_config.normalize_fdm(); From 31f6eb2718491ba34272786c826ba577a4410777 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:26:32 +0800 Subject: [PATCH 3/4] Keep the First Value When a Per-Filament Variant Option Is Too Short (#15639) update_values_to_printer_extruders_for_multiple_filaments picks each filament's value from the flattened (filament x variant) columns of every per-filament variant option. When a column index fell past the end of the option's values, it skipped that filament and left the zero the output vector was created with. The GUI always hands this function full columns, but the CLI does not: - a CLI override of a single value, such as --nozzle-temperature=211 on a four-filament project, came out as 211,0,0,0, so three filaments would print at 0 C; - loading fewer filament presets than the project has filaments left the remaining filaments' columns missing, so filament_cooling_before_tower came out as 10,10,0,0 and filament_ramming_volumetric_speed as -1,-1,0,0. An out-of-range column now keeps the option's first value, the fallback get_at() and the sibling gather step already use. The seven per-type copies of the loop are replaced by that same gather_option_values helper, moved above the function; it now takes its caller's name for its log lines. An empty option, which has no first value, is given one registered default per filament first; it used to be replaced with zeros. On a partial load a filament whose preset was not loaded takes the first filament's value rather than its own preset's, which the CLI does not load; for the options seen in practice those agree. --- src/libslic3r/PrintConfig.cpp | 215 ++++-------------- .../test_config_variant_expansion.cpp | 28 +++ 2 files changed, 67 insertions(+), 176 deletions(-) diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 4c27995ba0..e8ac749bd3 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -10936,6 +10936,28 @@ std::vector DynamicPrintConfig::update_values_to_printer_extruders(DynamicP return variant_index; } +// Regathers a vector option's values through per-slot source indices (one input index per +// output slot). Out-of-range indices keep the first value, matching get_at's fallback. +template +static void gather_option_values(const char *caller, const std::string &key, OptType *opt, const std::vector &slot_param_indices) +{ + if (!opt || opt->values.empty()) { + BOOST_LOG_TRIVIAL(warning) << caller << boost::format(", Line %1%: option %2% not found or empty, skipping")%__LINE__%key; + return; + } + std::vector new_values; + new_values.reserve(slot_param_indices.size()); + for (int idx : slot_param_indices) { + if (idx < 0 || static_cast(idx) >= opt->values.size()) { + BOOST_LOG_TRIVIAL(warning) << caller << boost::format(", Line %1%: option %2% slot index %3% out of range, keeping first value")%__LINE__%key%idx; + new_values.emplace_back(opt->values.front()); + } + else + new_values.emplace_back(opt->values[idx]); + } + opt->values = std::move(new_values); +} + void DynamicPrintConfig::update_values_to_printer_extruders_for_multiple_filaments(DynamicPrintConfig& printer_config, int extruder_count, int extruder_nozzle_volume_count, std::set& key_set, std::string id_name, std::string variant_name) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", Line %1%: extruder_count %2%, extruder_nozzle_volume_count %3%")%__LINE__ %extruder_count %extruder_nozzle_volume_count; @@ -11013,155 +11035,18 @@ void DynamicPrintConfig::update_values_to_printer_extruders_for_multiple_filamen BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: can not find opt define for %2%")%__LINE__%key; continue; } + // An empty option has no first value to fall back on; give it one registered default per filament. + if (auto *vec = dynamic_cast(this->option(key)); vec && vec->empty() && optdef->default_value) + vec->resize(filament_count, optdef->default_value.get()); switch (optdef->type) { - case coStrings: - { - ConfigOptionStrings * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coInts: - { - ConfigOptionInts * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coFloats: - { - ConfigOptionFloats * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coPercents: - { - ConfigOptionPercents * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coFloatsOrPercents: - { - ConfigOptionFloatsOrPercents * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coBools: - { - ConfigOptionBools * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } - case coEnums: - { - ConfigOptionEnumsGeneric * opt = this->option(key); - if (!opt) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found, skipping")%__LINE__%key; - break; - } - std::vector new_values; - - new_values.resize(filament_count); - for (int f_index = 0; f_index < filament_count; f_index++) - { - if (variant_index[f_index] < 0 || static_cast(variant_index[f_index]) >= opt->size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% variant index %3% out of range, skipping")%__LINE__%key%variant_index[f_index]; - continue; - } - new_values[f_index] = opt->get_at(variant_index[f_index]); - } - opt->values = new_values; - break; - } + case coStrings: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coInts: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coFloats: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coPercents: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coFloatsOrPercents: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coBools: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; + case coEnums: gather_option_values(__FUNCTION__, key, this->option(key), variant_index); break; default: BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: unsupported option type for %2%")%__LINE__%key; break; @@ -11180,28 +11065,6 @@ void DynamicPrintConfig::update_values_to_printer_extruders_for_multiple_filamen } } -// Regathers a vector option's values through per-slot source indices (one input index per -// output slot). Out-of-range indices keep the first value, matching get_at's fallback. -template -static void gather_option_values(const std::string &key, OptType *opt, const std::vector &slot_param_indices) -{ - if (!opt || opt->values.empty()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% not found or empty, skipping")%__LINE__%key; - return; - } - std::vector new_values; - new_values.reserve(slot_param_indices.size()); - for (int idx : slot_param_indices) { - if (idx < 0 || static_cast(idx) >= opt->values.size()) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: option %2% slot index %3% out of range, keeping first value")%__LINE__%key%idx; - new_values.emplace_back(opt->values.front()); - } - else - new_values.emplace_back(opt->values[idx]); - } - opt->values = std::move(new_values); -} - void DynamicPrintConfig::update_filament_config_values_for_multiple_extruders(DynamicPrintConfig& printer_config, const std::unordered_map>& filament_variant_uses, int extruder_count, int extruder_nozzle_volume_count, @@ -11296,13 +11159,13 @@ void DynamicPrintConfig::update_filament_config_values_for_multiple_extruders(Dy continue; } switch (optdef->type) { - case coStrings: gather_option_values(key, this->option(key), slot_param_indices); break; - case coInts: gather_option_values(key, this->option(key), slot_param_indices); break; - case coFloats: gather_option_values(key, this->option(key), slot_param_indices); break; - case coPercents: gather_option_values(key, this->option(key), slot_param_indices); break; - case coFloatsOrPercents: gather_option_values(key, this->option(key), slot_param_indices); break; - case coBools: gather_option_values(key, this->option(key), slot_param_indices); break; - case coEnums: gather_option_values(key, this->option(key), slot_param_indices); break; + case coStrings: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coInts: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coFloats: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coPercents: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coFloatsOrPercents: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coBools: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; + case coEnums: gather_option_values(__FUNCTION__, key, this->option(key), slot_param_indices); break; default: BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(", Line %1%: unsupported option type for %2%")%__LINE__%key; break; diff --git a/tests/libslic3r/test_config_variant_expansion.cpp b/tests/libslic3r/test_config_variant_expansion.cpp index 2469789d5b..d8e09539bb 100644 --- a/tests/libslic3r/test_config_variant_expansion.cpp +++ b/tests/libslic3r/test_config_variant_expansion.cpp @@ -484,6 +484,34 @@ TEST_CASE("update_values_to_printer_extruders_for_multiple_filaments resolves pe REQUIRE(config.option("filament_max_volumetric_speed")->values == std::vector({12., 21.})); REQUIRE(config.option("filament_self_index")->values == std::vector({1, 2})); } + + SECTION("a variant option shorter than the filament slots keeps its first value instead of zero") { + DynamicPrintConfig config; + config.option("extruder_type", true)->values = {etDirectDrive, etDirectDrive}; + config.option("nozzle_volume_type", true)->values = {nvtStandard, nvtHighFlow}; + config.option("extruder_variant_list", true)->values = {"Direct Drive Standard,Direct Drive High Flow", + "Direct Drive Standard,Direct Drive High Flow"}; + make_filament_arrays(config); + config.option("filament_map", true)->values = {1, 2}; + // no loaded preset carries the key, so only its single registered default is present + config.option("filament_cooling_before_tower", true)->values = {10.}; + // only the first filament's two variant columns were loaded + config.option("filament_ramming_volumetric_speed", true)->values = {-1., -2.}; + + std::vector> nozzle_volume_types; + int extruder_count = 2; + int count = config.get_extruder_nozzle_volume_count(extruder_count, nozzle_volume_types); + + config.update_values_to_printer_extruders_for_multiple_filaments(config, extruder_count, count, filament_keys, + "filament_self_index", "filament_extruder_variant"); + + // filament 2 resolves to column 3 (its extruder's High Flow column), past the end of both vectors + REQUIRE_THAT(config.option("filament_cooling_before_tower")->values, + Catch::Matchers::Approx(std::vector({10., 10.}))); + REQUIRE_THAT(config.option("filament_ramming_volumetric_speed")->values, + Catch::Matchers::Approx(std::vector({-1., -1.}))); + REQUIRE(config.option("filament_max_volumetric_speed")->values == std::vector({12., 21.})); + } } // update_values_from_multi_to_multi_2 walks the DESTINATION PRINTER's variant list while writing From 00429da73928550a88c5dc73c683a1d8078d61f5 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:28:08 +0800 Subject: [PATCH 4/4] Apply the GUI's Mixed Filament Rules on the CLI (#15636) A valid mixed filament already slices the same on the CLI as in the GUI; these are the places where the CLI still skipped a rule the GUI applies. - Keep the prime tower when a mixed filament is used, even if every --load-filaments preset is the same. A mixed filament swaps between its components every layer, so turning the tower off left the swaps with nothing to purge on. - Leave a mixed slot's row and column of the flush matrix at zero when --filament-colour triggers a recompute, as the GUI does; a mixed slot never reaches a nozzle. - Refuse a mixed slot that has no filament of its own. Feature filament ids aimed at it were past the filament count, got reset to filament 1 and the model silently printed in one colour. - Refuse a plate that uses a mixed filament whose components are different filament types, the type half of the GUI's Sidebar::has_broken_mixed_filament. Missing or out-of-range components are already rejected for the whole project by validate(). get_extruders_under_cli gains an expand_mixed_slots flag so the gate can see mixed slots rather than their components; existing callers keep the expanded list. Both refusals exit with the new CLI_MIXED_FILAMENT_INVALID (-69). --- src/OrcaSlicer.cpp | 68 +++++++++++++++++++++++++++++++++++- src/libslic3r/Utils.hpp | 1 + src/slic3r/GUI/PartPlate.cpp | 4 +-- src/slic3r/GUI/PartPlate.hpp | 3 +- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 499e73d073..b75c653eda 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -53,6 +53,7 @@ using namespace nlohmann; #include "libslic3r/libslic3r.h" #include "libslic3r/Config.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/GCode.hpp" @@ -162,6 +163,7 @@ std::map cli_errors = { {CLI_FILAMENT_CAN_NOT_MAP, "Some filaments cannot be mapped to correct extruders for multi-extruder Printer."}, {CLI_ONLY_ONE_TPU_SUPPORTED, "Not support printing 2 or more TPU filaments."}, {CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER, "Some filaments cannot be printed on the extruder mapped to."}, + {CLI_MIXED_FILAMENT_INVALID, "A mixed filament is invalid: its components are different filament types, or it has no filament of its own."}, {CLI_SLICING_ERROR, "Failed slicing the model. Please verify the slicing of all plates on Orca Slicer before uploading."}, {CLI_GCODE_PATH_CONFLICTS, " G-code conflicts detected after slicing. Please make sure the 3mf file can be successfully sliced in the latest Orca Slicer. If the file slices normally in Orca Slicer, try moving the wipe tower further from other models, as we use more conservative parameters for it during upload."}, {CLI_GCODE_PATH_IN_UNPRINTABLE_AREA, "Found G-code in unprintable area of multi-extruder printers after slicing. Please make sure the 3mf file can be successfully sliced in the latest Orca Slicer."} @@ -3700,6 +3702,15 @@ int CLI::run(int argc, char **argv) } } + // A mixed slot never reaches a nozzle, so its row and column stay empty, as in the GUI. + // Command line options are not merged into m_print_config yet, so they win here. + const ConfigOptionBools *is_mixed_opt = m_extra_config.option("filament_is_mixed"); + if (!is_mixed_opt) + is_mixed_opt = m_print_config.option("filament_is_mixed"); + auto is_mixed_slot = [is_mixed_opt](int idx) { + return is_mixed_opt && idx < static_cast(is_mixed_opt->values.size()) && is_mixed_opt->values[idx]; + }; + for (size_t nozzle_id = 0; nozzle_id < new_extruder_count; ++nozzle_id) { std::vector flush_vol_mtx = get_flush_volumes_matrix(flush_vol_matrix, nozzle_id, new_extruder_count); for (int from_idx = 0; from_idx < project_filament_count; from_idx++) { @@ -3709,7 +3720,7 @@ int CLI::run(int argc, char **argv) bool is_from_support = filament_is_support->get_at(from_idx); for (int to_idx = 0; to_idx < project_filament_count; to_idx++) { bool is_to_support = filament_is_support->get_at(to_idx); - if (from_idx == to_idx) { + if (from_idx == to_idx || is_mixed_slot(from_idx) || is_mixed_slot(to_idx)) { flush_vol_mtx[project_filament_count * from_idx + to_idx] = 0.f; } else { int flushing_volume = 0; @@ -3937,6 +3948,22 @@ int CLI::run(int argc, char **argv) // Normalizing after importing the 3MFs / AMFs m_print_config.normalize_fdm(); + // A mixed slot is virtual but still needs a filament entry of its own. Without one, feature + // filament ids aimed at it fall outside the filament count, are reset to the first filament + // and the model silently prints in a single colour. + if (const auto *is_mixed_opt = m_print_config.option("filament_is_mixed")) { + const auto &is_mixed = is_mixed_opt->values; + for (size_t slot = static_cast(std::max(filament_count, 0)); slot < is_mixed.size(); ++slot) { + if (!is_mixed[slot]) + continue; + BOOST_LOG_TRIVIAL(error) << boost::format("mixed filament slot %1% has no filament of its own, only %2% filaments are loaded; " + "load one filament per slot, including each mixed one") + % (slot + 1) % filament_count; + record_exit_reson(outfile_dir, CLI_MIXED_FILAMENT_INVALID, 0, cli_errors[CLI_MIXED_FILAMENT_INVALID], sliced_info); + flush_and_exit(CLI_MIXED_FILAMENT_INVALID); + } + } + m_print_config.option>("printer_technology", true)->value = printer_technology; bool has_wipe_tower_position = m_print_config.option("wipe_tower_x") && m_print_config.option("wipe_tower_y"); @@ -3991,6 +4018,15 @@ int CLI::run(int argc, char **argv) bool is_smooth_timelapse = false; if (enable_timelapse && timelapse_type_opt && (timelapse_type_opt->getInt() == TimelapseType::tlSmooth)) is_smooth_timelapse = true; + // A mixed filament swaps between its components every layer, so it needs the tower even when + // every loaded preset is the same. + if (disable_wipe_tower_after_mapping) { + if (const auto *is_mixed_opt = m_print_config.option("filament_is_mixed"); + is_mixed_opt && has_any_mixed_filament(is_mixed_opt->values)) { + disable_wipe_tower_after_mapping = false; + BOOST_LOG_TRIVIAL(info) << boost::format("%1%, set disable_wipe_tower_after_mapping back to false due to a mixed filament")%__LINE__; + } + } if (disable_wipe_tower_after_mapping) { if (is_smooth_timelapse) { @@ -6197,6 +6233,36 @@ int CLI::run(int argc, char **argv) flush_and_exit(CLI_ONLY_ONE_TPU_SUPPORTED); } + // Same type gate as the GUI's Sidebar::has_broken_mixed_filament: refuse a plate that uses a + // mixed slot whose components are different filament types. Missing or out-of-range + // components never get here, validate() already rejects them for the whole project. + const auto *is_mixed_opt = m_print_config.option("filament_is_mixed"); + const auto *components_opt = m_print_config.option("filament_mixed_components"); + if (is_mixed_opt && components_opt && has_any_mixed_filament(is_mixed_opt->values)) { + const auto &is_mixed = is_mixed_opt->values; + const auto &components = components_opt->values; + const size_t num_physical = static_cast(filament_count) - static_cast(std::count(is_mixed.begin(), is_mixed.end(), true)); + std::vector physical_types(num_physical); + for (size_t f_index = 0; f_index < num_physical; ++f_index) { + std::string displayed_type; + physical_types[f_index] = m_print_config.get_filament_type(displayed_type, static_cast(f_index)); + if (physical_types[f_index].empty()) + physical_types[f_index] = "PLA"; + } + const std::vector mismatched_slots = check_mixed_filament_type_consistency(is_mixed, components, physical_types); + // plate_filaments has mixed slots expanded to their components; the gate needs the slots. + const std::vector plate_slots = mismatched_slots.empty() ? std::vector() : + part_plate->get_extruders_under_cli(true, m_print_config, false); + for (size_t slot : mismatched_slots) { + if (std::find(plate_slots.begin(), plate_slots.end(), static_cast(slot) + 1) == plate_slots.end()) + continue; + BOOST_LOG_TRIVIAL(error) << boost::format("plate %1%: mixed filament %2% mixes components of different filament types") + % (index + 1) % (slot + 1); + record_exit_reson(outfile_dir, CLI_MIXED_FILAMENT_INVALID, index + 1, cli_errors[CLI_MIXED_FILAMENT_INVALID], sliced_info); + flush_and_exit(CLI_MIXED_FILAMENT_INVALID); + } + } + if (new_extruder_count > 1) { std::vector> unprintable_filament_vec; for (const std::set& filamnt_ids : unprintable_filament_ids) { diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 797894442a..c364860531 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -70,6 +70,7 @@ #define CLI_FILAMENT_CAN_NOT_MAP -66 #define CLI_ONLY_ONE_TPU_SUPPORTED -67 #define CLI_FILAMENTS_NOT_SUPPORTED_BY_EXTRUDER -68 +#define CLI_MIXED_FILAMENT_INVALID -69 #define CLI_SLICING_ERROR -100 #define CLI_GCODE_PATH_CONFLICTS -101 diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index c9370cc282..893260f934 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1717,7 +1717,7 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode, const Dynam return plate_extruders; } -std::vector PartPlate::get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config) const +std::vector PartPlate::get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config, bool expand_mixed_slots) const { std::vector plate_extruders; @@ -1878,7 +1878,7 @@ std::vector PartPlate::get_extruders_under_cli(bool conside_custom_gcode, D // Expand mixed filament slots to their physical components. A mixed slot is virtual and // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the // physical filaments it resolves to instead. - { + if (expand_mixed_slots) { auto* is_mixed_opt = full_config.option("filament_is_mixed"); auto* comp_strs_opt = full_config.option("filament_mixed_components"); if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 6d7eb18beb..e913ebaabf 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -350,7 +350,8 @@ public: // get used filaments from config, 1 based idx std::vector get_extruders(bool conside_custom_gcode = false) const; std::vector get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const; - std::vector get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config) const; + // expand_mixed_slots = false keeps mixed filament slots as slots instead of their components. + std::vector get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config, bool expand_mixed_slots = true) const; std::vector get_extruders_without_support(bool conside_custom_gcode = false) const; // get used filaments from gcode result, 1 based idx std::vector get_used_filaments();