From f7caf0db07266a6ec4f7518143028ec0bc93e5d7 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 23 Jul 2026 21:04:08 +0800 Subject: [PATCH 001/138] feat(plugin): storage API --- src/slic3r/plugin/host/PluginHost.cpp | 51 +++++++++++++++++++ src/slic3r/plugin/host/PluginHostBindings.hpp | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/slic3r/plugin/host/PluginHost.cpp b/src/slic3r/plugin/host/PluginHost.cpp index 524f07f12c..5fca4d4fbc 100644 --- a/src/slic3r/plugin/host/PluginHost.cpp +++ b/src/slic3r/plugin/host/PluginHost.cpp @@ -1,9 +1,59 @@ #include "PluginHost.hpp" #include "PluginHostBindings.hpp" #include "PluginHostUi.hpp" +#include +#include +#include +#include +#include + +#include namespace Slic3r { +namespace host_bindings { +void register_plugin(pybind11::module_& host) +{ + auto plugin_host = host.def_submodule("plugin", "Plugin host API"); + + plugin_host.def( + "storage", + []() -> std::string { + const std::string plugin_key = PluginAuditManager::instance().current_plugin(); + if (plugin_key.empty()) + throw std::runtime_error("plugin.storage() must be called from a plugin callback"); + + PluginDescriptor descriptor; + if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor)) + throw std::runtime_error("The current plugin is not registered"); + + // plugin_root is populated for installed packages. If it is unavailable, the entry + // path still identifies the same package directory. This is important for local + // plugins: their directory is based on the source filename (including its extension), + // while plugin_key is based on the filename stem. + const boost::filesystem::path plugin_root = resolve_plugin_root_from_descriptor(descriptor); + if (!plugin_root.empty()) + return plugin_root.string(); + + if (!descriptor.is_cloud_plugin()) + throw std::runtime_error("The current local plugin folder is unavailable"); + + if (wxTheApp == nullptr || GUI::wxGetApp().getAgent() == nullptr) + throw std::runtime_error("Cloud plugin storage is unavailable before networking is initialized"); + + const std::string user_id = GUI::wxGetApp().getAgent()->get_user_id(); + if (user_id.empty()) + throw std::runtime_error("Cloud plugin storage is unavailable without a logged-in user"); + + if (!is_valid_plugin_id(plugin_key)) + throw std::runtime_error("The current cloud plugin key is not a valid folder name"); + + return (boost::filesystem::path(get_cloud_plugin_dir(user_id)) / plugin_key).string(); + }, + "Return the installed folder of the current plugin."); +} +} // namespace host_bindings + void PluginHost::RegisterBindings(pybind11::module_& module) { auto host = module.def_submodule("host", "Host application API"); @@ -15,6 +65,7 @@ void PluginHost::RegisterBindings(pybind11::module_& module) host_bindings::register_presets(host); host_bindings::register_model(host); host_bindings::register_app(host); + host_bindings::register_plugin(host); // UI: native dialogs and interactive HTML windows for plugins. PluginHostUi::RegisterBindings(host); diff --git a/src/slic3r/plugin/host/PluginHostBindings.hpp b/src/slic3r/plugin/host/PluginHostBindings.hpp index 0f206d5992..94601ad99c 100644 --- a/src/slic3r/plugin/host/PluginHostBindings.hpp +++ b/src/slic3r/plugin/host/PluginHostBindings.hpp @@ -12,5 +12,5 @@ void register_presets(pybind11::module_& host); // PluginHostPresets.cpp void register_model(pybind11::module_& host); // PluginHostModel.cpp void register_app(pybind11::module_& host); // PluginHostApp.cpp void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp - +void register_plugin(pybind11::module_& host); // PluginHost.cpp } // namespace Slic3r::host_bindings From 2e246341d16bc655f409d2882365508b020c097b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 24 Jul 2026 18:57:46 +0800 Subject: [PATCH 002/138] move the storage directory outside the actual plugin code folder --- src/slic3r/plugin/PluginFsUtils.cpp | 2 +- src/slic3r/plugin/PluginFsUtils.hpp | 1 + src/slic3r/plugin/PluginManager.cpp | 32 +++++++++++++++++++++++++++ src/slic3r/plugin/PluginManager.hpp | 4 ++++ src/slic3r/plugin/host/PluginHost.cpp | 30 +------------------------ 5 files changed, 39 insertions(+), 30 deletions(-) diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 445dd00b9b..63dc229967 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -632,7 +632,7 @@ void parse_metadata_rfc822(const std::string& content, bool is_ignored_plugin_directory(const boost::filesystem::path& path) { const std::string name = path.filename().string(); - return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; + return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR || name == PLUGIN_DATA_DIR; } bool is_safe_relative_path(const boost::filesystem::path& path) diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 7922946b1c..5f57dbf807 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -12,6 +12,7 @@ #include #define PLUGIN_SUBSCRIBED_DIR "_subscribed" +#define PLUGIN_DATA_DIR "plugin_data" namespace Slic3r { diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 761a9aad63..abc2446d55 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -486,6 +486,38 @@ bool PluginManager::try_get_plugin_descriptor_for_capability(const std::string& return false; } +std::string PluginManager::get_storage_dir(const std::string& plugin_key) const +{ + namespace fs = boost::filesystem; + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) + throw std::runtime_error("The current plugin is not registered"); + + const fs::path base_storage_dir = fs::path(get_orca_plugins_dir()) / PLUGIN_DATA_DIR; + + if (!descriptor.is_cloud_plugin()) { + const fs::path local_storage_dir = base_storage_dir / plugin_key; + fs::create_directories(local_storage_dir); + return local_storage_dir.string(); + } + + auto agent = m_cloud_service.get_cloud_agent(); + if (!agent) + throw std::runtime_error("Cloud plugin storage is unavailable before networking is initialized"); + + const std::string user_id = agent->get_user_id(); + if (user_id.empty()) + throw std::runtime_error("Cloud plugin storage is unavailable without a logged-in user"); + + if (!is_valid_plugin_id(plugin_key)) + throw std::runtime_error("The current cloud plugin key is not a valid folder name"); + + const fs::path cloud_storage_dir = base_storage_dir / PLUGIN_SUBSCRIBED_DIR / user_id / plugin_key; + fs::create_directories(cloud_storage_dir); + return cloud_storage_dir.string(); +} + // ── Capability instances ──────────────────────────────────────────────────────────────────── std::vector> PluginManager::get_plugin_capabilities(const std::string& plugin_key, diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index 59a2791355..a0bab2afe3 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -138,6 +138,10 @@ public: bool try_get_plugin_descriptor_for_capability(const std::string& capability_name, PluginCapabilityType type, PluginDescriptor& out) const; + // Per-plugin storage directory under orca_plugins/plugin_data, created if missing. Throws + // std::runtime_error if the plugin is unregistered, the key is invalid, or (cloud plugins) + // no user is logged in yet. + std::string get_storage_dir(const std::string& plugin_key) const; std::vector> get_plugin_capabilities( const std::string& plugin_key = "", // "" => all plugins diff --git a/src/slic3r/plugin/host/PluginHost.cpp b/src/slic3r/plugin/host/PluginHost.cpp index 5fca4d4fbc..2830d6f276 100644 --- a/src/slic3r/plugin/host/PluginHost.cpp +++ b/src/slic3r/plugin/host/PluginHost.cpp @@ -2,10 +2,7 @@ #include "PluginHostBindings.hpp" #include "PluginHostUi.hpp" #include -#include -#include #include -#include #include @@ -23,32 +20,7 @@ void register_plugin(pybind11::module_& host) if (plugin_key.empty()) throw std::runtime_error("plugin.storage() must be called from a plugin callback"); - PluginDescriptor descriptor; - if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor)) - throw std::runtime_error("The current plugin is not registered"); - - // plugin_root is populated for installed packages. If it is unavailable, the entry - // path still identifies the same package directory. This is important for local - // plugins: their directory is based on the source filename (including its extension), - // while plugin_key is based on the filename stem. - const boost::filesystem::path plugin_root = resolve_plugin_root_from_descriptor(descriptor); - if (!plugin_root.empty()) - return plugin_root.string(); - - if (!descriptor.is_cloud_plugin()) - throw std::runtime_error("The current local plugin folder is unavailable"); - - if (wxTheApp == nullptr || GUI::wxGetApp().getAgent() == nullptr) - throw std::runtime_error("Cloud plugin storage is unavailable before networking is initialized"); - - const std::string user_id = GUI::wxGetApp().getAgent()->get_user_id(); - if (user_id.empty()) - throw std::runtime_error("Cloud plugin storage is unavailable without a logged-in user"); - - if (!is_valid_plugin_id(plugin_key)) - throw std::runtime_error("The current cloud plugin key is not a valid folder name"); - - return (boost::filesystem::path(get_cloud_plugin_dir(user_id)) / plugin_key).string(); + return PluginManager::instance().get_storage_dir(plugin_key); }, "Return the installed folder of the current plugin."); } From 01493d4e3ae2037393c249e318dc8e56a43c9896 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 4 Aug 2026 14:30:59 +0800 Subject: [PATCH 003/138] Add developer flag for printer agents --- src/libslic3r/AppConfig.cpp | 6 ++++++ src/slic3r/GUI/Preferences.cpp | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 159d9bbeda..1b170bf884 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -626,6 +626,12 @@ void AppConfig::set_defaults() set_bool("window_buttons_on_left", false); #endif + if (get("use_printer_agents").empty()) + { + // false = legacy behavior using print hosts + set_bool("use_printer_agents", false); + } + // Remove legacy window positions/sizes erase("app", "main_frame_maximized"); erase("app", "main_frame_pos"); diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 6bcc00848b..1a3c6fd26a 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -2101,6 +2101,12 @@ void PreferencesDialog::create_items() auto item_show_unsupported = create_item_checkbox(_L("Show unsupported presets"), _L("Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected."), "show_unsupported_presets"); g_sizer->Add(item_show_unsupported); + auto item_plugin_printer_agents = create_item_checkbox( + _L("(Experimental) Use printer agents instead of print hosts"), _L( + "Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\nWhen disabled, OrcaSlicer uses the legacy print-host behavior."), + "use_printer_agents"); + g_sizer->Add(item_plugin_printer_agents); + //// DEVELOPER > Experimental Features g_sizer->Add(create_item_title(_L("Experimental Features")), 1, wxEXPAND); From 75a2460649e13554d341e4e685bfc10324728eb6 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:21:05 +0800 Subject: [PATCH 004/138] Replace fake-enum printer agent dropdown (#121) A dedicated PrinterAgentChoice field reads rows straight from the live agent registry and stores the agent id string, replacing the fake-coEnum index mapping. The field moves to TabPrinter and registers with the searcher so UnsavedChanges renders it; the PhysicalPrinterDialog copy and its update hook are removed (#125). switch_printer_agent now resolves ids via resolve_printer_agent_id. --- src/libslic3r/Config.hpp | 2 + src/slic3r/GUI/Field.cpp | 268 +++++++++++++++-------- src/slic3r/GUI/Field.hpp | 38 ++++ src/slic3r/GUI/GUI_App.cpp | 23 +- src/slic3r/GUI/GUI_App.hpp | 7 +- src/slic3r/GUI/OptionsGroup.cpp | 26 +++ src/slic3r/GUI/PhysicalPrinterDialog.cpp | 88 +------- src/slic3r/GUI/PhysicalPrinterDialog.hpp | 1 - src/slic3r/GUI/Tab.cpp | 55 +++++ 9 files changed, 312 insertions(+), 196 deletions(-) diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 6f4117d249..509095cbfc 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2273,6 +2273,8 @@ public: plugin_picker, // Raw JSON string value, edited through a dialog behind a button rather than in the row. plugin_config, + // PrinterAgentChoice + printer_agent_select, }; // Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map. diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 1fcaef1b52..8d05de13a4 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -35,6 +35,7 @@ #include "Widgets/TextCtrl.h" #include "../Utils/ColorSpaceConvert.hpp" +#include "../Utils/NetworkAgentFactory.hpp" #ifdef __WXOSX__ #define wxOSX true #else @@ -1403,39 +1404,6 @@ using choice_ctrl = ::ComboBox; // BBS static std::map dynamic_lists; -static bool is_plugin_printer_agent_key(const std::string& value) -{ - return value.rfind("plugin:", 0) == 0; -} - -static int printer_agent_item_for_enum_index(const choice_ctrl* field, int enum_index) -{ - if (!field) - return -1; - - const unsigned int count = field->GetCount(); - for (unsigned int idx = 0; idx < count; ++idx) { - if (void* data = field->GetClientData(idx)) { - const int stored = static_cast(reinterpret_cast(data)) - 1; - if (stored == enum_index) - return static_cast(idx); - } - } - - return -1; -} - -static int printer_agent_enum_index_for_item(const choice_ctrl* field, int item_index, int fallback) -{ - if (!field || item_index < 0) - return fallback; - - if (void* data = field->GetClientData(item_index)) - return static_cast(reinterpret_cast(data)) - 1; - - return fallback; -} - void Choice::register_dynamic_list(std::string const &optname, DynamicList *list) { dynamic_lists.emplace(optname, list); } void DynamicList::update() @@ -1518,33 +1486,7 @@ void Choice::BUILD() window = dynamic_cast(temp); if (! m_opt.enum_labels.empty() || ! m_opt.enum_values.empty()) { - if (m_opt_id == "printer_agent") { - const bool has_builtin_agents = std::any_of(m_opt.enum_values.begin(), m_opt.enum_values.end(), - [](const std::string& value) { return !is_plugin_printer_agent_key(value); }); - const bool has_plugin_agents = std::any_of(m_opt.enum_values.begin(), m_opt.enum_values.end(), - [](const std::string& value) { return is_plugin_printer_agent_key(value); }); - - auto append_agent_rows = [this, temp](bool plugins) { - for (size_t i = 0; i < m_opt.enum_values.size(); ++i) { - const bool is_plugin = is_plugin_printer_agent_key(m_opt.enum_values[i]); - if (is_plugin != plugins) - continue; - - const wxString label = i < m_opt.enum_labels.size() ? _(m_opt.enum_labels[i]) : wxString(m_opt.enum_values[i]); - const int item = temp->Append(label); - temp->SetClientData(item, reinterpret_cast(static_cast(i + 1))); - } - }; - - if (has_builtin_agents) { - temp->Append(_L("System agents"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); - append_agent_rows(false); - } - if (has_plugin_agents) { - temp->Append(_L("Plugins"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); - append_agent_rows(true); - } - } else if (m_opt.enum_labels.empty()) { + if (m_opt.enum_labels.empty()) { // Append non-localized enum_values for (auto el : m_opt.enum_values) temp->Append(el); @@ -1651,7 +1593,7 @@ void Choice::set_selection() switch (m_opt.type) { case coEnum:{ const int val = m_opt.default_value->getInt(); - field->SetSelection(m_opt_id == "printer_agent" ? printer_agent_item_for_enum_index(field, val) : val); + field->SetSelection(val); break; } case coFloat: @@ -1701,12 +1643,7 @@ void Choice::set_value(const std::string& value, bool change_event) //! Redunda } choice_ctrl* field = dynamic_cast(window); - if (m_opt_id == "printer_agent") { - const int enum_index = idx == m_opt.enum_values.size() ? - (m_opt.default_value ? m_opt.default_value->getInt() : 0) : - static_cast(idx); - field->SetSelection(printer_agent_item_for_enum_index(field, enum_index)); - } else if (idx == m_opt.enum_values.size()) + if (idx == m_opt.enum_values.size()) field->SetValue(value); else field->SetSelection(idx); @@ -1772,33 +1709,11 @@ void Choice::set_value(const boost::any& value, bool change_event) case coEnum: // BBS case coEnums: { - auto printer_agent_index_from_key = [this](const std::string& key) { - auto it = std::find(m_opt.enum_values.begin(), m_opt.enum_values.end(), key); - if (it != m_opt.enum_values.end()) - return static_cast(it - m_opt.enum_values.begin()); - return m_opt.default_value ? m_opt.default_value->getInt() : 0; - }; - - int val = 0; - if (m_opt_id == "printer_agent") { - if (const int* int_value = boost::any_cast(&value)) - val = *int_value; - else if (const wxString* wx_value = boost::any_cast(&value)) - val = printer_agent_index_from_key(into_u8(*wx_value)); - else if (const std::string* string_value = boost::any_cast(&value)) - val = printer_agent_index_from_key(*string_value); - else { - m_disable_change_event = false; - return; - } - } else - val = boost::any_cast(value); + int val = boost::any_cast(value); int selection = val; - if (m_opt_id == "printer_agent") { - selection = printer_agent_item_for_enum_index(field, val); - } else if (m_opt_id == "input_shaping_type") { + if (m_opt_id == "input_shaping_type") { if (field != nullptr) { const unsigned int count = field->GetCount(); int match_index = -1; @@ -1920,12 +1835,6 @@ boost::any& Choice::get_value() { if (m_opt.nullable && field->GetSelection() == -1) m_value = ConfigOptionEnumsGenericNullable::nil_value(); - else if (m_opt_id == "printer_agent") - { - const int selection = field->GetSelection(); - const int fallback = m_opt.default_value ? m_opt.default_value->getInt() : 0; - m_value = printer_agent_enum_index_for_item(field, selection, fallback); - } else if (m_opt_id == "input_shaping_type") { int selection = field->GetSelection(); @@ -2067,6 +1976,171 @@ void Choice::msw_rescale() } +// PrinterAgentChoice + +void PrinterAgentChoice::reload_rows() +{ + auto* combo = dynamic_cast(window); // wxWidgets ComboBox + if (!combo) + return; + + // clear ComboBox + combo->Clear(); + + // helpers + const auto agents = NetworkAgentFactory::get_registered_printer_agents(); + const bool has_builtin_agents = std::any_of(agents.begin(), agents.end(), + [](const PrinterAgentInfo& a) { return !a.is_plugin(); }); + const bool has_plugin_agents = std::any_of(agents.begin(), agents.end(), + [](const PrinterAgentInfo& a) { return a.is_plugin(); }); + + auto append_agent_rows = [combo](bool is_plugin) + { + const auto agents = NetworkAgentFactory::get_registered_printer_agents(); + for (size_t i = 0; i < agents.size(); ++i) + { + if (agents[i].is_plugin() != is_plugin) + continue; + const int item = combo->Append(_(agents[i].display_name)); + // why: carry the agent-id string on the row. alias is an owned wxString (auto-freed, never rendered) + combo->SetItemAlias(item, from_u8(agents[i].id)); + } + }; + + // append rows + if (has_builtin_agents) + { + combo->Append(_L("System agents"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + append_agent_rows(false); // append rows for agents that are not plugins + } + if (has_plugin_agents) + { + combo->Append(_L("Plugins"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + append_agent_rows(true); // append rows for agents that are plugins + } +} + +void PrinterAgentChoice::BUILD() +{ + wxSize size(def_width_wider() * m_em_unit, wxDefaultCoord); + if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit); + if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit); + + static Builder builder; + choice_ctrl* temp = builder.build(m_parent, wxID_ANY, wxString(""), wxDefaultPosition, size, 0, nullptr, + wxCB_READONLY); + temp->Clear(); + temp->GetDropDown().SetUseContentWidth(true); + if (parent_is_custom_ctrl && m_opt.height < 0) + opt_height = (double)temp->GetTextCtrl()->GetSize().GetHeight() / m_em_unit; + temp->SetTextLabel(_L(m_opt.sidetext)); + m_combine_side_text = true; +#ifdef __WXGTK3__ + wxSize best_sz = temp->GetBestSize(); + if (best_sz.x > size.x) temp->SetSize(best_sz); +#endif + if (!wxOSX) temp->SetBackgroundStyle(wxBG_STYLE_PAINT); + + window = dynamic_cast(temp); + + reload_rows(); + + temp->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_change_field(); }, temp->GetId()); + temp->SetToolTip(get_tooltip_text(temp->GetValue())); +} + +// Resolve CONFIG id string to a matching row in the live REGISTRY. "" uses the vendor default. +// An unregistered id clears selection and shows " (missing)" as free text. +void PrinterAgentChoice::set_value(const std::string& value, bool change_event) +{ + m_disable_change_event = !change_event; + + auto* field = dynamic_cast(window); + + // check if any row's corresponding id matches the agent id we are attempting to set + const std::string effective_agent_id = wxGetApp().resolve_printer_agent_id(value); + const unsigned int count = field->GetCount(); + int match = wxNOT_FOUND; + for (unsigned int i = 0; i < count; ++i) + { + if (into_u8(field->GetItemAlias(i)) == effective_agent_id) // if alias == id + { + match = static_cast(i); + break; + } + } + + // based on match or not, set selection and value + // - SetSelection and SetValue are UI to manipulate the display of the ComboBox + // - SetSelection automatically calls SetValue for the same value + // - we can also SetValue separately from SetSelection + if (match == wxNOT_FOUND) + { + field->SetSelection(wxNOT_FOUND); // nothing shows as selected in the dropdown + field->SetValue(from_u8(value + " (missing)")); // set a value not in the selection (upper display field) + } + else + { + // display name of agent shows both in upper display field and appears selected in dropdown + field->SetSelection(match); + } + + m_disable_change_event = false; +} + +// Accept boost::any values from callers (usually to OptionsGroup/Field parent classes) and normalize them to an agent id. +// Then use PrinterAgentChoice::set_value(std::string& value, ...) +void PrinterAgentChoice::set_value(const boost::any& value, bool change_event) +{ + m_disable_change_event = !change_event; + + auto* field = dynamic_cast(window); + if (value.empty()) + { + field->SetValue(""); + m_value = value; + m_disable_change_event = false; + return; + } + + std::string id; + if (const std::string* s = boost::any_cast(&value)) + id = *s; + else if (const wxString* w = boost::any_cast(&value)) + id = into_u8(*w); + set_value(id, change_event); +} + +// A real row returns its alias, which is the agent id. Header rows, missing rows, +// and no selection return empty boost::any so the custom writer leaves config unchanged. +boost::any& PrinterAgentChoice::get_value() +{ + auto* field = dynamic_cast(window); + const int sel = field->GetSelection(); + const std::string id = sel < 0 ? std::string{} : into_u8(field->GetItemAlias(sel)); + if (id.empty()) + m_value = boost::any{}; + else + m_value = id; + return m_value; +} + +void PrinterAgentChoice::enable() { dynamic_cast(window)->Enable(); } +void PrinterAgentChoice::disable() { dynamic_cast(window)->Disable(); } + +void PrinterAgentChoice::msw_rescale() +{ + Field::msw_rescale(); + + auto* field = dynamic_cast(window)->GetTextCtrl(); + wxSize size(wxDefaultSize); + size.SetWidth((m_opt.width > 0 ? m_opt.width : def_width_wider()) * m_em_unit); + field->SetMinSize(wxSize(-1, int(1.5f * field->GetFont().GetPixelSize().y + 0.5f))); + field->SetSize(size); + + dynamic_cast(window)->Rescale(); +} + void PluginField::BUILD() { auto* panel = new wxPanel(m_parent, wxID_ANY); diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index 4e9c65da5d..e57a569561 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -469,6 +469,44 @@ public: void suppress_scroll(); }; +// printer_agent is a coString whose choices come from the live agent registry. +// PrinterAgentChoice uses a ComboBox directly because Choice expects static config enums. +// Real rows carry the stored agent id in the row alias (SetItemAlias/GetItemAlias). +class PrinterAgentChoice : public Field +{ + using Field::Field; + +public: + PrinterAgentChoice(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) + { + } + + PrinterAgentChoice(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field( + parent, opt, id) + { + } + + ~PrinterAgentChoice() + { + } + + wxWindow* window{nullptr}; + + void BUILD() override; + // Clear and repopulate rows from the live registry (grouped System agents / Plugins). + // Does not change selection; the caller follows with set_value(stored id). + void reload_rows(); + + void set_value(const std::string& value, bool change_event = false); + void set_value(const boost::any& value, bool change_event = false) override; + boost::any& get_value() override; + + void enable() override; + void disable() override; + void msw_rescale() override; + wxWindow* getWindow() override { return window; } +}; + class PluginField : public Field { using Field::Field; public: diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 4c4bdfd62c..83d4f2abaf 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3873,6 +3873,18 @@ unsigned GUI_App::get_colour_approx_luma(const wxColour &colour) )); } +std::string GUI_App::resolve_printer_agent_id(const std::string& stored_id) +{ + if (!stored_id.empty()) + return stored_id; + return (preset_bundle && preset_bundle->is_bbl_vendor()) ? BBL_PRINTER_AGENT_ID : ORCA_PRINTER_AGENT_ID; +} + +std::string GUI_App::canonical_printer_agent_id(const std::string& picked_id) +{ + return picked_id == resolve_printer_agent_id("") ? std::string() : picked_id; +} + void GUI_App::switch_printer_agent() { if (!m_agent) { @@ -3880,17 +3892,8 @@ void GUI_App::switch_printer_agent() return; } - // Read printer_agent from config, falling back to default - std::string effective_agent_id = ORCA_PRINTER_AGENT_ID; - if (preset_bundle->is_bbl_vendor()) - effective_agent_id = BBL_PRINTER_AGENT_ID; - const DynamicPrintConfig& config = preset_bundle->printers.get_edited_preset().config; - if (config.has("printer_agent")) { - const std::string& value = config.option("printer_agent")->value; - if (!value.empty()) - effective_agent_id = value; - } + const std::string effective_agent_id = resolve_printer_agent_id(config.opt_string("printer_agent")); // Check if agent is registered const PrinterAgentInfo* agent_info_ptr = NetworkAgentFactory::get_printer_agent_info(effective_agent_id); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index bda27d40ec..6a977d37fc 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -365,9 +365,14 @@ public: HMSQuery* get_hms_query() { return hms_query; } NetworkAgent* getAgent() { return m_agent; } - // Dynamic printer agent switching + // Reconcile the live printer agent with the stored preset selection. void switch_printer_agent(); + std::string resolve_printer_agent_id(const std::string& stored_id); + // ORCA TODO: in the future, bbl presets should specify "bbl" printer agent id + // then, all resolve and canonical would just be ORCA<->"" + std::string canonical_printer_agent_id(const std::string& picked_id); + FilamentColorCodeQuery* get_filament_color_code_query(); bool is_editor() const { return m_app_mode == EAppMode::Editor; } bool is_gcode_viewer() const { return m_app_mode == EAppMode::GCodeViewer; } diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index 9fb4483883..25c13c4b8d 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -54,6 +54,9 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create(this->ctrl_parent(), opt, id)); break; case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create(this->ctrl_parent(), opt, id)); break; case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create(this->ctrl_parent(), opt, id)); break; + case ConfigOptionDef::GUIType::printer_agent_select: m_fields.emplace( + id, PrinterAgentChoice::Create(this->ctrl_parent(), opt, id)); + break; default: switch (opt.type) { case coFloatOrPercent: @@ -654,6 +657,16 @@ Option ConfigOptionsGroup::get_option(const std::string& opt_key, int opt_index void ConfigOptionsGroup::on_change_OG(const t_config_option_key& opt_id, const boost::any& value) { + if (opt_id == "printer_agent") { + // TODO: Replace this option-specific branch with a generic value adapter if + // more fields need custom field-value to config-value conversion. + if (const std::string* id = boost::any_cast(&value)) + this->change_opt_value("printer_agent", wxGetApp().canonical_printer_agent_id(*id)); + + OptionsGroup::on_change_OG(opt_id, value); + return; + } + if (!m_opt_map.empty()) { auto it = m_opt_map.find(opt_id); if (it == m_opt_map.end()) { @@ -772,6 +785,19 @@ void ConfigOptionsGroup::back_to_config_value(const DynamicPrintConfig& config, } } #endif + else if (opt_key == "printer_agent") + { + // why: printer_agent is a coString kept out of m_opt_map. The generic non-opt_map revert + // below restores the edited config from get_value(), but a deregistered/"(missing)" saved + // id has no selectable row, so the field yields no value and the edited config keeps the + // user's interim pick -> stuck dirty. Restore the SAVED id straight into the edited config + // (displayable or not; config is the saved or system baseline), then repaint and notify. + const std::string saved_id = config.opt_string("printer_agent"); + set_value(opt_key, saved_id); + this->change_opt_value(opt_key, saved_id); + OptionsGroup::on_change_OG(opt_key, saved_id); + return; + } else if (m_opt_map.find(opt_key) == m_opt_map.end() || // This option don't have corresponded field opt_key == "printable_area" || opt_key == "compatible_printers" || opt_key == "compatible_prints" || opt_key == "thumbnails" || diff --git a/src/slic3r/GUI/PhysicalPrinterDialog.cpp b/src/slic3r/GUI/PhysicalPrinterDialog.cpp index b40cd22697..4c9dd60d55 100644 --- a/src/slic3r/GUI/PhysicalPrinterDialog.cpp +++ b/src/slic3r/GUI/PhysicalPrinterDialog.cpp @@ -25,7 +25,6 @@ #include "GUI.hpp" #include "GUI_App.hpp" #include "MainFrame.hpp" -#include "slic3r/Utils/NetworkAgentFactory.hpp" #include "format.hpp" #include "Tab.hpp" #include "wxExtensions.hpp" @@ -128,22 +127,8 @@ PhysicalPrinterDialog::~PhysicalPrinterDialog() void PhysicalPrinterDialog::build_printhost_settings(ConfigOptionsGroup* m_optgroup) { m_optgroup->m_on_change = [this](t_config_option_key opt_key, boost::any value) { - // Special handling for printer_agent: convert fake enum index to string agent ID - if (opt_key == "printer_agent") { - try { - int selected_idx = boost::any_cast(value); - auto agents = NetworkAgentFactory::get_registered_printer_agents(); - if (selected_idx >= 0 && selected_idx < static_cast(agents.size())) { - m_config->set_key_value("printer_agent", - new ConfigOptionString(agents[selected_idx].id)); - } - } catch (const boost::bad_any_cast&) { - // If value is not an int, ignore - } + if (opt_key == "host_type" || opt_key == "printhost_authorization_type") this->update(); - } else if (opt_key == "host_type" || opt_key == "printhost_authorization_type") { - this->update(); - } if (opt_key == "print_host") this->update_printhost_buttons(); if (opt_key == "printhost_port") @@ -154,47 +139,6 @@ void PhysicalPrinterDialog::build_printhost_settings(ConfigOptionsGroup* m_optgr m_optgroup->append_single_option_line("host_type"); - // Build printer agent dropdown from registry (only if network agent is available) - if (wxGetApp().getAgent() != nullptr) { - auto agents = NetworkAgentFactory::get_registered_printer_agents(); - - if (!agents.empty()) { - // Create a fake enum option to force a Choice widget instead of TextCtrl - // (printer_agent is coString in config, but we need a dropdown) - ConfigOptionDef def; - def.type = coEnum; - def.width = Field::def_width_wider(); - def.label = L("Printer Agent"); - def.tooltip = L("Select the network agent implementation for printer communication. " - "Available agents are registered at startup."); - def.mode = comAdvanced; - - // Populate enum values and labels from registered agents - for (const auto& agent : agents) { - def.enum_values.push_back(agent.id); - def.enum_labels.push_back(agent.display_name); - } - - // Resolve selected agent: use config value if valid, otherwise fall back to default - std::string selected_agent = m_config->opt_string("printer_agent"); - auto it = std::find_if(agents.begin(), agents.end(), [&selected_agent](const auto& a) { return a.id == selected_agent; }); - if (it == agents.end()) { - selected_agent = ORCA_PRINTER_AGENT_ID; - it = std::find_if(agents.begin(), agents.end(), [&selected_agent](const auto& a) { return a.id == selected_agent; }); - } - - if (it != agents.end()) { - size_t default_idx = std::distance(agents.begin(), it); - def.set_default_value(new ConfigOptionInt(static_cast(default_idx))); - } - - // Create and append the option line - auto agent_option = Option(def, "printer_agent"); - Line agent_line = m_optgroup->create_single_option_line(agent_option); - m_optgroup->append_line(agent_line); - } - } - auto create_sizer_with_btn = [](wxWindow* parent, Button** btn, const std::string& icon_name, const wxString& label) { *btn = new Button(parent, label); (*btn)->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); @@ -816,31 +760,6 @@ void PhysicalPrinterDialog::update_host_type(bool printer_change) } } -void PhysicalPrinterDialog::update_printer_agent_type() -{ - if (m_config == nullptr) - return; - - Field* agent_field = m_optgroup->get_field("printer_agent"); - if (!agent_field) - return; - - Choice* agent_choice = dynamic_cast(agent_field); - if (!agent_choice) - return; - - // Sync selection with current config value - const std::string current_agent = m_config->opt_string("printer_agent"); - - auto agents = NetworkAgentFactory::get_registered_printer_agents(); - for (size_t i = 0; i < agents.size(); ++i) { - if (agents[i].id == current_agent) { - agent_choice->set_value(i); - return; - } - } -} - void PhysicalPrinterDialog::update_printers() { wxBusyCursor wait; @@ -894,11 +813,6 @@ void PhysicalPrinterDialog::OnOK(wxEvent& event) { wxGetApp().get_tab(Preset::TYPE_PRINTER)->save_preset("", false, false, true, m_preset_name); event.Skip(); - - // Defer printer agent switch to ensure preset save completes first - wxGetApp().CallAfter([] { - wxGetApp().switch_printer_agent(); - }); } }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PhysicalPrinterDialog.hpp b/src/slic3r/GUI/PhysicalPrinterDialog.hpp index 694e7aaf90..0ba2cad54f 100644 --- a/src/slic3r/GUI/PhysicalPrinterDialog.hpp +++ b/src/slic3r/GUI/PhysicalPrinterDialog.hpp @@ -60,7 +60,6 @@ public: void update(bool printer_change = false); void update_host_type(bool printer_change); - void update_printer_agent_type(); void update_preset_input(); void update_printhost_buttons(); void update_printers(); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index c8fa524ca5..857abdce67 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -33,6 +33,7 @@ #include "GUI_App.hpp" #include "GUI_ObjectList.hpp" +#include "slic3r/Utils/NetworkAgentFactory.hpp" #include "slic3r/Utils/PresetUpdater.hpp" #include "slic3r/plugin/PluginConfig.hpp" #include "Plater.hpp" @@ -5018,6 +5019,40 @@ void TabPrinter::build_fff() optgroup->append_single_option_line("gcode_flavor", "printer_basic_information_advanced#g-code-flavor"); optgroup->append_single_option_line("pellet_modded_printer", "printer_basic_information_advanced#pellet-modded-printer"); optgroup->append_single_option_line("bbl_use_printhost", "printer_basic_information_advanced#use-3rd-party-print-host"); + + // "Printer Agent" dropdown - printer_agent is a coString; gui_type routes it to + // PrinterAgentChoice instead of a TextCtrl. Rows and values come from the live agent + // registry, and the value is stored as the agent-id string. + if (wxGetApp().getAgent() != nullptr) + { + auto registered_printer_agents = NetworkAgentFactory::get_registered_printer_agents(); + if (!registered_printer_agents.empty()) + { + ConfigOptionDef def; + def.type = coString; + def.gui_type = ConfigOptionDef::GUIType::printer_agent_select; + def.width = 3 * Field::def_width_wider() / 2; + def.label = L("Printer Agent"); + def.tooltip = L("Select the network agent implementation for printer communication. " + "Available agents are registered at startup."); + def.mode = comAdvanced; + + // Create the field without get_option() so it is not registered in m_opt_map. + // ConfigOptionsGroup handles printer_agent before the generic mapped write path. + Line agent_line = optgroup->create_single_option_line(Option(def, "printer_agent")); + optgroup->append_line(agent_line); + if (Field* agent_field = get_field("printer_agent")) + { + if (auto* choice = dynamic_cast(agent_field); choice && choice->getWindow()) + choice->set_value(m_config->opt_string("printer_agent"), false); + } + + // Register by hand so the UnsavedChanges dialog can render a row for it. + wxGetApp().sidebar().get_searcher().add_key("printer_agent", m_type, optgroup->title, + optgroup->config_category()); + } + } + optgroup->append_single_option_line("use_3mf"); optgroup->append_single_option_line("scan_first_layer" , "printer_basic_information_advanced#scan-first-layer"); optgroup->append_single_option_line("enable_power_loss_recovery", "printer_basic_information_advanced#power-loss-recovery"); @@ -5884,6 +5919,16 @@ void TabPrinter::reload_config() // so update it implicitly if (m_active_page && m_active_page->title() == "Multimaterial") m_active_page->set_value("extruders_count", int(m_extruders_count)); + + // m_opt_map-driven reload does not cover printer_agent, so sync this custom field explicitly. + if (Field* agent_field = get_field("printer_agent")) + { + if (auto* choice = dynamic_cast(agent_field); choice && choice->getWindow()) + { + const std::string selected_agent = m_config->opt_string("printer_agent"); + choice->set_value(selected_agent, false); + } + } } void TabPrinter::activate_selected_page(std::function throw_if_canceled) @@ -5894,6 +5939,16 @@ void TabPrinter::activate_selected_page(std::function throw_if_canceled) // so update it implicitly if (m_active_page && m_active_page->title() == "Multimaterial") m_active_page->set_value("extruders_count", int(m_extruders_count)); + + // m_opt_map-driven reload does not cover printer_agent, so sync this custom field explicitly. + if (Field* agent_field = get_field("printer_agent")) + { + if (auto* choice = dynamic_cast(agent_field); choice && choice->getWindow()) + { + const std::string selected_agent = m_config->opt_string("printer_agent"); + choice->set_value(selected_agent, false); + } + } } void TabPrinter::clear_pages() From b2f08c3ff806ff3558e5c23ecb92ce9c3862a530 Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:27:53 +0800 Subject: [PATCH 005/138] Reset device selection on agent swap or unload (#124) set_live_printer_agent centralizes the swap: deselect the machine, clear stale sidebar state and the previous agent's Other Devices, then install the new agent (or null when its provider vanished). Plugin load/unload callbacks refresh the dropdown and re-run agent selection. load_last_machine no longer falls back to the first available machine. --- src/slic3r/GUI/DeviceCore/DevManager.cpp | 70 ++++++++--------- src/slic3r/GUI/DeviceCore/DevManager.h | 8 +- src/slic3r/GUI/GUI_App.cpp | 95 +++++++++++++++++++++--- src/slic3r/GUI/GUI_App.hpp | 5 ++ src/slic3r/GUI/Tab.cpp | 18 +++++ src/slic3r/GUI/Tab.hpp | 1 + 6 files changed, 150 insertions(+), 47 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 2d54b5c85f..3c664facfd 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -496,6 +496,26 @@ namespace Slic3r OnSelectedMachineChanged(previous_selected_machine, selected_machine); } + void DeviceManager::clear_other_devices() + { + // why: on agent swap, keep "My Devices" but drop the transient "Other Devices" + // Those belong to the previous agent's network scan; the new agent's start_discovery re-populates its own. + const auto my = get_my_machine_list(); + for (auto it = localMachineList.begin(); it != localMachineList.end();) + { + if (my.find(it->first) == my.end()) + { + // not a "My Device" -> an "Other Device" + delete it->second; + it = localMachineList.erase(it); + } + else + { + ++it; + } + } + } + bool DeviceManager::set_selected_machine(std::string dev_id) { BOOST_LOG_TRIVIAL(info) << "set_selected_machine=" << dev_id @@ -558,7 +578,6 @@ namespace Slic3r } else { - Slic3r::GUI::wxGetApp().reset_unsigned_plugin_warning(); if (m_agent) { if (it->second->connection_type() != "lan" || it->second->connection_type().empty()) @@ -592,7 +611,6 @@ namespace Slic3r } selected_machine = dev_id; - record_user_last_machine(selected_machine); return true; } @@ -851,44 +869,26 @@ namespace Slic3r int result = m_agent->get_user_print_info(&http_code, &body, provider); if (result == 0) { - parse_user_print_info(body); + // parse_user_print_info and on_machine_alive (SSDP for discovery) both mutate the same userMachineList map. + // on_machine_alive mutates the map on the UI thread, do the same for parse_user_print_info. + Slic3r::GUI::wxGetApp().CallAfter([this, body]() { parse_user_print_info(body); }); } } - void DeviceManager::record_user_last_machine(const std::string& dev_id) - { - if (Slic3r::GUI::wxGetApp().app_config) { - Slic3r::GUI::wxGetApp().app_config->set("user_last_selected_machine", dev_id); - } - } - - std::string DeviceManager::get_user_last_machine() const - { - if (Slic3r::GUI::wxGetApp().app_config) { - const auto& user_last_machine = Slic3r::GUI::wxGetApp().app_config->get("user_last_selected_machine"); - if (!user_last_machine.empty()) { - return user_last_machine; - } else if (m_agent) { - return m_agent->get_user_selected_machine(); - } - } - - return ""; - } - void DeviceManager::load_last_machine() { - if (userMachineList.empty()) return; - else if (userMachineList.size() == 1) { - this->set_selected_machine(userMachineList.begin()->second->get_dev_id()); - } else { - const auto& last_monitor_machine = get_user_last_machine(); - if (userMachineList.find(last_monitor_machine) != userMachineList.end()) { - set_selected_machine(last_monitor_machine); - } else { - this->set_selected_machine(userMachineList.begin()->second->get_dev_id()); - } - } + // Get all available machines, include cloud machines and lan machines that have access right + auto all_machines = get_my_machine_list(); + if (all_machines.empty()) + return; + + // Reconnect the machine the user last selected, if it's still available. + // why: no first-available fallback - auto-connecting an arbitrary machine + // fights the agent-swap reset, which intentionally leaves nothing selected. + const std::string last_monitor_machine = m_agent ? m_agent->get_user_selected_machine() : ""; + const auto last_machine = all_machines.find(last_monitor_machine); + if (last_machine != all_machines.end()) + this->set_selected_machine(last_machine->second->get_dev_id()); } void DeviceManager::OnMachineBindStateChanged(MachineObject* obj, const std::string& new_state) diff --git a/src/slic3r/GUI/DeviceCore/DevManager.h b/src/slic3r/GUI/DeviceCore/DevManager.h index 1f7f87b7fb..70bee613a8 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.h +++ b/src/slic3r/GUI/DeviceCore/DevManager.h @@ -48,8 +48,9 @@ public: MachineObject* get_selected_machine(); bool set_selected_machine(std::string dev_id); - void record_user_last_machine(const std::string& dev_id); - std::string get_user_last_machine() const; + // why: clears stale sidebar sync-status / AMS visuals. Public so the printer-agent + // swap path can reuse it instead of duplicating the two sidebar calls. + void OnSelectedMachineLost(); // local machine void set_local_selected_machine(std::string dev_id) { local_selected_machine = dev_id; }; @@ -70,6 +71,8 @@ public: void erase_user_machine(std::string dev_id) { userMachineList.erase(dev_id); } void clean_user_info(bool keep_local_selection = false); + void clear_other_devices(); + void load_last_machine(); void update_user_machine_list_info(const std::string& provider); void parse_user_print_info(std::string body); @@ -110,7 +113,6 @@ private: void check_pushing(); void OnMachineBindStateChanged(MachineObject* obj, const std::string& new_state); - void OnSelectedMachineLost(); void OnSelectedMachineChanged(const std::string& pre_dev_id, const std::string& new_dev_id); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 83d4f2abaf..14edeb8038 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2809,16 +2809,58 @@ void GUI_App::init_plugin_gui_wiring() }); }; + // why: a newly loaded plugin only adds a selectable agent + // refresh the dropdown and leave the live agent alone + auto refresh_printer_agent_dropdown_after_load = [](const std::string&) + { + if (!wxTheApp) + return; + + GUI_App* app = &GUI::wxGetApp(); + if (app->is_closing()) + return; + + app->CallAfter([app] + { + if (!app->is_closing()) + app->refresh_printer_agent_dropdown(); + }); + }; + + // why: the unloaded plugin may have been the provider of the live agent + // re-run selection, where a now-missing agent will be cleared + // refresh dropdown after + auto switch_printer_agent_after_unload = [](const std::string&) + { + if (!wxTheApp) + return; + + GUI_App* app = &GUI::wxGetApp(); + if (app->is_closing()) + return; + + app->CallAfter([app] { + if (app->is_closing()) + return; + + app->switch_printer_agent(); + app->refresh_printer_agent_dropdown(); + }); + }; + plugin_mgr.subscribe_on_unload_callback(PluginHostUi::close_windows_for_plugin); plugin_mgr.subscribe_on_load_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); plugin_mgr.subscribe_on_unload_callback([refresh_plugins_dialog](const std::string&) { refresh_plugins_dialog(); }); plugin_mgr.subscribe_on_load_callback(NetworkAgentFactory::register_python_plugin); plugin_mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); + plugin_mgr.subscribe_on_load_callback(refresh_printer_agent_dropdown_after_load); + plugin_mgr.subscribe_on_unload_callback(switch_printer_agent_after_unload); plugin_mgr.subscribe_on_capability_load_callback( - [refresh_plugins_dialog](const PluginCapabilityId& capability) { + [refresh_plugins_dialog, refresh_printer_agent_dropdown_after_load](const PluginCapabilityId& capability) { if (capability.type == PluginCapabilityType::PrinterConnection) NetworkAgentFactory::register_python_printer_agent(capability.plugin_key, capability.name); refresh_plugins_dialog(); + refresh_printer_agent_dropdown_after_load(capability.plugin_key); // A newly loaded capability may satisfy a missing-plugin notification; re-validate the // current plate (on the UI thread) so the notification clears once its plugin is available. if (wxTheApp && !wxGetApp().is_closing()) @@ -2828,10 +2870,11 @@ void GUI_App::init_plugin_gui_wiring() }); }); plugin_mgr.subscribe_on_capability_unload_callback( - [refresh_plugins_dialog](const PluginCapabilityId& capability) { + [refresh_plugins_dialog, switch_printer_agent_after_unload](const PluginCapabilityId& capability) { if (capability.type == PluginCapabilityType::PrinterConnection) NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); refresh_plugins_dialog(); + switch_printer_agent_after_unload(capability.plugin_key); }); } @@ -3873,6 +3916,36 @@ unsigned GUI_App::get_colour_approx_luma(const wxColour &colour) )); } +void GUI_App::refresh_printer_agent_dropdown() +{ + if (Tab* tab = get_tab(Preset::TYPE_PRINTER)) + { + if (auto* printer_tab = dynamic_cast(tab)) + printer_tab->refresh_printer_agent_dropdown(); + } +} + +void GUI_App::set_live_printer_agent(std::shared_ptr agent) +{ + if (!m_agent) + return; + + // why: tearing down the old machine selection is only ever the prefix of setting the live + // agent (to a new one, or to null when the selection is missing) - so it lives here, not as + // a standalone helper. Pass nullptr to clear the selection. + if (DeviceManager* dev = getDeviceManager()) + { + dev->set_selected_machine(""); // why: empty id disconnects and deselects the current machine + m_agent->set_user_selected_machine(""); + // note: belt-and-suspenders (precedent: DeviceManagerRefresher::on_timer) + dev->OnSelectedMachineLost(); // why: clear stale sidebar sync-status / AMS + dev->clear_other_devices(); // why: drop stale LAN discoveries; keep My Devices + } + + m_agent->set_printer_agent(agent); + sidebar().update_all_preset_comboboxes(); +} + std::string GUI_App::resolve_printer_agent_id(const std::string& stored_id) { if (!stored_id.empty()) @@ -3898,9 +3971,11 @@ void GUI_App::switch_printer_agent() // Check if agent is registered const PrinterAgentInfo* agent_info_ptr = NetworkAgentFactory::get_printer_agent_info(effective_agent_id); if (!agent_info_ptr) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": unregistered agent ID '" << effective_agent_id - << "', keeping current agent"; - // Keep current agent, don't switch + // why: the selected agent's provider is gone (e.g. plugin unloaded); leaving the old + // live agent up would keep talking to a machine the user can no longer select. + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": agent ID '" << effective_agent_id + << "' is unregistered; clearing live printer agent"; + set_live_printer_agent(nullptr); return; } const PrinterAgentInfo agent_info = *agent_info_ptr; @@ -3914,7 +3989,9 @@ void GUI_App::switch_printer_agent() NetworkAgentFactory::create_printer_agent_by_id(effective_agent_id, cloud_agent, log_dir); if (!new_printer_agent) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": failed to create agent '" << effective_agent_id << "', keeping current agent"; + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": failed to create agent '" << effective_agent_id + << "'; clearing live printer agent"; + set_live_printer_agent(nullptr); return; } @@ -3937,9 +4014,9 @@ void GUI_App::switch_printer_agent() return; } - // Swap the agent - m_agent->set_printer_agent(new_printer_agent); - sidebar().update_all_preset_comboboxes(); + // Swap the agent; set_live_printer_agent resets the device selection so the new + // agent starts clean (#124). + set_live_printer_agent(new_printer_agent); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": printer agent switched to " << effective_agent_id; diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 6a977d37fc..8bf32df64c 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -803,6 +803,11 @@ private: void window_pos_center(wxTopLevelWindow *window); bool select_language(); + // Dynamic printer agent selection - internal helpers for switch_printer_agent + // and the plugin load/unload callbacks (init_plugin_gui_wiring). + void refresh_printer_agent_dropdown(); + void set_live_printer_agent(std::shared_ptr agent); // null clears the selection + bool config_wizard_startup(); void check_updates(const bool verbose); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 857abdce67..bade7fee98 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -7907,6 +7907,24 @@ bool TabPrinter::apply_extruder_cnt_from_cache() return false; } +void TabPrinter::refresh_printer_agent_dropdown() const +{ + auto* choice = dynamic_cast(get_field("printer_agent")); + if (!choice || !choice->getWindow()) + return; + + const auto agents = NetworkAgentFactory::get_registered_printer_agents(); + if (agents.empty()) + return; + + // why: rows live on PrinterAgentChoice now; rebuild them from the live registry and re-select the stored id. + const std::string selected_agent = wxGetApp().preset_bundle->printers.get_edited_preset() + .config.opt_string("printer_agent"); + choice->reload_rows(); + choice->set_value(selected_agent, false); + this->GetParent()->Layout(); +} + bool Tab::validate_custom_gcodes() { if (m_type != Preset::TYPE_FILAMENT && diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 7187aff467..19eb0b849d 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -675,6 +675,7 @@ public: wxSizer* create_bed_shape_widget(wxWindow* parent); void cache_extruder_cnt(const DynamicPrintConfig* config = nullptr); bool apply_extruder_cnt_from_cache(); + void refresh_printer_agent_dropdown() const; }; class TabSLAMaterial : public Tab From dd2cb92685b27820b059592d5c0f02856f6403bb Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:36:47 +0800 Subject: [PATCH 006/138] Gate agent mode behind use_printer_agents toggle Replace per-printer auto-activation (is_current_printer_agent_plugin) with a global experimental AppConfig toggle, default off: legacy print-host behavior is unchanged until the user opts in. The toggle drives device-tab routing, print button defaults, connect-button visibility and sidebar layout, and dedups machine-select dialog opens. --- src/slic3r/GUI/MainFrame.cpp | 9 ++-- src/slic3r/GUI/PhysicalPrinterDialog.cpp | 2 +- src/slic3r/GUI/Plater.cpp | 54 +++++++++++++----------- src/slic3r/GUI/Preferences.cpp | 8 ++++ src/slic3r/Utils/NetworkAgentFactory.cpp | 20 --------- src/slic3r/Utils/NetworkAgentFactory.hpp | 2 - 6 files changed, 43 insertions(+), 52 deletions(-) diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 7b638e3316..39082a9dca 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -708,7 +708,7 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ m_print_enable = get_enable_print_status(); m_print_btn->Enable(m_print_enable); if (m_print_enable) { - if (wxGetApp().preset_bundle->use_bbl_network()) + if (wxGetApp().preset_bundle->use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_PRINT_PLATE)); else wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SEND_GCODE)); @@ -1999,7 +1999,8 @@ wxBoxSizer* MainFrame::create_side_tools() SidePopup* p = new SidePopup(this); if (wxGetApp().preset_bundle - && !wxGetApp().preset_bundle->is_bbl_vendor()) { + && !wxGetApp().preset_bundle->is_bbl_vendor() + && !wxGetApp().app_config->get_bool("use_printer_agents")) { // ThirdParty Buttons SideButton* export_gcode_btn = new SideButton(p, _L("Export G-code file"), ""); export_gcode_btn->SetCornerRadius(0); @@ -2132,7 +2133,7 @@ wxBoxSizer* MainFrame::create_side_tools() const auto preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { - if (preset_bundle->use_bbl_network()) { + if (preset_bundle->use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) { // BBL network support everything } else { support_send = false; // All 3rd print hosts do not have the send options @@ -4253,7 +4254,7 @@ void MainFrame::load_printer_url(wxString url, wxString apikey) void MainFrame::load_printer_url() { PresetBundle &preset_bundle = *wxGetApp().preset_bundle; - if (preset_bundle.use_bbl_device_tab() || NetworkAgentFactory::is_current_printer_agent_plugin()) + if (preset_bundle.use_bbl_device_tab() || wxGetApp().app_config->get_bool("use_printer_agents")) return; auto cfg = preset_bundle.printers.get_edited_preset().config; diff --git a/src/slic3r/GUI/PhysicalPrinterDialog.cpp b/src/slic3r/GUI/PhysicalPrinterDialog.cpp index 4c9dd60d55..989cf204e1 100644 --- a/src/slic3r/GUI/PhysicalPrinterDialog.cpp +++ b/src/slic3r/GUI/PhysicalPrinterDialog.cpp @@ -669,7 +669,7 @@ void PhysicalPrinterDialog::update(bool printer_change) } // For bbl printers, show option to control the device tab - if (wxGetApp().preset_bundle->is_bbl_vendor()) { + if (wxGetApp().preset_bundle->is_bbl_vendor() || wxGetApp().app_config->get_bool("use_printer_agents")) { m_optgroup->show_field("bbl_use_print_host_webui"); const bool use_print_host_webui = !current_webui.empty(); if (Field* printhost_webui_field = m_optgroup->get_field("bbl_use_print_host_webui"); printhost_webui_field) { diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 106c142fea..d83ddded34 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3246,7 +3246,7 @@ void Sidebar::update_all_preset_comboboxes() auto p_mainframe = wxGetApp().mainframe; auto cfg = preset_bundle.printers.get_edited_preset().config; - const bool use_native_device_tab = preset_bundle.use_bbl_device_tab() || NetworkAgentFactory::is_current_printer_agent_plugin(); + const bool use_native_device_tab = preset_bundle.use_bbl_device_tab() || wxGetApp().app_config->get_bool("use_printer_agents"); if (preset_bundle.use_bbl_network()) { //only show connection button for not-BBL printer @@ -3258,7 +3258,8 @@ void Sidebar::update_all_preset_comboboxes() p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate); } else { //p->btn_connect_printer->Show(); - p->m_printer_connect->Show(); + // ORCA: hide the physical-printer connection button when printer agents are enabled + p->m_printer_connect->Show(!wxGetApp().app_config->get_bool("use_printer_agents")); // ORCA: show/hide sync-ams button based on filament sync mode auto agent = wxGetApp().getAgent(); @@ -3280,7 +3281,9 @@ void Sidebar::update_all_preset_comboboxes() const auto host_type = cfg.option>("host_type")->value; if (cfg.has("printhost_apikey") && (host_type != htSimplyPrint)) apikey = cfg.opt_string("printhost_apikey"); - print_btn_type = preset_bundle.is_bbl_vendor() ? MainFrame::PrintSelectType::ePrintPlate : MainFrame::PrintSelectType::eSendGcode; + print_btn_type = (preset_bundle.is_bbl_vendor() || wxGetApp().app_config->get_bool("use_printer_agents")) + ? MainFrame::PrintSelectType::ePrintPlate + : MainFrame::PrintSelectType::eSendGcode; } if (!use_native_device_tab) @@ -3439,7 +3442,10 @@ void Sidebar::update_presets(Preset::Type preset_type) bool isBBL = preset_bundle.is_bbl_vendor(); bool is_dual_extruder = extruder_variants->size() == 2; - p->layout_printer(preset_bundle.use_bbl_network(), isBBL && is_dual_extruder); + // why: agent mode drives the native device tab, so the sidebar lays out like BBL + // (no physical-printer connect button). + p->layout_printer(preset_bundle.use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents"), + isBBL && is_dual_extruder); // Update nozzle titles from printer config (e.g. "Main Nozzle" / "Auxiliary Nozzle" for N6) // UI left = DEPUTY_EXTRUDER_ID(1), UI right = MAIN_EXTRUDER_ID(0) @@ -5625,6 +5631,7 @@ struct Plater::priv void on_action_slice_all(SimpleEvent&); void on_action_publish(wxCommandEvent &evt); void on_action_print_plate(SimpleEvent&); + void open_machine_select_dialog(int plate_idx, PrintFromType print_type = PrintFromType::FROM_NORMAL); void on_action_print_all(SimpleEvent&); void on_action_export_gcode(SimpleEvent&); void on_action_send_gcode(SimpleEvent&); @@ -11166,18 +11173,23 @@ void Plater::priv::on_action_print_plate(SimpleEvent&) } PresetBundle& preset_bundle = *wxGetApp().preset_bundle; - if (preset_bundle.use_bbl_network()) { - // BBS - if (!m_select_machine_dlg) - m_select_machine_dlg = new SelectMachineDialog(q); - m_select_machine_dlg->set_print_type(PrintFromType::FROM_NORMAL); - m_select_machine_dlg->prepare(partplate_list.get_curr_plate_index()); - m_select_machine_dlg->ShowModal(); + if (preset_bundle.use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) { + open_machine_select_dialog(partplate_list.get_curr_plate_index()); } else { q->send_gcode_legacy(PLATE_CURRENT_IDX, nullptr); } } +void Plater::priv::open_machine_select_dialog(int plate_idx, PrintFromType print_type) +{ + // BBS + if (!m_select_machine_dlg) + m_select_machine_dlg = new SelectMachineDialog(q); + m_select_machine_dlg->set_print_type(print_type); + m_select_machine_dlg->prepare(plate_idx); + m_select_machine_dlg->ShowModal(); +} + void Plater::priv::on_action_send_to_multi_machine(SimpleEvent&) { if (!m_send_multi_dlg) @@ -11193,10 +11205,7 @@ void Plater::priv::on_action_print_plate_from_sdcard(SimpleEvent&) } //BBS - if (!m_select_machine_dlg) m_select_machine_dlg = new SelectMachineDialog(q); - m_select_machine_dlg->set_print_type(PrintFromType::FROM_SDCARD_VIEW); - m_select_machine_dlg->prepare(0); - m_select_machine_dlg->ShowModal(); + open_machine_select_dialog(0, PrintFromType::FROM_SDCARD_VIEW); } void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e) @@ -11211,13 +11220,13 @@ void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e) sidebar_layout.show = new_sel == MainFrame::tp3DEditor || new_sel == MainFrame::tpPreview; update_sidebar(); int old_sel = e.GetOldSelection(); - const bool is_printer_agent_plugin = NetworkAgentFactory::is_current_printer_agent_plugin(); + const bool use_printer_agents = wxGetApp().app_config->get_bool("use_printer_agents"); const bool use_native_device_tab = wxGetApp().preset_bundle && - (wxGetApp().preset_bundle->use_bbl_device_tab() || is_printer_agent_plugin); + (wxGetApp().preset_bundle->use_bbl_device_tab() || use_printer_agents); if (use_native_device_tab && new_sel == MainFrame::tpMonitor) { // BBL network module is only required for BBL-vendor printers. // Non-BBL Python plugins (e.g. moonraker) drive the Device tab without it. - if (!is_printer_agent_plugin && wxGetApp().preset_bundle->is_bbl_vendor() && !Slic3r::NetworkAgent::is_network_module_loaded()) { + if (!use_printer_agents && wxGetApp().preset_bundle->is_bbl_vendor() && !Slic3r::NetworkAgent::is_network_module_loaded()) { e.Veto(); BOOST_LOG_TRIVIAL(info) << boost::format("skipped tab switch from %1% to %2%, lack of network plugins") % old_sel % new_sel; if (q) { @@ -11273,13 +11282,8 @@ void Plater::priv::on_action_print_all(SimpleEvent&) } PresetBundle& preset_bundle = *wxGetApp().preset_bundle; - if (preset_bundle.use_bbl_network()) { - // BBS - if (!m_select_machine_dlg) - m_select_machine_dlg = new SelectMachineDialog(q); - m_select_machine_dlg->set_print_type(PrintFromType::FROM_NORMAL); - m_select_machine_dlg->prepare(PLATE_ALL_IDX); - m_select_machine_dlg->ShowModal(); + if (preset_bundle.use_bbl_network() || wxGetApp().app_config->get_bool("use_printer_agents")) { + open_machine_select_dialog(PLATE_ALL_IDX); } else { q->send_gcode_legacy(PLATE_ALL_IDX, nullptr); } diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 1a3c6fd26a..f802ba6ecb 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -1135,6 +1135,14 @@ wxBoxSizer *PreferencesDialog::create_item_checkbox(wxString title, wxString too wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); } + if (param == "use_printer_agents") + { + // Rebuild the Device tab so the native/web-UI choice reflects the new flag + // immediately, instead of only on the next printer-preset change or restart. + if (wxGetApp().plater()) + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(); + } + if (param == "enable_high_low_temp_mixed_printing") { if (checkbox->GetValue()) { const wxString warning_title = _L("Bed Temperature Difference Warning"); diff --git a/src/slic3r/Utils/NetworkAgentFactory.cpp b/src/slic3r/Utils/NetworkAgentFactory.cpp index 3883d99f2e..ff950d0946 100644 --- a/src/slic3r/Utils/NetworkAgentFactory.cpp +++ b/src/slic3r/Utils/NetworkAgentFactory.cpp @@ -465,25 +465,5 @@ void NetworkAgentFactory::deregister_python_printer_agent(const std::string& plu << plugin_key << "' with agent ID '" << agent_id << "'"; } -bool NetworkAgentFactory::is_current_printer_agent_plugin() -{ - auto* preset_bundle = GUI::wxGetApp().preset_bundle; - if (!preset_bundle) - return false; - - std::string agent_key = ORCA_PRINTER_AGENT_ID; - if (preset_bundle->is_bbl_vendor()) - agent_key = BBL_PRINTER_AGENT_ID; - - const auto& cfg = preset_bundle->printers.get_edited_preset().config; - if (cfg.has("printer_agent")) { - const std::string& value = cfg.option("printer_agent")->value; - if (!value.empty()) - agent_key = value; - } - - const PrinterAgentInfo* info = get_printer_agent_info(agent_key); - return info && info->is_plugin(); -} } // namespace Slic3r diff --git a/src/slic3r/Utils/NetworkAgentFactory.hpp b/src/slic3r/Utils/NetworkAgentFactory.hpp index cfff6fb1c7..a055b19493 100644 --- a/src/slic3r/Utils/NetworkAgentFactory.hpp +++ b/src/slic3r/Utils/NetworkAgentFactory.hpp @@ -166,8 +166,6 @@ public: static void register_python_printer_agent(const std::string& plugin_key, const std::string& capability_name); static void deregister_python_printer_agent(const std::string& plugin_key, const std::string& capability_name); - static bool is_current_printer_agent_plugin(); - private: // Factory is not instantiable NetworkAgentFactory() = delete; From 5d953f915aaeabed49316579de84dd30a077f7fb Mon Sep 17 00:00:00 2001 From: Andrew <159703254+andrewsoonqn@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:37:38 +0800 Subject: [PATCH 007/138] Keep Bambu AMS dialect out of the agent waist M620 is Bambu firmware dialect, not a neutral command. Composing it in MachineObject let non-Bambu agents (Moonraker/Klipper) forward it and report success on firmware that cannot run it. Agents now own the dialect: the default refusal on IPrinterAgent returns not-supported so the UI can say so; BBLPrinterAgent keeps the byte-identical composition. --- src/slic3r/GUI/DeviceManager.cpp | 24 +++++++---- src/slic3r/Utils/BBLPrinterAgent.cpp | 61 ++++++++++++++++++++++++++++ src/slic3r/Utils/BBLPrinterAgent.hpp | 10 +++++ src/slic3r/Utils/IPrinterAgent.hpp | 10 +++++ src/slic3r/Utils/NetworkAgent.cpp | 21 ++++++++++ src/slic3r/Utils/NetworkAgent.hpp | 3 ++ 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 5499694686..c6c8006160 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -1733,9 +1733,11 @@ int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read int MachineObject::command_ams_calibrate(int ams_id) { - std::string gcode_cmd = (boost::format("M620 C%1% \n") % ams_id).str(); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; - return this->publish_gcode(gcode_cmd); + if (!m_agent) return -1; + int rtn = m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); + if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) + show_unsupported_dlg(rtn); + return rtn; } int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max) @@ -1773,9 +1775,11 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s int MachineObject::command_ams_refresh_rfid(std::string tray_id) { - std::string gcode_cmd = (boost::format("M620 R%1% \n") % tray_id).str(); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; - return this->publish_gcode(gcode_cmd); + if (!m_agent) return -1; + int rtn = m_agent->command_ams_refresh_rfid(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); + if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) + show_unsupported_dlg(rtn); + return rtn; } int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id) @@ -1791,9 +1795,11 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id) int MachineObject::command_ams_select_tray(std::string tray_id) { - std::string gcode_cmd = (boost::format("M620 P%1% \n") % tray_id).str(); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; - return this->publish_gcode(gcode_cmd); + if (!m_agent) return -1; + int rtn = m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); + if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) + show_unsupported_dlg(rtn); + return rtn; } int MachineObject::command_ams_control(std::string action) diff --git a/src/slic3r/Utils/BBLPrinterAgent.cpp b/src/slic3r/Utils/BBLPrinterAgent.cpp index ef85e0a1ff..9d422552fe 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.cpp +++ b/src/slic3r/Utils/BBLPrinterAgent.cpp @@ -2,7 +2,9 @@ #include "BBLNetworkPlugin.hpp" #include "NetworkAgentFactory.hpp" +#include #include +#include namespace Slic3r { @@ -20,6 +22,65 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr cloud) // Communication // ============================================================================ +std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& tray_id) +{ + return (boost::format("M620 R%1% \n") % tray_id).str(); +} + +std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id) +{ + return (boost::format("M620 C%1% \n") % ams_id).str(); +} + +std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id) +{ + return (boost::format("M620 P%1% \n") % tray_id).str(); +} + +int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + const std::string gcode = ams_refresh_rfid_gcode(tray_id); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; + nlohmann::json j; + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = gcode; + j["print"]["sequence_id"] = std::to_string(sequence_id); + return publish(dev_id, j, lan_mode); +} + +int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) +{ + const std::string gcode = ams_calibrate_gcode(ams_id); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; + nlohmann::json j; + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = gcode; + j["print"]["sequence_id"] = std::to_string(sequence_id); + return publish(dev_id, j, lan_mode); +} + +int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + const std::string gcode = ams_select_tray_gcode(tray_id); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; + nlohmann::json j; + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = gcode; + j["print"]["sequence_id"] = std::to_string(sequence_id); + return publish(dev_id, j, lan_mode); +} + +int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode) +{ + const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0); + if (rtn == 0) { + BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn; + } else { + BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn; + } + return rtn; +} + int BBLPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { auto& plugin = BBLNetworkPlugin::instance(); diff --git a/src/slic3r/Utils/BBLPrinterAgent.hpp b/src/slic3r/Utils/BBLPrinterAgent.hpp index a8880bf6bf..a04cd00175 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.hpp +++ b/src/slic3r/Utils/BBLPrinterAgent.hpp @@ -5,6 +5,7 @@ #include "ICloudServiceAgent.hpp" #include #include +#include namespace Slic3r { @@ -28,6 +29,12 @@ public: // Communication int send_message(std::string dev_id, std::string json_str, int qos, int flag) override; + static std::string ams_refresh_rfid_gcode(const std::string& tray_id); + static std::string ams_calibrate_gcode(int ams_id); + static std::string ams_select_tray_gcode(const std::string& tray_id); + int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; + int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override; + int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override; int disconnect_printer() override; int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override; @@ -85,6 +92,9 @@ public: FilamentSyncMode get_filament_sync_mode() const override; private: + // why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json. + int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode); + std::shared_ptr m_cloud_agent; }; diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index 91d271316e..22c109946e 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -77,6 +77,16 @@ public: */ virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0; + // why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect + // gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's + // publish funnel turns into a dialog. + virtual int command_ams_refresh_rfid(std::string, std::string, int, bool) + { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } + virtual int command_ams_calibrate(std::string, int, int, bool) + { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } + virtual int command_ams_select_tray(std::string, std::string, int, bool) + { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } + /** * Establish a direct LAN connection to a printer. */ diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index 0d77e5e660..b169fca052 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -767,6 +767,27 @@ int NetworkAgent::send_message(std::string dev_id, std::string json_str, int qos return -1; } +int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + if (m_printer_agent) + return m_printer_agent->command_ams_refresh_rfid(dev_id, tray_id, sequence_id, lan_mode); + return -1; +} + +int NetworkAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) +{ + if (m_printer_agent) + return m_printer_agent->command_ams_calibrate(dev_id, ams_id, sequence_id, lan_mode); + return -1; +} + +int NetworkAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + if (m_printer_agent) + return m_printer_agent->command_ams_select_tray(dev_id, tray_id, sequence_id, lan_mode); + return -1; +} + int NetworkAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) { if (m_printer_agent) diff --git a/src/slic3r/Utils/NetworkAgent.hpp b/src/slic3r/Utils/NetworkAgent.hpp index d7032b7a20..317a357135 100644 --- a/src/slic3r/Utils/NetworkAgent.hpp +++ b/src/slic3r/Utils/NetworkAgent.hpp @@ -142,6 +142,9 @@ public: int set_on_local_message_fn(OnMessageFn fn); int set_server_callback(OnServerErrFn fn); int send_message(std::string dev_id, std::string json_str, int qos, int flag); + int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode); + int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode); + int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode); int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl); int disconnect_printer(); int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag); From df5a08517ab3b56136d96e079a06afcd01896843 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 4 Aug 2026 19:44:39 +0800 Subject: [PATCH 008/138] Keep printer-agent error codes with the interface --- src/slic3r/Utils/IPrinterAgent.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index 22c109946e..85a1ffb8fc 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -2,6 +2,13 @@ #define __I_PRINTER_AGENT_HPP__ #include "bambu_networking.hpp" +// why: these extend the BAMBU_NETWORK_* return space rather than opening a new one - the value +// flows through the same int domain callers already compare against BAMBU_NETWORK_SUCCESS. +// They live here and not in bambu_networking.hpp because that file is a vendor header replaced +// wholesale by header-sync commits (see c09252ce11), which would silently clobber them. +// -70xx is free: the vendor occupies -1..-25 and -10xx through -60xx. +#define ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED -7010 // no translation exists for this command +#define ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE -7020 // a translation exists; this printer lacks the capability #include #include From 56236f56a8ff506aeaffec5aa3c58145d021dd94 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 4 Aug 2026 21:26:50 +0800 Subject: [PATCH 009/138] Add unsupported-command feedback to the device UI --- src/slic3r/GUI/DeviceManager.cpp | 34 ++++++++++++++++++++++++++++++++ src/slic3r/GUI/DeviceManager.hpp | 2 ++ 2 files changed, 36 insertions(+) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index c6c8006160..ffddf5307d 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -4653,6 +4653,40 @@ void MachineObject::set_ctt_dlg( wxString text){ } } +void MachineObject::show_unsupported_dlg(int code) +{ + // why: a dead control invites repeat clicks, and the frame is modeless - without the guard + // every click stacks another one. Same shape as set_ctt_dlg above, including the reset on + // both hide and close so a dismissed dialog can reappear on the next attempt. + if (m_unsupported_dlg_shown) { + return; + } + m_unsupported_dlg_shown = true; + + // why: two codes so the user learns which kind of dead end this is - the slicer having no + // translation for the command, or the printer's own config lacking the hardware to run it. + const wxString text = (code == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) ? + _L("This printer is not configured with the hardware this control needs.") : + _L("This control is not supported on this printer."); + + // note: constructed directly rather than through CallAfter because every publish_json caller + // is on the UI thread - clicks come from wx handlers, and the agent marshals its own push + // callbacks back to main before parse_json runs. set_ctt_dlg relies on the same property. + auto unsupported_dlg = new GUI::SecondaryCheckDialog(nullptr, wxID_ANY, _L("Warning"), + GUI::SecondaryCheckDialog::VisibleButtons::ONLY_CONFIRM); + unsupported_dlg->update_text(text); + unsupported_dlg->Bind(wxEVT_SHOW, [this](auto& e) { + if (!e.IsShown()) { + m_unsupported_dlg_shown = false; + } + }); + unsupported_dlg->Bind(wxEVT_CLOSE_WINDOW, [this](auto& e) { + e.Skip(); + m_unsupported_dlg_shown = false; + }); + unsupported_dlg->on_show(); +} + int MachineObject::publish_gcode(std::string gcode_str) { json j; diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 456901cf84..2790e37cfa 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -272,9 +272,11 @@ public: bool m_is_online; bool m_lan_mode_connection_state{false}; bool m_set_ctt_dlg{ false }; + bool m_unsupported_dlg_shown{ false }; void set_lan_mode_connection_state(bool state) {m_lan_mode_connection_state = state;}; bool get_lan_mode_connection_state() {return m_lan_mode_connection_state;}; void set_ctt_dlg( wxString text); + void show_unsupported_dlg(int code); int parse_msg_count = 0; int keep_alive_count = 0; std::chrono::system_clock::time_point last_update_time; /* last received print data from machine */ From aae83220f15af3df94a6591703be2d82a2e20c6f Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 5 Aug 2026 19:30:26 +0800 Subject: [PATCH 010/138] fix: merge duplicated access code and allow empty access code in UI --- src/slic3r/GUI/ConnectPrinter.cpp | 4 ++- src/slic3r/GUI/DeviceCore/DevManager.cpp | 19 +++++++++--- src/slic3r/GUI/DeviceManager.cpp | 36 +--------------------- src/slic3r/GUI/DeviceManager.hpp | 6 ---- src/slic3r/GUI/GUI_App.cpp | 4 +-- src/slic3r/GUI/ReleaseNote.cpp | 9 ++++-- src/slic3r/GUI/SelectMachinePop.cpp | 1 - src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 1 - 8 files changed, 26 insertions(+), 54 deletions(-) diff --git a/src/slic3r/GUI/ConnectPrinter.cpp b/src/slic3r/GUI/ConnectPrinter.cpp index b4cd7f4f2f..3e78e7fe5c 100644 --- a/src/slic3r/GUI/ConnectPrinter.cpp +++ b/src/slic3r/GUI/ConnectPrinter.cpp @@ -156,6 +156,8 @@ void ConnectPrinterDialog::on_input_enter(wxCommandEvent& evt) void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event) { wxString code = m_textCtrl_code->GetTextCtrl()->GetValue(); + if (code.empty()) + code = "88888888"; for (char c : code) { if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) { show_error(this, _L("Invalid input")); @@ -163,7 +165,7 @@ void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event) } } if (m_obj) { - m_obj->set_user_access_code(code.ToStdString()); + m_obj->set_access_code(code.ToStdString()); } EndModal(wxID_OK); } diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index d13f8b7215..edc958ec53 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -15,6 +15,18 @@ using namespace nlohmann; +namespace { + // Orca: access_code and user_access_code used to be separate AppConfig keys before the two + // fields were merged; fall back to the legacy key so existing users' saved codes aren't lost. + std::string get_access_code_with_legacy_fallback(Slic3r::AppConfig* config, const std::string& dev_id) + { + std::string code = config->get("access_code", dev_id); + if (code.empty()) + code = config->get("user_access_code", dev_id); + return code; + } +} + namespace Slic3r { DeviceManager::DeviceManager(NetworkAgent* agent) @@ -48,8 +60,7 @@ namespace Slic3r obj->bind_sec_link = "secure"; obj->m_is_online = true; obj->last_alive = Slic3r::Utils::get_current_time_utc(); - obj->set_access_code(config->get("access_code", m.dev_id), false); - obj->set_user_access_code(config->get("user_access_code", m.dev_id), false); + obj->set_access_code(get_access_code_with_legacy_fallback(config, m.dev_id), false); if (obj->has_access_right()) { localMachineList.insert(std::make_pair(m.dev_id, obj)); } else { @@ -339,8 +350,7 @@ namespace Slic3r //load access code AppConfig* config = Slic3r::GUI::wxGetApp().app_config; if (config) { - obj->set_access_code(Slic3r::GUI::wxGetApp().app_config->get("access_code", dev_id), false); - obj->set_user_access_code(Slic3r::GUI::wxGetApp().app_config->get("user_access_code", dev_id), false); + obj->set_access_code(get_access_code_with_legacy_fallback(config, dev_id), false); } localMachineList.insert(std::make_pair(dev_id, obj)); @@ -382,7 +392,6 @@ namespace Slic3r obj->m_is_online = true; obj->last_alive = Slic3r::Utils::get_current_time_utc(); obj->set_access_code(access_code, false); - obj->set_user_access_code(access_code, false); update_local_machine(*obj); diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index ef85870461..f4befe78c1 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -449,9 +449,7 @@ bool MachineObject::HasRecentLanMessage() std::string MachineObject::get_access_code() const { - if (get_user_access_code().empty()) - return access_code; - return get_user_access_code(); + return access_code; } void MachineObject::set_access_code(std::string code, bool only_refresh) @@ -470,37 +468,6 @@ void MachineObject::set_access_code(std::string code, bool only_refresh) } } -void MachineObject::erase_user_access_code() -{ - this->user_access_code = ""; - AppConfig* config = GUI::wxGetApp().app_config; - if (config) { - GUI::wxGetApp().app_config->erase("user_access_code", get_dev_id()); - //GUI::wxGetApp().app_config->save(); - } -} - -void MachineObject::set_user_access_code(std::string code, bool only_refresh) -{ - this->user_access_code = code; - if (only_refresh && !code.empty()) { - AppConfig* config = GUI::wxGetApp().app_config; - if (config && !code.empty()) { - GUI::wxGetApp().app_config->set_str("user_access_code", get_dev_id(), code); - DeviceManager::update_local_machine(*this); - } - } -} - -std::string MachineObject::get_user_access_code() const -{ - AppConfig* config = GUI::wxGetApp().app_config; - if (config) { - return GUI::wxGetApp().app_config->get("user_access_code", get_dev_id()); - } - return ""; -} - std::string MachineObject::get_show_printer_type() const { std::string printer_type = this->printer_type; @@ -2907,7 +2874,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ std::string access_code = j_pre["system"]["access_code"].get(); if (!access_code.empty()) { set_access_code(access_code); - set_user_access_code(access_code); } } } diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 2790e37cfa..33635fbe6e 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -113,7 +113,6 @@ private: std::string dev_name; std::string dev_ip; std::string access_code; - std::string user_access_code; // type, time stamp, delay std::vector> message_delay; @@ -228,11 +227,6 @@ public: std::string get_access_code() const; void set_access_code(std::string code, bool only_refresh = true); - /*user access code*/ - void set_user_access_code(std::string code, bool only_refresh = true); - void erase_user_access_code(); - std::string get_user_access_code() const; - //PRINTER_TYPE printer_type = PRINTER_3DPrinter_UKNOWN; std::string printer_type; /* model_id */ std::string get_show_printer_type() const; diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 14edeb8038..db51bd9d8e 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -2166,7 +2166,6 @@ void GUI_App::init_networking_callbacks() obj->is_tunnel_mqtt = tunnel; obj->command_request_push_all(true); obj->command_get_version(); - obj->erase_user_access_code(); obj->command_get_access_code(); if (m_agent) m_agent->install_device_cert(obj->get_dev_id(), obj->is_lan_mode_printer()); @@ -2216,7 +2215,6 @@ void GUI_App::init_networking_callbacks() wxString text; if (msg == "5") { obj->set_access_code(""); - obj->erase_user_access_code(); text = wxString::Format(_L("Incorrect password")); wxGetApp().show_dialog(text); } else { @@ -8286,7 +8284,7 @@ bool GUI_App::show_modal_ip_address_enter_dialog(bool input_sn, wxString title) wxGetApp().app_config->save(); obj->set_dev_ip(ip_address.ToStdString()); - obj->set_user_access_code(access_code.ToStdString()); + obj->set_access_code(access_code.ToStdString()); } } }); diff --git a/src/slic3r/GUI/ReleaseNote.cpp b/src/slic3r/GUI/ReleaseNote.cpp index 22f65f4a60..7b2d091176 100644 --- a/src/slic3r/GUI/ReleaseNote.cpp +++ b/src/slic3r/GUI/ReleaseNote.cpp @@ -1991,7 +1991,7 @@ void InputIpAddressDialog::workerThreadFunc(std::string str_ip, std::string str_ if (w.expired()) return; if (m_obj) { - m_obj->set_user_access_code(str_access_code); + m_obj->set_access_code(str_access_code); wxGetApp().getDeviceManager()->set_selected_machine(m_obj->get_dev_id()); } @@ -2055,6 +2055,11 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt) { auto str_ip = m_input_ip->GetTextCtrl()->GetValue(); auto str_access_code = m_input_access_code->GetTextCtrl()->GetValue(); + + if (str_access_code.empty()) { + str_access_code = "88888888"; + } + auto str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both); auto str_sn = m_input_sn->GetTextCtrl()->GetValue().Strip(wxString::both); bool invalid_access_code = true; @@ -2062,7 +2067,7 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt) for (char c : str_access_code) { if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) { invalid_access_code = false; - return; + break; } } diff --git a/src/slic3r/GUI/SelectMachinePop.cpp b/src/slic3r/GUI/SelectMachinePop.cpp index 492199569e..96324fb4d8 100644 --- a/src/slic3r/GUI/SelectMachinePop.cpp +++ b/src/slic3r/GUI/SelectMachinePop.cpp @@ -704,7 +704,6 @@ void SelectMachinePopup::update_user_devices() } mobj->set_access_code(""); - mobj->erase_user_access_code(); } if (GUI::wxGetApp().plater()) diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index d21dce5070..cd3ef82b62 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -1359,7 +1359,6 @@ void MoonrakerPrinterAgent::announce_printhost_device() if (auto* app_config = GUI::wxGetApp().app_config) { const std::string access_code = device_info.api_key.empty() ? "88888888" : device_info.api_key; app_config->set_str("access_code", device_info.dev_id, access_code); - app_config->set_str("user_access_code", device_info.dev_id, access_code); } nlohmann::json payload; From ced1058b3168e9764210644ba1cfbae65e8d5755 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 5 Aug 2026 19:52:51 +0800 Subject: [PATCH 011/138] fix: naming and print host propagation --- src/slic3r/GUI/MainFrame.cpp | 24 ++++++++++++++++++------ src/slic3r/GUI/Plater.cpp | 13 ++++++++++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 5ef81a32e1..5a0e70b74c 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1373,8 +1373,8 @@ void MainFrame::show_device(bool should_use_native) { const bool use_printer_agents = wxGetApp().app_config->get_bool("use_printer_agents"); - // The legacy page is appended when printer agents are enabled. Remove that - // extra page before switching back to the normal native/legacy layout. + // The web page is appended when printer agents are enabled. Remove that + // extra page before switching back to the normal native/Web layout. if (!use_printer_agents) { if ((idx = m_tabpanel->FindPage(m_printer_view)) != wxNOT_FOUND && idx != tpMonitor) { m_printer_view->Show(false); @@ -1434,10 +1434,10 @@ void MainFrame::show_device(bool should_use_native) { if ((idx = m_tabpanel->FindPage(m_printer_view)) == wxNOT_FOUND) { m_printer_view->Show(false); - m_tabpanel->AddPage(m_printer_view, _L("Device (legacy)"), std::string("tab_monitor_active"), + m_tabpanel->AddPage(m_printer_view, _L("Device (Web)"), std::string("tab_monitor_active"), std::string("tab_monitor_active"), false); } else { - m_tabpanel->SetPageText(idx, _L("Device (legacy)")); + m_tabpanel->SetPageText(idx, _L("Device (Web)")); } #ifdef _MSW_DARK_MODE @@ -4333,14 +4333,26 @@ void MainFrame::load_printer_url(wxString url, wxString apikey) void MainFrame::load_printer_url() { PresetBundle &preset_bundle = *wxGetApp().preset_bundle; - if (preset_bundle.use_bbl_device_tab() || wxGetApp().app_config->get_bool("use_printer_agents")) + if (preset_bundle.use_bbl_device_tab() && !wxGetApp().app_config->get_bool("use_printer_agents")) return; auto cfg = preset_bundle.printers.get_edited_preset().config; + if (cfg.opt_string("print_host").empty()) { + if (auto *device_manager = wxGetApp().getDeviceManager()) { + auto *machine = device_manager->get_selected_machine(); + if (!machine) { + auto machines = device_manager->get_my_machine_list(); + if (machines.size() == 1) + machine = machines.begin()->second; + } + if (machine && !machine->get_dev_ip().empty()) + cfg.opt_string("print_host") = machine->get_dev_ip(); + } + } wxString url = from_u8(PrintHost::get_print_host_webui(&cfg)); wxString apikey; const auto host_type = cfg.option>("host_type")->value; - if (cfg.has("printhost_apikey") && (host_type == htPrusaLink || host_type == htPrusaConnect)) + if (cfg.has("printhost_apikey") && host_type != htSimplyPrint) apikey = cfg.opt_string("printhost_apikey"); if (!url.empty()) { load_printer_url(url, apikey); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 3ee09fed06..0abb2341ff 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3287,7 +3287,9 @@ void Sidebar::update_all_preset_comboboxes() : MainFrame::PrintSelectType::eSendGcode; } - if (!use_native_device_tab || use_printer_agents) + if (use_printer_agents) + p_mainframe->load_printer_url(); + else if (!use_native_device_tab) p_mainframe->load_printer_url(url, apikey); @@ -11236,9 +11238,14 @@ void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e) } } } else { - if (new_sel == MainFrame::tpMonitor && wxGetApp().preset_bundle != nullptr) { + const bool selecting_web_device_tab = main_frame->m_printer_view && + main_frame->m_tabpanel->GetPage(new_sel) == main_frame->m_printer_view; + if (selecting_web_device_tab) { + // Use the selected discovered machine when the preset has no host. + main_frame->load_printer_url(); + } else if (new_sel == MainFrame::tpMonitor && wxGetApp().preset_bundle != nullptr) { auto cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; - wxString url = cfg.opt_string("print_host_webui").empty() ? cfg.opt_string("print_host") : cfg.opt_string("print_host_webui"); + wxString url = from_u8(PrintHost::get_print_host_webui(&cfg)); if (main_frame->m_printer_view && url.empty()) { // It's missing_connection page, reload so that we can replay the gif image main_frame->m_printer_view->reload(); From 159e577543c9bd3e3a6ccc95c36b1b549827b53b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 6 Aug 2026 16:11:44 +0800 Subject: [PATCH 012/138] feat: Isolate devices across different printer agents --- src/libslic3r/AppConfig.cpp | 6 ++ src/libslic3r/AppConfig.hpp | 11 ++- src/slic3r/GUI/DeviceCore/DevManager.cpp | 91 ++++++++++++++++++------ src/slic3r/GUI/DeviceCore/DevManager.h | 14 +++- src/slic3r/GUI/DeviceManager.cpp | 39 ++++++++-- src/slic3r/GUI/DeviceManager.hpp | 10 +++ src/slic3r/GUI/GUI_App.cpp | 8 ++- src/slic3r/GUI/SelectMachine.cpp | 3 +- src/slic3r/GUI/SelectMachinePop.cpp | 7 +- 9 files changed, 157 insertions(+), 32 deletions(-) diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 1b170bf884..da82016a6f 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -853,6 +853,10 @@ std::string AppConfig::load() local_machine.dev_ip = p["dev_ip"].get(); if (p.contains("printer_type")) local_machine.printer_type = p["printer_type"].get(); + if (p.contains("printer_agent_id")) + local_machine.printer_agent_id = p["printer_agent_id"].get(); + if (p.contains("access_code")) + local_machine.access_code = p["access_code"].get(); m_local_machines[local_machine.dev_id] = local_machine; } } else { @@ -1065,6 +1069,8 @@ void AppConfig::save() m_json["dev_name"] = local_machine.second.dev_name; m_json["dev_ip"] = local_machine.second.dev_ip; m_json["printer_type"] = local_machine.second.printer_type; + m_json["printer_agent_id"] = local_machine.second.printer_agent_id; + m_json["access_code"] = local_machine.second.access_code; j["local_machines"][local_machine.first] = m_json; } diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 2c83ebb488..b73ff5eac4 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -61,10 +61,19 @@ struct BBLocalMachine std::string dev_ip; std::string dev_id; /* serial number */ std::string printer_type; /* model_id */ + std::string printer_agent_id; /* id of the IPrinterAgent that discovered/bound this device, e.g. "bbl"; empty for entries persisted before this field existed */ + // Access code, scoped to printer_agent_id above - so a code saved while bound under one + // printer agent isn't treated as valid for a different, independent agent talking to the + // same physical dev_id. Empty for entries persisted before this field existed; those fall + // back to the legacy flat "access_code"/"user_access_code" AppConfig sections (BBL-only, + // since BBL was the only agent when they were saved) - see + // get_access_code_with_legacy_fallback() in DevManager.cpp. + std::string access_code; bool operator==(const BBLocalMachine& other) const { - return dev_name == other.dev_name && dev_ip == other.dev_ip && dev_id == other.dev_id && printer_type == other.printer_type; + return dev_name == other.dev_name && dev_ip == other.dev_ip && dev_id == other.dev_id && printer_type == other.printer_type && + printer_agent_id == other.printer_agent_id && access_code == other.access_code; } bool operator!=(const BBLocalMachine& other) const { return !operator==(other); } }; diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index edc958ec53..8844303793 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -10,20 +10,36 @@ #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/Utils/NetworkAgentFactory.hpp" #include "libslic3r/Time.hpp" using namespace nlohmann; namespace { - // Orca: access_code and user_access_code used to be separate AppConfig keys before the two - // fields were merged; fall back to the legacy key so existing users' saved codes aren't lost. - std::string get_access_code_with_legacy_fallback(Slic3r::AppConfig* config, const std::string& dev_id) + // Orca: access_code lives on BBLocalMachine::access_code (keyed by dev_id via + // get_local_machines(), scoped by the record's own printer_agent_id field) - so binding a + // printer under one agent doesn't silently appear as already-bound under a different, + // independent agent. This only covers LAN devices (BBLocalMachine's own scope); access_code + // and user_access_code used to be the only, flat dev_id-only AppConfig keys before + // BBLocalMachine::access_code existed, and codes saved back then are still stored flat (no + // agent association at all). Since BBL was the only agent that existed at the time, honor + // those flat legacy keys as implicitly BBL's - but only for the BBL agent, so they aren't + // leaked to other agents that never bound the device themselves. + std::string get_access_code_with_legacy_fallback(Slic3r::AppConfig* config, const std::string& dev_id, const std::string& agent_id) { - std::string code = config->get("access_code", dev_id); - if (code.empty()) - code = config->get("user_access_code", dev_id); - return code; + const auto& machines = config->get_local_machines(); + auto it = machines.find(dev_id); + if (it != machines.end() && it->second.printer_agent_id == agent_id && !it->second.access_code.empty()) + return it->second.access_code; + + if (agent_id == Slic3r::BBL_PRINTER_AGENT_ID || agent_id.empty()) { + std::string code = config->get("access_code", dev_id); + if (code.empty()) + code = config->get("user_access_code", dev_id); + return code; + } + return ""; } } @@ -55,12 +71,13 @@ namespace Slic3r continue; MachineObject* obj = new MachineObject(this, m_agent, m.dev_name, m.dev_id, m.dev_ip); obj->printer_type = m.printer_type; + obj->printer_agent_id = m.printer_agent_id; obj->dev_connection_type = "lan"; obj->bind_state = "free"; obj->bind_sec_link = "secure"; obj->m_is_online = true; obj->last_alive = Slic3r::Utils::get_current_time_utc(); - obj->set_access_code(get_access_code_with_legacy_fallback(config, m.dev_id), false); + obj->set_access_code(get_access_code_with_legacy_fallback(config, m.dev_id, obj->printer_agent_id), false); if (obj->has_access_right()) { localMachineList.insert(std::make_pair(m.dev_id, obj)); } else { @@ -77,10 +94,12 @@ namespace Slic3r if (m.is_lan_mode_printer()) { if (m.has_access_right()) { BBLocalMachine local_machine; - local_machine.dev_id = m.get_dev_id(); - local_machine.dev_name = m.get_dev_name(); - local_machine.dev_ip = m.get_dev_ip(); - local_machine.printer_type = m.printer_type; + local_machine.dev_id = m.get_dev_id(); + local_machine.dev_name = m.get_dev_name(); + local_machine.dev_ip = m.get_dev_ip(); + local_machine.printer_type = m.printer_type; + local_machine.printer_agent_id = m.printer_agent_id; + local_machine.access_code = m.get_access_code(); config->update_local_machine(local_machine); } } else { @@ -143,6 +162,14 @@ namespace Slic3r } } + std::string DeviceManager::get_current_printer_agent_id() const + { + if (!m_agent) + return ""; + auto printer_agent = m_agent->get_printer_agent(); + return printer_agent ? printer_agent->get_agent_info().id : ""; + } + void DeviceManager::EnableMultiMachine(bool enable) { m_agent->enable_multi_machine(enable); @@ -339,6 +366,7 @@ namespace Slic3r /* insert a new machine */ obj = new MachineObject(this, m_agent, dev_name, dev_id, dev_ip); obj->printer_type = _parse_printer_type(printer_type_str); + obj->printer_agent_id = get_current_printer_agent_id(); obj->wifi_signal = printer_signal; obj->dev_connection_type = connect_type; obj->bind_state = bind_state; @@ -350,7 +378,7 @@ namespace Slic3r //load access code AppConfig* config = Slic3r::GUI::wxGetApp().app_config; if (config) { - obj->set_access_code(get_access_code_with_legacy_fallback(config, dev_id), false); + obj->set_access_code(get_access_code_with_legacy_fallback(config, dev_id, obj->printer_agent_id), false); } localMachineList.insert(std::make_pair(dev_id, obj)); @@ -379,6 +407,7 @@ namespace Slic3r obj = it->second; } else { obj = new MachineObject(this, m_agent, machine.dev_name, machine.dev_id, machine.dev_ip); + obj->printer_agent_id = get_current_printer_agent_id(); localMachineList.insert(std::make_pair(machine.dev_id, obj)); } if (machine.printer_type.empty()) @@ -505,16 +534,26 @@ namespace Slic3r OnSelectedMachineChanged(previous_selected_machine, selected_machine); } - void DeviceManager::clear_other_devices() + void DeviceManager::clear_other_devices(const std::string& target_agent_id) { // why: on agent swap, keep "My Devices" but drop the transient "Other Devices" // Those belong to the previous agent's network scan; the new agent's start_discovery re-populates its own. + // + // Also drop "My Devices" stamped by a different agent than the one we're swapping to + // (target_agent_id, passed by the caller since the live agent hasn't been repointed yet + // at this point): otherwise a device first discovered under agent A survives every swap + // with a stale printer_agent_id, stays hidden from every agent's filtered list, and only + // gets re-tagged if something happens to delete and re-create it (e.g. account logout). + // Dropping it here instead lets the new agent's start_discovery re-insert and re-stamp it + // like any other fresh device. const auto my = get_my_machine_list(); for (auto it = localMachineList.begin(); it != localMachineList.end();) { - if (my.find(it->first) == my.end()) + const bool is_my_device = my.find(it->first) != my.end(); + const bool agent_mismatch = !target_agent_id.empty() && it->second && + it->second->printer_agent_id != target_agent_id; + if (!is_my_device || agent_mismatch) { - // not a "My Device" -> an "Other Device" delete it->second; it = localMachineList.erase(it); } @@ -697,13 +736,16 @@ namespace Slic3r m_agent->add_subscribe(subscribe_list_cache); } - std::map DeviceManager::get_my_machine_list() + std::map DeviceManager::get_my_machine_list(const std::string& agent_id) { std::map result; for (auto it = userMachineList.begin(); it != userMachineList.end(); it++) { - if (it->second && !it->second->is_lan_mode_printer()) + if (!it->second || (!agent_id.empty() && it->second->printer_agent_id != agent_id)) + continue; + + if (!it->second->is_lan_mode_printer()) { result.insert(std::make_pair(it->first, it->second)); } @@ -711,7 +753,10 @@ namespace Slic3r for (auto it = localMachineList.begin(); it != localMachineList.end(); it++) { - if (it->second && it->second->has_access_right() && it->second->is_avaliable() && it->second->is_lan_mode_printer()) + if (!it->second || (!agent_id.empty() && it->second->printer_agent_id != agent_id)) + continue; + + if (it->second->has_access_right() && it->second->is_avaliable() && it->second->is_lan_mode_printer()) { // remove redundant in userMachineList if (result.find(it->first) == result.end()) @@ -723,12 +768,15 @@ namespace Slic3r return result; } - std::map DeviceManager::get_my_cloud_machine_list() + std::map DeviceManager::get_my_cloud_machine_list(const std::string& agent_id) { std::map result; for (auto it = userMachineList.begin(); it != userMachineList.end(); it++) { - if (it->second && !it->second->is_lan_mode_printer()) { result.emplace(*it); } + if (!it->second || (!agent_id.empty() && it->second->printer_agent_id != agent_id)) + continue; + + if (!it->second->is_lan_mode_printer()) { result.emplace(*it); } } return result; } @@ -801,6 +849,7 @@ namespace Slic3r else { obj = new MachineObject(this, m_agent, "", "", ""); + obj->printer_agent_id = get_current_printer_agent_id(); if (m_agent) { obj->set_bind_status(m_agent->get_user_name(provider)); diff --git a/src/slic3r/GUI/DeviceCore/DevManager.h b/src/slic3r/GUI/DeviceCore/DevManager.h index e3ac0064b9..1f48baba98 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.h +++ b/src/slic3r/GUI/DeviceCore/DevManager.h @@ -74,7 +74,10 @@ public: void erase_user_machine(std::string dev_id) { userMachineList.erase(dev_id); } void clean_user_info(bool keep_local_selection = false); - void clear_other_devices(); + // target_agent_id: id of the agent being swapped to (empty = no agent-mismatch check, + // just the original "drop Other Devices" behavior). Pass the incoming agent's id, not the + // live one - this runs before the live agent is repointed. + void clear_other_devices(const std::string& target_agent_id = ""); void load_last_machine(); void update_user_machine_list_info(const std::string& provider); @@ -90,10 +93,15 @@ public: /* my machine*/ MachineObject* get_my_machine(std::string dev_id); - std::map get_my_machine_list(); - std::map get_my_cloud_machine_list(); + std::map get_my_machine_list(const std::string& agent_id = ""); + std::map get_my_cloud_machine_list(const std::string& agent_id = ""); void modify_device_name(std::string dev_id, std::string dev_name, const std::string& provider); + // id of the currently live IPrinterAgent (IPrinterAgent::get_agent_info().id), or empty if + // m_agent has no printer agent set yet. Pass to get_my_machine_list()/get_my_cloud_machine_list() + // to scope results to the active agent. + std::string get_current_printer_agent_id() const; + /* create machine or update machine properties */ void on_machine_alive(std::string json_str); int query_bind_status(std::string& msg, const std::string& provider); diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index cb29fbe11a..48f3cb60fc 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Time.hpp" #include "libslic3r/Thread.hpp" #include "slic3r/Utils/NetworkAgent.hpp" +#include "slic3r/Utils/NetworkAgentFactory.hpp" #include "GuiColor.hpp" #include "GUI_App.hpp" @@ -458,11 +459,41 @@ void MachineObject::set_access_code(std::string code, bool only_refresh) if (only_refresh) { AppConfig* config = GUI::wxGetApp().app_config; if (config) { - if (!code.empty()) { - GUI::wxGetApp().app_config->set_str("access_code", get_dev_id(), code); - DeviceManager::update_local_machine(*this); + if (is_lan_mode_printer()) { + // why: LAN codes are scoped via BBLocalMachine::access_code, keyed by dev_id and + // scoped by that record's own printer_agent_id field - see the matching comment + // on get_access_code_with_legacy_fallback() in DevManager.cpp - so binding this + // device under one printer agent doesn't silently read as already-bound under a + // different, independent one. Cloud devices (the else branch below) aren't + // scoped this way: they're never recalled from a stale local cache across a + // session boundary, since parse_user_print_info() always overwrites their code + // fresh from the cloud API's current response, so there's no cross-agent leakage + // risk to guard against there. + if (!code.empty()) { + DeviceManager::update_local_machine(*this); + } else { + // Only patch an existing record's code - don't persist a brand-new + // never-bound entry just because set_access_code("") was called on it. + const auto& machines = config->get_local_machines(); + auto it = machines.find(get_dev_id()); + if (it != machines.end()) { + BBLocalMachine local_machine = it->second; + local_machine.access_code = ""; + config->update_local_machine(local_machine); + } + // Also clear the pre-scoping flat legacy key when unbinding under BBL, so an + // old BBL-era code can't silently "re-bind" this device again via + // get_access_code_with_legacy_fallback()'s legacy fallback. + if (printer_agent_id == BBL_PRINTER_AGENT_ID || printer_agent_id.empty()) { + config->erase("access_code", get_dev_id()); + config->erase("user_access_code", get_dev_id()); + } + } } else { - GUI::wxGetApp().app_config->erase("access_code", get_dev_id()); + if (!code.empty()) + config->set_str("access_code", get_dev_id(), code); + else + config->erase("access_code", get_dev_id()); } } } diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 33635fbe6e..914c8f7868 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -229,6 +229,16 @@ public: //PRINTER_TYPE printer_type = PRINTER_3DPrinter_UKNOWN; std::string printer_type; /* model_id */ + + // id of the IPrinterAgent that was used to discover or bind this device (IPrinterAgent::get_agent_info().id, + // e.g. "bbl"), stamped at creation time — not derived from get_agent(), since m_agent is a single + // process-wide NetworkAgent shared by every MachineObject and gets repointed on agent swap + // (see DeviceManager::set_agent()), so it can't tell which agent originally found this device. + // We persist this as well so that when the printer agent is swapped, we don't show unrelated devices, + // e.g. if the current printer agent is elegoo, we shouldn't show printers connected by BBL printer agent + // under local machines. + std::string printer_agent_id; + std::string get_show_printer_type() const; PrinterSeries get_printer_series() const; PrinterArch get_printer_arch() const; diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index db51bd9d8e..5faf112db3 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3937,7 +3937,13 @@ void GUI_App::set_live_printer_agent(std::shared_ptr agent) m_agent->set_user_selected_machine(""); // note: belt-and-suspenders (precedent: DeviceManagerRefresher::on_timer) dev->OnSelectedMachineLost(); // why: clear stale sidebar sync-status / AMS - dev->clear_other_devices(); // why: drop stale LAN discoveries; keep My Devices + // why: drop stale LAN discoveries; keep My Devices, but only those belonging to the + // agent we're about to swap to, so a device stamped by the outgoing agent doesn't + // linger hidden - the new agent's start_discovery re-inserts and re-stamps it fresh. + // agent is null when clearing the live agent entirely (e.g. plugin unload); there's no + // target to filter against then, so fall back to the original "keep all My Devices" + // behavior rather than guessing. + dev->clear_other_devices(agent ? agent->get_agent_info().id : std::string()); } m_agent->set_printer_agent(agent); diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 1ab78fcc11..23f13d1497 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -3913,7 +3913,8 @@ _collect_sorted_machines(Slic3r::DeviceManager* dev_manager, }; // collect from user machine list - const auto& user_machine_list = dev_manager->get_my_machine_list();// user machine list + const std::string agent_id = wxGetApp().preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"); + const auto& user_machine_list = dev_manager->get_my_machine_list(agent_id);// user machine list for (const auto& elem : user_machine_list) { MachineObject* mobj = elem.second; diff --git a/src/slic3r/GUI/SelectMachinePop.cpp b/src/slic3r/GUI/SelectMachinePop.cpp index 96324fb4d8..df0a566917 100644 --- a/src/slic3r/GUI/SelectMachinePop.cpp +++ b/src/slic3r/GUI/SelectMachinePop.cpp @@ -501,6 +501,7 @@ void SelectMachinePopup::update_other_devices() DeviceManager* dev = wxGetApp().getDeviceManager(); if (!dev) return; m_free_machine_list = dev->get_local_machinelist(); + const std::string current_agent_id = dev->get_current_printer_agent_id(); BOOST_LOG_TRIVIAL(trace) << "SelectMachinePopup update_other_devices start"; this->Freeze(); @@ -512,6 +513,10 @@ void SelectMachinePopup::update_other_devices() /* do not show printer bind state is empty */ if (!mobj->is_avaliable()) continue; + /* do not show devices discovered/bound by a different printer agent */ + if (mobj->printer_agent_id != current_agent_id) + continue; + if (!wxGetApp().is_user_login(wxGetApp().get_printer_cloud_provider()) && !mobj->is_lan_mode_printer()) continue; @@ -634,7 +639,7 @@ void SelectMachinePopup::update_user_devices() } m_bind_machine_list.clear(); - m_bind_machine_list = dev->get_my_machine_list(); + m_bind_machine_list = dev->get_my_machine_list(dev->get_current_printer_agent_id()); //sort list std::vector> user_machine_list; From 6345d57512eca5e86ab2bac8ad43ec2daf09a939 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 6 Aug 2026 16:26:39 +0800 Subject: [PATCH 013/138] fix: use get_current_printer_agent_id --- src/slic3r/GUI/SelectMachine.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 23f13d1497..e2b9ce78ad 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -3913,8 +3913,7 @@ _collect_sorted_machines(Slic3r::DeviceManager* dev_manager, }; // collect from user machine list - const std::string agent_id = wxGetApp().preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"); - const auto& user_machine_list = dev_manager->get_my_machine_list(agent_id);// user machine list + const auto& user_machine_list = dev_manager->get_my_machine_list(dev_manager->get_current_printer_agent_id());// user machine list for (const auto& elem : user_machine_list) { MachineObject* mobj = elem.second; From 4196c23d44648bf6689b3cce7826382a24ada14e Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 17 Jul 2026 22:59:34 +0800 Subject: [PATCH 014/138] =?UTF-8?q?=EF=BB=BFAdd=20ffmepg=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deps/CMakeLists.txt | 3 ++ deps/FFMPEG/FFMPEG.cmake | 79 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 deps/FFMPEG/FFMPEG.cmake diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 39cc5de182..b7435df295 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -367,6 +367,8 @@ include(libnoise/libnoise.cmake) include(Draco/Draco.cmake) +include(FFMPEG/FFMPEG.cmake) + # I *think* 1.1 is used for *just* md5 hashing? # 3.1 has everything in the right place, but the md5 funcs used are deprecated @@ -448,6 +450,7 @@ set(_dep_list dep_libnoise dep_python3 dep_wxInspector + dep_FFMPEG ) if (MSVC) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake new file mode 100644 index 0000000000..26ce001833 --- /dev/null +++ b/deps/FFMPEG/FFMPEG.cmake @@ -0,0 +1,79 @@ +set(_conf_cmd ./configure) + +if (MSVC) + set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG") + + set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-winarm64-orca-shared-7.0.zip") + set(PREBUILD_HASH_arm64 "12f4140279f2f8469885e1b5b2e8be9d788882914c21523cacd56989f3548054") + set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-win64-orca-shared-7.0.zip") + set(PREBUILD_HASH_x64 "e65916020ddb9ef84b2666dfbcbfc9b1d67f69d15b4a66db53754637bf2d498c") + + ExternalProject_Add(dep_FFMPEG + URL ${PREBUILD_URL_${DEPS_ARCH}} + URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}} + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/bin" "${DESTDIR}/bin" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/lib" "${DESTDIR}/lib" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/include" "${DESTDIR}/include" + ) + +else () + if (APPLE) + set(_minos_cmd + "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + ) + if (IS_CROSS_COMPILE) + set(_cross_cmd --enable-cross-compile) + set(_pic_cmd --enable-pic) + if (${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64") + set(_arch_cmd --arch=arm64) + set(_cc_cmd "--cc=clang -arch arm64") + else() + set(_arch_cmd --arch=x86_64) + set(_cc_cmd "--cc=clang -arch x86_64") + endif() + endif() + endif() + + set(_build_j -j) + if(DEFINED ENV{CMAKE_BUILD_PARALLEL_LEVEL}) + set(_build_j "-j$ENV{CMAKE_BUILD_PARALLEL_LEVEL}") + endif() + + ExternalProject_Add(dep_FFMPEG + URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz + URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG + CONFIGURE_COMMAND ${_conf_cmd} + ${_cross_cmd} + ${_pic_cmd} + ${_arch_cmd} + ${_cc_cmd} + "--prefix=${DESTDIR}" + --enable-shared + --disable-doc + --enable-small + --disable-outdevs + --disable-filters + --enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test* + --disable-protocols + --enable-protocol=file,fd,pipe,rtp,udp + --disable-muxers + --enable-muxer=rtp + --disable-encoders + --disable-decoders + --enable-decoder=*aac*,h264*,mp3*,mjpeg,rv* + --disable-demuxers + --enable-demuxer=h264,mp3,mov + --disable-zlib + --disable-avdevice + BUILD_IN_SOURCE ON + BUILD_COMMAND make ${_build_j} + INSTALL_COMMAND make install + ) + +endif() From 56ac17f0850a7b7d57c0a2cd809e751152e0605f Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Wed, 26 Jun 2024 19:51:59 +0800 Subject: [PATCH 015/138] NEW: reimpl wxMediaCtrl from ffmpeg Jira: none Change-Id: I46a47118a7649b2a50fcce8911e2888342ef25de (cherry picked from commit d6c7f08769c8cfdbbf0e80ad280c9b3408a3c27d) (cherry picked from commit 94d91be60bfe9bbbcdd21f85b46abc3faf126f17) --- CMakeLists.txt | 33 +++- src/CMakeLists.txt | 16 +- src/slic3r/CMakeLists.txt | 14 ++ src/slic3r/GUI/AVVideoDecoder.cpp | 126 ++++++++++++++ src/slic3r/GUI/AVVideoDecoder.hpp | 39 +++++ src/slic3r/GUI/MediaPlayCtrl.cpp | 42 +++-- src/slic3r/GUI/MediaPlayCtrl.h | 13 +- src/slic3r/GUI/StatusPanel.cpp | 74 ++++----- src/slic3r/GUI/StatusPanel.hpp | 21 ++- src/slic3r/GUI/wxMediaCtrl2.cpp | 26 ++- src/slic3r/GUI/wxMediaCtrl2.h | 8 +- src/slic3r/GUI/wxMediaCtrl2.mm | 11 ++ src/slic3r/GUI/wxMediaCtrl3.cpp | 267 ++++++++++++++++++++++++++++++ src/slic3r/GUI/wxMediaCtrl3.h | 83 ++++++++++ 14 files changed, 683 insertions(+), 90 deletions(-) create mode 100644 src/slic3r/GUI/AVVideoDecoder.cpp create mode 100644 src/slic3r/GUI/AVVideoDecoder.hpp create mode 100644 src/slic3r/GUI/wxMediaCtrl3.cpp create mode 100644 src/slic3r/GUI/wxMediaCtrl3.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fc688b35df..cabbb42b33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -460,7 +460,8 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # WIN10SDK_PATH is used to point CMake to the WIN10 SDK installation directory. # We pick it from environment if it is not defined in another way # ORCA: Removed Netfabb STL fixing service support in favor of CGAL. -# if(WIN32) +if(WIN32) + find_package(PkgConfig REQUIRED) # if(NOT DEFINED WIN10SDK_PATH) # if(DEFINED ENV{WIN10SDK_PATH}) # set(WIN10SDK_PATH "$ENV{WIN10SDK_PATH}") @@ -496,7 +497,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # else() # message("Building without Win10 Netfabb STL fixing service support") # endif() -# endif() +endif() if (APPLE) message("OS X SDK Path: ${CMAKE_OSX_SYSROOT}") @@ -1044,6 +1045,10 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll ${CMAKE_PREFIX_PATH}/bin/freetype.dll + ${CMAKE_PREFIX_PATH}/bin/avcodec-59.dll + ${CMAKE_PREFIX_PATH}/bin/swresample-4.dll + ${CMAKE_PREFIX_PATH}/bin/swscale-6.dll + ${CMAKE_PREFIX_PATH}/bin/avutil-57.dll DESTINATION ${_out_dir}) set(${output_dlls} @@ -1079,12 +1084,34 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${_out_dir}/TKXSBase.dll ${_out_dir}/freetype.dll - + ${_out_dir}/avcodec-59.dll + ${_out_dir}/swresample-4.dll + ${_out_dir}/swscale-6.dll + ${_out_dir}/avutil-57.dll PARENT_SCOPE ) endfunction() +function(bambustudio_copy_sos target config postfix output_sos) + + set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") + message ("set out_dir to CMAKE_CURRENT_BINARY_DIR: ${_out_dir}") + + file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so + ${CMAKE_PREFIX_PATH}/lib/libavutil.so + ${CMAKE_PREFIX_PATH}/lib/libswscale.so + ${CMAKE_PREFIX_PATH}/lib/libswresample.so + DESTINATION ${_out_dir}) + + set(${output_dlls} + ${_out_dir}/libavcodec.so + ${_out_dir}/libavutil.so + ${_out_dir}/libswscale.so + ${_out_dir}/libswresample.so + PARENT_SCOPE + ) +endfunction() # libslic3r, OrcaSlicer GUI and the OrcaSlicer executable. add_subdirectory(deps_src) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 79b49cfd16..df559aa397 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,7 +75,7 @@ if (SLIC3R_GUI) list(FILTER wxWidgets_LIBRARIES EXCLUDE REGEX expat) list(APPEND wxWidgets_LIBRARIES ${EXPAT_LIBRARIES}) endif () - + # This is an issue in the new wxWidgets cmake build, doesn't deal with librt find_library(LIBRT rt) if(LIBRT) @@ -281,6 +281,16 @@ if (WIN32) else () + if (NOT APPLE) + set(output_sos_Release "") + set(output_sos_Debug "") + add_custom_target(BambuStudioSosCopy ALL DEPENDS BambuStudio) + if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") + bambustudio_copy_sos(BambuStudioSosCopy "Debug" "d" output_sos_Debug) + else() + bambustudio_copy_sos(BambuStudioSosCopy "Release" "" output_sos_Release) + endif() + endif() if (APPLE AND NOT CMAKE_MACOSX_BUNDLE) # On OSX, the name of the binary matches the name of the Application. add_custom_command(TARGET OrcaSlicer POST_BUILD @@ -359,5 +369,9 @@ if (WIN32) install(FILES ${output_dlls_${build_type}} DESTINATION ".") install(DIRECTORY "${CMAKE_PREFIX_PATH}/libpython/" DESTINATION "python") else () + if (APPLE) + else() + install(FILES ${output_sos_${build_type}} DESTINATION "${CMAKE_INSTALL_PREFIX}") + endif() install(TARGETS OrcaSlicer RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR}) endif () diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index b98396f943..0436c9a817 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -786,12 +786,17 @@ if (APPLE) GUI/GUI_UtilsMac.mm GUI/wxMediaCtrl2.mm GUI/wxMediaCtrl2.h + GUI/wxMediaCtrl3.h ) FIND_LIBRARY(DISKARBITRATION_LIBRARY DiskArbitration) else () list(APPEND SLIC3R_GUI_SOURCES + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp GUI/wxMediaCtrl2.cpp GUI/wxMediaCtrl2.h + GUI/wxMediaCtrl3.cpp + GUI/wxMediaCtrl3.h ) endif () @@ -898,6 +903,15 @@ if (SLIC3R_PCH AND NOT SLIC3R_SYNTAXONLY) add_precompiled_header(libslic3r_gui pchheader.hpp FORCEINCLUDE) endif () +if (NOT APPLE) + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() + # We need to implement some hacks for wxWidgets and touch the underlying GTK # layer and sub-libraries. This forces us to use the include locations and # link these libraries. diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp new file mode 100644 index 0000000000..cb9ed33a2a --- /dev/null +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -0,0 +1,126 @@ +#include "AVVideoDecoder.hpp" + +extern "C" +{ + #include + #include +} + +AVVideoDecoder::AVVideoDecoder() +{ + codec_ctx_ = avcodec_alloc_context3(nullptr); +} + +AVVideoDecoder::~AVVideoDecoder() +{ + if (sws_ctx_) + sws_freeContext(sws_ctx_); + if (frame_) + av_frame_free(&frame_); + if (codec_ctx_) + avcodec_free_context(&codec_ctx_); +} + +int AVVideoDecoder::open(Bambu_StreamInfo const &info) +{ + auto codec_id = info.sub_type == AVC1 ? AV_CODEC_ID_H264 : AV_CODEC_ID_MJPEG; + auto codec = avcodec_find_decoder(codec_id); + if (codec == nullptr) { + fprintf(stderr, "Unsupported codec!\n"); + return -1; // Codec not found + } + /* open the coderc */ + if (avcodec_open2(codec_ctx_, codec, nullptr) < 0) { + fprintf(stderr, "could not open codec\n"); + return -1; + } + + // Allocate an AVFrame structure + frame_ = av_frame_alloc(); + if (frame_ == nullptr) + return -1; + + return 0; +} + +int AVVideoDecoder::decode(const Bambu_Sample &sample) +{ + auto pkt = av_packet_alloc(); + int ret = av_new_packet(pkt, sample.size); + if (ret == 0) + memcpy(pkt->data, sample.buffer, size_t(sample.size)); + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + ret = avcodec_send_packet(codec_ctx_, pkt); + return ret; +} + +int AVVideoDecoder::flush() +{ + int ret = avcodec_send_packet(codec_ctx_, nullptr); + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + return ret; +} + +void AVVideoDecoder::close() +{ +} + +bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) +{ + if (!got_frame_) + return false; + + auto size = size2; + if (!size.IsFullySpecified()) + size = {frame_->width, frame_->height }; + if (size.GetWidth() & 0x0f) + size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + AVPixelFormat wxFmt = AV_PIX_FMT_RGB24; + sws_ctx_ = sws_getCachedContext(sws_ctx_, + frame_->width, frame_->height, AVPixelFormat(frame_->format), + size.GetWidth(), size.GetHeight(), wxFmt, + SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC + nullptr, nullptr, nullptr); + uint8_t *data = (uint8_t*)malloc(size.GetWidth() * size.GetHeight() * 3); + if (data == nullptr) + return false; + uint8_t * datas[] = {data }; + int strides[] = {size.GetWidth() * 3}; + int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); + if (result_h != size.GetHeight()) { + delete[] data; + return false; + } + image = wxImage(size.GetWidth(), size.GetHeight(), data); + return true; +} + +bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) +{ + if (!got_frame_) + return false; + + auto size = size2; + if (!size.IsFullySpecified()) + size = {frame_->width, frame_->height }; + if (size.GetWidth() & 0x0f) + size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + AVPixelFormat wxFmt = AV_PIX_FMT_RGB32; + sws_ctx_ = sws_getCachedContext(sws_ctx_, + frame_->width, frame_->height, AVPixelFormat(frame_->format), + size.GetWidth(), size.GetHeight(), wxFmt, + SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC + nullptr, nullptr, nullptr); + uint8_t *data = (uint8_t*)malloc(size.GetWidth() * size.GetHeight() * 4); + if (data == nullptr) + return false; + uint8_t *datas[] = {data}; + int strides[] = {size.GetWidth() * 4}; + int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); + if (result_h != size.GetHeight()) { + delete[] data; + return false; + } + bitmap = wxBitmap((char *) data, size.GetWidth(), size.GetHeight(), 32); + return true; +} diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp new file mode 100644 index 0000000000..7c6e5e7e79 --- /dev/null +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -0,0 +1,39 @@ +#ifndef AVVIDEODECODER_HPP +#define AVVIDEODECODER_HPP + +#include "BambuTunnel.h" + +extern "C" { + #include + #include +} +class wxBitmap; + +class AVVideoDecoder +{ +public: + AVVideoDecoder(); + + ~AVVideoDecoder(); + +public: + int open(Bambu_StreamInfo const &info); + + int decode(Bambu_Sample const &sample); + + int flush(); + + void close(); + + bool toWxImage(wxImage &image, wxSize const &size); + + bool toWxBitmap(wxBitmap &bitmap, wxSize const & size); + +private: + AVCodecContext *codec_ctx_ = nullptr; + AVFrame * frame_ = nullptr; + SwsContext * sws_ctx_ = nullptr; + bool got_frame_ = false; +}; + +#endif // AVVIDEODECODER_HPP diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 90e956be69..7e94775e02 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -46,6 +46,7 @@ MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl2 *media_ctrl, const w SetLabel("MediaPlayCtrl"); SetBackgroundColour(*wxWHITE); m_media_ctrl->Bind(wxEVT_MEDIA_STATECHANGED, &MediaPlayCtrl::onStateChanged, this); + m_media_ctrl->SetIdleImage(from_u8(resources_dir() + "/images/live_stream_default.png")); m_button_play = new Button(this, "", "media_play", wxBORDER_NONE); m_button_play->SetCanFocus(false); @@ -313,7 +314,7 @@ void MediaPlayCtrl::Play() // !m_lan_mode && !m_remote_proto && m_lan_proto == LVL_None (x) if (m_lan_proto <= MachineObject::LVL_Disable && (m_lan_mode || !m_remote_proto)) { - Stop(m_lan_proto == MachineObject::LVL_None + Stop(m_lan_proto == MachineObject::LVL_None ? _L("A problem occurred. Please update the printer firmware and try again.") : _L("LAN Only Liveview is off. Please turn on the liveview on printer screen.")); return; @@ -351,7 +352,7 @@ void MediaPlayCtrl::Play() url += "&cli_id=" + wxGetApp().app_config->get("slicer_uuid"); url += "&cli_ver=" + std::string(SLIC3R_VERSION); } - BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: " << hide_passwd(url, + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: " << hide_passwd(url, {"?uid=", "authkey=", "passwd=", "license=", "token="}); CallAfter([this, m, url] { if (m != m_machine) { @@ -426,7 +427,7 @@ void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) auto tunnel = m_url.empty() ? "" : into_u8(wxURI(m_url).GetPath()).substr(1); if (auto n = tunnel.find_first_of("/_"); n != std::string::npos) tunnel = tunnel.substr(0, n); - if (last_state != wxMEDIASTATE_PLAYING && m_failed_code != 0 + if (last_state != wxMEDIASTATE_PLAYING && m_failed_code != 0 && m_last_failed_codes.find(m_failed_code) == m_last_failed_codes.end() && (m_user_triggered || m_failed_retry > 3)) { m_last_failed_codes.insert(m_failed_code); @@ -560,7 +561,7 @@ void MediaPlayCtrl::ToggleStream() url += "&cli_id=" + wxGetApp().app_config->get("slicer_uuid"); url += "&cli_ver=" + std::string(SLIC3R_VERSION); } - BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::ToggleStream: " << hide_passwd(url, + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::ToggleStream: " << hide_passwd(url, {"?uid=", "authkey=", "passwd=", "license=", "token="}); CallAfter([this, m, url] { if (m != m_machine) return; @@ -580,8 +581,8 @@ void MediaPlayCtrl::ToggleStream() }, wxGetApp().get_printer_cloud_provider()); } -void MediaPlayCtrl::msw_rescale() { - m_button_play->Rescale(); +void MediaPlayCtrl::msw_rescale() { + m_button_play->Rescale(); } void MediaPlayCtrl::jump_to_play() @@ -771,15 +772,15 @@ bool MediaPlayCtrl::start_stream_service(bool *need_install) if (!boost::filesystem::exists(file_dll) || boost::filesystem::last_write_time(file_dll) != boost::filesystem::last_write_time(file_dll2)) boost::filesystem::copy_file(file_dll2, file_dll, boost::filesystem::copy_options::overwrite_existing); } - boost::process::child process_source(file_source, file_url2.ToStdWstring(), boost::process::start_dir(tools_dir), - boost::process::windows::create_no_window, + boost::process::child process_source(file_source, file_url2.ToStdWstring(), boost::process::start_dir(tools_dir), + boost::process::windows::create_no_window, boost::process::std_out > intermediate, boost::process::limit_handles); - boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::windows::create_no_window, + boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::windows::create_no_window, boost::process::std_in < intermediate, boost::process::limit_handles); #else boost::filesystem::permissions(file_source, boost::filesystem::owner_exe | boost::filesystem::add_perms); boost::filesystem::permissions(file_ffmpeg, boost::filesystem::owner_exe | boost::filesystem::add_perms); - boost::process::child process_source(file_source, file_url2.data().AsInternal(), boost::process::start_dir(start_dir), + boost::process::child process_source(file_source, file_url2.data().AsInternal(), boost::process::start_dir(start_dir), boost::process::std_out > intermediate, boost::process::limit_handles); boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::std_in < intermediate, boost::process::limit_handles); #endif @@ -830,27 +831,22 @@ bool MediaPlayCtrl::get_stream_url(std::string *url) }} -void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height) { -#ifdef __WXMAC__ - wxWindow::DoSetSize(x, y, width, height, sizeFlags); -#else - wxMediaCtrl::DoSetSize(x, y, width, height, sizeFlags); -#endif #if defined(__LINUX__) && defined(__WXGTK__) if (m_gtk_video_window) { const wxSize client_size = GetClientSize(); m_gtk_video_window->SetSize(0, 0, client_size.GetWidth(), client_size.GetHeight()); } #endif - if (sizeFlags & wxSIZE_USE_EXISTING) return; - wxSize size = m_video_size; + wxSize size = videoSize; + if (!size.IsFullySpecified()) size = {16, 9}; int maxHeight = (width * size.GetHeight() + size.GetHeight() - 1) / size.GetWidth(); - if (maxHeight != GetMaxHeight()) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2::DoSetSize: width: " << width << ", height: " << height << ", maxHeight: " << maxHeight; - SetMaxSize({-1, maxHeight}); - CallAfter([this] { - if (auto p = GetParent()) { + if (maxHeight != ctrl->GetMaxHeight()) { + // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl_OnSize: width: " << width << ", height: " << height << ", maxHeight: " << maxHeight; + ctrl->SetMaxSize({-1, maxHeight}); + ctrl->CallAfter([ctrl] { + if (auto p = ctrl->GetParent()) { p->Layout(); p->Refresh(); } diff --git a/src/slic3r/GUI/MediaPlayCtrl.h b/src/slic3r/GUI/MediaPlayCtrl.h index f5e5dcddfc..4908a782ca 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.h +++ b/src/slic3r/GUI/MediaPlayCtrl.h @@ -8,7 +8,14 @@ #ifndef MediaPlayCtrl_h #define MediaPlayCtrl_h +#define USE_WX_MEDIA_CTRL_2 0 + +#if USE_WX_MEDIA_CTRL_2 #include "wxMediaCtrl2.h" +#define wxMediaCtrl3 wxMediaCtrl2 +#else +#include "wxMediaCtrl3.h" +#endif #include @@ -30,7 +37,7 @@ namespace GUI { class MediaPlayCtrl : public wxPanel { public: - MediaPlayCtrl(wxWindow *parent, wxMediaCtrl2 *media_ctrl, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize); + MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize); ~MediaPlayCtrl(); @@ -75,7 +82,7 @@ private: // token std::shared_ptr m_token = std::make_shared(0); - wxMediaCtrl2 * m_media_ctrl; + wxMediaCtrl3 * m_media_ctrl; wxMediaState m_last_state = MEDIASTATE_IDLE; std::string m_machine; int m_lan_proto = 0; @@ -90,7 +97,7 @@ private: bool m_device_busy = false; bool m_disable_lan = false; wxString m_url; - + std::deque m_tasks; boost::mutex m_mutex; boost::condition_variable m_cond; diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 43522f352b..f7bd41fe84 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -983,7 +983,7 @@ void PrintingTaskPanel::paint(wxPaintEvent&) dc.DrawBitmap(m_thumbnail_bmp_display, wxPoint(0, 0)); } dc.SetFont(Label::Body_12); - + if (m_plate_index >= 0) { wxString plate_id_str = wxString::Format("%d", m_plate_index); dc.DrawText(plate_id_str, wxPoint(4, 4)); @@ -1271,7 +1271,7 @@ void PrintingTaskPanel::set_plate_index(int plate_idx) } void PrintingTaskPanel::market_scoring_show() -{ +{ m_score_staticline->Show(); m_score_subtask_info->Show(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " show market scoring page"; @@ -1402,7 +1402,7 @@ void StatusBasePanel::init_bitmaps() m_bitmap_fan_off = ScalableBitmap(this, "monitor_fan_off", 22); m_bitmap_speed = ScalableBitmap(this, "monitor_speed", 24); m_bitmap_speed_active = ScalableBitmap(this, "monitor_speed_active", 24); - + m_thumbnail_brokenimg = ScalableBitmap(this, "monitor_brokenimg", 120); m_thumbnail_sdcard = ScalableBitmap(this, "monitor_sdcard_thumbnail", 120); //m_bitmap_camera = create_scaled_bitmap("monitor_camera", nullptr, 18); @@ -1530,7 +1530,7 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() // media_ctrl_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize); // media_ctrl_panel->SetBackgroundColour(*wxBLACK); // wxBoxSizer *bSizer_monitoring = new wxBoxSizer(wxVERTICAL); - m_media_ctrl = new wxMediaCtrl2(this); + m_media_ctrl = new wxMediaCtrl3(this); m_media_ctrl->SetMinSize(wxSize(PAGE_MIN_WIDTH, FromDIP(288))); m_custom_camera_view = WebView::CreateWebView(this, wxEmptyString); @@ -2458,7 +2458,7 @@ StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, co m_project_task_panel->get_pause_resume_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_pause_resume), NULL, this); m_project_task_panel->get_abort_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_abort), NULL, this); m_project_task_panel->get_market_scoring_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_scoring), NULL, this); - m_project_task_panel->get_market_retry_buttom()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); + m_project_task_panel->get_market_retry_buttom()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); m_project_task_panel->get_clean_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_print_error_clean), NULL, this); m_setting_button->Connect(wxEVT_LEFT_DOWN, wxMouseEventHandler(StatusPanel::on_camera_enter), NULL, this); @@ -2520,7 +2520,7 @@ StatusPanel::~StatusPanel() m_project_task_panel->get_pause_resume_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_pause_resume), NULL, this); m_project_task_panel->get_abort_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_abort), NULL, this); m_project_task_panel->get_market_scoring_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_scoring), NULL, this); - m_project_task_panel->get_market_retry_buttom()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); + m_project_task_panel->get_market_retry_buttom()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); m_project_task_panel->get_clean_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_print_error_clean), NULL, this); m_setting_button->Disconnect(wxEVT_LEFT_DOWN, wxMouseEventHandler(StatusPanel::on_camera_enter), NULL, this); @@ -2566,7 +2566,7 @@ StatusPanel::~StatusPanel() if (sdcard_hint_dlg != nullptr) delete sdcard_hint_dlg; - if (m_score_data != nullptr) { + if (m_score_data != nullptr) { delete m_score_data; } } @@ -2590,7 +2590,7 @@ void StatusPanel::init_scaled_buttons() m_bpButton_e_down_10->SetCornerRadius(FromDIP(12)); } -void StatusPanel::on_market_scoring(wxCommandEvent &event) { +void StatusPanel::on_market_scoring(wxCommandEvent &event) { if (obj && obj->is_makeworld_subtask() && obj->rating_info && obj->rating_info->request_successful) { // model is mall model and has rating_id BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": on_market_scoring" ; if (m_score_data && m_score_data->rating_id == obj->rating_info->rating_id) { // current score data for model is same as mall model @@ -2599,7 +2599,7 @@ void StatusPanel::on_market_scoring(wxCommandEvent &event) { int ret = m_score_dlg.ShowModal(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": old data"; - if (ret == wxID_OK) { + if (ret == wxID_OK) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": old data is upload"; m_score_data->rating_id = -1; m_project_task_panel->set_star_count_dirty(false); @@ -2621,11 +2621,11 @@ void StatusPanel::on_market_scoring(wxCommandEvent &event) { std::string comment = obj->rating_info->content; if (!comment.empty()) { m_score_dlg.set_comment(comment); } - + std::vector images_json_array; images_json_array = obj->rating_info->image_url_paths; if (!images_json_array.empty()) m_score_dlg.set_cloud_bitmap(images_json_array); - + int ret = m_score_dlg.ShowModal(); if (ret == wxID_OK) { @@ -3651,14 +3651,14 @@ void StatusPanel::update_basic_print_data(bool def) void StatusPanel::update_model_info() { auto get_subtask_fn = [this](BBLModelTask* subtask) { - CallAfter([this, subtask]() { + CallAfter([this, subtask]() { if (obj && obj->subtask_id_ == subtask->task_id) { obj->set_modeltask(subtask); } }); }; - + if (wxGetApp().getAgent() && obj) { BBLSubTask* curr_task = obj->get_subtask(); if (curr_task) { @@ -3982,7 +3982,7 @@ void StatusPanel::reset_printing_values() m_project_task_panel->update_left_time(NA_STR); m_project_task_panel->update_layers_num(true, wxString::Format(_L("Layer: %s"), NA_STR)); update_calib_bitmap(); - + task_thumbnail_state = ThumbnailState::PLACE_HOLDER; m_start_loading_thumbnail = false; m_load_sdcard_thumbnail = false; @@ -4037,7 +4037,7 @@ bool StatusPanel::check_axis_z_at_home(MachineObject* obj) } void StatusPanel::on_axis_ctrl_z_up_10(wxCommandEvent &event) -{ +{ if (obj) { obj->command_axis_control("Z", 1.0, -10.0f, 900); if (!check_axis_z_at_home(obj)) @@ -5396,7 +5396,7 @@ void StatusPanel::msw_rescale() m_calibration_btn->Rescale(); m_options_btn->SetMinSize(wxSize(-1, FromDIP(26))); - m_options_btn->Rescale(); + m_options_btn->Rescale(); m_safety_btn->SetMinSize(wxSize(-1, FromDIP(26))); m_safety_btn->Rescale(); @@ -5578,11 +5578,11 @@ ScoreDialog::ScoreDialog(wxWindow *parent, ScoreData *score_data) , m_upload_status_code(StatusCode::CODE_NUMBER) { m_tocken.reset(new int(0)); - + wxBoxSizer *m_main_sizer = get_main_sizer(score_data->local_to_url_image, score_data->comment_text); m_image_url_paths = score_data->image_url_paths; - + this->SetSizer(m_main_sizer); Fit(); @@ -5598,16 +5598,16 @@ void ScoreDialog::on_dpi_changed(const wxRect &suggested_rect) {} void ScoreDialog::OnBitmapClicked(wxMouseEvent &event) { wxStaticBitmap *clickedBitmap = dynamic_cast(event.GetEventObject()); - if (m_image.find(clickedBitmap) != m_image.end()) { + if (m_image.find(clickedBitmap) != m_image.end()) { if (!m_image[clickedBitmap].is_selected) { - for (auto panel : m_image[clickedBitmap].image_broad) { + for (auto panel : m_image[clickedBitmap].image_broad) { panel->Show(); } m_image[clickedBitmap].is_selected = true; m_selected_image_list.insert(clickedBitmap); } else { - for (auto panel : m_image[clickedBitmap].image_broad) { - panel->Hide(); + for (auto panel : m_image[clickedBitmap].image_broad) { + panel->Hide(); } m_image[clickedBitmap].is_selected = false; m_selected_image_list.erase(clickedBitmap); @@ -5624,9 +5624,9 @@ void ScoreDialog::OnBitmapClicked(wxMouseEvent &event) } std::set > ScoreDialog::add_need_upload_imgs() -{ +{ std::set> need_upload_images; - for (auto bitmap : m_image) { + for (auto bitmap : m_image) { if (!bitmap.second.is_uploaded) { wxString &local_image_path = bitmap.second.local_image_url; if (!local_image_path.empty()) { need_upload_images.insert(std::make_pair(bitmap.first, local_image_path)); } @@ -5646,7 +5646,7 @@ std::pair ScoreDialog::create_local_thu cur_image_msg.local_image_url = local_path; cur_image_msg.img_url_paths = ""; cur_image_msg.is_uploaded = false; - + wxStaticBitmap *imageCtrl = new wxStaticBitmap(this, wxID_ANY, wxBitmap(wxImage(local_path, wxBITMAP_TYPE_ANY).Rescale(FromDIP(80), FromDIP(60))), wxDefaultPosition, wxDefaultSize, 0); imageCtrl->Bind(wxEVT_LEFT_DOWN, &ScoreDialog::OnBitmapClicked, this); @@ -5711,7 +5711,7 @@ void ScoreDialog::update_static_bitmap(wxStaticBitmap* static_bitmap, wxImage im } wxBoxSizer *ScoreDialog::create_broad_sizer(wxStaticBitmap *bitmap, ImageMsg& cur_image_msg) -{ +{ // tb: top and bottom lr: left and right auto m_image_tb_broad = new wxBoxSizer(wxVERTICAL); auto line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL); @@ -5755,7 +5755,7 @@ void ScoreDialog::init() { fail_image = wxImage(Slic3r::resources_dir() + "/images/oss_picture_load_failed.png", wxBITMAP_TYPE_ANY); } -wxBoxSizer *ScoreDialog::get_score_sizer() { +wxBoxSizer *ScoreDialog::get_score_sizer() { wxBoxSizer *score_sizer = new wxBoxSizer(wxHORIZONTAL); wxStaticText *static_score_text = new wxStaticText(this, wxID_ANY, _L("Rate"), wxDefaultPosition, wxDefaultSize, 0); static_score_text->Wrap(-1); @@ -5878,18 +5878,18 @@ wxBoxSizer *ScoreDialog::get_photo_btn_sizer() { for (int i = 0; i < filePaths.GetCount(); i++) { //It's ugly, but useful bool is_repeat = false; for (auto image : m_image) { - if (filePaths[i] == image.second.local_image_url) { + if (filePaths[i] == image.second.local_image_url) { is_repeat = true; continue; } } if (!is_repeat) { local_path.push_back(std::make_pair(filePaths[i], "")); - if (local_path.size() + m_image.size() > m_photo_nums) { - break; + if (local_path.size() + m_image.size() > m_photo_nums) { + break; } } - + } load_photo(local_path); @@ -6007,7 +6007,7 @@ wxBoxSizer *ScoreDialog::get_button_sizer() } } progress_dialog->Hide(); - if (progress_dialog) { + if (progress_dialog) { delete progress_dialog; progress_dialog = nullptr; } @@ -6141,7 +6141,7 @@ wxBoxSizer *ScoreDialog::get_main_sizer(const std::vectorAdd(m_photo_sizer, 0, wxEXPAND | wxTOP, FromDIP(8)); m_image_sizer = new wxGridSizer(5, FromDIP(5), FromDIP(5)); - if (!images.empty()) { + if (!images.empty()) { load_photo(images); } m_main_sizer->Add(m_image_sizer, 0, wxEXPAND | wxLEFT, FromDIP(24)); @@ -6153,7 +6153,7 @@ wxBoxSizer *ScoreDialog::get_main_sizer(const std::vectorGetValue(); score_data.image_url_paths = m_image_url_paths; for (auto img : m_image) { score_data.local_to_url_image.push_back(std::make_pair(img.second.local_image_url, img.second.img_url_paths)); } - + return score_data; } void ScoreDialog::set_comment(std::string comment) { - if (m_comment_text) { + if (m_comment_text) { m_comment_text->SetValue(wxString::FromUTF8(comment)); } } void ScoreDialog::set_cloud_bitmap(std::vector cloud_bitmaps) -{ +{ m_image_url_paths = cloud_bitmaps; for (std::string &url : cloud_bitmaps) { if (std::string::npos == url.find(m_model_id)) continue; diff --git a/src/slic3r/GUI/StatusPanel.hpp b/src/slic3r/GUI/StatusPanel.hpp index d0c8c99f49..341bb9ab69 100644 --- a/src/slic3r/GUI/StatusPanel.hpp +++ b/src/slic3r/GUI/StatusPanel.hpp @@ -14,7 +14,6 @@ #include #include #include -#include "wxMediaCtrl2.h" #include "MediaPlayCtrl.h" #include "AMSSetting.hpp" #include "Calibration.hpp" @@ -195,11 +194,11 @@ public: void set_cloud_bitmap(std::vector cloud_bitmaps); protected: - enum StatusCode { - UPLOAD_PROGRESS = 0, - UPLOAD_EXIST_ISSUE, + enum StatusCode { + UPLOAD_PROGRESS = 0, + UPLOAD_EXIST_ISSUE, UPLOAD_IMG_FAILED, - CODE_NUMBER + CODE_NUMBER }; std::shared_ptr m_tocken; @@ -217,7 +216,7 @@ protected: { wxString local_image_url; //local image path std::string img_url_paths; // oss url path - vector image_broad; + vector image_broad; bool is_selected; bool is_uploaded; // load wxBoxSizer * image_tb_broad = nullptr; @@ -252,7 +251,7 @@ protected: std::set> add_need_upload_imgs(); std::pair create_local_thumbnail(wxString &local_path); std::pair create_oss_thumbnail(std::string &oss_path); - + }; class PrintingTaskPanel : public wxPanel @@ -261,7 +260,7 @@ public: PrintingTaskPanel(wxWindow* parent, PrintingTaskType type); ~PrintingTaskPanel(); void create_panel(wxWindow* parent); - + private: MachineObject* m_obj{nullptr}; @@ -353,7 +352,7 @@ public: void set_plate_index(int plate_idx = -1); void market_scoring_show(); void market_scoring_hide(); - + public: ScalableButton* get_abort_button() {return m_button_abort;}; ScalableButton* get_pause_resume_button() {return m_button_pause_resume;}; @@ -443,7 +442,7 @@ protected: wxStaticBitmap* m_camera_switch_button; - wxMediaCtrl2 * m_media_ctrl; + wxMediaCtrl3 * m_media_ctrl; MediaPlayCtrl * m_media_play_ctrl; Label * m_staticText_printing; @@ -567,7 +566,7 @@ protected: virtual void on_bed_temp_kill_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_bed_temp_set_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_nozzle_temp_kill_focus(wxFocusEvent &event) { event.Skip(); } - virtual void on_nozzle_temp_set_focus(wxFocusEvent &event) { event.Skip(); } + virtual void on_nozzle_temp_set_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_nozzle_fan_switch(wxCommandEvent &event) { event.Skip(); } virtual void on_printing_fan_switch(wxCommandEvent &event) { event.Skip(); } virtual void on_axis_ctrl_z_up_10(wxCommandEvent &event) { event.Skip(); } diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp index f2d1d2701e..9ff657ff73 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ b/src/slic3r/GUI/wxMediaCtrl2.cpp @@ -1,7 +1,7 @@ #include "wxMediaCtrl2.h" #include "libslic3r/Time.hpp" #include "I18N.hpp" -#include "GUI_App.hpp" +#include "libslic3r/Utils.hpp" #include "LinuxDisplayBackend.hpp" #include #include @@ -329,8 +329,8 @@ void wxMediaCtrl2::Load(wxURI url) if (!notified) CallAfter([] { auto res = wxMessageBox(_L("Windows Media Player is required for this task! Do you want to enable 'Windows Media Player' for your operation system?"), _L("Error"), wxOK | wxCANCEL); if (res == wxOK) { - wxString url = IsWindows10OrGreater() - ? "ms-settings:optionalfeatures?activationSource=SMC-Article-14209" + wxString url = IsWindows10OrGreater() + ? "ms-settings:optionalfeatures?activationSource=SMC-Article-14209" : "https://support.microsoft.com/en-au/windows/get-windows-media-player-81718e0d-cfce-25b1-aee3-94596b658287"; wxExecute("cmd /c start " + url, wxEXEC_HIDE_CONSOLE); } @@ -345,7 +345,7 @@ void wxMediaCtrl2::Load(wxURI url) { wxRegKey key11(wxRegKey::HKCU, L"SOFTWARE\\Classes\\CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); wxRegKey key12(wxRegKey::HKCR, L"CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); - wxString path = key11.Exists() ? key11.QueryDefaultValue() + wxString path = key11.Exists() ? key11.QueryDefaultValue() : key12.Exists() ? key12.QueryDefaultValue() : wxString{}; wxRegKey key2(wxRegKey::HKCR, "bambu"); wxString clsid; @@ -428,14 +428,14 @@ void wxMediaCtrl2::Load(wxURI url) #ifdef __WXGTK3__ GstElementFactory *factory; int hasplugins = 1; - + factory = gst_element_factory_find("h264parse"); if (!factory) { hasplugins = 0; } else { gst_object_unref(factory); } - + factory = gst_element_factory_find("openh264dec"); if (!factory) { factory = gst_element_factory_find("avdec_h264"); @@ -457,7 +457,7 @@ void wxMediaCtrl2::Load(wxURI url) } else { gst_object_unref(factory); } - + if (!hasplugins) { CallAfter([] { wxMessageBox(_L("Your system is missing H.264 codecs for GStreamer, which are required to play video. (Try installing the gstreamer1.0-plugins-bad or gstreamer1.0-libav packages, then restart Orca Slicer?)"), _L("Error"), wxOK); @@ -547,8 +547,9 @@ void wxMediaCtrl2::Stop() if (!m_imp) return; #endif - wxMediaCtrl::Stop(); -} + wxMediaCtrl::Stop(); } + +void wxMediaCtrl2::SetIdleImage(wxString const &image) {} wxMediaState wxMediaCtrl2::GetState() { @@ -596,6 +597,13 @@ wxSize wxMediaCtrl2::DoGetBestSize() const return {-1, -1}; } +void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (sizeFlags & wxSIZE_USE_EXISTING) return; + wxMediaCtrl_OnSize(this, m_video_size, width, height); +} + #ifdef __WIN32__ WXLRESULT wxMediaCtrl2::MSWWindowProc(WXUINT nMsg, diff --git a/src/slic3r/GUI/wxMediaCtrl2.h b/src/slic3r/GUI/wxMediaCtrl2.h index 4f37b5cd1e..c22cc10f2f 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.h +++ b/src/slic3r/GUI/wxMediaCtrl2.h @@ -13,6 +13,8 @@ wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + #if defined(__LINUX__) && defined(__WXGTK__) typedef struct _GstElement GstElement; #endif @@ -23,7 +25,7 @@ class wxMediaCtrl2 : public wxWindow { public: wxMediaCtrl2(wxWindow * parent); - + ~wxMediaCtrl2(); void Load(wxURI url); @@ -45,8 +47,8 @@ public: protected: void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; - static void bambu_log(void const * ctx, int level, char const * msg); - + static void bambu_log(void const *ctx, int level, char const *msg); + void NotifyStopped(); private: diff --git a/src/slic3r/GUI/wxMediaCtrl2.mm b/src/slic3r/GUI/wxMediaCtrl2.mm index 063472eccc..cc081a89fe 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.mm +++ b/src/slic3r/GUI/wxMediaCtrl2.mm @@ -140,6 +140,10 @@ void wxMediaCtrl2::Stop() NotifyStopped(); } +void wxMediaCtrl2::SetIdleImage(wxString const &image) +{ +} + void wxMediaCtrl2::NotifyStopped() { if (m_state != wxMEDIASTATE_STOPPED) { @@ -168,3 +172,10 @@ wxSize wxMediaCtrl2::GetVideoSize() const return {0, 0}; } } + +void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (sizeFlags & wxSIZE_USE_EXISTING) return; + wxMediaCtrl_OnSize(this, m_video_size, width, height); +} diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp new file mode 100644 index 0000000000..302af5fe2c --- /dev/null +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -0,0 +1,267 @@ +#include "wxMediaCtrl3.h" +#include "AVVideoDecoder.hpp" +#include "I18N.hpp" +#include "libslic3r/Utils.hpp" +#ifdef __WIN32__ +#include +#include +#include +#endif + +//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); + +BEGIN_EVENT_TABLE(wxMediaCtrl3, wxWindow) + +// catch paint events +EVT_PAINT(wxMediaCtrl3::paintEvent) + +END_EVENT_TABLE() + +struct StaticBambuLib : BambuLib +{ + static StaticBambuLib &get(); +}; + +wxMediaCtrl3::wxMediaCtrl3(wxWindow *parent) + : wxWindow(parent, wxID_ANY) + , BambuLib(StaticBambuLib::get()) + , m_thread([this] { PlayThread(); }) +{ + SetBackgroundColour(*wxBLACK); +} + +wxMediaCtrl3::~wxMediaCtrl3() +{ + { + std::unique_lock lk(m_mutex); + m_url.reset(new wxURI); + m_frame = wxImage(m_idle_image); + m_cond.notify_all(); + } + m_thread.join(); +} + +void wxMediaCtrl3::Load(wxURI url) +{ + std::unique_lock lk(m_mutex); + m_video_size = wxDefaultSize; + m_error = 0; + m_url.reset(new wxURI(url)); + m_cond.notify_all(); +} + +void wxMediaCtrl3::Play() +{ + std::unique_lock lk(m_mutex); + if (m_state != wxMEDIASTATE_PLAYING) { + m_state = wxMEDIASTATE_PLAYING; + wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); + event.SetId(GetId()); + event.SetEventObject(this); + wxPostEvent(this, event); + } +} + +void wxMediaCtrl3::Stop() +{ + std::unique_lock lk(m_mutex); + m_url.reset(); + m_frame = wxImage(m_idle_image); + m_cond.notify_all(); + Refresh(); +} + +void wxMediaCtrl3::SetIdleImage(wxString const &image) +{ + if (m_idle_image == image) + return; + m_idle_image = image; + if (m_url == nullptr) { + std::unique_lock lk(m_mutex); + m_frame = wxImage(m_idle_image); + assert(m_frame.IsOk()); + Refresh(); + } +} + +wxMediaState wxMediaCtrl3::GetState() +{ + std::unique_lock lk(m_mutex); + return m_state; +} + +int wxMediaCtrl3::GetLastError() +{ + std::unique_lock lk(m_mutex); + return m_error; +} + +wxSize wxMediaCtrl3::GetVideoSize() +{ + std::unique_lock lk(m_mutex); + return m_video_size; +} + +wxSize wxMediaCtrl3::DoGetBestSize() const +{ + return {-1, -1}; +} + +static void adjust_frame_size(wxSize & frame, wxSize const & video, wxSize const & window) +{ + if (video.x * window.y < video.y * window.x) + frame = { video.x * window.y / video.y, window.y }; + else + frame = { window.x, video.y * window.x / video.x }; +} + +void wxMediaCtrl3::paintEvent(wxPaintEvent &evt) +{ + wxPaintDC dc(this); + auto size = GetSize(); + std::unique_lock lk(m_mutex); + if (!m_frame.IsOk()) + return; + auto size2 = m_frame.GetSize(); + if (size2.x != m_frame_size.x && size2.y == m_frame_size.y) + size2.x = m_frame_size.x; + if (size2.x != size.x && size2.y != size.y) { + auto scale = std::min(double(size.x) / size2.x, double(size.y) / size2.y); + dc.SetUserScale(scale, scale); + adjust_frame_size(size2, size2, size); + } + size2 = (size - size2) / 2; + dc.DrawBitmap(m_frame, size2.x, size2.y); +} + +void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (sizeFlags & wxSIZE_USE_EXISTING) return; + wxMediaCtrl_OnSize(this, m_video_size, width, height); +} + +void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) +{ +#ifdef _WIN32 + wxString msg(msg2); +#else + wxString msg = wxString::FromUTF8(msg2); +#endif + if (level == 1) { + if (msg.EndsWith("]")) { + int n = msg.find_last_of('['); + if (n != wxString::npos) { + long val = 0; + wxMediaCtrl3 *ctrl = (wxMediaCtrl3 *) ctx; + if (msg.SubString(n + 1, msg.Length() - 2).ToLong(&val)) { + std::unique_lock lk(ctrl->m_mutex); + ctrl->m_error = (int) val; + } + } + } else if (msg.Contains("stat_log")) { + wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); + wxMediaCtrl3 *ctrl = (wxMediaCtrl3 *) ctx; + evt.SetEventObject(ctrl); + evt.SetString(msg.Mid(msg.Find(' ') + 1)); + wxPostEvent(ctrl, evt); + } + } + BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); +} + +void wxMediaCtrl3::PlayThread() +{ + using namespace std::chrono_literals; + std::shared_ptr url; + std::unique_lock lk(m_mutex); + while (true) { + m_cond.wait(lk, [this, &url] { return m_url != url; }); + url = m_url; + if (url == nullptr) + continue; + if (!url->HasScheme()) + break; + lk.unlock(); + Bambu_Tunnel tunnel = nullptr; + int error = Bambu_Create(&tunnel, m_url->BuildURI().ToUTF8()); + if (error == 0) { + Bambu_SetLogger(tunnel, &wxMediaCtrl3::bambu_log, this); + error = Bambu_Open(tunnel); + if (error == 0) + error = Bambu_would_block; + } + lk.lock(); + while (error == int(Bambu_would_block)) { + m_cond.wait_for(lk, 100ms); + if (m_url != url) { + error = 1; + break; + } + lk.unlock(); + error = Bambu_StartStream(tunnel, true); + lk.lock(); + } + Bambu_StreamInfo info; + if (error == 0) + error = Bambu_GetStreamInfo(tunnel, 0, &info); + AVVideoDecoder decoder; + if (error == 0) { + decoder.open(info); + m_video_size = { info.format.video.width, info.format.video.height }; + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + NotifyStopped(); + } + Bambu_Sample sample; + while (error == 0) { + lk.unlock(); + error = Bambu_ReadSample(tunnel, &sample); + lk.lock(); + while (error == int(Bambu_would_block)) { + m_cond.wait_for(lk, 100ms); + if (m_url != url) { + error = 1; + break; + } + lk.unlock(); + error = Bambu_ReadSample(tunnel, &sample); + lk.lock(); + } + if (error == 0) { + if (m_url != url) { + error = 1; + break; + } + lk.unlock(); + wxBitmap bm; + decoder.decode(sample); + decoder.toWxBitmap(bm, m_frame_size); + lk.lock(); + if (bm.IsOk()) + m_frame = bm; + CallAfter([this] { Refresh(); }); + } + } + if (tunnel) { + lk.unlock(); + Bambu_Close(tunnel); + Bambu_Destroy(tunnel); + tunnel = nullptr; + lk.lock(); + } + if (m_url == url) + m_error = error; + m_video_size = wxDefaultSize; + NotifyStopped(); + } + +} + +void wxMediaCtrl3::NotifyStopped() +{ + m_state = wxMEDIASTATE_STOPPED; + wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); + event.SetId(GetId()); + event.SetEventObject(this); + wxPostEvent(this, event); +} diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h new file mode 100644 index 0000000000..108c039f45 --- /dev/null +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -0,0 +1,83 @@ +// +// wxMediaCtrl3.h +// libslic3r_gui +// +// Created by cmguo on 2024/6/22. +// + +#ifndef wxMediaCtrl3_h +#define wxMediaCtrl3_h + +#include "wx/uri.h" +#include "wx/mediactrl.h" + +wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); + +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#ifdef __WXMAC__ + +#include "wxMediaCtrl2.h" +#define wxMediaCtrl3 wxMediaCtrl2 + +#else + +#define BAMBU_DYNAMIC +#include + +class AVVideoDecoder; + +class wxMediaCtrl3 : public wxWindow, BambuLib +{ +public: + wxMediaCtrl3(wxWindow *parent); + + ~wxMediaCtrl3(); + + void Load(wxURI url); + + void Play(); + + void Stop(); + + void SetIdleImage(wxString const & image); + + wxMediaState GetState(); + + int GetLastError(); + + wxSize GetVideoSize(); + +protected: + DECLARE_EVENT_TABLE() + + void paintEvent(wxPaintEvent &evt); + + wxSize DoGetBestSize() const override; + + void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; + + static void bambu_log(void *ctx, int level, tchar const *msg); + + void PlayThread(); + + void NotifyStopped(); + +private: + wxString m_idle_image; + wxMediaState m_state = wxMEDIASTATE_STOPPED; + int m_error = 0; + wxSize m_video_size = wxDefaultSize; + wxSize m_frame_size = wxDefaultSize; + wxBitmap m_frame; + wxImage m_frame2; + + std::shared_ptr m_url; + std::mutex m_mutex; + std::condition_variable m_cond; + std::thread m_thread; +}; + +#endif + +#endif /* wxMediaCtrl3_h */ From 7c09b0bcbaa8fa74d7ea6a655fb96face212b546 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Tue, 9 Jul 2024 16:36:19 +0800 Subject: [PATCH 016/138] FIX: reset bambu lib after restart network plugin Change-Id: I4a3a4b7420745835ca3fa00c6edebe9d8d98cbf6 Jira: STUDIO-7571 (cherry picked from commit 28d9c6743fae80bfd40e4ee391e30d62cb16d4ab) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 302af5fe2c..ca371314da 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -19,12 +19,12 @@ END_EVENT_TABLE() struct StaticBambuLib : BambuLib { - static StaticBambuLib &get(); + static StaticBambuLib &get(BambuLib *); }; wxMediaCtrl3::wxMediaCtrl3(wxWindow *parent) : wxWindow(parent, wxID_ANY) - , BambuLib(StaticBambuLib::get()) + , BambuLib(StaticBambuLib::get(this)) , m_thread([this] { PlayThread(); }) { SetBackgroundColour(*wxBLACK); From 8832d54b53e84f36ec3bcf37939a08b470b53a4e Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Fri, 12 Jul 2024 17:00:24 +0800 Subject: [PATCH 017/138] FIX: ffmpeg decoder memory leak Change-Id: I997572b5730618a969959f9b24c405d80fa9f83c Jira: STUDIO-7597 (cherry picked from commit 342cea29bd9593fa89cbb33caff58055b46ebeec) --- src/slic3r/GUI/AVVideoDecoder.cpp | 26 ++++++++++++-------------- src/slic3r/GUI/AVVideoDecoder.hpp | 1 + src/slic3r/GUI/wxMediaCtrl3.cpp | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index cb9ed33a2a..599b883818 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -81,17 +81,16 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) size.GetWidth(), size.GetHeight(), wxFmt, SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC nullptr, nullptr, nullptr); - uint8_t *data = (uint8_t*)malloc(size.GetWidth() * size.GetHeight() * 3); - if (data == nullptr) - return false; - uint8_t * datas[] = {data }; - int strides[] = {size.GetWidth() * 3}; + int length = size.GetWidth() * size.GetHeight() * 3; + if (bits_.size() < length) + bits_.resize(length); + uint8_t * datas[] = { bits_.data() }; + int strides[] = { size.GetWidth() * 3 }; int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); if (result_h != size.GetHeight()) { - delete[] data; return false; } - image = wxImage(size.GetWidth(), size.GetHeight(), data); + image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data()); return true; } @@ -111,16 +110,15 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) size.GetWidth(), size.GetHeight(), wxFmt, SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC nullptr, nullptr, nullptr); - uint8_t *data = (uint8_t*)malloc(size.GetWidth() * size.GetHeight() * 4); - if (data == nullptr) - return false; - uint8_t *datas[] = {data}; - int strides[] = {size.GetWidth() * 4}; + int length = size.GetWidth() * size.GetHeight() * 4; + if (bits_.size() < length) + bits_.resize(length); + uint8_t *datas[] = { bits_.data() }; + int strides[] = { size.GetWidth() * 4 }; int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); if (result_h != size.GetHeight()) { - delete[] data; return false; } - bitmap = wxBitmap((char *) data, size.GetWidth(), size.GetHeight(), 32); + bitmap = wxBitmap((char const *) bits_.data(), size.GetWidth(), size.GetHeight(), 32); return true; } diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index 7c6e5e7e79..a32241a62c 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -34,6 +34,7 @@ private: AVFrame * frame_ = nullptr; SwsContext * sws_ctx_ = nullptr; bool got_frame_ = false; + std::vector bits_; }; #endif // AVVIDEODECODER_HPP diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index ca371314da..e326485bfe 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -27,7 +27,7 @@ wxMediaCtrl3::wxMediaCtrl3(wxWindow *parent) , BambuLib(StaticBambuLib::get(this)) , m_thread([this] { PlayThread(); }) { - SetBackgroundColour(*wxBLACK); + SetBackgroundColour("#000001ff"); } wxMediaCtrl3::~wxMediaCtrl3() From 297572dc03e71d436a9db9d4fde316da8f446a0d Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Fri, 12 Jul 2024 11:17:20 +0800 Subject: [PATCH 018/138] FIX: install ffmpeg symbolic sos Change-Id: Ia4a45182cefcf62a7a4b4a5c89c92251609c5a68 Jira: none (cherry picked from commit b7f8fa1efdbe0ac2cc896ca24f063f5894fe9f90) --- CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index cabbb42b33..cb7fda82c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1099,16 +1099,24 @@ function(bambustudio_copy_sos target config postfix output_sos) message ("set out_dir to CMAKE_CURRENT_BINARY_DIR: ${_out_dir}") file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 ${CMAKE_PREFIX_PATH}/lib/libavutil.so + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 ${CMAKE_PREFIX_PATH}/lib/libswscale.so + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 ${CMAKE_PREFIX_PATH}/lib/libswresample.so + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 DESTINATION ${_out_dir}) set(${output_dlls} ${_out_dir}/libavcodec.so + ${_out_dir}/libavcodec.so.61 ${_out_dir}/libavutil.so + ${_out_dir}/libavutil.so.59 ${_out_dir}/libswscale.so + ${_out_dir}/libswscale.so.8 ${_out_dir}/libswresample.so + ${_out_dir}/libswresample.so.5 PARENT_SCOPE ) endfunction() From fbc5dbcfd4a807493b1c67d3a65409d00159aafc Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Tue, 16 Jul 2024 17:44:52 +0800 Subject: [PATCH 019/138] FIX: ffmpeg swscale & frame_size Change-Id: I9f4cb8c739b726f7e5cdbe0df7ed06b2eb2154d5 Jira: STUDIO-7624 (cherry picked from commit 5a2c75d835fb437667b590a803eef148baa30875) --- src/slic3r/GUI/AVVideoDecoder.cpp | 4 ++-- src/slic3r/GUI/wxMediaCtrl3.cpp | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index 599b883818..ce9771be03 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -79,7 +79,7 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), size.GetWidth(), size.GetHeight(), wxFmt, - SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC + SWS_GAUSS, nullptr, nullptr, nullptr); int length = size.GetWidth() * size.GetHeight() * 3; if (bits_.size() < length) @@ -108,7 +108,7 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), size.GetWidth(), size.GetHeight(), wxFmt, - SWS_POINT, // SWS_FAST_BILINEAR //SWS_BICUBIC + SWS_GAUSS, nullptr, nullptr, nullptr); int length = size.GetWidth() * size.GetHeight() * 4; if (bits_.size() < length) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index e326485bfe..a3b1d3efd3 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -139,6 +139,8 @@ void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) wxWindow::DoSetSize(x, y, width, height, sizeFlags); if (sizeFlags & wxSIZE_USE_EXISTING) return; wxMediaCtrl_OnSize(this, m_video_size, width, height); + std::unique_lock lk(m_mutex); + adjust_frame_size(m_frame_size, m_video_size, GetSize()); } void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) @@ -232,10 +234,11 @@ void wxMediaCtrl3::PlayThread() error = 1; break; } + auto frame_size = m_frame_size; lk.unlock(); wxBitmap bm; decoder.decode(sample); - decoder.toWxBitmap(bm, m_frame_size); + decoder.toWxBitmap(bm, frame_size); lk.lock(); if (bm.IsOk()) m_frame = bm; From 0f06620d40b20a8497b682ba0f6a52eff0571ee7 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Mon, 22 Jul 2024 12:19:00 +0800 Subject: [PATCH 020/138] FIX: wxMediaCtrl3 idle image & center pos Change-Id: Ib9652573e31bfd6229f174c0a1388942d9d98822 Jira: STUDIO-7633 (cherry picked from commit d51247c46e26460b151de79c598d81151280e79c) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index a3b1d3efd3..99e31ca983 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -125,13 +125,20 @@ void wxMediaCtrl3::paintEvent(wxPaintEvent &evt) auto size2 = m_frame.GetSize(); if (size2.x != m_frame_size.x && size2.y == m_frame_size.y) size2.x = m_frame_size.x; + auto size3 = (size - size2) / 2; if (size2.x != size.x && size2.y != size.y) { - auto scale = std::min(double(size.x) / size2.x, double(size.y) / size2.y); + double scale = 1.; + if (size.x * size2.y > size.y * size2.x) { + size3 = {size.x * size2.y / size.y, size2.y}; + scale = double(size.y) / size2.y; + } else { + size3 = {size2.x, size.y * size2.x / size.x}; + scale = double(size.x) / size2.x; + } dc.SetUserScale(scale, scale); - adjust_frame_size(size2, size2, size); + size3 = (size3 - size2) / 2; } - size2 = (size - size2) / 2; - dc.DrawBitmap(m_frame, size2.x, size2.y); + dc.DrawBitmap(m_frame, size3.x, size3.y); } void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) @@ -141,6 +148,7 @@ void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) wxMediaCtrl_OnSize(this, m_video_size, width, height); std::unique_lock lk(m_mutex); adjust_frame_size(m_frame_size, m_video_size, GetSize()); + Refresh(); } void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) @@ -230,16 +238,16 @@ void wxMediaCtrl3::PlayThread() lk.lock(); } if (error == 0) { - if (m_url != url) { - error = 1; - break; - } auto frame_size = m_frame_size; lk.unlock(); wxBitmap bm; decoder.decode(sample); decoder.toWxBitmap(bm, frame_size); lk.lock(); + if (m_url != url) { + error = 1; + break; + } if (bm.IsOk()) m_frame = bm; CallAfter([this] { Refresh(); }); @@ -254,6 +262,7 @@ void wxMediaCtrl3::PlayThread() } if (m_url == url) m_error = error; + m_frame_size = wxDefaultSize; m_video_size = wxDefaultSize; NotifyStopped(); } From b612cfa38b42cff26c080e2712e888be12ac9597 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Thu, 25 Jul 2024 15:48:15 +0800 Subject: [PATCH 021/138] FIX: AVVideoDecoder sws_ctx_ == nullptr on zero size Change-Id: I9698354bb1f341e276ec9780d4ef4fcd9f8a1028 Jira: STUDIO-7706 (cherry picked from commit ff622e25026a8471c39eb308cf5b115c4a9d84aa) --- src/slic3r/GUI/AVVideoDecoder.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index ce9771be03..3cf4e751d0 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -81,6 +81,8 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) size.GetWidth(), size.GetHeight(), wxFmt, SWS_GAUSS, nullptr, nullptr, nullptr); + if (sws_ctx_ == nullptr) + return false; int length = size.GetWidth() * size.GetHeight() * 3; if (bits_.size() < length) bits_.resize(length); @@ -110,6 +112,8 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) size.GetWidth(), size.GetHeight(), wxFmt, SWS_GAUSS, nullptr, nullptr, nullptr); + if (sws_ctx_ == nullptr) + return false; int length = size.GetWidth() * size.GetHeight() * 4; if (bits_.size() < length) bits_.resize(length); From d325b6b85ca496226fa6837f0bfe7d98bfed759e Mon Sep 17 00:00:00 2001 From: "BBL\\chuan.he" Date: Tue, 16 Jul 2024 11:29:34 +0800 Subject: [PATCH 022/138] fix:cannot open shared object file on linux Change-Id: Ica66500506cfe8932eac3ae0a58fb7ff30d1da9b jira:none (cherry picked from commit febd1aeb4d453bc96571fa5e5727e9e10046cb80) (cherry picked from commit 5ad579f929154779abd84b01438fd235c647dbf5) --- CMakeLists.txt | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index cb7fda82c0..7b81541982 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -289,6 +289,8 @@ if (APPLE) SET(CMAKE_XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.orcaslicer.OrcaSlicer") message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}") +elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(CMAKE_INSTALL_RPATH "$ORIGIN") endif () # Proposal for C++ unit tests and sandboxes @@ -1100,23 +1102,31 @@ function(bambustudio_copy_sos target config postfix output_sos) file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.10.100 ${CMAKE_PREFIX_PATH}/lib/libavutil.so ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.28.100 ${CMAKE_PREFIX_PATH}/lib/libswscale.so ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.2.100 ${CMAKE_PREFIX_PATH}/lib/libswresample.so ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.2.100 DESTINATION ${_out_dir}) set(${output_dlls} ${_out_dir}/libavcodec.so ${_out_dir}/libavcodec.so.61 + ${_out_dir}/libavcodec.so.61.10.100 ${_out_dir}/libavutil.so ${_out_dir}/libavutil.so.59 + ${_out_dir}/libavutil.so.59.28.100 ${_out_dir}/libswscale.so ${_out_dir}/libswscale.so.8 + ${_out_dir}/libswscale.so.8.2.100 ${_out_dir}/libswresample.so ${_out_dir}/libswresample.so.5 + ${_out_dir}/libswresample.so.5.2.100 PARENT_SCOPE ) endfunction() @@ -1178,6 +1188,20 @@ else () endif() endif () +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(LIBRARY_FILES + ${LIBDIR_BIN}/libavcodec.so.61 + ${LIBDIR_BIN}/libavcodec.so.61.10.100 + ${LIBDIR_BIN}/libavutil.so.59 + ${LIBDIR_BIN}/libavutil.so.59.28.100 + ${LIBDIR_BIN}/libswresample.so.5 + ${LIBDIR_BIN}/libswresample.so.5.2.100 + ${LIBDIR_BIN}/libswscale.so.8 + ${LIBDIR_BIN}/libswscale.so.8.2.100 + ) + install(FILES ${LIBRARY_FILES} DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") +endif () + install(FILES ${CMAKE_SOURCE_DIR}/LICENSE.txt DESTINATION ".") configure_file(${LIBDIR}/dev-utils/platform/unix/fhs.hpp.in ${LIBDIR_BIN}/dev-utils/platform/unix/fhs.hpp) From 2ae0a52928312f652f9fbe29df0f23e5257d5d0b Mon Sep 17 00:00:00 2001 From: MackBambu Date: Sat, 28 Sep 2024 02:00:31 +0800 Subject: [PATCH 023/138] NEW:add ffmepg build Cmake buildLinuxImage add ffmpeg so file jira:nojira Change-Id: I3e1be53aa58a179b8d9ae048ed7538de3ae8d111 (cherry picked from commit 2d70a1bcb6a5ba601525b08a38e7610f018fe106) --- CMakeLists.txt | 32 +++++++++++++++---------------- src/slic3r/GUI/AVVideoDecoder.hpp | 2 +- src/slic3r/GUI/wxMediaCtrl3.h | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b81541982..922e113037 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1047,10 +1047,10 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll ${CMAKE_PREFIX_PATH}/bin/freetype.dll - ${CMAKE_PREFIX_PATH}/bin/avcodec-59.dll - ${CMAKE_PREFIX_PATH}/bin/swresample-4.dll - ${CMAKE_PREFIX_PATH}/bin/swscale-6.dll - ${CMAKE_PREFIX_PATH}/bin/avutil-57.dll + ${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll + ${CMAKE_PREFIX_PATH}/bin/swresample-5.dll + ${CMAKE_PREFIX_PATH}/bin/swscale-8.dll + ${CMAKE_PREFIX_PATH}/bin/avutil-59.dll DESTINATION ${_out_dir}) set(${output_dlls} @@ -1086,10 +1086,10 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${_out_dir}/TKXSBase.dll ${_out_dir}/freetype.dll - ${_out_dir}/avcodec-59.dll - ${_out_dir}/swresample-4.dll - ${_out_dir}/swscale-6.dll - ${_out_dir}/avutil-57.dll + ${_out_dir}/avcodec-61.dll + ${_out_dir}/swresample-5.dll + ${_out_dir}/swscale-8.dll + ${_out_dir}/avutil-59.dll PARENT_SCOPE ) @@ -1102,31 +1102,31 @@ function(bambustudio_copy_sos target config postfix output_sos) file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 - ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.10.100 + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 ${CMAKE_PREFIX_PATH}/lib/libavutil.so ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 - ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.28.100 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.8.100 ${CMAKE_PREFIX_PATH}/lib/libswscale.so ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 - ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.2.100 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.1.100 ${CMAKE_PREFIX_PATH}/lib/libswresample.so ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 - ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.2.100 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.1.100 DESTINATION ${_out_dir}) set(${output_dlls} ${_out_dir}/libavcodec.so ${_out_dir}/libavcodec.so.61 - ${_out_dir}/libavcodec.so.61.10.100 + ${_out_dir}/libavcodec.so.61.3.100 ${_out_dir}/libavutil.so ${_out_dir}/libavutil.so.59 - ${_out_dir}/libavutil.so.59.28.100 + ${_out_dir}/libavutil.so.59.8.100 ${_out_dir}/libswscale.so ${_out_dir}/libswscale.so.8 - ${_out_dir}/libswscale.so.8.2.100 + ${_out_dir}/libswscale.so.8.1.100 ${_out_dir}/libswresample.so ${_out_dir}/libswresample.so.5 - ${_out_dir}/libswresample.so.5.2.100 + ${_out_dir}/libswresample.so.5.1.100 PARENT_SCOPE ) endfunction() diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index a32241a62c..99a7349e55 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -1,7 +1,7 @@ #ifndef AVVIDEODECODER_HPP #define AVVIDEODECODER_HPP -#include "BambuTunnel.h" +#include "Printer/BambuTunnel.h" extern "C" { #include diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index 108c039f45..73224bc384 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -23,7 +23,7 @@ void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, in #else #define BAMBU_DYNAMIC -#include +#include "Printer/BambuTunnel.h" class AVVideoDecoder; From be2be5c83132e6c2cb39a981be138bceffe49190 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Oct 2024 14:31:17 +0800 Subject: [PATCH 024/138] FIX: ffmpeg cmake install error jira:nojira Change-Id: I74cc0f7c86b5364e55cad2af2bd9a82306ee6864 (cherry picked from commit 805df79e3bb044dac29ec1c06736751ccf3675f9) --- CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 922e113037..ac80726085 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1191,13 +1191,13 @@ endif () if (CMAKE_SYSTEM_NAME STREQUAL "Linux") set(LIBRARY_FILES ${LIBDIR_BIN}/libavcodec.so.61 - ${LIBDIR_BIN}/libavcodec.so.61.10.100 + ${LIBDIR_BIN}/libavcodec.so.61.3.100 ${LIBDIR_BIN}/libavutil.so.59 - ${LIBDIR_BIN}/libavutil.so.59.28.100 + ${LIBDIR_BIN}/libavutil.so.59.8.100 ${LIBDIR_BIN}/libswresample.so.5 - ${LIBDIR_BIN}/libswresample.so.5.2.100 + ${LIBDIR_BIN}/libswresample.so.5.1.100 ${LIBDIR_BIN}/libswscale.so.8 - ${LIBDIR_BIN}/libswscale.so.8.2.100 + ${LIBDIR_BIN}/libswscale.so.8.1.100 ) install(FILES ${LIBRARY_FILES} DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") endif () From 240227de7951bab321de0d222c3f8cd32d4fe5e6 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Sun, 29 Sep 2024 16:29:31 +0800 Subject: [PATCH 025/138] FIX: decode video to wxImage on Linux Change-Id: I5e332a1b0622b3dfc70ac5c4c3bfa62b3411ebdc Jira: none (cherry picked from commit c787ba921a31f259e8eb23fd59f178e96279caf9) --- src/slic3r/GUI/AVVideoDecoder.cpp | 36 +++++++++++++++++++++---------- src/slic3r/GUI/wxMediaCtrl3.cpp | 7 +++++- src/slic3r/GUI/wxMediaCtrl3.h | 5 ++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index 3cf4e751d0..a56579ccaa 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -1,5 +1,7 @@ #include "AVVideoDecoder.hpp" +#include + extern "C" { #include @@ -26,12 +28,12 @@ int AVVideoDecoder::open(Bambu_StreamInfo const &info) auto codec_id = info.sub_type == AVC1 ? AV_CODEC_ID_H264 : AV_CODEC_ID_MJPEG; auto codec = avcodec_find_decoder(codec_id); if (codec == nullptr) { - fprintf(stderr, "Unsupported codec!\n"); + fprintf(stderr, "AVVideoDecoder: unsupported codec!\n"); return -1; // Codec not found } /* open the coderc */ if (avcodec_open2(codec_ctx_, codec, nullptr) < 0) { - fprintf(stderr, "could not open codec\n"); + fprintf(stderr, "AVVideoDecoder: could not open codec\n"); return -1; } @@ -70,15 +72,16 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) if (!got_frame_) return false; - auto size = size2; - if (!size.IsFullySpecified()) - size = {frame_->width, frame_->height }; + auto size1 = size2; + if (!size1.IsFullySpecified()) + size1 = {frame_->width, frame_->height }; + auto size = size1; if (size.GetWidth() & 0x0f) size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); AVPixelFormat wxFmt = AV_PIX_FMT_RGB24; sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), - size.GetWidth(), size.GetHeight(), wxFmt, + size1.GetWidth(), size1.GetHeight(), wxFmt, SWS_GAUSS, nullptr, nullptr, nullptr); if (sws_ctx_ == nullptr) @@ -92,7 +95,11 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) if (result_h != size.GetHeight()) { return false; } - image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data()); + image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data(), true); + if (!image.IsOk()) { + fprintf(stderr, "AVVideoDecoder: image not ok %dx%d\n", size.GetWidth(), size.GetHeight()); + return false; + } return true; } @@ -101,15 +108,16 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) if (!got_frame_) return false; - auto size = size2; - if (!size.IsFullySpecified()) - size = {frame_->width, frame_->height }; + auto size1 = size2; + if (!size1.IsFullySpecified()) + size1 = {frame_->width, frame_->height }; + auto size = size1; if (size.GetWidth() & 0x0f) size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); AVPixelFormat wxFmt = AV_PIX_FMT_RGB32; sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), - size.GetWidth(), size.GetHeight(), wxFmt, + size1.GetWidth(), size1.GetHeight(), wxFmt, SWS_GAUSS, nullptr, nullptr, nullptr); if (sws_ctx_ == nullptr) @@ -121,8 +129,14 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) int strides[] = { size.GetWidth() * 4 }; int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); if (result_h != size.GetHeight()) { + fprintf(stderr, "AVVideoDecoder: result_h %d %d\n", result_h, size.GetHeight()); return false; } bitmap = wxBitmap((char const *) bits_.data(), size.GetWidth(), size.GetHeight(), 32); + assert(bitmap.IsOk()); + if (!bitmap.IsOk()) { + fprintf(stderr, "AVVideoDecoder: bitmap not ok %dx%d\n", size.GetWidth(), size.GetHeight()); + return false; + } return true; } diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 99e31ca983..f6912b8f3e 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -240,9 +240,14 @@ void wxMediaCtrl3::PlayThread() if (error == 0) { auto frame_size = m_frame_size; lk.unlock(); - wxBitmap bm; decoder.decode(sample); +#ifdef _WIN32 + wxBitmap bm; decoder.toWxBitmap(bm, frame_size); +#else + wxImage bm; + decoder.toWxImage(bm, frame_size); +#endif lk.lock(); if (m_url != url) { error = 1; diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index 73224bc384..d75dff9b5c 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -69,8 +69,11 @@ private: int m_error = 0; wxSize m_video_size = wxDefaultSize; wxSize m_frame_size = wxDefaultSize; +#ifdef _WIN32 wxBitmap m_frame; - wxImage m_frame2; +#else + wxImage m_frame; +#endif std::shared_ptr m_url; std::mutex m_mutex; From c3d9c27091948b5d97d9cf663366556be082c8a4 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Thu, 26 Sep 2024 17:14:20 +0800 Subject: [PATCH 026/138] FIX: wxMediaCtrl3 enter Stopped state soon Change-Id: I120e9d4b9f85599a184650d1d95fe2bec42af171 Jira: STUDIO-8280 (cherry picked from commit 7648d96305d510b9e97f22124961de5115cde830) --- src/slic3r/GUI/MediaPlayCtrl.cpp | 2 ++ src/slic3r/GUI/wxMediaCtrl3.cpp | 1 + 2 files changed, 3 insertions(+) diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 7e94775e02..dd5132b4b6 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -716,7 +716,9 @@ void MediaPlayCtrl::media_proc() break; } else if (url == "") { + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: start play"; m_media_ctrl->Play(); + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: end play"; } else { BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: start load"; diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index f6912b8f3e..dc91b8525c 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -67,6 +67,7 @@ void wxMediaCtrl3::Stop() std::unique_lock lk(m_mutex); m_url.reset(); m_frame = wxImage(m_idle_image); + NotifyStopped(); m_cond.notify_all(); Refresh(); } From fed03193e2d98e09afc14926aedf63b05ccf4296 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Sat, 12 Oct 2024 11:33:52 +0800 Subject: [PATCH 027/138] FIX: reset decode buffer zero when scale width changed Change-Id: Iaa2f99111dd5f7228b7b25e1be0a8cbdbfe982a6 Jira: STUDIO-8422 (cherry picked from commit 659ebc7d07a8f6045ba5443141b44277d7257cec) --- src/slic3r/GUI/AVVideoDecoder.cpp | 14 ++++++++++++-- src/slic3r/GUI/AVVideoDecoder.hpp | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index a56579ccaa..1848040546 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -76,8 +76,13 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) if (!size1.IsFullySpecified()) size1 = {frame_->width, frame_->height }; auto size = size1; - if (size.GetWidth() & 0x0f) + if (size.GetWidth() & 0x0f) { size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + if (size.GetWidth() != width_) { + std::fill(bits_.begin(), bits_.end(), 0); + width_ = size.GetWidth(); + } + } AVPixelFormat wxFmt = AV_PIX_FMT_RGB24; sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), @@ -112,8 +117,13 @@ bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) if (!size1.IsFullySpecified()) size1 = {frame_->width, frame_->height }; auto size = size1; - if (size.GetWidth() & 0x0f) + if (size.GetWidth() & 0x0f) { size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + if (size.GetWidth() != width_) { + std::fill(bits_.begin(), bits_.end(), 0); + width_ = size.GetWidth(); + } + } AVPixelFormat wxFmt = AV_PIX_FMT_RGB32; sws_ctx_ = sws_getCachedContext(sws_ctx_, frame_->width, frame_->height, AVPixelFormat(frame_->format), diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index 99a7349e55..239352aeaa 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -34,6 +34,7 @@ private: AVFrame * frame_ = nullptr; SwsContext * sws_ctx_ = nullptr; bool got_frame_ = false; + int width_ { 0 }; // scale result width std::vector bits_; }; From 3045ff778846256d55fbd4a4c4ccc1e46c97fb6b Mon Sep 17 00:00:00 2001 From: Bastien Nocera Date: Tue, 15 Oct 2024 21:40:59 +0200 Subject: [PATCH 028/138] slic3r: Fix missing declarations in wxMediaCtrl3.h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/slic3r/GUI/wxMediaCtrl3.h:80:10: error: ‘condition_variable’ in namespace ‘std’ does not name a type 80 | std::condition_variable m_cond; | ^~~~~~~~~~~~~~~~~~ src/slic3r/GUI/wxMediaCtrl3.h:27:1: note: ‘std::condition_variable’ is defined in header ‘’; did you forget to ‘#include ’? 26 | #include "Printer/BambuTunnel.h" +++ |+#include 27 | src/slic3r/GUI/wxMediaCtrl3.h:81:10: error: ‘thread’ in namespace ‘std’ does not name a type 81 | std::thread m_thread; | ^~~~~~ src/slic3r/GUI/wxMediaCtrl3.h:27:1: note: ‘std::thread’ is defined in header ‘’; did you forget to ‘#include ’? 26 | #include "Printer/BambuTunnel.h" +++ |+#include 27 | In file included from src/slic3r/GUI/MediaPlayCtrl.h:17, from src/slic3r/GUI/MediaPlayCtrl.cpp:1: src/slic3r/GUI/wxMediaCtrl3.h:77:13: error: field ‘m_frame’ has incomplete type ‘wxImage’ 77 | wxImage m_frame; | ^~~~~~~ (cherry picked from commit 727a73333bd67acf5ff2b1c51ff284c2bacdb413) --- src/slic3r/GUI/wxMediaCtrl3.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index d75dff9b5c..86bc7755ea 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -23,6 +23,11 @@ void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, in #else #define BAMBU_DYNAMIC +#include +#include +#ifndef _WIN32 +#include +#endif #include "Printer/BambuTunnel.h" class AVVideoDecoder; From 004aea23c8470cf620282b9b62a8aca0c5337aba Mon Sep 17 00:00:00 2001 From: Bastien Nocera Date: Wed, 16 Oct 2024 22:59:30 +0200 Subject: [PATCH 029/138] slic3r: Fix missing includes in AVVideoDecoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In file included from src/slic3r/GUI/AVVideoDecoder.cpp:1: src/slic3r/GUI/AVVideoDecoder.hpp:28:20: error: ‘wxImage’ has not been declared 28 | bool toWxImage(wxImage &image, wxSize const &size); | ^~~~~~~ src/slic3r/GUI/AVVideoDecoder.hpp:28:36: error: ‘wxSize’ has not been declared 28 | bool toWxImage(wxImage &image, wxSize const &size); | ^~~~~~ src/slic3r/GUI/AVVideoDecoder.hpp:38:10: error: ‘vector’ in namespace ‘std’ does not name a template type 38 | std::vector bits_; | ^~~~~~ src/slic3r/GUI/AVVideoDecoder.hpp:9:1: note: ‘std::vector’ is defined in header ‘’; did you forget to ‘#include ’? 8 | #include +++ |+#include 9 | } src/slic3r/GUI/AVVideoDecoder.cpp:145:89: error: invalid use of incomplete type ‘class wxBitmap’ 145 | bitmap = wxBitmap((char const *) bits_.data(), size.GetWidth(), size.GetHeight(), 32); | ^ (cherry picked from commit 781ce14e061366da64fdc2d0d592fa35ee57e67e) --- src/slic3r/GUI/AVVideoDecoder.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index 239352aeaa..4111e860a2 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -7,6 +7,11 @@ extern "C" { #include #include } +#include +#include +#include +#include + class wxBitmap; class AVVideoDecoder From 76546d89f1c05e6161940d2d4375102345879dee Mon Sep 17 00:00:00 2001 From: Bastien Nocera Date: Wed, 16 Oct 2024 23:04:43 +0200 Subject: [PATCH 030/138] slic3r: Fix missing includes in wxMediaCtrl2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/slic3r/GUI/wxMediaCtrl2.cpp: In lambda function: src/slic3r/GUI/wxMediaCtrl2.cpp:170:13: error: ‘wxMessageBox’ was not declared in this scope; did you mean ‘wxInfoMessageBox’? 170 | wxMessageBox(_L("Your system is missing H.264 codecs for GStreamer, which are required to play video. (Try installing the gstreamer1.0-plugins-bad or gstreamer1.0-libav packages, then restart Bambu Studio?)"), _L("Error"), wxOK); | ^~~~~~~~~~~~ | wxInfoMessageBox src/slic3r/GUI/wxMediaCtrl2.cpp: In member function ‘void wxMediaCtrl2::Load(wxURI)’: src/slic3r/GUI/wxMediaCtrl2.cpp:179:5: error: ‘wxLog’ has not been declared 179 | wxLog::EnableLogging(false); | ^~~~~ (cherry picked from commit 73908d38d8b1f7c8dcae92d55711bc08cbfff23c) --- src/slic3r/GUI/wxMediaCtrl2.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp index 9ff657ff73..8871f0d2dd 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ b/src/slic3r/GUI/wxMediaCtrl2.cpp @@ -2,6 +2,8 @@ #include "libslic3r/Time.hpp" #include "I18N.hpp" #include "libslic3r/Utils.hpp" +#include +#include #include "LinuxDisplayBackend.hpp" #include #include From 7b5f8d00a0e4ff351e1fe5df4ef3ebe3770292a5 Mon Sep 17 00:00:00 2001 From: Bastien Nocera Date: Thu, 17 Oct 2024 11:30:14 +0200 Subject: [PATCH 031/138] slic3r: Fix missing wxPaintDC declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/slic3r/GUI/wxMediaCtrl3.cpp: In member function ‘void wxMediaCtrl3::paintEvent(wxPaintEvent&)’: src/slic3r/GUI/wxMediaCtrl3.cpp:121:5: error: ‘wxPaintDC’ was not declared in this scope; did you mean ‘wxPoint’? 121 | wxPaintDC dc(this); | ^~~~~~~~~ | wxPoint (cherry picked from commit 9ab5009235d212699f91e01d7f930f92849ed1e3) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index dc91b8525c..acd04beef7 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -2,6 +2,7 @@ #include "AVVideoDecoder.hpp" #include "I18N.hpp" #include "libslic3r/Utils.hpp" +#include #ifdef __WIN32__ #include #include From a9be080ebcd388cf5068b75ef5c74c317b1aa64a Mon Sep 17 00:00:00 2001 From: Bastien Nocera Date: Thu, 17 Oct 2024 11:31:26 +0200 Subject: [PATCH 032/138] slic3r: Fix missing BOOST_LOG_TRIVIAL declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/slic3r/GUI/wxMediaCtrl3.cpp:181:23: error: ‘info’ was not declared in this scope 181 | BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); | ^~~~ src/slic3r/GUI/wxMediaCtrl3.cpp:181:5: error: ‘BOOST_LOG_TRIVIAL’ was not declared in this scope 181 | BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); | ^~~~~~~~~~~~~~~~~ (cherry picked from commit c5c41e20ca2fc7f3b53a4c769961f73df6992008) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index acd04beef7..90950b6b10 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -2,6 +2,7 @@ #include "AVVideoDecoder.hpp" #include "I18N.hpp" #include "libslic3r/Utils.hpp" +#include #include #ifdef __WIN32__ #include From 76e3da15e3d1072c7f0442e2a700cf92665a0657 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Fri, 25 Oct 2024 09:27:32 +0800 Subject: [PATCH 033/138] FIX: wxMediaCtrl3 zero size crash Change-Id: I16a3f7b3afe142bb957a1740b8e8c9820c92b349 Jira: STUDIO-8522 (cherry picked from commit 8cdaea1162ccbcc0bd03ecd99346f3b9cf52cf64) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 90950b6b10..b2a97b19e8 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -122,6 +122,8 @@ void wxMediaCtrl3::paintEvent(wxPaintEvent &evt) { wxPaintDC dc(this); auto size = GetSize(); + if (size.x <= 0 || size.y <= 0) + return; std::unique_lock lk(m_mutex); if (!m_frame.IsOk()) return; From 26383d5c22624870e0cd56a43a9a6f42d602d8e2 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Mon, 4 Nov 2024 12:11:14 +0800 Subject: [PATCH 034/138] FIX: TabCtrl button margin Change-Id: If8b05a4ef9efb8b57989ee1de6543631e5a3cf90 Jira: STUDIO-8265 (cherry picked from commit 1c5e65707109ad0582b6442cf8e515344f799c27) --- src/slic3r/GUI/Widgets/TabCtrl.cpp | 5 +++-- src/slic3r/GUI/wxMediaCtrl3.cpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 40e291d5ad..882d2f6897 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -111,7 +111,7 @@ int TabCtrl::AppendItem(const wxString &item, btns.push_back(btn); if (btns.size() > 1) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); - sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE * 2); + sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE); sizer->AddStretchSpacer(1); relayout(); return btns.size() - 1; @@ -225,8 +225,9 @@ bool TabCtrl::IsVisible(unsigned int item) const void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) { + auto size = GetSize(); wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; + if (size == GetSize()) return; relayout(); } diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index b2a97b19e8..cee4df7290 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -149,7 +149,7 @@ void wxMediaCtrl3::paintEvent(wxPaintEvent &evt) void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) { wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; + if (sizeFlags == wxSIZE_USE_EXISTING) return; wxMediaCtrl_OnSize(this, m_video_size, width, height); std::unique_lock lk(m_mutex); adjust_frame_size(m_frame_size, m_video_size, GetSize()); From ce53d34b4d4854810d06d89c3b1c69d112f52093 Mon Sep 17 00:00:00 2001 From: "chunmao.guo" Date: Wed, 18 Dec 2024 19:27:19 +0800 Subject: [PATCH 035/138] ENH: wxMediaCtrl3 display video frame at pts Change-Id: I8847236d2307101e5f2befc6477cd20b3691841c Jira: none (cherry picked from commit 05328da4612c11d50f6fd90e872b97f5f8f46b1d) --- src/slic3r/GUI/wxMediaCtrl3.cpp | 26 +++++++++++++++++++++++++- src/slic3r/GUI/wxMediaCtrl3.h | 3 +++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index cee4df7290..0e4bffb3a7 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -221,10 +221,12 @@ void wxMediaCtrl3::PlayThread() if (error == 0) error = Bambu_GetStreamInfo(tunnel, 0, &info); AVVideoDecoder decoder; + int minFrameDuration = 0; if (error == 0) { decoder.open(info); m_video_size = { info.format.video.width, info.format.video.height }; adjust_frame_size(m_frame_size, m_video_size, GetSize()); + minFrameDuration = 800 / info.format.video.frame_rate; // 80% NotifyStopped(); } Bambu_Sample sample; @@ -258,8 +260,30 @@ void wxMediaCtrl3::PlayThread() error = 1; break; } - if (bm.IsOk()) + if (bm.IsOk()) { + auto now = std::chrono::system_clock::now(); + if (m_last_PTS && (sample.decode_time - m_last_PTS) < 30000000ULL) { // 3s + auto next_PTS_expected = m_last_PTS_expected + std::chrono::milliseconds((sample.decode_time - m_last_PTS) / 10000ULL); + // The frame is late, catch up a little + auto next_PTS_practical = m_last_PTS_practical + std::chrono::milliseconds(minFrameDuration); + auto next_PTS = std::max(next_PTS_expected, next_PTS_practical); + if(now < next_PTS) + std::this_thread::sleep_until(next_PTS); + else + next_PTS = now; + //auto text = wxString::Format(L"wxMediaCtrl3 pts diff %ld\n", std::chrono::duration_cast(next_PTS - next_PTS_expected).count()); + //OutputDebugString(text); + m_last_PTS = sample.decode_time; + m_last_PTS_expected = next_PTS_expected; + m_last_PTS_practical = next_PTS; + } else { + // Resync + m_last_PTS = sample.decode_time; + m_last_PTS_expected = now; + m_last_PTS_practical = now; + } m_frame = bm; + } CallAfter([this] { Refresh(); }); } } diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index 86bc7755ea..859722d421 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -81,6 +81,9 @@ private: #endif std::shared_ptr m_url; + std::uint64_t m_last_PTS{0}; + std::chrono::system_clock::time_point m_last_PTS_expected; + std::chrono::system_clock::time_point m_last_PTS_practical; std::mutex m_mutex; std::condition_variable m_cond; std::thread m_thread; From 0325f94de14b0889b6727aa1642e1af214559a87 Mon Sep 17 00:00:00 2001 From: "chao.zhang" Date: Sat, 11 Jan 2025 14:13:42 +0800 Subject: [PATCH 036/138] Fix: fix memory leak caused by ffmpeg decoding Change-Id: I162ad4ea8d4601c1ffe17a65f292566c9dea6f0b jira: no-jira (cherry picked from commit eb20d03186c86b7398b97e3bae0a3c7a7b81c58c) --- src/slic3r/GUI/AVVideoDecoder.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index 1848040546..4c951fdb61 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -47,12 +47,27 @@ int AVVideoDecoder::open(Bambu_StreamInfo const &info) int AVVideoDecoder::decode(const Bambu_Sample &sample) { - auto pkt = av_packet_alloc(); - int ret = av_new_packet(pkt, sample.size); - if (ret == 0) - memcpy(pkt->data, sample.buffer, size_t(sample.size)); - got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + int ret = -1; + AVPacket *pkt = av_packet_alloc(); + if (!pkt) { + return ret; + } + + ret = av_new_packet(pkt, sample.size); + if (ret != 0) { + av_packet_free(&pkt); + return ret; + } + + memcpy(pkt->data, sample.buffer, size_t(sample.size)); + ret = avcodec_send_packet(codec_ctx_, pkt); + if (ret == 0) { + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + } + + av_packet_unref(pkt); + av_packet_free(&pkt); return ret; } From 7382a15b89c5e050fb222663a018a30714f25430 Mon Sep 17 00:00:00 2001 From: "lane.wei" Date: Tue, 15 Oct 2024 15:44:29 +0800 Subject: [PATCH 037/138] ENH: update some missing codes jira: no-jira Change-Id: Icb2da53911430ac144b0fb601637a7ad31e7e8db (cherry picked from commit 13b4213f8a24c76c16e49daf905fa29c0f646a5a) --- src/slic3r/GUI/MediaPlayCtrl.cpp | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index dd5132b4b6..dc1f95b88b 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -39,7 +39,7 @@ static std::map error_messages = { namespace Slic3r { namespace GUI { -MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl2 *media_ctrl, const wxPoint &pos, const wxSize &size) +MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const wxPoint &pos, const wxSize &size) : wxPanel(parent, wxID_ANY, pos, size) , m_media_ctrl(media_ctrl) { @@ -178,13 +178,6 @@ void MediaPlayCtrl::SetMachineObject(MachineObject* obj) if (machine == m_machine) { if (m_last_state == MEDIASTATE_IDLE && IsEnabled()) Play(); - else if (m_last_state == MEDIASTATE_LOADING && m_tutk_state == "disable" - && m_last_user_play + wxTimeSpan::Seconds(3) < wxDateTime::Now()) { - // resend ttcode to printer - if (auto agent = wxGetApp().getAgent()) - agent->get_camera_url(machine, [](auto) {}, wxGetApp().get_printer_cloud_provider()); - m_last_user_play = wxDateTime::Now(); - } return; } m_machine = machine; From dc4562cbfcde6cd4dbafe3e2c1127f884ce865c8 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Sat, 18 Jul 2026 00:49:33 +0800 Subject: [PATCH 038/138] Fix build --- src/slic3r/GUI/wxMediaCtrl2.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp index 8871f0d2dd..6a2e07d8d6 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ b/src/slic3r/GUI/wxMediaCtrl2.cpp @@ -1,6 +1,7 @@ #include "wxMediaCtrl2.h" #include "libslic3r/Time.hpp" #include "I18N.hpp" +#include "GUI_App.hpp" #include "libslic3r/Utils.hpp" #include #include From e2eebc69dd5e03e1e2f988cd3fff357076d2d605 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Sat, 18 Jul 2026 00:56:27 +0800 Subject: [PATCH 039/138] Update idle image --- resources/images/live_stream_default.png | Bin 174784 -> 592991 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/images/live_stream_default.png b/resources/images/live_stream_default.png index 326ceaf994ebd78a8513482720ab28a5d2e74ee1..c1aa90bf1700bb1eee736a8f031a61adf0971714 100644 GIT binary patch literal 592991 zcmV)rK$*XZP)R%TSqM%JI83pH-2k-Kid|dw8G`UmYwLhfSs(V=J!rHPtFebmF{Ml~*OM`l&{8P04 znDU3Z^+_3|c2mKI$fnRO*Zv{37W@w>{Z5bD%tr+7Y1%gP@3{HC?d6~FMnI^ny^1m^ zftJ5C?ysJHXmGNi{mU8=y1IgI|0O}}MEqqn09MZlvUZ{LgUT3*mklKW+v&ef(1lfa zyq)?>>2f_d^x<^vz*&s3d+D<_HRi1cq76^X!Gogy<@w6aUpiWU5gpT(ColtFdj}Hru~1!hz+q4|C|>xU^--~WA$2b>gDf1?RQD0>xg-Kc>zUpDN4dguF`Vgko-Uo*ZZ74tp=X(Si#m?@If0E@Ws+s{uMVKQ~K2){rk%3L;osTebpNV@068W zADACS>mjAIfYivVr=ws)1WshE1T?r={<;Katp(pG;|52XHqZu_;pwZnE3ni=wQY~- zU-E=c-!8vpllF%S+kff2^N>Q>)2GI9{c)p;>9aJt^<;4{+AkV{8aN)D2?QSB1hX3o zKDp~9pp1AwtO!D1#%XI@EMxzqjnj|+`(^R;2Pz0U5&Y7s?OeX$(DMu8Z8-lg`Jw!Q zN#eeIhKo5=RG{Oe{MNow9lCF-uP>1ya~$!j8+II&uhb{ew2|P7y7+$_fH0rSHq)W# zp+<2@8GN3)@6QK+9L>LU^IiUWuLNMeh>w^4bHj9;u{%`a-01lAj}+9m;a}vxjxigD z($}0nFauM|80T0h-D4cHaJ*#3uU+Xe<~{K%vHL5=ZQ`m zM8?HFVWqN`M^j7-a1|+>HIQN=W2bLu;grO#z|F!f%=M~A!!6Fuy*18#jAJ!;?TyV% zvN6%u`d~oop6$0jrE6R*w;so_rX1J92x?(>J`HhUAI3TF zj_(Ro?~rTle)3NdQ93VuS4fpU6|-?v&gJ!dS`D-IA5LB_%zWNiyF-nv%sw7ITF$BQ z9h~#~%cPF;Ys|qr8pv>`4l$n0c)0cc;oCJ&uMdWw@jcnYK;V$hjyO55KHNp#*JB>Q z()gDac1M!F6X=LgH>yTDwZrv~|Nq~zC|eKfF1M`|59#4rp3!BMJg+loeN1nve=@#Q zutB}0>Y?&g<^O%<$#9Z~;*P1V#k|_ujF5nKUo8!jm!9ERfj}#WDDI$1GB>t zz;J5dOhKD?>wr;U{L64G84T?^ye+*!;p!<(RH8Z3ShAP<;;-7Cqh(j-FTFB*_Iiya zW*=rUcj5CCw6bb^Iab*%c?_5Fi#ll|>^kO2-#n=RQj_}83(kTj&P?X~a%w8amu&L| z|GoLa+HaNP9JqIWRwaE|{q$p8v*DdoeeX9Z-N!$8F!m2yr`;<7jA`TT|4jOdp~;m1 zesAc!`JM)r=*3L*YQ7j+RX;~#j)7cBVR0Kro@d8%1YOUS-2^b(Ea}Sr>&n<>xEuH! z=XL3!rq*;p2Z~k!NfW&p-KXu-L1zRA95|LT_Tv#UIdq*RX7ay? zLKDMA>-%4x298etQaQ=W!F8b7)Qn71jyc0#E+j`HXJB^B>OL(n9pM+w#<~<+cC_;0 z-N>YuN#N8#O~|?3XWH!-QBU8_1AV3UUjNz}AM*2h1!Rl|3yM}t^)yj*hasrRu}ziXai$cv&=UM=@r5bObkS`KRHiUz8TQ)IDpB=wSt5;E{4Dw{2s~ znOfqZe%&p9s$c&rw&d%t^~1O;Td(nu^3C%IMBnQh5D~qT07kS|wL6JjYm?*P{p}`z zQ`$jIX%Z5^)~>tieq_mBgK<3XN3e}VMwW@-EXk|&nk)02%BcPjzjQn-MuaAG(Mb5h z&qXznuu23=vO$H$W*vA-kw+z=kD?x&EA`+iug@_RR0-fq_eKKPCTict#&s(F+*Q#D z;OUR0-AMq${)>Jb={TJLc1d3$G@l5@;hRwY5p$i;JvJd;^}rLgcQbj{0J77-f&3;! z=%M_h;?`?zb^pcdgRvygo4ShF@{yr6j6@2lgsi>U5>^nu1^8i6r(n*DdVGDVT+)6% zW8g;$NUb9E*hoPwnWKrObr5li3Am*94jg%e{vzzt$x7e{62LrpW@(@_>hCT6x$Ntc z(OTFwozJaI=42;h=oDt+EFhVC8383WXQ0F<@~i$Vj4WpX(?&&sK0 zAZ5)m*WZ3U2E`5u-izmwyqaFoNc&2*zMN?!e-&=K{z)R(odTZ8;YPxEDkGFT~IQd9y+DK;sS zBT0Hvt|m@wodC9Hc`ps*Q@J9xF=_5J@MYw^S@fL*@Y{jmPXgoZ`Oqf}6pbZ%armfd zhA&V*1uZezt4D^M4xR|!eyof>vzGww`?l4KyIpSIV?Uy1mJuq>7&x3k*G7(}e>N>8 zBzcmw%tiRP=J4n7JfC;CF3*gSd1@`mpOz<8qG+uQp~Gpbj6gK4lc^tYh+>>iz+5Eb z<#g)eAC&+Gjisfrwf^&dygnJNg}p;RcrOinZ(s~ zhxa;@MDY3NUD(e>KXVFrgEs-|)4=CCT=^?SV(^Wf-$x!x{zfaoh11W@sa6fk(S(!q zpPF5{q4=zqouWp_(Mo|Oc9)(3?wte<8lQqL9)BCAK6_#1`e2(M6nxOPrM3tBE482G zFO@yTkGS!*zI^~!v;Z;{M?=jT8BN+b0oStIn zmOcC@3E9c@mGiMm{g|!SMA& z@WTa5!W})Rm*g!P#0E;-0$<-i0e~f*gV8pzvq#r$NT)p(K=Zl3Oa_kWFp%r)`dr0=;F-#!hj zjccE^3tjfoV&nJ%8wZN&nzF$?V}7z zrDfdUd{kC_9340qlq*;j^!(uP%#X2Wa|wroZ|Gl!Lp`U@#cB<0=#9Wq^aH(ZZp+42 z;MP*x{d)eM`z$8+;_=^F@-<5T!CtR(lCQzUUd7jUyyhA0V0J)h-d0SiS$n#zlf268 ztNnBW7`hL~!W5Hn)lGu;@{7^5K5HYlwMh0lAEw=YBAEL1Q8IU4_hqC=Oj8JcmLnnokK&p5=~9EB5{l@JW5| zAIb7Gu&~6hSUa~8z{N@68UQRMY0*+>r}Mi9|V~LC7N*>W6#zS4hJ6b-D{)4-<_^Q-{ehRk_tc^dB|fWt{(K*CQ# zUmH2FiQtzJxst-?rqX-!q)G&f+JAWk;Oe4i-qDz(cmLL>ff;VEj7`Mo2~pX9U0HP& z@s6V~xnYN{{EtpIq|9u(HRxLB`J*Av4Zxj%|0#1zCK%6#oMAwGESEN)gTg>nG|MxxkppMd-N1Agsi2K#PEeZD^~c`y0DP^r=U+P z=&9h-gZV|l^uXcI??FtzNRgctVq{CYnQ8@`9;@BhwZQI_}( zJ2lXl-2`xc5_k-?xpnd0bLdL$ZX|-=lSn0LO(Ix-PM#h05hlWOr-7xR$O1;&@PElV zp!i8Z17i@`GIU%}DjA$yax%8LmtAXF0(?{chYCXF6Fv3VtmP95c4Zw0H|5qU0v;vU zMF|{(D3bFX2FVyy;{)bMl0c+?3Sur4EcGT0Bpb!FI){WORN?ve6y;LYWG3W+XrX!Gh#$!u}+2?lUiBxxz;Li?PaO%xi>6# z`0vmDgm0I2=#6ca{C%(Z0GU3AZ@iC)RDf3=Oyfx=_Ran+uCw-3rO~&PBX^P*rq6GiW_@9*C zi(>J}Yde7tA|w6@Pp~`1Le0E{jlH$$Cc*OXnd;R zsnS`Sjasnw_-235Q;9@)R6@IU#|jDu8-4wwb_NF8)Vt6*YYUHCIytBmUFz+1CPlUN z-E&)8p zY}R?Qm(jF7Yr|X_&FDn%Crk1&pS(Z*YW(x|jNLpD9R0YT2;NEZdcQfA@d{etle)uq z+8OPlEH=4_gwr%!bP*yG3kaw2D5<4)>G48~hA< zzwF$3j}{iJMPj(;tGnaF17MPo%A;0~Tg()`DYaJkP(i4CphGL!(1(hGUD>rfx*E~x zhJPp4xH{f(4Y@!#cR3FpsQB2Rr|Quz1K^g7p2Zqb-NPUHUVVfX1T-bcybbhd;( z_i&Sl7LMpIab65PI8v_+pLmmEg1%BU%6_5K@t|Uj-!H`zc3dA<8P4xF?v1(DeV$&? z4ja`;;1hq!Puc#7-&2=O^kSV9{xlIBehykVPX+Y(Q@}S*1E1GR2I@OZT>C*ZMysom zznpj4m8}!N=Qk3?v{0uz!KEfWqwq~imm@7@c$$s9-BY4fh~OXW!yYpGT3iQ0K+NDcw0$dT|WkGj$KT4 zUR3C7)K}4rPDUeFZkO1cp>dK<(<#1aNW^*d&6* zCWp_>BMG#3zw}m%N$~n)uj+H$IEouKT29Vxi4ox^gpFW}LCYrz;L(G9rHotm+bkc7 z+Y(NBOSqpMsZzES4$p@M$Fn1HAzPMDWS<+0PO@UHBi6dQP*Jce>p0kv;j&#kmSBZT zv2p#lK)7&m9z0O-DG#{}PxQVROWNflo}saI1gFKp{0Z7cZS-vqo5%M}=JbOpDHqC* zly5v+V0Lf`zUYdPY2ifO6Ifzq6(uIbc2Ic=(+*ZCEutULhgUJP-02m zjP7avbTB&!{Gxnx61Ywf<4BZ^rxL+36Pxkppx?ISr1|x|+noL0DPa8%I1lROFT^H$ ztum@9tnbCdfi2o2&wR76_KuI%3E&xGtN*GOvaGKaJoWW#vvF(cN#fp@Uojo3VPNxb z0^fNU^>y^MgHzfH@PX_E@5}uu@=k}Td{U6N9?{>o{lD@rExq-gErWCOXWzrSIn~q~ z^%L(gAF)=EJT)C`d~CiVH{7<^c-X~Q`d4= zJ#Tw_MKk<4*mWT}BTa@OjLX3*)uJjs6&rsRdg^;8UElfwU2J30&K(_R?Zj=L0=^s( zyJI9$25gli)|+2fk{2NXjAP@*oCGct!E=L%(yIDvF!d&zTwAkOc}v6i@54$ULl4fl7c10{(dmYk={6IS#y`Y|1w>X5aXdXQ1DE7y}pM{Cao ziSN7`OJ0>ZCc)Kf*+5Vis*LlH%g}eb{c!XTXw#8RS{%TX#$xz1?Ojo{(1n{priGQ|8dMY;l3>A_NcjO%sca>EJD3G_k8H|VAkSp1kqP(q zP_KIR45bO-H}+Z&zL+DnkFkB`B(RmOPYNTR4*qo?ZJz$UGJ%BGN%rodgOfe!t1^7ArzLpfGdo7W*~w60SREc z*~1gSPzp>PUO!I&S4me((|sp29hX(ImC+DwF`uZ-mJjO$@ak&fzMIrdd?{tzRnH@i z>B{ANZC|-aaiQ&Gb?I!xq2fE%U}Sar_r$i0#z zeli-f4n!;zE*|9xU~Mv(xB7$nt8R5NH-f>9qQ6Jwn6&GJX2(MJHyEiqv-JGD z`%e|?lfVd-o}C_X1A?5|at+VOZG<7bj_%5C$vq`+1wYf1>usSPv-?T;Bb@lGY~EkD zdpugCHutfDCV52e*ZfDVHhb!gOyI7~Q9)P?7H_`>)IA+#e5_Mz935yIur%M+H~M1t znfq!+TcTZ3E&GfgE6ZC0iNr`HzE{wBMnCi3>b;AxDYM#GQw(_e*#MzA-39IG1aR2& z`ke`2XU|?$-^*m{#gJB#x8gODyR*-0EOz`#leqqR_~5TpSAsRy1y9ATq$T>aAIde# zqiy@)=$4k3c^3`J)#do&taM*_n^>y;@T1M=P63}+p$>WdHla+eMFO}y z32YLkoiZ*J>AJo zwz}^+sGg4R6cnv3d3+#y);3g7znk)?DA<)<+eb%Z-vx>9z#8jbl`9m*!4m_V^C|Bj zWpZI&oGtX}JMa4~nsx9yH2fJpFX>3`q-0|HHCdOJ&K1R<7o>bk>fxx~1Q*zy8+6q- z#p?90^GroF&iUCmS`fz|l}t&EXr;hWJ(+z15O!aYod(u&_1^`~+v}6S@A$`)z>3~U z3Xk`92A|-4Q5!Uy>_vVu_`*m(*-QQrsQDtG&?4Paz%svE?Vbi!{WPb6oj(?-IY7%bn`ddZm=Sm~_z6()>ASe28dpu`j=t=XW$`2H@NI7@VDk=9Kq|37Me{k;Oz9LsuJQ0bdhHY>= zImFuv2xiEE%8OYro6x6Q#Hf6HrUy5g+cL zZrR;fpS40E|Jey(c>jt7Fuaejc6hQE)@MxJ=E`WQ@0dn}CRLT69K&oDJZvU+JD9Ft zy(Cv&sJa0hE+v*snV8+Fyqw5|K@iyqHTL8$q~+|)IK%t@Vz*GVl-=h8Bq4>&IT zM6kCFN3u@Of28wE>hTji-NJS(yOFnJgBHS^W%jClUM}IJ^0lo&eT3iY1tg zcIf_mwLM~M`}O?$*Czd%fYG z9T|nd4t}D#dwQK=!NLi61{QqPyu*v{)T4zvbTV5UozW)g85*op3mTgjPt&B|k0!RF zPx2=3nk!tcoJ$T<;%?0Q6u-c|P|2Jn3e)>xiQ%`MWInd|h2L@5xsp0O>oF{)tA!2X zVG_7C+&m3D6JY*lpbf2&45o?ULswaMiR6$J_Iw=o$*@V>7uC~7o$QS~NfN^Ep-B_M zE|GXjWr^XvQ@|gb26i^61&UScd@ZPXDV`fW{moAcXZ4`b5NhPAy)M&l#^@+G;=9sE z6lbuG>A$_e$7NFqZX%GgO4;sW@e>(r#o(^olokUW0tCjI{Az zR`PWl2M%*7%0ofztjkEEF1Nnv7iu-hT}iBreLEwAcpIayH(R3`;Hck)r7rE|=tA3o zQl!P?+noD4bA7BV)fmPyFzxcY>^@!g-v>YZF8iPq-)%EwTduvy21Y*{Ahe)8rFkEW zHrENBU^{ex)t!IeOaNcHEp58INV+Ot0#C(-W0?MF+n=)CP5>9Scsv?@6<0GZTUR|! z1!iV=1MiPW1ltc&ps|r%6xi?UM+v!}7vW94cpjwMn)MbPvr~ad?0NMU?%eVyx*2v@ zh$4MW61f*^d38SrO*e5MHUW%-H1-q0VmEy>?p3Z$xT-F0PX#Wa>o$*mV;R@Sw$#6{ zBsY-2w%WA88Fq-~8B`aU*ezav0UVRccdD!ih z?i-=cSi9AEK(FP{>N+;@R&FoEpV``%i}F|N(`iBUuAq;Y{95_kyK4RL+X{Wgee&r1 zLg_A=M!pzwK>4^-3(Ube<5Pc7i?gTV_Sm9^gVY8CZsb6p739%IqwBqSuZs-k+{)=1c5%p4`o4NdKF>8UKV| z)O8$YeYSUKx~%TuS1}2|NQTE231E5(SOXF&M=*vl7W~C^wXIaI+oXZ&!}hshk^nBR z(o6uquJ1mDMDVMr6TuTl6TVlET-{(iPbB;^_kd4mA}Ur7lx;OX@(sci$de_X?;q>? zk%E$iU6~VsJ-k7oFB6csDR;IS;$=+gaONEB;JI8o%Kfei@=2gDu7(CCt?u(F?`USc z?7h50hy5t&Da|VXkEUJpMjUo^w0Ozbo}_dh1wO%~_hHH);a zu|Jf-UZm=NbD5q`pU6bnd-A!i=k$JV|9_=f=-l3q>sUSmpLv$tz!@xGim5LB6=2chrXIfl*%VO>-lKh;wZ6*6TK~T8 zj@_Wq&#|$bApa|yCHWp(Ava}F;`E*9`Bn~51)TnZ2D)woZS&;Xp#!Wwh0?nA%hj1) ztH;uyoHjJ*ST7Qt+9zmz+?K^M{goYmW%jabWh=KGeMXWXUi*;iB7F-o5mWP29Zw{J z`|W8z(tJV2cjUpz$40`jF~s;M)#t4^*vgb@SWV-HurDcgV>(z4hJr zOj*w1Zx)!#wk$G6E#@E%d}ypahHo`H2}Wtirm?`TjN)KNhTCgJYI7c-5fc#oCB_m+ zGh!+}GEM06*!#Ihw{x7)i4}Z6qht6m4Nv>LBQLUwnT2KaJzg{sCn(WIo9HpNo-6MB^`K*}_`XNp)fArmbu?X^X{*!9Y18NMs4pEN4c@xVTx^~OW~wL+!haGv^F>Ez zh2Z6H4g9!t9_IUsEC+jXSh~@DP~XK*8S>sJ@qPVwWj>MPIzHFc)Bm;dQ>L~zAO78C z_r5wyy-D+hAg&f+>J7HGe#5`SFp{_AxN8@+7+e0147}1k7#of~Zs|HfEp4duSY{g< z09&{0P_Fc4ZGC_Gs#gao9=rnsEUW0*v+`Fp^xi%ssncV)=`R>vX9GMmAJ!2##ua*u!#?ibuX=4FDhV^hG-fC?8@YFOE+8} z+dW1XE9SjfXf(QOOpc?yL64tC0tHVbyuVEiPtqOD(My_gl+mpG9<6KR6I$07OEawo zos2RW56iRksK)UZbBWJIM?orGDIkwe0`l0ZFR2=Cyt==*6teJ8!!2Gmp0sP#GoZ#U z5UFMC(o?<6^Mfj=T*IG7N8?%$a zw-Ul$_GM4z5?{`xS^Um$`xLMqbK&1B*r{OKjB9q?*h=)KIOnx)F6&ly*-|7QtJq#A zeR;&07nNs?vJ|KQllOP6;Cltv2jy@XEH(ZZe!Z5*2G+{#;=eBwWzXj4v!tm3Tj)Qu ztY7NiD5LXe`N09Ws+6qlr>o}PuwA2MEwPCu{)6#iUUt51rx3BvV+vZ!$ANnx+g9<`riM%C`#RJpa z`x%sN##{{$TEuxjyj|VSl4&vNtLv!i+E-WqW&(I75w#weK3~3>E79u{t;9}kZ^wSe zkNrgOjCHh=C1^|kQO5yvF7$b}9(}FqCWfv)?PfHsN5d>VF9q)WmwksWlY=t3srWO{ zFZST-H93JS3X^iQYL^9G(kFWp)nrd7HJK!aKS}_niC}R&d2eFrt*Mt|wtns!U06;3 zRdiC<^F&l{7v+pu#(aDo&{$PJVN!2uCG7pV{mpXM8-yly@Fx|Uc*?@sDtdSh-^4fN z(jH=0CYM`!4(#ETO#f|y7P#}Cg0Ep8LwlU6n~anvQYOBhND6~9DZQjAhZxPu{~Kwu zweFuqmGo?!2%eK1-!?5D<YqfxLyHC*h;?Q*)wqqEuYcw2Yxf&QoAqujo1uE&#atNq0G ze_25%4mUzIQsUE_FYA&&Qv&L{r;AZG!<%V5;RosWV<(4E!`%|tX&OD9gR!C5P5%aG zde26sBLJ*{(p-dB#07^jm)2{YslL5A-_zgob+`0}3X1vj&!RuIAXHlDn>w(>n)@7{ zem0QMoeiVPD)Sg`SGRf6%qoHcw(8nfSHG12b~fyF_WzT=_Zv_5?j(Y(PHUIglL%I3(so8u8Z%a1Mo?ZPv=qmHW+Rtf_<&TUY3}M@$|-<9 zD6E;~DPU$R*~KaLgkl1iCW1vI@I>@a;;}@qPX1o|Cz>vAf;}CqcmXSNZQzBVo*j-ZK@xB__n8yZx)U5afyBw3c zWP5Tx>HF(46sGet(33V3#5fkX^dzv1WBrSZ*R8it0-ry546KvGZ=mo*aIp@SxK=I` z!c6vvuDy0o{eE%^*p~wpz9evEN7S8Vt;!~3OJ1Ncea_8DD)A!3ABF41h?}^y-}pPg z=N#of|I9RHI-7`ux%ivF8>M*YZz|YecV8U2%vZ2j^=9Wq@Bx152a##p$mN|250q!L z{3&K$0Tn^6Wd82`^n+4r+Y9KG)q#Q;JPLo zhB>-^4bH^MBWo^Yq%aRJbo4k(7cyi|r(rC6@{s=L@9FiBE5vx-{=cE@EzPi|E|+Rp zVsU;}&%?!+apn`~It+`pH;0e_1_7c|*S@-Xl>pZ9F*NAp`rQODOe`)R%~gtotJ6j7 zW!GRo=#KqdC0FHx1hBW+vA6P{%U+T>hVcl^){)HHyslKvw*5$Hy1>&JY;gWyB4ZK; zQO9UMQI4%A@#lH{{>Vast2$}5XT+4f$k{1iy(Obg7wFRjG~s(Y5zIEwMEEISm{Y&` z>0k+oU&T!+a&}W#A^3;OSGE793Ewx#@Bg)Yl*40qa0+@@L&X|PIoOVXw3C=$kXd@0 zt-~to=weS3ZOwb}#bS(g3wxL}lBj1^l^o)8HRJ8j6>Uj(G)3>ydOH7f+N5;j6R{SF zir$h5jdDJq7GqVEOsLqC zJF9*tOecOPe5_9SjBBdxle`wsiq{dH{=9G-6jkt$C4p}} ziuz?!efg4Wl6x-f&Yr=B{_!{7M)`*uO7v?X$CW6iR{n`c4wUSE7E zXLjxGvWY z6^8K$Ew6VH!P?gKq5N<(g0lrF$1?^ei%pcObn9^7&>j+=>xnmvV`#bPQqKxF`{bn0 z{U9dllfF*v8*?_)=9k38@K!=tus#VvH_(Ki0v5X=bNVhCPk8K~#D^2YRw|HJr-A9F zRe}G{STvw)exhTo3Z{xK_M~(lG*UgXP;0;%%VACIMf@^8ky(yWw)R@!+tUc;8^c4( zejD5ZYsae-7c_1aEatv`zu(}^s^OJ%!vkw3^c>6PI-@Ohz8b%a_WpRbFB$d@y~lgi z4BhcTriSCI3&$ZyCF&4KDIvkuLd=YK2h;vmeO-LV@ ze;U})4qR;elfvFtWE-ZzDpF3W9_SG=1-hRp5#3=%Et*|nQO}fCi_1F-MyYh zN)lfYVQKmKvI%>aAzNh)S<&g+K zCOA6WAJ`W`+OS09O6u1G4zKRHCzHh0E#%&KxjgKyDc3{5z0WSqovO?u^fc{rmMqnN zLHiGv2qrpYodD*^WPjy$?Nwxbs&|nHZuEI6o5g}$shvAAPPWo(;=*Fps@p`cQk}Q$ zhZyei%LDegLo)_N9os*dhJv$V?v{o!1u+S2hpCdkEY5}(lD={!gu}n~7yq}3cTNJ^ zWUuJIcjkG=g0cN~gGDU9;#K8zD`S>x%OjTmHRZEqjd!OT%aYh$AZS>}e%y(@Uj*XdwjTzL63)nQ}Qdsk=k-MGv4ZKVyr$;?tO$j51 z9onbze+X@XouAT*=N{cQ;#fL<;3R8yYU zVX>^;N%}Gju}f)BmUBdeCWrt2E0fu^u`OhTH`vQp*^=7*Lh&dLJnHX~QLek@B)+oAJxlZL*iqF#TC3 zfMI>ilB6Qb1Z8H;+PY_!cGF?*krS(7knbvo&xqb5uPZ1m(!!bTeYKxKwj+Ch}(u* z6l*83`qgrOKBFY{^jd-EWh}S52Xj%dfekFIEnx#IrBjKk8`uP2#@DiS#;=hHmD5XXV?BP*yR^QYFVnW=@6ky! z^0;FOPx1L=oS1UXp0LLAf^+~WCUVgbTJ=?RC-SR)X3sZeuz!zqcn7}>`Yx`e>MCrl zgJGW!HYtoipUK^~##S=;kT<7vmZv^vxtjZev%nQ@+v76;AUz^*I#Rmy2}m?!N_i@76&*=baL%Oj5W7eaEtxr+4!}(Msas=-*edlR?X-l2xtqRJ_smeXKe3CX>9TNRM?Z3?Tdpe&i#K8d zgJX_u9q4)E`!u-X!qvowNZiv=qK!zcD@92uMj80{KCEp(F@E3yCDoMnH|b!YA1$AK zA>3FEwCo^4(LiR;rB?$9{r$mgfv#cL8VI3n;^{UoO`g{z);vaU#?3lU_A;8Y?P*Yb zG&F1H#H0yFxW4#=rQ+=-fW3{VJNKh&EA$mf3{#t47m-n}OZ^yVqa7Yx(xyy6-`W7UK-UXk^5LwCC3rt7fj*70CHK|chl6yLFL(a;e zixp|_(_9pMf<-Nf9QP46cPNjQ3rrbP_AD>)8`uNio{woB`Zuf3Q^EdcqhbDGB&Ct$@O~m#pORI{S?MK&&tGg7k{I5; zP9@?z$glYDG_b!qFLX=KBMFIN$20PlrwL59tXpxlk2-{#IpfRZqUzXgo(2v8;?o&q zLz6`JveUJ^qkr^s(7Oc<%C3f-OssWq>B|ERT@KgKRX_W&&~p>l!hRw{WE0Zv4A{2! zuPfW5@~D}54`b{KY?t+Ir4}c$^5rwP%+--U6wg`=KWnIl6Do?m7)tk4^q6 z&e14O0n7Zm!1koU#OU1~zOf}H0nASNM&5g7484(c;;Q-)=J@x8RqQ=Jqg8Yb!F$uH z|4yIS9jSIfi?pvqk$d`ZL(a;eixuhZtCU#e6f|+U&^8v7(7+}5GL|kNHFhFe^8g!I zvG1pz8_ii#U}LPCaYiS;Sj^=$KX*OnRmr8fHeAbu(F4I|^a8u1_a|sA-#2{>)Elv@ z=$Zb&ixa%yP2%!a1Y`v{<1jxnj71>D6u3la*;iD)p-_#&&wOm|+?&&#WyH<*&2CwY zkYbV4)Uf^Wr+!~jBq6L5yrB)X{uGhb>0e$y62#f5Ur9~^Cx07Qe9}`SO$5uOPwc7J z^6vm=*TdGGt92*6rV6&p!Xw|*vGhib2;g@ir@uzHUD;uQCdupD zvACX4wMUnm<|DZo*z^&m}@yyFR+WRzZU>3O5Qu--pHg8>0ej*sbm<^^zKQ?66@S5iq(s z!Oi;x>d?%ljyDU2F*<&b{$S+=mAAyy1eNFLzK#;v*R^pvxYP;79f7-5avS;q^LrnT zt#!t${Is9vNZWC zv!=clUv+en&=`oV%b`Z#oMDRPNS8bB+6|_kE zN))-L4>#nj47ym6cJh}zl5!lyB9}x%-ww_YAH)%C8S5SF3($OyU+|3?l@FlBHL&2y zbIgm-jqfT3Sj=&h?pH~ojAuMTBI9^S@8cdFi6fx2Pvsx9gQ}kk+70=ipa*W?b@&G+ zpSTJJSwB~11pv7~M!!qkVY`7zNF0+HuKY*kN1hbU$EJ?E{w~Pf=?qBJpGUsjT|3rn zUawjm4f`Cb62hHius-E`>Z|JZR4_XUoYQ(g3GGh7iWH}My^QdL@RzUE>EQEvCLzNW z-#8UKtyo{=nl~~$hVe4m*;mac^^8RE&Zfh89qTsYY2eZkPCMt{>NN1AF`b#My8Im# zY?d978|qhrtzdG24 z81HOg;TI?!bbZ6tPoQlroF{ztIl+PqA zbS)Z4T`F|#;s~~kF`781w~Ot}P1uhb*T8}+&oK#ZH4dc*SjWBMSBapEXS@QDq4X#d zW&{X!L1z&HoM476<=3>f&JEi8a^KFpd`=I(WMjR_Gnj;TVXy#P`n$>D0!waIlzfND z`dRe>DDQtzI-etfo{dR%Pd8#s?}K(>cbjID-L_%ltJg{MXQ0&y%SQ6|Z0nP~Coj~; zJ8t|Wu;;VW!MINS3efmTXp#t)BS{D>P6k)W(hm~5I|*LY30(1NZWr#4XNMTDmG%p} zO8zRHV!l<7x^w5M7x)x+Fv`=vz8#AX_tCu%$6&u?c0K*aehwN%D`30NJuC?cHGA=Y zfWg{2aEGrjs3O<#Wk+AcI!aHj*St|a*YIbS_J}-crhYz+E%FC$5CGQZHWOJ(Ka%^j zHgm&vb^l$PTLoaDn>O8>aO~)13)&F@W2Fn+BwM!L`RQjrW5!B;c;evfR5qgT$&VVA zGLfOyy6mc7y7Rc!2gUg8A(7ireg5Ia)xWW3p)W!gAr-=B*n=+HLnvf^ZrHSLpbsO(xoiu*tRB+YVb4|TW{vM2Vl9yqbE(5*3ggkp1nC#q! zM6pF3T{oCF?HpJwmB~)JZDtdW)BmQpt+Ffd9De;E6!L{5KB2MFoWDhSI=;epxDIBC zv-+ImS|+bH3vRI0TF7aeKMiG;j%YtZU$!IE(-Dx`Mdt`i6q%N?cqO`F+$ zI*2_ENcGa@u$09TGS&AYjmRA8bE(6;7-1OY3qHt0eHI=BSxca*o3fme$>+$Hj!~l4 zx}dnD*0Hbe^M@C=13QWTn1}VBQIIy)QmdsCXYmJ@^#ch#v3;#Y*g6KTJsVX!O#o+~ z2JhXq$vorl*)$2eNtUNa7%A zjB=SgzSS5+nvJXJlf*gb>mla4Gp2fhj9p!_%`ZZz%g97h#Aq})#g9|i(tN6!ayJ-Zbv z@RqFQ0#9J~ap0m+7bQa$q<9Xeu&p_R9@2IRH7*KpOo9C=-~RpfLkdY{fB33kscvHP@7<*82aJ7g!JJTFhtDWT*AosJhU3ID>Z zCc06xQg&9c#84c)Cl>vz?nC5$WQpl)yeePT_q3F8ruWrWSv((K1ivQ+d#Hn)`hDR~ z(0OwB#JM*AS?FHfCy28(<%Qdp37$Yu)?(Ok-HetozBwV^7BPBe5GXJa#Ourcau$$4b?8 zJg5Auv$nUPs1}tsco`Np5~+-CZH)%6!u`Gy`gajlJx`Lq*SMT~ehPT%=80grkq~B^ zb|De$aMzkk*Dw6fCxU+gN4;=wmc~8sfcmu+P|}v%5)WI$Cpg4bf$KbjzJf7?0&@`; zm>rzLF2}KpJFp!j>_?4TV37-OOrmoY({-F`jD9!o@~@eSj2HQ#XARyHbQ*c^NV7dz z@Bt0$j(ap^$3ULZnPF-FKaP1&T#or{a$bjT$i-Z?>;ea3H^HPh+7Fcc3zPAXDwcNQ zH=}3$$;L4or_4u>Eg`>{^*Y6H!)f66x@su?9CnfrhDy#2)uzedaV#Tos9v249!CP! zodkA2|IBD6Is9=Vn4O0GJfXXj=smC2hj`_~f5?hUNiX~R{Hfq8c4XCk^+EY<62hTx zS#mh5dp#8|n%i&d?=!r-WSn4r2e0pKrZ3ZTVex}M2i+}LKLT{vdm7Q3eN3p~KPqv|kvW5ROU@rkyp3D~Rlp-NsFoBpa*U~#SVG!m`a zudGiW*^ahaez_}+;li&VgRH+YN2Mj>`ypZO-(y4tH zM~yYz2m&@It4K&%w+f{7#duR_q3NFdyS)3Xdw%S(y$JjSu&%S@uhvnW0%o-PCxhpD z%Wc$&O60oa@0GaK^$YLYPb7jxsdaF!|$!Jdd#8 zcQJ|5+|32X;mUE@#4YECejlx%d9a!35{q0wQpv5D&&n$1tDaY<3#XbzKT-7$>~^C( z|0bT}N2KvxI!&H#VDBY#_vJs;nbIGOuet~WS3Jv)ynrd@vkg;@vuX;Q=^IkMZKfT! z7*u|t&qLy0_H1N*qAnNXRET3c_UCn-;FTxNUubWs zAG|M?XE9t(+qr)NSl68%DQw6&ISj<^e{Y`#W{F`E7Z-qYu4>q%@wigGNx78-?mWaO z{-K8%8n`6}3k(X4U>ckJyS$XYl8+d5`2ff9j@l?_kv28_)z{+zY8x%#=R2GUkBKwByQb&-l=S;2V;BKxa!Sv}D)`P3lI6d?`ba>{!x~!g2 zUFjV?L2C614+$jSK(gFIz(TBD?+eGDZkSfi)3#Y1N`tGzxTk3_JaM#n#k`8hcA}Oh zIahi%63W`Y(6@Nqv(0Vo_t(d;7n2bGY2NlL#nw9NIQqV_dFGSDuFs^ewhl=X!Iz4w zhZ64Fus|VmT0CY!QPmMh$Hu~-O6meJ3CI#wL;mnG zOnF~TGTQXnhZyk8mn_!VeI59V_$T7q<=U-sNu_f=rFW&S?Gly{_9uapWUxL7eEkeC zrk*S5E9$hcI|(d0i7JREhDjng@KB!&)>+rbPRa_|^Ey`6?Nh=s#GkHRC8UXABKzr8 z%7nG{H+W(&E1K=Y-U(n`kMRgUKdsC=Pl6ubv)KOm)4)9&R?i=I68K%ZPrB7xEx~}i zK)ZY-FXyv{oOfFcVjdQcHbnJVBs^x=}upezN?eCs(@b38OdmHL%GYOFxIX zJmQC1T#dq{%p#G#xHsgE~l2*4OzY79+6$il9R;@77*%Q_$(o;IOE!+iNy!FBC~$hqVbaYvb}G_zDG6&V(eN_r)hlHP$}2 zc(QaqA=DMZDwr=%% zJzmc1JSk3vp|b*48&qPHI&K%p;u#+SOOXmlh1(h>#DRoGlV;r|e8ne!5BzJ>#wxvN zuO#l_?Lm7y5qzH5kS7ws!udzY-~G^$k~NPMeUFSYU{4m%>KeR&VMh;ockru)H7-6M z_A!VeTl^eD_(>yIo#-&c4yLe0bd_TYkxsIAj`d=O1$mUS{m5_9KgGu90Hf_-IqmxFV!s^AbR1z&j$J%cR&%n00hKDu zB)HHAP=0J;%|~L6Pn!HWmOTd<9kn0rd%JorAmw~i-dgP5$F*xU6n~Oll{3-HXeRmV zlDyYHJ{+trDq;J^o20Mj<5!a%vSGrP$}2o31FmaGNj77kC4zwhHpr;cL7qt^0CnGw)G3H^Rg zVg^SCO4ZMeqgg+g=Z2g<1$^?)B(9=;D-ryj?4Klpi}pDhN0XiWow3outBbzp^f>K| zc=s;ZE~upN5kB9m>EK{%1*XNP7(_BY4sMeC6_B5W&Q1cWdM7D7-glop?TaI4#@3ZeZk5axn}UV7 zeJMkY*S)|0E8Nb}@JZ8;P6eA{3~a-t$q)StUW`Qf_cIm4jlqP0F@xK9*qeFNXe7 zu%2I3kN)!YFG&b1OTs2v{d|+hd_t%^s+Ci=-n}SgtX?#1g3^($$=Lb_#GKo3fz=q3 zzPgT3Hy0OzkN`fLD(N<5byDxVTL0@L<@EiTlfMrnfX}PxUrb*5>y~Y&d#B$@WEl%R zcIGPTYOru)$ztI9f{nO{k)ggD8@kW-YU}#`KsxWW?x;iCHwYzbX?sP<47s5Kl0TRg z&+L*+`l|+I`#K_!sWxDFyd0`~p&%!Kj}5u0#MUQXOma9+1WV*SkO*ETe?LkDpMjK| zev~`OU|Vo&)_+`JE;+zc(Zd4`<0%fdhF$(7=8u+5e$v?D6N7d4dzf{o*OoxH`ET?~}_jA9V ziBBH?1rOD{FXj(%qJs&P&i#*6`XVi?6muOJ^k|^)iSUyCP&$$bGCBgPpi4bI$$k21 zflsh$>TnpSb<#NKm@DNo`FrBxNnppTP6e~1@VQSI5~xLzS6Gs`nFvm=Jh{30yT9Qo z>RZO)e|lP2Y`XP~v!wb@?e_Z7X<)80UP~cIHNGA^LOZ2&qBZ%-)+-l&juQh_KM}od zpW`Wo8!RPFVr4qZyKClCsJWK+^Jsi<68Q6n(S5y0$*3?3KC%`b=Ap2DhX;d7`JZ7T zuEr`ZmWYjlE%MJ7<;Ykf9xWql-8I;Fz_!NUt3%V^$M!|eRoz&c?7vNrHr|9m^{0f~ z4ss9p3#=b71Thw;@=X@2R8R9JI2U_{)Rl!JcTWlHw?W}CxQyOLtmQ!GSt`4bTgyT* zcno{;>-m)H!KC)5e4+Dv_YBYYVnOx<(~v)?pvP(Y?C+}9tGC+l0@bRXNpW!j3y6L> zMeuFo8l0O*>SF3F*()M}1|D(Z^lYWvf>YBgwH7j{@bi>vjevI!g4 z43qS;6N@-bYLuKz94e$&<^8>Qq!_q4>*f;Cp=nusybdeF+FHiauA0+#x13uFUAiq0 zl^X0oStH=m@A@l4El1Yn*gJda)Ah;pRk8th!e1LnZ)BDTmWy_7D9Oi(;4_e4O$3Wv zCj#__2&L;`I^%ac8T187{}ahhPiC9|J4?B)0g8jpXqZWjlOgtnNDUSYwHJ z4kKco`D;v$i(>(fS>RT2r5K`f9p4~iVT-s}cF|XY;eD*;&B9yc{ROZogY>>-bl8Ae zdY~PjSnDXUJ9qD^M8xkcHxV({>kz|l0y{nK)O82`a zCm~U@pa-`yx>eAAMienkhU7+Jzd|^STkDg+2@P_c;62xvC$D!B*d>MaX<|n!lf2@S z#7`uGJBeZEgUsvuS;Tx&*NGIL(>)WVw@w8MNemv$-BZBgZ2Iffaz5Bj9iBk)fy7mj(GYGc{DGf??mn+txDOwG$zzw0Agva1 zUb>jZ4Sir9L$f0W1r{&hy|8F>QmSsL--}opMHX~5L)*ivC zLBmkfEi4(ysp2FF*S3Yo%~fvLKw^0?EepNu)>8v8y1Kj`H;AK>_x%~~m!~B)WUm*# ztgRY3{dO2mdM%@t%?mluIhD!=IkH(<%ASk)eB5$*#krjjR_~C_)4^P)e$VVZ13oY8 zru#ADu57!b5m-(p5vNGPg4 zLU#tof0(9_>Cn1lK_N1*vABKmH@h02b)WdPWz)r>?^nlXH7`Ko=JjRcLdNOR)Q*FT zN5JIqwsMh1`+8Z8&V`RHYSy zfXq$+6Xa`?=1r;V9{Wrp7~|N;`dT9R-D4aq|4#uriQb6exF0F$ZG`9KnpmL5Ecv$C zl6mj$}#R+-YBzh8=E?sw&&(_(^VCNJ`o{^qvZY7Iek$ zttpO-%@6%eU_1!Hf}dbeD^6vRG%^{akMo>Y(4B@t#u9mV;oZ53iMTahaTjCVmT8KS80my)(vH_ zoA;&HIrX0Jli?XZSgHrmQ_k!??6D+NvaOfDTNWYbT?7!h+$Lfp(DYsE6=wfD%L(=X|gHHVP`)wvht6Bep6@Kwf0U)3C{VQ~Sx^v+k0U>JOyR|w zlLv-ia$FpnVs;j|RXi!i5qoiSLLRn=i)AnFM2D&RnD?ztbfX-Xgoizul0GL}NjFiz zPhq3q_z=6K(>O(Jg|B4ZBI%4U>pgBz@)@=D46_ZSm`JExG&<{vPb9ct>z>5Q{(_F& zk3QYm2^CJrah)=S>^9JFF1>N%0BQOucP3_EHG@0Wg9raQ39L^ASM?<6`&PaBNobM? zKCTD)W$Uqou;d9}o5WQ%M5RPuu&fU*@oNN6eOYq&ZH(Hyroz=}kq1u!`)i;=WdCmj z8koV;XGnKs=L)83{0sI-I0aZ6(xqr7zLKdXeFwf#_CMY&@nd5+Yoz*V4WVDVI3 z+Qve~#_2?qM3N-Fu}PFa#`M3`D-ETJgUX$RuF|6}>LsJ4$5Q@k-cAZbT9U9blQfj% zTINLBRw8pvx)Ie$X3NSjwb9`Mzv zLM7XKxknqw`R-xHcuysOb=&5yoPY54NFprgf%O+wH#Ed2&JNndz+J`k55{&(M)s1n znJsnI))F0c5;aRqF6^o}>3BVv0M3)AF^&Qs^8(|q5@nU?FfZzU!}#v6dAxZ{m~LD5 z*eK`grk|m$WbB74h_$maYzshOxY|RtD z3G#J$*8@!Rj!igTH`b1S?SIzo2GVO;@JJ$998Gv#<^~T<3?Kb8$zek~196C)eQlij z&A!ufbUwD^j<{FAG~_ygM2T-MxQjd`H5C=%{sxhs`5nrUkjj5_F==ZSJ zk4JrO>bQ7m%pvKcVsi{NVD)@?SJ#*nl}~0_-)5_Kg2J#dCBp4{FS~CMgo$qjoeiV@ zUMSo*V%FM9`B|wzLkd@}%G?e*_N@-k+?GmC0-re5z7o5~nyY;hm?edg{3P@^W+_&G zm-k2np9E2<%~VEQ0(EuLm&jf^tc*_l@)N&@-DzCXQ^C$r+w_?CwRIZUGrj)i_3Fsk zU%LzVRt=HP-w3{R(xS0hok&5Fr0|>*)Wotq=S7B}qfH%BB0O%q>~sIHBydsi5j#gA z;eozNnsmjxL3f&O=(0o}8+i9iey)dOYkV!YkoZ@Xu>*dpb84^aj)C$yyVIkQsF2ct z6-7hw!llyYv0zJ8jFMa1fo+VSmo{qs;2i#P>;)z~Q-+rwOC6>;{5WpNKavzqth(`- z_es&yMCN=o*K$zI5!7{M44ca3GFmQh^|zW)FC3!0es+)66pniI1nlWp>(wu{p&9?N z1!BR0W8+1>?fhm`!0~2PM9^7^2#Un3{BJ(QC5m%CamA9#IM(L z*<4+NE^J<1AImF=S7zH`p$z4IAi>1AiNqvseyZAC$vCB0ay7OD#&>_E{jK6hc&v|R zt|)nnv30HIYot zVO4#%kIl7T)RH&mybcLov2}Ce0ytvzX(BkG`MMlF2cC-KHEy`#blqB>evSZUOoZ^O zf(`0N1x#yg2S^k>jH&qfW1-?JUcx7jr4}zCaard?9fq2ZY^;!aW-#yq9)g;0F&xKL zK+;yy$Yc0!q#nXo@Au`R7I8sdi7hlddtff0@)Z@Y>fdKFHV!*r|c}G&y|w#C(_->zJ6YCD0>2ofrASDPU)9QqM7GiD5M# za+)4{HI&1wZKk3N z?kK^-TzVQA16;)jmA(zv$W=9U^BeWpj+Iq_T1 zPpG+Xk)t7_w-Iw0DCV%LmNFxWn_a#ig*L5}`VNsSOv;(vN8*_c0_uCC7MQMa9a=uzsrkA&la=pv!_`-7Gq__%^N1GOa7CF*r2*HG!U}?P}ed}{#W0sSJ}y@W-T%E?2gB` z%PUqYV5+SXzc1F9Q}s&z>iVnSc_LWGxp5}XC4yyKn?^GcEC>CFCOIK2XZv{nTbk7L zqu6B;Tu^_wkb7=VCYO!3C}L}cLE^wd9=6%!-{rjt1kQO*Q56EhZS6%6f37~~zc z)B=Y_qc#I6rpO9)VLSK0s=w8|p%P9Y@5hQ?_b+>g-sQmpBE90JbgO~UGyOaCU>#j- zLVv=)M&BzvR;E8*(M051vDLGt#>{Gs(L}Ux3Y*^sa*lVQLQ)U2=XLg+Cm{MhL~@5A z^8vSgOGu9t_G}#XLxp!Hd8h1|Bz82_)=Az|$2*Zr>TWF>lEMXTFA*$tLU?iA{dwq^ z_Mdr5ZWoXojC zDm{h8pC}k>Pst$6k6H$bF`_yqr=P=%gmBP^S}vE@lWqULGLgWf+B7`=f54?{vfd}DP&GaF5a2}?v_nT~5c-ihpL z`_R2!zL=|a#rfXWJ>$GeX7U*6&7eHlY2umr;p|Z#i)3h;02Wch6ERZu3uX>Oy0Jn# zCew|GkwxF>98vnLT$J@#))p7LSTgz(<(Ka28+%8F@oGJ9Zh|4?j<#hv-7?iCi?0az^5}yFg?^sh zlm{a+C~6$PWnpW$$4?#;VtS67yujb!8U7}3ga3I#IL81Beqcdps~WppT-LE!_tA3i z&EJc%nxjSI))*@cmgBfThOi$k9wLD761wB<&|~O#=umO`OgEnb_)NEoWrmM3cg7|1 zB6&5B5=`e_18c5vlZ++iLZV~~9kYu^_}Q~QduE~O6EZ*YcLsPMg>qMMillY9wo^zF z&VJCuucqT*j7fa0{m%BqKk<-|$j$2WC!tv)SR_mGzQ?Lc1W)X-OqFF~7mb9ku*B~X zvT9_F6cT&)>FUB9zsZjn#}V0->Fd~(*i>ZTAAFzZWsurN*$>JTrB9Z+jEd9sg*@}} zf+&WD(y`K(+pF{Hqcu=S3CH5esZ1KR{=`UpVf7pi&x_%u$5My7$o-#zMlZSRDT+cbd!o<8){Cj%L z$zaQecAhL)68f5UbcJUF78rNWWIRSkYy=vdFIJD$o1XfOn%_Rb>b^YHb+NafrLheJ z=R2lO=sv6xh(vz++`n2q4ZEs`R+mUfpoV_!^fU20#K8Am`QhwQA4w84PXJ$PWSJyx z#4pAozlyrreu7~0xJlV}JLkREm&jFjM2tt?H8c*6s89IL_5>OJi>!H_MDWqOj}pOh{PZ4{KLed6e~(kZ z=7jLkCzBk$Xrcl&=KVF6E52gi;@*6|Rp71Vwxhq;#(;{Uke~cY7E#qj;Jiw%)|qd{ zqv%?_egca-s5fh#!u0q#rb+@;F%RSph7eHrzR+|rJM+6WZ>e~vA89YtIHri@I3jn> z-80zZMGzU&jJKBAq4W6e(6e#cO~!pl&z=VvUFk6}M_=)r2_`{ANMt(q6H@IBu{Cb8 zu*9U}tMpgT-h7-Kk3F}TT42kE`n}MJX+NIJlG&B_y3Ihe;~jyb6)@JP6Lw|lCCXj|Bk;2eD}eJoW53x zHJ#}}@>*wfBrjSYBRQLx{T8n;&L|hWuMEkZ|&E$?hQF z!xO(98JsY|>MnxCpReN`yrM*z1mBk-8Jy zAicfTfxbLoZ&k1)+|#(Lw^}n$3@n8|>-d*(#73aO`C|20z2-~0^ULUm_X#1X=<8-9 z>Li7cV+x(1|cd^L}{G8{XS=vC}E$~R9;6h)} z=P3-lfM(~NpOgYq#dRTikg_-;3Iq&q&2P{Xj)l&D9tse5vnW>w7gmtFgn9 zhum<=i*c#qg)KV2XR`ZjgP!VsfWExn^B5>`v5ji%_V~_RiV;XQ8)*|wTqGgI7{p@V zxEiCgQgs=LA0?em&+0jkE&8mk;~80tc)d!w`)2wQ6T&#?Z#{3?+phiz=&XH?=}ZhC z$=%mFYbJ#+JVf??5?W#OKNfT;$#!B`#3nCAOcM57tEQ)DmxD3ai);Zp3@>hG}aH@Os>z6AWZ+g1Wbw;P#j@lz4f}%$~jGH|K zXJh}X{2@tT+~;D2*x(_55C=L|58(wip;<|WwS-;z9`7Uui6v%IDf33A_45I z>Ll0cGko0{g|A<5;n9ZlZk?eGLQHj&a%_$>W>Pp@v?EpZ$S+BN6QHQ4y_|(>rB!CC;Rxu@k?5 zhZDeV(ECGEt6U+Q*Z+8be;`@vcV2 zdslcIlAfWK*`xD&F`GX~NuFmT?xkf3Vg3DJCVSWI^-n_k1aTt~ zEMgLfX(IStwQEXgTu(gWwU_vna7sAChQ!`WP9=p+;&)QIPUpYoQJup3_|esU zbuvb+Ka{6jx3j=VM(kD3@j33@-a8F^j#<`@@^f^lL&EzkG*0bui2eP~QQv;1{Aj@q zPR`37yxF^v;+?h>3-S@KQQMsu2$k80Y8pD(GbMC$l=8L;1AqL) zua~>|GthoC2E{`$|D=TQh^nXz$LBI?GW<(U2fszm7n_-Q4;Fuibm#Q%C?GY;?rT6p zjhdfGz^%;^V=`GI`DYdu31E}NQrZy=3L!a~vageh;*)x_U&s`itLDS_onM7qL2chp z1dHv@*0rmD8nKDYTsQ4g#cL;kSMicKzS}W{@k6x#{!06v#Yf|t+L3hh(HP9S`;$E?voOewjU;9ly5iALB zod~{?@a8J(1h7j4i`&Qx$=_=;Q{k_BNVVl1D4hK{WDH&OW{{5N0W0U;&rVF}Mg{{B z+{9FTM7WqcdXwiKFD)KhPY{k+SbPp89QAl3ac=Qcqr*E$)%e~W6H}|kCdE{-OSJ?X zY6FZa>pm79boxPX$c#XkM;5ZWuL7wWqr{Tq-{I}g_arLFWl)YYT6iRb{#)po?-g$u z*MwU=(+J-IU2dHOzP>W5?~ZHm50~sEvGk?)#APp%oTlhYnkAs5 zt+4?QaJu7l_!N`iN_<1^lhT=@UoAJfMHh)pN5_uPhHuwB&pAD-Xmr9o2Saa)gw5BLZ0?7ic*7fbu&}%MaU;#r zcn>$9>ufDj<0L$EGTYfkA=|KpYxuQ{U&HveZNjmT)U7d{P&CP2O4A8VrOV)_PUwEX z>zG&LoNpe&*)`ZxvB*!hx_(ffyI0<)7%wX~`;hCVeJOb&daNu)@zOX=1y?L(6+_hH z>h&z*DIq?^mG+ZgnY``S^l{#62=|PQ->h3(OvYj@DcM{;N}T}y#n^o&YnfjT4)?t| z*ZJaHY=$oUadHibd*${0+kOcTiD1`$z5ZRY_nHTUS0#ca-CPs5aaL@?9pmCpbjT_9 z3fy~J<^7++pHV#$ye7F@oB)o?Es((eZ1MNl8e8M-eS`(yVz?_PdAUn>cn!MaW$O|8 z%t0*!tKc)r!y(jZW(M9$Wyi59?%)D^kyUOERB5vL9mXWWsoB{+&#ICxq zFbMlmn5be*3098L(e&lUoT0uEC?_og&)K8eC%aa5ZX0N>|A#t``C4@)ZGI}))|W}) zBH3$rZz-vMBAO?JB~1+5)KyXK4~oR>v@p%X<*+M zg$dZ|s)8k(r-Idd$Z3pU)pgLUY`_`bIhPE{Lrlz9r-9utT(L;RBi_prjRa1S-?q{E{y*-C zRi)`@7~h8Bwzpj$Qkz&?LCT>*xl6EcER@~pGCuz~XwZi9-89yEkt=p?jisXST&UM_uh*jUbu93&PZy`L!2bgL-h{Bl8Z{-}Enuh(HGtcvgGcM07@D$>^YT`9 zXkx2aKa|!qcuyAgWO&jb_7U6f2$XVMKR93Cx55>HEH>a8evRYTF;%-x0E?@~KOOE<$B(9Qy!M^U#bk$tB&)sXMZ+UFk7(|TN#IJb!a2RXb@SwC% zo|`w~F>Jia)s+&ft~+D>SOOTapPHrp=Z;mKf6KLX+B z98U^|h{|4J_eWoJc~6PIcd+0`OvXoqe#C=?MGjI4$Ae^D&SVgkt4qcsF|Wp?&5QhM zjFz#!#eWkQQ>TniN(AMlwR=) z86=bL-$(Du2)c8O^E&*KfRayeDMpNQCCqlhR~@u**Yg|3iC6Pcs58h3Grlau_6YREW8n zW@mjWi_^gYWBqEyJR+}dOA82ziCof+!SzqN)x_U!(v4DrM%kEItRoih_xvz zVG-xNljhGu2kVv#`7Nh`8O4(bf_TFAh$Vi9OqBg0e-pSUHy?V~q%$D)BoZupd458( z1eCNMJ@D9zE;JUHLf2AeVduA(mP^g}=rTI&-OUAQ`HNr(3qKZ|PK-tcGfR|&HB!A? zQ*I~ka#$TD8W;n)=#vTJSgCa~r9(@PrBg`ddx3gy6pFGqmNG9X*Vj?XK+Ulge)i*g z3E{bzqj-X?@rsiqggJiDKtvhXdu=g9QEqgml9JUgs`Y)B>6#-rq#m4al%IKDVM$oY zW-tHB1`6J+AW_0LUf~*kJ>%CbUN?+;aIX5E+N6~Ek`9G{QF7dth}&5ucc?GtrrTA@nh`u1z@<_8nOqLRMD{Y><062|23{Fnc(Eoa}wC3IIO zfR9y5YaV`YvZ8r2&~^R>CYnLPn}m#qsOGlJlJ0nH?Lj2+XH!OR@Zf>lyvRoq`>R%s z4duUyt0|MkEAgVL%DT^mX5_e49|$U7({VXSsDz~+15;a#6KUjXD_-7bI&TdxC`#i~ zK2NYG^BCPU@6+$tE_u~G9;*3$hlRWqTVssH6yubj0VN$~_FAgxot zSN|mWZq&IFy}=35JyI3#l?RIXddBNIKu(Xx8QwW>7&iJ52OOn68Qcjt$v50O;@kHrNMyh(Adif?!|MPW(g>16I16T!6cZx*l@vt5dCgzphp$BpoFt0HORt zdKGo;COE~$2s*|HagP`w31I7o#aJI=d~C27&nH4vKGgb0u>H7rKa#7rGhONH@bjS> zf7TcIG!d-EQhiRAYKLM3Tjkuq*(;FwLeO>3cRaUQqM`K5{K>xH%+{C6>82+A@-rlY zhrq2p%0#eC{QMih^4_o#oy1kLH%|gzn~sY1`cKzoCVTBBC4?n@c{2FjITr@u6Y;y_ zXU1ZQy=n~%$5ng;5)%5F$6d|nii1>>d$cs@ipQQ!lzucb`dVhii+-d$@#AIr%WmJr~Rsb8d>`|Nja{HeH@#J%Y9sr1D1Ym%)f)wn}dB`_PntDz9Tam z-=;_2eV>-RFf7e`9f3mmi8mB8$!m;eVu@3D>bHk2o(9=GTJf`3XR%~-AhAb$!ODkf zthr-(+|V3@Oxo__u)U=DD=Jg!%~FZ5V{JE7rcJj^V)$Ge$}n~P_k{CAuvnTTN#ZOa z{D#POqL-{g^;$nm{9fl^t5dg1!hB=_Bjrj0lkbRsL}_gjd}e^s;F+J$ z#L=L+2@T@3bZH{=#bpgN@R7&)l$zi;wvQ3+{M5zj#;Hd=#Q-E+gK>&_N?TPl4~VW}iM8BCYf^8#VM^%A_e zBct#}Jm9BKJ4xQJB!tzNrub_<3_9n-q5=_{;uGDuOs?O{Ou2J146m$|zayLLJD&qX z6L0JM$$}-JBcHwe8CDZI?|*fl2f7GfA6by6`7h51>fv0}?Suy<$^6zd7zKxnm32Zg z`vP|rb*&SE;+wGjfK_Jtusi_@c!^wZ!|(da;s84aQeh-X0Nc2&6Tq{tHa4!`fhQ&= zNMbEnjI(i$BamN{*oF8suQ9O;-p2wz&086ZcXjQv`kdA9^biO5sGJ)k?CZFvD%Op1 zTkz}U9fS9$p$`eq)~!Ua$6p&}Z&Rr?b8q2VpMc4rlYyQk(Pkq~tf!_WUfL z1r@JFe-ldj$2O|_sP=2gOXdQ(NN7<$$HZoiv3M*o2HnDml>5zwk@h~lp~~?~#v#t@ z#7C#Rq~Gu%h$R@pX%)UtbhhSxj^FEf!OZDkx5oNKc@xyBOSmQ1qB5z;&j9NYEj-#Dp^Pm+bzjmJxdAt#OMF+{xn|G!D#f3C)V zO2H}4W)Ilx~IaIcU6{e&jMU%GHF0gewm!si%D&GCl#pEDP)Qgpc0+fk8k zJc+Tv%8$V&md8RNu{ml_a(2;o_h3ydTWIV&t#U1diXujhVT+Fo5|d$CwLbO$zd%60 z{1N>U8cT=5(_}Q}p}MzWOg)$scFzxe>Nm&FKKz!1u)*3zSJbV7Pp}rR2Wq7ELd5fV zi3)ESh1~gas%55nLA6IVLGhzZscR_lQG2d5MJ?AbwR{u$+$ z8zw>F{4^{iIxo{|y=0P!iht2y?42}ReSo`)x>ku&e~m;eu9uwdhr6ae1br%2so$$> zjECt@8Z$N?pmDSq#?!WZOcYNzPfr9NTe;#Rd>lp3$XQUyEMmszf;^G{uI7=Jv;0W2 zc$slmpEE{U=I}#U;Z#<}7Mfcc3rZ!((V9vS7s>OQwq2=Wz)t?Y=`QJe(9Bpi62U?e zy}xKLiw1w33)oErYdd3}4j#v*Rh9gGcW<2x)^x_xk;W&JZL@Sw1WySgf%9PFemA7%J)iSFcus8CxIQ0}S@B*qI(Me; zlwCe=mJB{OxFLn=;idrAP#07*FbPTOXJQp^@DL#J3ze#~q)~t~Wa7*^3R=Q9=+!7n0R0__`W5Q7=Dnf~jibS;ewz0PniNi3h zS{J(~uj##d`;d7!L`FmI{YsoNrIhj!3O_%&{@16A#VD}v_0%`8b9qMb`uW?&4tg88 zCCt1Rl6gNEHYy_ioG+dFKEMqbF0VB4&TbUwyZc@9H|uTPyPkB3U0qnpV1=q2#3e%q_{Sxo$A zSG~-raJtz$Q!Dxo9U5n-A=;^Iv4GGWGH$5hT zSBc;Hr=p8Q@ZsNfB3MLi4iefcRwVg*ZGxdp2Fq~k6U|exjVrSdP1!5jo@^EU%&IXQ z7sc@rkGrFv2hY=YlD-)o5wu4?n()9t@AJ~c!HQ>_LOl-rJYIAMLWD=9nyBgH& z1Gk@xIVt4U4H_8=Of1u4Ff0G>%(GYP##~#@v-WeK9yjpL6?8@1)fIRs#`8lSogdRb zK7Fcji8`W`!=-byhodss0?Vo!x`74%s%7&si#h8eoK9jOj&bG3YV1~H&wXo^06$hS zgHjLtI4_azXgb+z6OVQcw*1?`zjO`ubH*+~3$cmCDj|INc?pyWU(x^2uv&}C{OV98 zeowIfIcn=q^pz#H(>S~%O=vRL#m@g2nxpR$r_So9q5D^+Tp5u?@>dC2oYN>@P$RF@Gfo~RU(o-zfG;uV9R{*DZS&u<@ zWyBg{n_(|ZsrH8rH8Z;sC`9)aI444Xu@%&gS3eU9bUAFENoZNC=AO4~% zqx!d%n>i*5b{^nq295f2z#j#bZ~Hg*K(B3{2#MtM(Id_Cu_ot_Mr$DD`O zksBpy^(^;z{-~_qb)V>E>zNAgC@XRGaLIUVlM4~Fr1|f;Apxw{E&5qtmt{nsN}%cW zlTJdxM)RaiGYA$$^E{g*=JYlHyRfljsxGt0%vu20Rn*n_5|WBWoz*vgxo+WY^ z9TD^l{b+(al0MTAhHA}=@pZg}Ukq{DPvVs3qU+uAP_q_CYbnYFsL_ zzF{3~fmgW2G!}9P<^1e9$&7&Lb&6tlGWrL8cw9a$w+h|gIW9;re*MH~fNJ|S*STgZ z>NXMV~>Xffuy`MpZ)Cpg0)5&MDq7s6ayyv|c-P6E6aeX9Oby9d-Lj|k6CXC8&yEI;VGV-;ZClYUO@wZ)WE(E-jTBvt5#fE~kwdSs zRlYOFW@+C0_lt`|yFbHMKkyV~b5cg>MoLMPTGprqsGU`?2GbPvk4TbWajRSNMoi<= zcwk!s@%Po}jXUgz6L0A7&$UDB@l(HEM)@}i?A2W2HFhFKrS~<}Dy)u4_iqB*5`K>nrz&2H7S~k0i)eU1lPui*eKYIGp zUqxMAQc{Qx-64cHp^THUk;Kr%q`?a`fe#UhtL5nd^0Cn4hU98+;>ECejV*r-l2~2c zd?E8DjD^6=kx7pj?Twuqr0wKo}8~eJ4vrorHYV z6t9&Se!G|?eEq3g2v7VTwnbHR;#Z1O!6qrJlc9`u%I=*IzML{K6;VUPPY7Qzg@nIp zOs+m&>nr;Tb@iV3nf#q(9e0_-B+4_dQ)A-bDH9jY^Ukrm+jc&@+U_*4<2z?n)7=9e zSM={!`SMBNV}hLX&@=xmWrG-9590ra<+CkdWfM|u%f~J7Ah$s{FMLwkz z*uHEF{bO*tXzW-yl;}2W`cPchK+!+mv%oL0>RH%M2=ATxO|cHAEI2I0 z1WUc&bL>dx_X?}=9cRG?Z#tr$7<_DBLT&gdul@y_1g^_sw5djcI zLBg+V7|pq~lK?ht{fvRJX!IDqO8`ebQy(s0mCvfr_A2D65K50$mL>0ue31|izIZ*f zAEaN}D$x=8Yr0bC-AMq89S23EyfP`Q2VHU=49|*~{s z2Xhiw%2r8V%a53T5)#{V?$#b%KSwAURD)Rdcj)k-K)1X!)?wJPm`?tZm+)DPh(`;4 z#86ipAjgQ*ee&9JqbTg>n22rpszee^vK7l59+(v$sq@XbackaGaejsuVz!|7dC&Pi zn^i2MFFX7bg?f@sZMiE7w!AAGVQt*PumF;{63ncogDk6ONxDX2?^-yWgie}~SKX7X zaTGd6nv!po+C58uPmBwDtb(p((K#?N@%yTs>!xe1H~FjN?_AT#YhCG+zE`44(frRr z^MtUh6T?Chl$dMHRpM7;ed0Hq3RXN}KL@S)I`zx-v%gml4bGhg7Ev^iM0M=T9Dd}d zfUV0`=O=~q+1iZBUok&}tYw{s2%|(pxw;;ExA;1^Svt|P7;k@HLnNvsF!?e`NPm7o zi$odCmzkFdFQFACZHI>Nmo}k+cmX3((1LbKkG#mwS$TYF*uQQ99tEpvuUs$FhYIzD zcr|D>rBrq(DcLw#+NuILGBz@8X0gNcaV?2%6bg!v(K`rCZ0v=e+`ljJ?x|rW|5mUN ze1E_Rjvbl9&RF1IpUS7Pfu;C%LRj^#oe&;jj*bF1y$(x}Id9#9z+z$NcNbgC`?1g) z^`9@Q9GlAMU^O3r=UZ^xl<#PtaRcWt82fLc& zCKgu}1J`G7^Xet)B(E8A$1}7rPkOx259PaL@g`R(uiPJd)neKBSMqx77yc&w4r5JZ zMBJDvx{?y*@QD(!T;ZgHaD7i5T<)6%!gejl#>4Za)7x`PiZkCD28f=J^kw+>j?hdd z&o-0}TUQvG2o`cWV>k}eneNcj#BN5n;w@e=%rpi>s}xm{H0C*mxMHp5 z0P7o87}dfGUEpa+);|X=N{4db<)_3`!^#%_Y3LdM^-n|lSt1hu&anqbjMLUNKNVahe^qGd zcP)*Ip8+mh;T4`g`!ukl<^E2Z=6rM#_?*DA@^tx%(k<1^*PUVc7Z55@P~q^Rhr=M1 zNoX)yl}RHNun`|9M3!T?t{R^eED2vOgI1p}n=k%X86x9TuRyDoGJ;uIQ3|D&JUEbR z91OYvfrlybDD`nIL@yEri(!IqA!v|ez+2^g`$kX4M~UHvEIv&NztN+>UZ3sLydsba ztDb?6B!t!Yjce(=g|k@L8^+CXgS#$vt{~OP=cmP=dh@wF@oQ`<^B$WJob6 z9~Aa9c|tk*)asc>AxP!;I~4`b+@X%{r2LwN(Gb@rFiqDJeSd0SA&zd1nPjj_GHMyb zhA0h(O1P^swMwu}b=EE$q6~uoi9Cc55bPAft*aC4SBb15~|$I43Q4V$cw9xza0-T-oBO5%FHPA0b!!O73Z4ZB&leG>Ru zZ)VBh*%!a!I`JWL$#;}?!5zu=D2-E)H=G!U~mwngM%oY zL@=j|xUtLV$}>8IJZ_4kRd|gBufvZR>S7v;h(oT#Y0H(Nu%BZhcHzbgwDNZ^mJzq| zv%ddp|9Ww({N9^Cw69;{@mmFnOvU@$N6JU`REV+>laHCftzHh7sveGbxxnYBoSjd` z(mIVb7Vu*3BOd^yw)5mK>dC;u+w-YYwAiTFm=Ko`))>uC6Te4$<{>BlE#F84%S`a< z-|J0J246f$PyH@_^I2#jb8404mtWE>luNNvQETf>ECM``=95n6zPeViU6T;Jx z@H5vwkXu(*l>gSY>!*pL|8(kUT?ZxVp3^V-X{M&6J!zJXwClCE@7h#DfeA_Ab9nw6 z3n;-p$o&H!TGF=@?YPyR27KFKK;+sqN$f^ml#BeFRnM?K^f%5_&}vZzr`&&fC$^@$B zs8b?IB3LDS*>&qAu-<4qV{v$mV}h7uE>s_nZt0%u^6Uv!VA;kxe|9vmM-?2@!xnV7K61LUM2C>aMsc=ySzjY`#kUyhy27mHGS-T$ z?^t0R6)Vi-cJ8}u&P9xCE&b@k^bJ_Yv=b*hHb?0Es6te|lgjrM^7t%trXgGKDxE2> z*?)yD`2mB~4C&!Cc(LLNcrsfGY>q<8;*&Ft9EInD@V3htNCgPbxKR01O^AcleeoMT z`N+N3X=JSu_J1(RBq4mI$sjTDdx%`8gojGYxf8ce{i%_@5qoltW8m2_lWTmtZLRxkKY1FMl_L&#BBz<6Jx~Tv)y_muC{lSDulk0`4(wHwo&z=!R!`kK8iJoabPWe^nHQEpoN9T;#xt94*jn&%M zYJAH+3c(t^mvb5|p<8C8)+PTsnRE6EWWDW?@hTZ!R$iZ*SGAJ&CRM3Ro%|JpJ9l}s zJfSIhc$>7op9nSy;A6g(CxPLTzrHQ{{V+n(_e>OrFu4eQQm~~4OT2wgFka+a5hV24 z*r79%Ka~td6x_nX3yL2v5UkP`h9RTBd3@+HdKO1xiNw>@z<7#M@+XG#CZ;pb{riLR z4FPM6=3s@<=w+vDEK%{fA30{?R=!q#_Wi5oiE5?sTg7(K7vNvRdtE+|P31u&p7JR! zdr(kF=PnTQURX+?CZNO;B8vG`SEZiw2`_)p3KIW}PDc;C^gcyvIIuHrm+mN2{W{u- zzjoCiYRiS43O@Um{TwvJoDe?NajdTY9<8NmS6D(g`x)r-dUYdS!#Y!vleWw&?oUAni|>J#b7l=^$0my% zZca6;c3F@1W=Mh;Ce|FWQ##T$#{09Qdz}QH-}<_Y-&eNW!8T&`GV%|5w3Xp@`y%_r z30rU=cxh;Ty5)8F3zl>T4}0*HmPF0{+j)e_Q{@=9jed7eayHERs%;Hgl~xc?kdn=o zsC6uLp0L$D9CSAm#8yk97l~kDg2o(PY_#Xw`?exYf=AfS|x>#XHOrm^gogi z)^of6^UiDBkYepEI=QSDpBATdsmmj{WIhkwS|)>Q++Dnu5piEo7II7zl+dsa{nmnQ zMg>bB-kgLr_=Xc>KPyz4I?bo}%@@UhjMka(Z51djoPmVInP z6Jarsyv>TmK==zHqu(hvbe)?jOWeCR^rxSRvJf98A82p+tNmaiI3$2yOBTfwk@iVo z%r$`hSNby!S+W;qf_ue&CQ35e6ww3a&eqzR8a{e2$n%8rlpynGQ))ix`g02spm;p84+1j5Ai#}DchnnXP%Es>vJ>IAOl+t=XV!_WboWmbO>l<(3QNViw z+So|M1~#8xi+eAd``H|TdIk`s>>XmO5GHQ;tNXf|pErvhsVpI^{U2(72BOkszudoA zM?+(-#PFG*yYhA7>RroA9OYha+A>ul@x<-jvP3GK>jxU5FTvoyDtd9`%R zI-g!efY`qdmvcrhI2E~TL!ts#Bi+Og-oe?&mO)Q?e{_0OxF zB(LhzSYexq-a85595)xbQ77!=RXD5f#Emd7jjrp`!P%f8^(_Y1V!6JEt#KOP(aYq{ zwMr34>1mmQgAd$S3vM$rX!=0TNV49Lcz^J9l8xk91p)bImVYjqX9=uDzgeTPnrAk* zFVk91dF!4W~;h3C^PGnerE$^s-(f9epHG|3dC#nY7DT6g8!LR)2+)q|d z?l(U51!%-gq;hx_HxQdC@1$bQ&&NW|S;o$8fR^k%y?1#_{=(mq&z_qL%npuH3LFz; z&$orsO^?W z%M!uwwoU}c`g=_W@u!51{%Z{=b5ZOQ!V(k18cVXp5;Pz=9jxPqpd=&bx<4Fd0Ti}{7b58gSJZ=4A(WiK8xif z!e3x=TrIO(cD`VbHt~~kCl1z;5lNpFgi%k;6EE5}NcG(s)D&%HIswtqaX)=?S{vT^KDLFX@=wm0n)hE%vvP!sGle1FL7?kg_}ax%X&7_{pDv zwit_pol6~@o6ZLFy4ZkqaS^L8%bA7qqG-!$Jr^(=`Ns-=^ZGDxMCqN9IK4fe1 zY9r`);e*m=K`}j1Td1i*QcKu+E3*N;N6{Qh$6BG!CfWA)X6yQYfR)B zpWRhwC#yv5OlBtXMM9AGK=+>aXoYlH)vtlLFW)?tie6{G_}_ z8+zV~p39by=<@R@j=3KfIzBJJVoo@Hwq9YBJ<-_NHcj8(thn!@@1`X>$h+-{Yt>sA zYO^EVaqhs3EM@_J?%u?W3!`*c44k|_0X(tC8VObMS6YeSRRXwk68N0+AsH-tCxPwM zduUN$N@goTFF^jiY3Y&2?!Sy4RezccW;8{;MNSeheJalm0#H{8oh&{?T1k@PA#2RB%uX@DwmzW#=Z28iT-D*r_?O%gmd7V9rPNkLh zN*4Cx5#F?U&!;8-dZZK)Q}QS=7J(+N2U*QlNuDQIVi}|1rl{a8R2I()RP!OA=`;SU zqVUaDbB9;=bHMV4V?j~-TA<`Z1{cHUkFFJyITfsa9(v4C9Cs4I()@|&6MgYyE}Qi! zUm=NM)urqDRvoUo?^jRIITbul17EvZr*7p%ei~Stw6wP#o*#5oUzco$>MnHUnuuN`f(glLK_Ct<*a$a!P|{BfvoH3&*Fl?eYgDG( zHL%eRdd5AVIT{c*`&5_P?v-q*X z23y(XdX)u8b6y{xBkxeNB#~g(fNkA{xmTjq1+*YnUy==>N&rXOh@iNpaq9$sU5T%5 zRvN(d=!Z60-TMtC2+eT`HOo6|=BU)8n+P-qd-PC)z533N#$VXu?XyOCd3EIdkRlFs zT#P+81{R|b%C70t4voe`j4d5_5T1|QI!+dc_L2DtrOwAu_dU+h*|+GENC_x$$AmIZA! zSYm%>7jR9FYC%L9O(q#qSWr_NY^Q(okQSWZkU z2y)&HsS(w+K422;%7>#XOO#mEs4;rFMkmc@HBpOf568n?E&aQ7qFF*$TsFownWMDYD2$h`1aJ+pzxAc^49XSy@^ z)^H_;wZNrPC59JAEj$yd4|WC5?ubbWUop9LD4LGY>ebL!zhu5V+4>b}t7Pz`oia-J z_RYmteEb7obyRjm_^#j?qFm170xZfIlIx&Br##~rwL+}QQ*>XW@=(vSAa~G664-og zUX&i{=XK2b5_VDVihC@aP}&9+TuGfx$3X*=;v`x^kYprb!2u=nAR=eACU^FpS&Y&yz`_R&mrTzU6_NZY2AYn7dk0vaKI>9Wh{ngSGvbiSFVZZ1rbU}R!h82 z4K3ITaTvIb`QRUFtdhcTm$o6LDf6Iv6y1WFf1~ccz7BqQ=P$=ujGj7N*9g;VB$$&D zF`t7Cg2i<(mpvX#EOcp{wQE+qp{S^rdb#X1Q_>DK3y*#Z`Na~>d&}sNR}YNtb1Aoz zCxAg{lkUGf`|rO9z9|iCy&}l#+7<0LED;uL0pc z7Rc6*^1djiGp$!XU)veP$D&YvKShy5&SK`X6WjOUXouT}KjZZ68tcIl!8h&N8BC56 zcVisxEp`Qqgk-Q?uj>27-`ELkM+nP-Mu~~nJ549{42hkC89m2FQ=q3b`wr5fAvP@S ztm-SxbDGkQ744w&At(~T1P|_riX%9E#dCb(lD%FhrIqOIBsnIy#w6I&r0qUjb0O7PZS~QInmMZv@#K_ zv4B~NjU@Sd0)*`XHVNTRGfPOW;xO*xOx(Jo_~&bUH}G`%>XS<2-ntUnlUGBVgYAR$ zRrZs?SD%%zjTR$BQhEGy3JjUQ;jG8&;#qGL&qPJU)wPd0IegSj#4O%+zTo+$ahSH( zyMJ9(@b>a*gc&WI?ZeU)NVxca0?w=mX-N0aQxeq?{MXF|p}od-UKD zD7AsBWtJtq>DFbu2CNEt9MpzK&3;tCCZjiVJjIcR21r#A2dj>@&OjaLO%t6ULanfv3&<%pHgx#;(A`!3CMi5`n4`cRwF=wCiF_~7t8v3jfeyyvEas#< z!+Z`l02cQg!*OZH6*_MQGg!T9c!s7@lOC#~G6(k>-y|zn9bg-YD3k zhK1f_`qVM{AC1L6A3WMGVSSD?AFGUpE=^)zVek_BtNNw`%lfxE+NK{%J%9B)_PVC; zV$M82R?N?APBs(3uw#7qu6$_J=NNncBdsc6t@FF-xGBRT1HURgR7a9&T4`@t*YA&0 za=L(N9*P4icgIcrQ1Ccueh2VPW657R61t!I^`PuelO=+M99aSw_3s1wWboD2=a^lq zTDlLb3DApzwhAn1ik)na-lRt!>jURm=K@dh z`RuwN6<1FY)Orgwkx=m;;5-2>T+YB!CSfi~SmG@kOUwbpqI=}J_GFImlhYq8f7NAh$yBBhkNo+a z?(4aFgM??O_fFee4+5=D1Y4eH8<6`E&z;|7$>HO0!WRkQ*6v@KI=!5zMDlIl?glnp zKurA3eyR1Vdj<9}AI9we6}g{C1`~hHOn}<8e0y>Y_oXoMCdLs~M{D73_LIL3Z)Cl^ z)0MJgta4s3=;BVTO3sb_jl#7?kMT9{Uq1%5vmeG)gH8_?-9UrFUGH}@|~Ur#Fk9{ErkoEUPcGT^5q-tQYzRCV`ZX{?1LT!}Q|-1J^ZI!~jl=08aQ+i{x%Vb5*r=QJ@frsb{^ zWw~xuZY-|vqT{B`nywyq4$RjPd*#>f*lMv4dG6WObhEYCw-t@<_*HoQ+@kKkLL#cu z1)k%AHZ>`}3E2>zAconMtdhT^P7}e);}H{`96pw2L(J}9mkgfQxPQt{U^my71B(&^ za{f$uWlx`%60RRnEw-?p=99i%8e*fMowW^`i_Y;G!ijcrHybZRW3wMy^vH`YAm=H5 zI<0`5C-z#O@+YvyaG}z^24IbR=8rt^N%He9wUwQx^1`2HY*xO)eiVGJr{Z(d-{I#j zeTToYZ(0TarsgLDC8LRBjMbcJGa;+lRW>;bOYDtY-8j$SudXu*$?~i5p>bGUpDA^L z?-#Fr+#dlYIG?jv%#norkRW$b?5NJ?GTlUQS-+kT=|u4D1|&@gTja!;V@&kYR~cUi zuR}yaa#(A)St==f#e>9Xdi-a8M4I{S;Pc~gGWhmat>>}#O6bU&aV!3GvpCMa)yZG> zoh921>}0aKxLApq?o&WEb^{wPz2kE{L#(-K*GIzF<4^Pj0aubF@cg2=d7VkiQr9<) zRRwDzDsIb1wBXm}HW&a!oz1sxrZb~CdgfQ?Wb8E>aj&si`H4yMPG^)J8AEd$#61ss z^R}A=?3pE&jqc$IO})H{pr)lii^ZtVT&la!#Z4x9HhuzPi03&Wd>?a4Kjx;?x=ki_qq8~9LHE3)M1$U6*1Gu&tl99Q&UyHVu!6RuE*+% z{&?I$R+@>vs9V_1k7h!9Ib>57p6cK^-mmlr;*n(KCVkP+) zAn4QM4LU#X`0M$DTS(J}b zZ;3#<^lM>uCst>{$`OC_15s`C>3jF=?9hD0J6C@PO#fDJwhLIYMDU@qlK{p!5c;>< zuDMHp2RI~u^Th9ZZMz)(vY;i77WVlr;Eb+pFKI`^K@u82cRdZU@t6i>2!k`RmoN47~~a6CE5Hdogm}MVVy=9kE^ouN%*-$ zF^P>@Qqk&F3CB! zm|}Z=5$bvhi<`(P5X*kA=hXdKjU`eC2wFpm^Q$B-2SJta1nU+bfepEzwm?Gm%hUby zmE)c^*Haq5ll(O{I|*TkWWVt%R#L0Jk3o-+Br!`u-+CZvQdp85!tQs1C(T4eV&>9l zynQ|utHTvP*-r*vd?W06d3ale%rDJjiQzCWU3jdXPTlL#k@IlR3&y2R?AmdQeJ+Jb z=d+Syw23dg4*DAXA1Xhro|okno42d`5$J6uYJ@(Zx+{siO#nNij8@V^SHRpWF*&Z5 z*@1VYTi3297r2H8d6>r9{9~V{Au_bY%y?(Z%xhaa4XDG0p=_)N?@Xyb>;Ld$G~_;?dhV9f2I>;G!b8oW3hQuYUcJFJcL$3slE&A9 z*JJbvvcgOQ`NT%T;RR5o@@7dt}VaT|E+>;M#e_( zepo&HgS(~}x|2e@PO=0rjA_4a$lE3XtaO?>VqHIIWxEu`t-Gcfh-$b3cdU3uvt$Xk z=`)-!u)ag-^>TVRz>j)N^Mr6Pp^jJ_wLI5<^~?5;^<5&5#mdo)%!($CrsLyd20i1Z zbF4r9m?lqsAKh5S9oEd&^j$eDG!c9yeU(l!$mh|=j~z2ir0IV7x|h*?xCk z#(b?D$1CM&>j=H|4Q8YzZy|D1nbysUd_ z1pkch>i=-ONe@5c3UPl(r~cSZ_?}Hu*YYPr)52OX^ui~Euj5P%r$@;XzIRjR`aSLj zRy^of@nkShfd82+W7;}DbTZf^5Ch-)=EpNhqMI?ulfl*NqFtB!N26sn>*TMd*_5i0 z-KskuBz9`ya`2aQhRd4shG|&r26o;B{;fauaW4sc4P3>dm_qQW!EY5T+BtD?aW7;s zFQe=N??uzL5yhe{RzIvJ5JL{Kq;no6Ebil2vc5elTKX2C@tD~&SWnGSn|fqlo58yO zjp2*9RvWx>xudW$kj z=t}AHqCjOy-$WSpL+LRHi7+&2N25dYNnS=nY)l%6#~U;kfi{gMf~|o`CBuVsTo7p$ z-SH3=M!J(rrx%d(MBEyK@C`fZ@1l|W=Bt&bBHYy?rYQppOD8JtT&0>edNEjCB-Xv% zic!bc>>Nq?4zc1BOqdIA`44m)q*@0>C74iK;5>=X475lQ@=PuO>+nSxED_lR4&d}$ zQHoacxHfn4*B4K&J`fkz&Ss&{y&U)IQ~U!szAN}n5UT_ey!;PU-$U_Ga!XRK{I;!Yy(rjG^XGe#^$68IRF zYjFNq<+)dH`+^MwnnE1sg#3a%S-|@n6C4o)RMQ+i@sWKg3Y0VsprpSu!q3ZLc~s21 zl&58V&X(3~jj{FPoVPZeiDK1rJ)C)_*jG=2Q#Tl~(B*|u08-tBJ_}puZR#oYfux4Z zaKN47u9qX1yFX!pt0XU4c-#kweMmVv*bH|=CtxJHt{Z%cD=}7&9^QzV!W5%)C@Hlx zs&uH9TQ5)@bj=ZMXv{&%F;SQG#p9+v8W1A$bXTy5UdY{I+dAGCJZ5GPPzJv*6jwcn zyiEXmnqNQYc1!?EG;DafpKzwZ^}^Di4M9uVH?%AL$Y1sD&Ik8jZG zriz`jmZ|5MkTK=*M&`s;iQG=Ix8D8h#>&-MTRB~PpKxX1zP1|?p8y`>b^*K8?B{Fe z0lJcuGg&#mlU^`qOhTg9>5Yltad%QbvWW8T+kX+4>b3@t&^JVQ9gSo#E<8{E94(jDj;Mlfvbo#0TK&PXMO26oWIiFXAN)bYC zUHtH8N(z4>p=0l;mdL^$a$G^!i$f8EwXtMK6YU*ev-#iU6HJ)ZDV$^a{O39>sm2`S zy3eWQ?xZE8#@Vt0rgCfF64}~iMQogd`AP3guKMermvKdlRK3PC&~I97+a1HbMDYE@2zm0?dT)OcQMRr zW5&c-GZ#(mExvUuUJZR?RV);3l?>L7&XIeT48HZyvC#3A=Y<(`-SPO(_b>j?^5oC2 zFgG^PeVrInb2lIy+)G#3Em?7MMzWCS1L2~2kKKRd|DCkvh2%})Uyc<|`t;{zPaD#P zBcj%fB?#)%sFfh(ZzSQyqT0yYDGiBV8+5KK$F1U*%DsfOOZx)%q!%M)mS!JabU|uD z+#c#v-d*5Dl81Ry_jqCetO@F?>&;S^7e)c1x{>ROFn;A5IV&mb%NOykke{DdMaeJZ zJ~dcindJ5Ok=@j;>P*;`*w3|Oq4Tus%oANhAD&^9k%+N6=3H|aw`CYxIwJp~QX9i) zEp=EuV9q&RxgM)eS%)-E-FLf!wZ%^>4#L+99y2o-7_nG#6jwFStI3?s=DZ|#6+-jt ziuz%;wUNFO8eYa4XUSkhLwBEySy&D82zQjIgGdDw;xTAXlcox9j<)zjqnOz7W1bLJ zy|^L7FL6XXPnB%of51hEL$NT|4(_I%JQOJuKuK7+4!`+m53hcHV7Ux{6@$#!#; zxD!Ma?fPEP-w7<{As52Lsn)`cxri=Op9eKANCjOL3%+WVCRdneb|S9s_hj{OLHiO* z6rzn3+43uq-BN*`f3Z@SUd_*ygYJ7{Vz5pQ+seie?-w1yL7pRN3WV&g^>mLta3K*a z8!jsSSKYkb<@DsHP1mM<@hRPzPfCvo(`dXCzY?E=xvZ$sNod};J6fw`u&B76{M9R= zHO+?W@OeqQSu6>EjfdEzaQ7H`CSlik?EhrhgfR2EDg^)0nZ--ucv>N^2CgDf%(l{BDOj{C2`nKf*jOs?YWV#Jv<;@qLmdOmXfTn{ zS3JwVnmh5%$iFgk-1Kgj78o0xL7C%~{oHasm8%CL`=;m@yb&z5^-_LQ_0KKF5zPy%t0T8-hM*3ljuD-pt?B5 z>YBFHp@qr#*P?A=R11uMVMc&j9V>oxF4aLUMSpu&@B!)Jy|fkpA-7d6EW8JKSbj6i zml(xWE4v>5R{Cgcgnc_xUNV5 z4^hd=v;O&tdVEhkKHam7p}I`n`qKb-x@TZgvOq6bZC2t;7AYGZ1LgE z=#xU+%y}m<`_^eqCiBd&l733+SZ*1tTKJi=QM5R5;n~D>lu6q4KrAhSiFU><$79zY zG*A@d7^w)yq!C4r|4hd-Jsg)C>EbM^bcjl9nn>lkgmyvI&slN^(W9UOwO=O2D|WTe)dXeBg2xsUE0mMlEEg>Y9ts(r1MH> z`(0&(KJuA*hf#%8y;59AJ>XTmoZc{Y(M9T0h24^MedrfDdN*y61h!v#SEa@^v~&%9 zwcrtJdyLYF6ueR+!z*mYK`aPHuQDO&x9!=oZuHO|N*PSQB>T61s5Z05ZxAV7yQ3t(THnwU^kAJP*l*S(4E&9;lTOF29 zb^HT&1s^g`cLiSpBFt$$$G%#&ZYUm(jFH^_vEYkrC4dpN`PxnbIB4)|l;8zy9kH&z zi2EdDJh+F~SHrYL(yVE~P5aqVgK_GD(rI5^ZS|WzntsDQ{CB$CQ40B(AY?}gvEhkd zF@3WMV4H$v{n(|M(?ikhw5@1LfmW$(W#bn6=#N7EkK#Oj(@i*k#P$jBp33!GzFO}3 z{N#U4ERpyL?q`|+)^T?6&Fap7QJ(6m8zsd&?*xPEI-X(p1n~Jq^yK}ik4xLd_YaS5 zTi(|(y}RI!ce{2udL<`SFQ&66g>-mzTSA3oYhWJKd2bzd%rp<~bSi_(KJYHyIm3pW#NNO`%9vJmS)lmuU2^Pz)q>CQ2j@ zaLeN6N5WuKl-#e`Gu(yECZrq-4O&D%YQGXo+4Y4xnUFv+G!jd-sNeyUM-2-d`xuVf z>=$S9nYx$~7YJK;2gVZdNVe3v-$mIw|MLKM;*14ukKI9rbGuecED>zT`gnFw^c!dG7NFNvd4YM$Nf7A;?s&&>#fL+ zE+;J2Qmee-B>FT6E3H`Rus{%Cm{Vyu{Iz97k;3+;hSdYI0{;rDC&PY1_&H|i!zPCF zC&u#isrpm=i@M@l9exF=F7pb(Ei-X{{B{PZ*F zvpG5+lH{bu@dRa&sPspzF-wXPhv8yjXvk=>uMi@0eIi&ew5ia>`;N_NY&lM1<@nJV zRQ?FjZ*XAqDU(;e`FKKtPdsrA;fZ1Er-_f`N&2niFC@~+5))H@%CSCi3BW(M3Y7q! zuN+o%B@chRL{I$O4Q!Lcw^!f2u4QJRP2|8MN^nVetoBYsS7T%OR}3M~NU|!hR^cL8MsQ zM4Lv`hq#98li2G3!x+!DF_V_JbK29SFQd))5c&>w3R#-t5jShz$xpNrcPigFu3)KV z>Kz%QM3JW;Q&-q>vI`55idtzk26?kHF1f;vZ>Swmq#8&4Mhc(Q4kkiVJ@p7)IY<(} zmxhvdD9e()7vDB%dR)RQp-q_Wn4B<0B7D`iJQ*Alu6NW_7iH(@qM&_c5u(OvtUC2M z_r9?kxN>g`D~q$n2PqY11ALVI*FlTSfm;(d zXE3t1QN?;F-O|(zuE&FrfJUYx(G%fAXgC(mB9k2pnXe^zcd+`pk|l8!4sUh`PyXFn zVHW&&pYHJI*pz&_PT>6MBbZXvjP^?mSyvrmImYVC?Qxj{uh&6)rETa(3rLMM6O#OX z0vOzox$S$MboHWiBdJ^6Fmi}|ntZf&phd3O%!U}_S>nr!sH1qdJ$UzX?&Y`f z-{GCR#!iyNl?YYc=l-x{yCCr;<$1a20YWhIL?ffwq(h{^(*)~r^o=-0$dM)2eN44| z*m)W3LiBm-$IHY+qJ0)u<~+#cw*HX>u=0z>+t-aA?~gf17!X(Hx@DMocal^hQEor4 zjRqQXBqw!}@}$f0Sm7h752k)7JW1#Cs%hkYjxo-bI}iO`2w`zkP0IbER-QKjG4ON6 zJD{hRuwB3``K#*_Qze2W#9p$b@W-E+3_hCY3dPI?y*dJk;V4?MtcM_uStV0FL{6{U z;hYwJ4<}gL1f@k)2qxLQ=_P(Um`U^P8gqK#!-lvT>Lhr#Xs4Y>yFbL#O8QX&H9|Qx z@8p~Kdv<0lxI$YifDT^4%2KhlM@KMHr&S~x{%V+mplUus#7cctQyVKaeS8j1@}&f)<_hN-Skt5dfV z2X?>ODH(h)2;%xqf$XkewI+g;yMGx*^9|4KfW7K;XYdrf^|9XszIl?pbU1W%uE#Go z)G;K7@5Hb8JZRear=8HDPLh6jmY&3jI(Sq*pJQZhd#J5)~z${mL~Ui0sG=- z;c-om$>=y^k*DY0gB`%M9{Vt3OrqrE%-OKB8`#YmJiJ7g zXiouZ1@oHp$bN3o)-+*6wOjg`$z<_;J{WtXo;Z(G(Zd9>xyU#5B6T5vkbuV0AFF59 z=BjCwa)~Syd?mBoCCsrhBw4mOY=vv>4xYEgC1#qm{0^q%4-aedFZ;v8EdR%L1xNeZ z>xl`5UGHkl)VKS(XZ=MY*S*s5Q1DeLuD)H8bP~X_US)Wq*z<5Dg^1CojbAXp>yGOR zy>>C{n0sx3pdZABO`J|OQ2gU%s?+Q7 zM{&pCTV|8_C4uMtsA+iu*iP3-V-G^Z*qCMwNG#u zvA&yrKw@u9Ed5c4ADUHSB_3qu6__fM04pQO_r~UjaQrGmTeO3TX8@=$mVj!TPm-S6kOF9!m=QWV^&fMGD3bIIQo4{o&4gw0nMdk?RY!l|4Hjhl3^RMwvq z>?H9`ZuHqsV39eFKVG;YO8rTnP&QK;G|}CK04Uh=ing$}5Z#$QXmqGp>g?4`IsH(@Q0xY6N>yP zt0>VoI~7uStAq0=C;GpOnOOB#tOd?W9C))<-7YpFKUw_QO_&Q3RwDT|RfIlBrfZtz z`T%7+tV6(8Dw+za3(I71m2|vg;bH=975b>m0Q@L$-XqZkHx^Tm@!QGkK9gYx2|S}K zNm@$KogIZ7Gu_%Zk72%9dn;NTVmEW(qhM)aEM_G1hvefT=E#UXft|*b&73g5te@g0 zWEfG{IUfB`m>5ij_5B4s>Pcyb2t3w^vM>t7&0*!DC-|aRdQCIeA-LBp*B_OcS2>f! zuT4-UyMS+f@s-j#L3#Vdb||YF)H<^2t}lbs`a&+c@UUyGq34O>nAl~>U(Fk5tROVb zEbb-&4Eri*RsYnIcWFI%gwBFf`_o#^#7WmuuSnA*QD2;6j9cdqfm(Y^EGEYzDh67xF-)W3o1JGMA%p!qC7ht+jFA)O7)y!z1Y* zc`UG${Xk|}P|W+5{I-OWJ1Q;d;-ZtEr~Kqgd%U`qtu8TzkGYV%D_Z`3&Xep4hNwp@ z&;lXp8)&3`KYY0pp1Llg)oFaS8(6%(+pd%p zQQ@%77iQe`W7taDL>hyQB<@Nd8EhngVPQ)>wx;xK$RDj?$b-swxnZswqk~t?=Lz9W z+*fw~j(J@(7|V}3Hu=L)gb^WRiJ*w^x?k-I4nnHQ_v z3*m-#RLrVrsVFcgEP+e0E0w?M_%F6~+X{r_a3q*IxEh;KyXroH+Z zMzN{5M^i>uZJYXfm&xOl<%6%)W}#<)7|lj@5i$bxX* zCWh(5xaohI$M@5r+WF4ZVuz~Cgm6d%%eebB&Zi`1{c|vcJ@hs!!l8TUCgfL(Tic#b zOy(Fl+{IV)nC$iKSf5Gq{RFW6ePEF*_QzUN$9~S;F`XBd(2Ki)P3QN@a=6sU7AD^A z0=7-OqQ&_DZ%#luzDf@B_N4iC2je5d)+3P=V~uu#w@bS}#MBfWTuAukFJIdwQZZhh z%?;TaHJW%+UdF5Wot46SY)b^-0wM407eJ&O3Pcawx`!Z>LyBAA!_xUO6L6qStpEx*@agG z%LhbQ3|8&tPT=!Pm8>p4Ys3<|q2jNVFZf5V|E7*yhESLHTW`>{{exPIS>saQP`MTD z^h5hdXsY$AA$X0DB&RzAm>yd760=79M+L0Z7t_%?NC|Z(V z`v?G)&TTzT2YRwc%d8ez7`cohl}TRdCWME*u8izVQn)vmG+}IYD5$y>M}8_l4~jn+ zER_wmzq~6rv+blL>ClpIaW!Uovj2K$rM)Rr>AK2eB@(VtIb=l=e_yTczevOxhdx|h ztRp~m=(fK;fr4#%)D7Rin^w^aWSxg*yiA@JU>y6~15A?GK9f z%jsed#1ydzoIRc6y~_I4%KTzn|`t6p_x1OL*|%RAw*}2gs&PCdQ__D z42nr;Kf_dKtdH6+-4Sn}o*y=rCL)+eW11y?6##CWq(}h6`(yM8?Q2=ie&}_}K^kWh z2Mu-?EB%eyQ{A2NO@6EPV^7yVdUcX_CN*{6hT84Gz6+l2+uA5Q-iwhHNs4+Hqu0Jr z{PV_ICxdNb*d~3&CW7U9Jfn_D0@(aM@JD@-48Hguwx!u{poq!teV;1=YMG3m%@KOP zwjUCkct!Ije@(chK~qrqnkaF#X&f zyM41{FQb+HhorYBa%dE^z&|ku#twMpz~}-ExJ1)yR~yU<7Ss`l~JD6lq9 zU7dumwqvwi{yM+&J~>@1$*g=cy33<>lj}AmNiyEH#dq7`5}HjoxN&xT6to$8t;&yw zIk4`9qtIh#ZW-;iZu+nMG=`^KoGYhnh*{Pdab2+U5su5|ct`+~`08;p9?#C@VWFdP zmd~wZ-Y15t9QLmS<5ic>!yqvUVEdrx-a#xeA$+VmlAWk`EO;XLbT>Q*sJfSSS~hQO z$vO(QX?35A&ph#a(QNYf;?JGYBoX|1uy;M+14#gn6Mw7(1a{~36~{Xne6>GKm^kZs zsJzzQb)`8V=%7`skz>+?FiT3>j|Od4Iy65gKsvgc`0ZfAH7sq5#Pv=O6T*qOI^v1o z)5f&M<2}KSHe2!1meEh2#XFO7R17MfQxshZf|(DTlryRNEnk=vDrVMP+g}SgNEwul zD&gnk3hi20;XD&f+;vzx#?Fr#mt#2_4ct3@J34Rf&cq=;w4@q-_r?11O@)2YU3uih zo+^7Xme-=rIkY_|3VW@hal^F|_`Nhf8iVlJvldS)UkjbsoON&y6Fr~S&11@^cay@8 zlEvhs-zA05b*V7hI-#ffmF)s%fxbVpS3>K$>4Q%8&N0ZI(WSeDnP`~|KJiyr&UJYj z^YVJl2L&dHO)^kZYRap?AaV#VS|!n{#N#)_ALI?0^q}uc=%*pDTe^0@He6 zBe6^8&%CleQV-sEoE&B`9+82`h|o+=0bu$bC3Y2iP&qG4UISg)TD>+JM!`BE>~Qqv zeKM{oH2hL>@^4CkdB#`z&3q;H1Ahb9LJFC6>Hw5 zMSZ}@nApCqU;hl}eT-3*(JbdbLWIKnFxKGpssz3{FQsuI9=0{5Z|RPyqaQOVzXjs%{_^_#dy2x4!t z*Aj=}{c3URUbl|5ZqCH+s4v(#76Or5} zkIL;O)+jzvFR4GFkx4ATD7jQ1i# zbTG9h9310><8EO6S!0Zjb6w~ST#3zVgYTVmx{r$KcL9rj-wb&ySv&({JA`+V!TGLK zN6^iWC{J8!A49sh80!#k??IIec4a1o-BHM}B>kj34VfRSP^miK8C;zd$y5*X)iZW> z1J}O=?El!J|IE|(0%vVwC-C{@N>-Pn6=JPn$DiLS+qbj({s0SY)w|Xb92gHy1k@Br zi`@v*F9s-l^)94^EBWqd?`T_H=JqtVauZv^Ym04_=zvxQMQa$s2$iQLnO|;!ux$pU zVY(1Cj+QPBX6NxjFZ#^oZeJrG73)jJpfX-Aky+ud@D{iarGvYs!|>yQ1qQ~}5|i@n zVpVx>WIvR^w>Nd{$~qX84eJIjdsO#jt;NuhyFl};(k;xyK1l?N>B&83w=WiZWp&lg zK&XJIFe2r*T5LL^Xki`UuA90Y9{h%fq6X~hFl!u88~R>g4C#Zl1r0GNP@~7E@Q`p* zG-m(JQR88gVm|4M=(dT8jg3pNg=DWP)|`uz(4NPmq9itr$&f%+mPk?geWzXy2kPkKpkKU(}-H%sK*sa zm#&WO->_>xk+6}6b#IK^ob2i5IKHE{XCoOM_y>=9svMnT*9SouQYuKdS zA2rRRZPa(n1hJaD*Q1)X%$tmlJ(~xQX8L_ zOV2;AAbfTOVn-vmwWq=Sndrd&G{mo`y%lCCe@QJ9xcOneeB3A@z=vq zC0*%V;)cBx48Lr2n2dimIY#k^>4nk%p#=UgYVZ@XBSbe4QRubRU`(l@3O~gxlw4Q? ze-cdTU?%opjs=cc(5=sWZOQJH)~yLUu3ztCjN8Np{ z+w?48La$x&)Xf1sS2bUB>>VEoUtq`kG^A;;9TbJMH0!iE`UuD3WeAVO#Op;oIZ&yj z^U~^=Ap~)g`E6-F*&X6S6TmPw;B4CXd;8XlxC}aNsKv;&{=r$%$zWfa#D3YhvC(zW*@6PySbd%y^q36Bz)VR7Rd6NfD8bBJ^4=J7ed$)&qZ<9+8*ZC zUbdT<-%9+db2pWd82_!M@kkk{9O0BBvKgkGHbVc5Q?_4tD^lU>0OlR$#R26?gN#9G}9|`F`Ug`}tNeY|pOuuH8CxdmSFNVgAK?u|8_0YP050qhs-NTJI zvpF$6?!JJV8KSd}x_&tuUukU%kA<(3-N0nR(m8D4WY$-^fv@k5s-5lzc4JHji}@Ng z9Sv;%Ch#?AUn)f1mW2TRqJQKf{@oI9Lu+f`6cjvNL*4cdbE$nKmI`{NZNeHi%Ut+p zhiPGYNu(F0?H1(%3Wicvr zqQe*w>Rs>1K~d>q^I(A4sPr%OI$VLdFBL=FGW8^`_?M1hEF1X`CCCq>$}R??tkrE- zhLBBJ2dnfRFe(sbV_5PYD5iiI zV#zhrFQ2%K!=uc@9(7+$Ka9&p#@jPy%H*)4sm|^tdm}Bz+1B-pi*YAO%Gn{;4>e2N zSS;s3fW*1L=bj!D7IWJd-G49;tZq=fCeVeyLi-)(rgqEEIgQWTu_j{Yi-g6-@H(%1 zr``#CwtambMI;1ap48=#A3AoTlb<4V?x~wmyeGwZP!{a6ti@^<@Zsh) zPtLg0;RS zjgwy`Ezdgh#p$~^?6+jOqw45i4ej}=|19VHDjBRT&?I;Wg0Ns(E=deCmAd9)#1gBA z8#Vw{X!A9DauhPtjC;&i8-;QQa8Q`*qjNJ>$kcn8@InG`n@Nhe*^aayplZm>TEB{yB4DSx^wulXdK=lg4@fNU(}?o*=GY zH@sWO%mPt3UYM|oEh))3x39CF5NL-Mi7A2<(j9&}_JLvckt( z^R2ID9IAwCWJ$qlAlM-24k7r!6YNJ_$HD8c^Tg@C=bjcV{7q(wZWshP-~G95-35b7 z8fD+t`yM*1PvcyXoj3Q-9mLgVB3M-77j!4^{X4cFW3GPo+q5rVR6_LdQ!!bn?$Wx+ zwcv3jD8Vqal3`YLbDe(CKmTIezY<#4Vw5xfy`y?SOBdkwCJd*IV z3NmpYLylp*Jo)JKG8(-e)HH7nc4ACgfw?u8?MchqGS8>1-N8Wlctd>q1QxV*+%uY^ zgD}0{35Um0RpV6Z%t2>|%}3$(I3}0oK2}n-Ukw&T?L2x~_NyI-O7}W2Xfd(IrY09X zMaktY3k*)tjb^|kcHFwNz zU}3)bj^=aYlLWrzaVRO{A?}^X|B-@Fg|9zXl@nZ>E_Rs`2w%3W{O#T}2|k6$@0V8> zJrs3ksL*gAvjkMSt2Nd zx?j$&nHRF6A<%*hGCoW8iUdo>oQoCdhcRD5@p66;f8)F(^GDRt&jYbB4GCb^PuLGyE>5;~Iuf-Pe?AoXQ|Y`CnkRzA?F3c{ z-z@p7=a+gd^l<$UyBKln64LPp#fm2P!Fz3DPE^rr@G)TN`foehS+X$5BT`0w26Kftsam@kl+kks0g zitH%f4uQfyPyE&^p>5EXiDB3OL=E2stm|xK^!Jnt;o>WlXZ_;yh!uxy%^A-l3E|QK zttdBW=cAvBQ{e77*|M9u!QpG(Cf3{c?FT!7ui@Q529dswN@8EjZU)gYj#oY&!ZF&h2VMDWFyuC9fSEf~MXtyE?AX?nLu zlmIuI5M$ED%!IRWjc zwh7W_rAq@qbOFTgU_R|d)^Q=3U;=?T+CKvHyqYGI#$svf!J>}(W>;x7GzYlz_6RPB|pEqN6O7*vh%T7TSVYD#agfQ&K9?hR~ zhVAe1DmHLZ9%H*`FuX0YPV^$i>kEnB4rVX$o6}slC0Jh2-zR+8*hftClW7TqEVWp~ zr!Hh3H3ZP*Zu*d8b45G44IXPrY%q7OY25T;M2opZ*@Zh)qB{=@JThjfdl$bu3BX&+ z;lfMy#>{L%H)r}cbfSaojUAQ34ww_rmibP()P_fDZGr-M3C970H>V=Tysl5pRCnxG!-N4tU9_+%x;dPk4; zodmvyER=+i;{T78tr)xt7`_5wu<*U7wN}YiJIJ}_!Cz63Bfc`{RyXjm&B#s3LsPp7 z#|mITJyjjs?GDt|K%gVS9u~GDu0>g9^lX{!yhN%;Nnn4zq_|-hP zqf$Z-b8^Ph)DCAr-Y)w}LgZ;Qw>(M#g8O8j;( z>Y6tp?3z^5*t)45UsoZjgx!ybmKayZtLA5QNzLqY5-6@vZ)9qmY}w55Ywg$+YC)CK z9e=PSqQ(~TG>(-BmI!hlZc3oS_V8uD{sP{zmkMPEfETj+u)Q;BdqP%X4|R29{e7Zc z$YlT4aJ(&l%l2vP z;kvOX=87;OeT5J4dc>cSanDM%5Gq# zMUQD}g^g*1J^D>xTCPFO3E!3b&GPtOi680;3JL-QvJ|gvfzvWC_>DSvn3`+d_)B@( zc&MYrILf#`UYmM!4O=Ry{2!aB1pGzEv!Q$t=GrbK+D#v}?V9k8J3Cz)A4^} z%n8c4e4@sD)hJE%yvgL+&@YzqE{K#fsg*xSrIy~%2bl_o!VgsI0J;-U?8y&r1B#{U z&PQz04IHyO{(I*09(wD_V91n&O6T&`$EYVMZIm`P9<^)K`X#hk& zV4o0=v9Xvmv631mI%Y%$HUVt>qKt1Qu!_uA-=E|XZg`V@7Z5!$k-aUXiI@+bD=x>Jbg1m%6>z7o9R zA1qxDO>{!?-4I9((3lz0|lw$#%Pb@y`&ut2l3PxU&3?V-3Lo2c5{HWg^bVb=9p)` zSoDH}F22-aDa$x*>PMjD*rhVx><1DnK&VMeJvIgb8E9wD^~9g!+vERa^07Z^kw*E} zq(p%2)5FZibM2H(Al63o)iN4yS4pUU@BW9DJ4VrP-I$k;b(RNqf14N&8GQY;@8qwiC&92XddHD z(L}GJ@wSL;Cw38IwoLTyCw@Day~J-$i$nAx>5IT#@RWwgkYGKVkG_Q!CdZd<1bt`; zb#)$K?BTmM*m?Ix_k!5czYHI8O49&R1q_{l+Vcrt!7t;{Iu9JBJxANlNDGLax%SNy zX;bK%x*uFmHUmXOexje);P0(Z#)6VbFyx1(7#3p_bC1d?YgG6@qGUkYoPWgWUkR-f zszHf0>->wp=)|v!gwvzGUl_!bandjnG#kLE>(6{1{`nm1@MYrS(NQRl$*Z9yAIod( z0xbQ}eDt=IuR+%Y`&#*m zDkSv;iCE_ZDqT{-cOEPR(wi*`?jmTUAImUf3;p*?=C3&)8y9uqz3oXpQIN%U^ii)J zuB_ObGGje5WR~piPq~9TcL7F9n^>~yt=m*l|;k275 zVLs8hmCqTgQuTxkpb5PgH~2WHI4B3DK8dVTrZ)&?*#uM7MPmIV_#qDddc9M9#xgNY zdg{Y_%4Jao4Fg|$oP<+ksBh`@!x@$k7PDTN$;UA}suRG;Zs5bCs}Dm&?T)>4hc4J2 z(uWND1za7XP3~Jv_FjiO;d}9MN)Giq%VHD2^Ea4rzj~X$68g~Te+%r7u}3?B)gfLn z9u#C6(8ADZ4}bQExi;RkaN^1`8SGRLS|=Io+qct2TVy*q9NNl1g{j*lmQNDD4`NN4 zV!UB7tmtQz%#4KRv~Z@igNpAcSYf!#8fPR@J$-yrKbZ$5zN;T8NA@_Ji4{OP{!k=| z&q{&-x5f%PDkk?OzLr4=z>3OH=5P?n4kc>#N zs7X-=wzU^Cuvw5?9Llai2p_wR+#TK)Q7#3qADP%Sq3?x9~5 z4O#C979L0ayiEO#YkcPDLUoLh35Mfcp>csrk5UYY^&y{O59bj9F6Uh}Zbabrui6bv z;@O{}O(E{Wr<_UPYZSg;Zq32OZhE%oBQdige5ZUxWq%QXihw3=$De|95jhJD(cTTu zGWP@ibcnW3#+`zsG4?#zTO>3TECqWOks+`YZI(h22*N(od1nt!=~;a>7XEw~QL+^} zKd%Ny(qY%c*9l=RV_z3tV4*{h;7;;bV3x^Zvj9>$Fz;b0e}rX-XlVMnm9X&2^m5*$ zV9m3KAhXSj5XnN2$xSNfb@-u_80Sq#Utjs_vbbCF%Z~>nq6Zs&vbz@12bdn7_<^ct zCdrm`5K)WqCtT|ljU@Yg(hW{Og5UngsANYhv@baSf$I)QUu(#ya*U7=_PP+%DV-(= ztQW=xV#kR+k|Y)}c6j2^CU%!JZ9|VCN%q?L6#1*2zdDl%G8nf;Kh`v0B!sp%$=uT2 zfzucpA#M`;#Ku4p+rf!;ahjOCHWCcFdS;%T>rRCKRK>W(bt2fy4_a}5&+Z;{d{9;W znrun!=+R-59rjI^`DLwMY>?r16?hDb9DynpIpX=i1$r9UB*vB*R zHWS~mu`s%21D$?@w;F~(YQ6bHs9W>vfhhU=7&qmd6juU9VRler=oZ89ie*Fgfqf`81|e#VetW)R}0t9EZD_=aD9H zv%Wy`q_90$0n;i4h?)<`kA;gzeOyyfss&Ohw)a}-ySC+=mH(OY^!|n=pWe?AC0ftd;Pm>o zZh9`t4DCo>b!Nje}vUQcQU6<22(?B$=v(OhPv3 zl3JHbaQZF_J35K_7z9XvG=}ji8j&S@-Pbsuro0kANg!;J2-SL35ZPH~W9;WjGI#ud zG6$lgV&lNDluG)=c*q5*-_(*)7dl7vt`as{7q=L+HvSOJ{uMrZE%g0;-9525565wl z3>J<1xZr6z>1q4$r#37>mBl?OVJ}8Z%<@RDhLj^?5@_<*w)JbF?W>@x1aO`RmieIP zTomr^1RlD#b{Qw@8CDc`sszSaO~)YNJ^nplSpAlb2Ii!c?df~omU#Ydx0Qdaeag7* z)YldS4L_9l&F(KCG(`yX$aCJ`2TND{l&5?`#^d^Gj2A%9DHb^U(4I{fA zuVdLF$g_kLbep_P0K{x z1ERt#S3)ywG~UE$*5AzX6w==z z64{$LxrF3BmwGp_(u=UxDd! z*@>m_Y3ck?g;rVVik1sdK2{48K~-Ev*oQ4g48{cNI(@`(w*3>Q%SZC$<^yr^sXeeI~2FO9rVR1W8+-u6XORfk@w z{9)`o5Q+GN>=DvX-r{!Zl~%qh*h{Q^zL)Z!BbT$weWLRu*?zFZFeHA*NIjD}DwzYs z8G|em!<}Sss@Fzt9;;_|XG9_7_m_1yNGvka`LRs)X1XkgRi;nv-N4R3h_m8zwU2o! z349I9UU??4vWpIg7r`9njBIp=RIU@rvoY;Ww26Dak zq9DNpJ(2%mXF707Rs3OZ`CK;PSF((EO`nW6;u0kCz9VCtO=oB@u;dwg>7uLB%guC{ zbdk9%9Vhyl;+Yx%1*(v(q>$0ry_tLI*9|5&cbyC}&s%ETB;>quzVlbS?m$aBw@zH$ zdi+t;5&S45ZA^6K3E?QOZF@JEP7$XIJUQpxB8n|J3k$Loxwe-jpq1Q;hTd-9&ODnW zkUD=rPtU_D{<4ZKj~YD6PmyG7h@9h~^^rDSl@HdG%M;APvzjl^-44EFJ3G#^uoIcP+U?C%1$bk~!Dm-*!p z>BLkTt_Ieaz(9>z=>w(a_^u7?X?bE}5!wk_Qfk0s8ylUf=G_|KGv4d>{(QRRp2*D4 zuH5E1vXE;U{f-)0~p=l>WCxcBdA>a93V2u&}U4|!ORh-2?`YV#b z7dm_~%8s%MVP!>=NdK}PSH&u;<3wUa%-p_^-M|`D?Z)OaH)}V_De~RdL4U1Wr{iy^ z{y|$mQo0LJvT*v4r z>#l|`M`pF=Q9T>(yvnj#$n@!zY!S>Le-S#ngS%Mc0>8m66TO!n>txiC-IP!Md=5x% z+LG&E%6^ka7A!)$)s)E;1evHLE*J}5(`-Q^V*9U!{wwGx z7P7RJ5E1R(c04e-SlhI&N5s$TWHuTQ?SREU+R&1DDi_YqhTPt$;H7*^^S4x1HX7uw*lOzT zUZ);NzJho4m9P3GBF~>@C>>srYUefz>y*{?_P|}}dzpO`^znLvw0M$q${5Y7#IF~2 z)u}T{42zW1ZXC=2S!bh#N`09Oem<9~ME>fTEsv)og)rDZMxbL=nIgfu=f&dW<70ZX z?^#fe0W&duUcD0fSCYWjWSEuzmhumF7)kvb`pz~Qu$HcE1-_7%Xx$J@9jw)e<;wxFa(it5?WUjQB@grOL<{4fnLjlv-{c7?m#{O+#c zc&A6f|J7t~#bb+7+l{`Ap%Wh%C5V$)dS2|BeCM^$>2Uv0$>(t;gC2}kUzhB4lG=)| zfx#1kU82BV;tUg^t}!6`wO=$D3}x5bF^OBBWF^MJk(G&oKSKM4^PfXG-;6&9-J7FP z=+wzoHcr;9ZekcjwyVoxw2#K~X0q4zC-Ti_k^tsAe@Vc_CgH6IHvVBQICrB(5~l#S zKuEt(@;*~O^YK2^a=8bf)W-#zOd3qA<*(|^PyWt${Sh0pZR#S&!!nxy&YsrIUB507ER%NC4_|QF zQf;|xUR~n92i%EUtAJL8UL%7n`gFwe)h8+$txg8d>wn(1O7t}|Z;Sj!GPq3i-YT(Z zD;iXP5)7h~Jf6*bft+in4AvORK#i;P<&6b(VPoy|1MaS^+QL!{ zEz(kfpZa}CPi)Sk60kQ{=y|mvw`tAzxj(EA=Pz2Qt`px;Rq0=lzG?4kp|NUr84q-Q zvHZEdkIu7|iLYM?-PE(RQIpd;q_qS1PUpfcBdJBjG^9mR_{193&BU+mU-Et82Hk_G zO*HC<0z*&iYE!B+x3l@HhIumBX;>doC4ULe#4&zGP&T6ykE?Ma!Q%1L8l87!BIi2n z{E5E>EQ*(Z-SxchCxNdK{RX(9Ys=hIc@27NP`)5vDQfA)1EFWOR!bTzg;pANnXAHt zhqMcxiDF;mSxNnS-tR5Rhw;f36iVRG<85pc+ex3^%_QyokW9E8&Tk}yN8kk6VHD*u zH{Mhhxa{sK=e<;N%-w|%ytO*o{CJ1|wPbJLSwF(!O;vgH5(Syenh<#u!g*$y#6wNH zSfGkxaqP>*URhnqAy3tncS7X+?gJ*MPfKJ@Ahh8HQCsp-$n1h3K{O%iym4T?b4sA~ zJlPG*^f{8}8I0Eh&zW%nQK1NVLYO3Iebpan!Q#mDSzm0}t-Pg0oT+OV`GW%Px8;M) zV~C5p(*qHZ^99b#jS3UV*>>|V3__Shq%fbv$uklXKtay2Xv^5IQ)pmfd+m>^j|Fyp zV`<2G*80s9@d3x57!GppJ7PPEHK9Wrl@neFj;2PaUJc71_C17hCwvdF4`rmuV4DD* zG<%$Xwr>-`pXh)mf9ETqkM_;~ROU+S^4i8sb!elYLE)ziavqXD$spH;kj%6n3NvXx zk_`53B?ITkPTRT~=lpt?uWuU*%Ve-`Q;cOx8q+{i1V#@u)dHhWUZhXwQ=aho6Zn*X z9Fr*8lqmX?GkTia>Y8$URRj;j7lock5viH6F{XUUVoC(TUFR4ARiN+PN0*4d|s4#dB%){ppZ1W=LN;(V1iO zQ@epB5nwus$y~)rpn9h1?Uc7jY)67*HP?XuQD z)?{DOyrbzU_r3mFa($0S$_8yug4Lew0*-YA?H~fiE{JbnUR-Rgcj`wff#g`Bd?FdP zv(8#b9&2PUnNg0wz^fJh`k5*0%m zYs*jO2}@*CN}Pi^*3wMeOPLt6ys&f2@k4W^^JmNRuWXR@6<=ctJd`8hxdnv&xjs{q zu=N9?%z-%+?$OEiO)Xvz@8qu2S0#V{{2S+@>hS*r4BMZb1h6@t50U1>r@2e`(93oO zAMJ~OI13lAxw6qceO=P?id}OYpV61sq?1IhZF|YzA4&#?w(>_gYF*oHyLhzP$^!(C zma$_rA3si0zFxjqXf>K){qP5ZnwKmY~^#*5Q@}VF)7Ev~v zc2upY04*l^G70E;!q?V*Qtqhk`$-zo<479gcLm>7*R@HizhN#Rd`WL4f9Kgl?ZS`P zaEfP>TP*oE>Capnl=zUh)LcCuVhEE9BA_- z(HUj@!orCy7q4?ADQck;>6O+sBp=V#YoU3Vzj!^|7>h4j{0^vsB3}b^ZlWNx1dd7! ziI7ncl^G;408I!oigqV{p)laaF5TuRt zjp+w^G^ypgyVfTdF(zk6OXDQrw_g3M0sy7!)&??9VB?K)4kbP#j6hLHJUq2^Zpp&L z%9q|XAmewxwP{>erqa>H@6bbmB=vmnqFNxxHMK^y%qqO`H4(GZ_bRTh z_js}7??qQM)5i5%$Bq{gz;f>L&5k4yEXZF8{ps4(KKMstnEnrQ(|NxdK+&StjEjO~ zPLt#v$obr%>Fc3wA2hGxXOh3Zt!zJT8;ZMbXKf>#v?ndZPQknJRMZ&PK(qc>x^>de zkD7PV(?z3_D=?sqa`vZ6(3hxDsAPJ&o9Y&}9tc$kJdkOiu^*C#f9T090PXS0&W)z* z18*PC+X&m#u!!?^VHc<$E%;q?&a|t1M_o$Tlt0T?o=0FqQG(r35x|wO;%S(vgDi~| zg7Yub;?>Ys`*Zv=ABTAsY1q;9Y_1Z&l6Uwr?bpOvJhGnl(b_QMR*B&$yWXg>b1ag- zLXzcVf*hp!GxPS_#yv0iC{_OxIePgmnaiu^YyGND z=OHA_Xb*HVT>KKAz!0~P8b47Y#j_|vPLNAq++!x`YN`^x)8oDe%hX_l{J!|E^(;GyK>^u%J$B9P(Y9s4GQ&t#s+s-RCp zg~Zy1gN&s-*EvKQ{d!pvBjaS?!Vs~HI67GQ!ov>ALR?3s@IPZc8V_FJOic6$m0ZWA z#V2?TA9g45o$x)^lR6oE_yU^@Rtex^2##Flns z&8BbFdGQl+4)WCBir77uHYEBbKiPk`JeZ^3xBdGj-#pU5OZkolk9co0v4i+5?yi+a z59S5&$6RD0^WO!wS`IM?eajDfo_5#Wj$nfEYHDU4hYS{?`e&UGmc@?O9qjv$IwgO` z$9OuF_xFSu_t^V>JVKnKN5M%q6C?>z(x<35?|b4=)ur2&2>Le(tjl%wZ@{wpKcN_Z zb{xZ(Fuzf@{PlxC3b}pRH;O|STD>}~4yz?V9zHee*iGweFe4}tI(QCcQ@3#@8 z(DWQmPFMCvcLnFTB?c!aOBRy_WS7561WUoI62VlCuPK~Zv6x7S$s&}{tCHDIfEA(1 z`({bTUcPI+eFs9HnL*Jsh7J*vh#Ev&b{8-bOl*MoprsLT+>Td_)ox%z*GD8rj%TIF z^(u_jKS4;6zPZjS$qaXtXigB0NTf0OK@Z0=UPiM!e?vvm-~QEg@pY(fv$~t1ur{X) zup_}HK*4eli3wwdT9HA|simGa-xmK6#SjaD3AxgHiml|2sz%#3GccBY7)|;JpSHSp zm(X9~m%jx@`H*!2SVdBuZo4~%kLf*#$S6+sf8P z8%~MqitHzUecOO-TfPxHPSf`vOA87qJ(>Wg-^42I%jZ#0!7GweBC+-&5yujdv^MNK z6@2Nzx_^b-mOwE-p4h`0xwM6s8Xybglk4(Z*}l(~+#H!k+_y{T#Qn*FwDJ4pvH7=t z*XxW&&nF%WentG&Q0h@knmb*ZK=FK${fe)Ij$KXary<4^;0>XF*m=+nzNlwzi6dpv zwyhspi+1CvpJSlu8ZUA8?W|6x(`4|a;a;DghWwFAs&%=?ApYL78;Yz!`Y2>Ka1#3< zn2b6Z&wI8n{$jW4H^(u5tFejY-z4zA<#)=L?%&r1;=i*rFYH$boA-eCH{=IJ55o8g z@VU9KnAlX}C*h!^2lj_;DiT8{?1-YfH#+*zlC=w|<_)Ziy}k_>3FEiP;(j7nwq&0s ziPN6kH}+(;>KkT3vSCLybY)5cWNmG~7^f)z%`2fx)eqEtU2aopf6pOYyTBp{OWQI1 z0Lna!f-!(#)OlFZe8N_UGVWY*>|uFgnd^*6U!@uLsBQ9uu+yb)15PI+I4u(hc9b+c zUK5i6ac)J+lD&jz=Oe8XdC!NluRh{jby|@HiH}`9J41lILm5(DJ;^?c{&LQaJ330n zrydAYL4dX`4%!@!*17Hv+8Tw9jk^g%7EhmQ!E0)cu2p?*9TMaIh`1YL7l0ut923Ap zJ&@A7f#uv3pmzgHdelkZYd6899d(iZF5vJxz(eCRq(yc+KQkF?SERw=T7@VK7 z3%?3Q$QWzUHWSY^HayCKZP}hWDFU}5_wAwNZg6DbW5d&Bcd?XzG`c9cC5NBq4Y?(x zKeAYgyqTFD!fgL(M`dBboNpT0l z^ft0py^h6%_{4gZ_}$qJ?EEeJtpA9$_C0b4_H^qH=l7Gq*NAuN|B$ll-tTwk?*;HH zmY*otlKH1huGK~O$D!XZ?NvydUmdFEMo;E!>MFPQ^xJA zDDmyTdV>7@MDV_B$&+kBC@+5>ID?thom%f@^e1IcGIMXmOGr&a|H}n?tX6^u^K*U} zK0F$Vlht!m+TyJc8f0*eR@t}{uDY2eZG{}sI>DIxob^(h)P?kMX z7QB{e-+`k=A#*{GAu+5+8$K>xyWJfIRf+YhNu`x(MzeFZO60Q=x)zSGAq9(n^kc8! znV4Vv`)~FPb~Yx>D_N_zm}6IH?*HPVAC^ReVg{=d+Q=}K@sdIcD&WbqhlR4MI`>Ye zzd?L@$(Lg`!F$cnGyyDX4+F?<;FFde71YtxgDZh~J@nDdarR=;t_N^8-JBEe77ida z--Ks?N=TNZL(DXW>jA>WMwUs;(6-`IC3|nEPf8|jIH#;@*lK?#+3VYrwu&30`Pkjm z7A-hpeR?vNPA4gSvZas~&PI+~5NmIwVS6pnx4@+3k%9-k$|)Q6ANTd4QEW3V_7cz` zNofd6M9#%SImc?A8BI2Nunj$r%S-)V@SrT_Il{91v^P(SU7>i+-+sK2KcZ9K6^`!q zfj(O!N^a6sDGd?Or~2mtFoY$A(}q1nD>N~zTij8O_?aES%=nM}E^w6yt$1bQ>|gj) zTs=;jQ2KhwTl<#Cv)&epMq7d)Mk_XP>YaCyg?&2)@y1Q@>OtHR_cjqMd0Dty zYN=#XyUC5&3dM!P&%72oqsImImW|xWrsQv31fS2Fj^^)x(Sq`%6B-mtL<(vukhSb# z1BwJ82jdVlA>a^gxWuo;bvoF=Dyhp)cVxOkem3Y4@*uX>jl$iVMH0jCM{lD%Du10u ziVZwTk`%trVuPh*kku8Ce!{2`{pwKo-XQ9vaW0bho~|`UJ}cHV=pghF`O~T}@xt7){;r&NDtjofGJ$_6V}&2dQti6#xCF4- z5gZf5{?*Ti4w<`wWh{@MB-WYarHSBE>758xM)aJUlWdq@Qqlc;T|ldp5%?fpnCsJu zX0_%?Oy53e2ySB0wn}3f%e+|dTtoaw(=_~FEFQd)8;fpWt3K9uLd~%CKM?* zGFK86y2UXiq?zu8InY z5>GzFiAY}s?W7Me=8M8Gg!p2DtAGhm#FQ+}3OR0<-2g%F7N#K8axlWkBpr`u)XIIDHDgPW# z{^!f~{hKdno|@KWc;YJ)4-^rRXYOEINgh?j!LE+$+tKLD;G7WEnnPoMIuZO4Z!UtU ztnsGulsUh}ORd~D_vQ-QEM77@8}<|Z6+N@@wNeD@?JJ>Obz8;Lg2&8faI}#cMUpR2 zoUBeX<;h_8m@3eeZfe8Y>is4#LK1c!Q66WX;QU)8hL?JTCyT}QLsRqJz+R4;OAB%@ z?!Lw`5r^U9#goC47UrITBB_g*iE3iy>kR8~ghuCFJ`G!OUMqVJ`%Z~#Yr5JF#PPWg z>!c#icRTC4Vopht2>$5%gTJoVMGW}zVcdtIX}ay|({5cxZj=D2OJ6SW#-=C)dw9W@ zULd?b=iM+5J7|JBhq5REukE6RT2NMu4J~M)axOY@Yh&AXMHcgZ zA-iC0f(-OgU(WKiM{M5#{%XO3xE&j>Fj;%ml-j`4XCqxZD_rq7??=mS&#!)%{x)yRJL{n z)0G0pqM`F;BDlJVuH>slYn3D}d6IZ~hpif*xaxe8ApTZ~%c`ynYc#$@$i`y+zwuhS zRj|iG=Pru*IX_ZtMhT;g3S;!V|utWrU2E;)VW+?*!%R-b3k(^{a`xE+pA(`gX-qsBb|B>q3MJn*$j& zS6ty&Wnb2RiD%5o8^^sBOamveZ!q_pgA<{>2TOy<4E1@`XnjnFh9^a(H<-}j zu}zq#du8>9A@qI(#uM2~{=$oj*P17Lefyxnipw{51H(CGUBRoiUl$k)?e=WA9)vgv z;<&bxziE;ekyg?t#hIyj3R5AFD^6Nc8cSJ{`L!VsYJ1|3Dx zh=ZU5>Yz)3UTlyy*YenD%96Wtf>a_8^z4-WIbQ?+(O=Uao>)xm{UcIH`+3rXlIr@V zk%sN8&cJuIlSsS>*|Fi9Z{)uVEZ1^c#!Bn5^M5YVgVb|bo$QT3=E}j>CaVW^uU-Xx z>nX-8-Qr6`W0pILg?csU{oTNtyPB(4gAZ|xh1A|P=j{ajyA$|Z<+^D6tjJgOcscWs zW>aHdA|ko$TBCnW+!Nb>r@X#Q{({CYmBj_=2LwxvR~c91D<(FT`ID|D_w6m*;A}nA>It*GdG-bH1z!N0mup$rse?*FtA9-z;$v8kQ&-{e`K75P9FfEO0xI zxE#N2zRBFvjRwUMk%BZ2wM|^LqJG0Mbx(jtahkWsGN9fLgd97&fUS*~WcIc(efCF< zOZ4eJgxu}q2uldx(JZpe$aGgjo0hE4rWX$5c)@;>>;;jzRRuL|V|nbKl~C$^7-v8EHw?XwH@kt==f$%_Jw7+_4;kJ&(m&dZ zX_xU9xmsVfK&NyNd{$>|<&|_xiG=uDItNhZH7{MV_XJy!`|-q4*S`r2;mP1@+_T-l zN~=l!3j2-W!%%n~v<&xmjMwBEcejPcYSXO`+D9y_pnWhVKeIJr}kg54<_) zzEHIJE#Dy_k(9DqI{tx0`*+v>+$+ajr&|C>zot0OFB($x{bF9W)Uk?lF(yZg&`y7j zx`ZWnwy&3l#?&)((p3@3b_54`Jp>uAJGbsoLwdvgb#d&=)&cb+k>u z3d=(KswL=eJy-rMNdG2*{~_i2e%l542PDsxzA2djOaDnX_I3YwP=3Aqlkef5@_3vsx#Goomi2!kB#=}i)vTN-d_9s^leMN8+&o<(-4)ZF0bj?eBr#?3!w$cyku&Sa-_lD zk=n4|kXumNvYYlFxZ9J*8ui9DwuQ}^R6#b`Sb{cYkEuSI1-!Z~TcB#{*0?mm56Ajf z%JSM#m*0N^u`b&8-t#RDhCJ<6DY?n)d*P8sn?JJaXkB3{{%KqI?*d2YpBv*4{d|yN zFQ{f5G}_7FPf5P(moL6<@!Die_NLxj9a6mtI!4WhiXN5Lpn)caW7^-qY(so7!l&*g z8!%=)hDJleSWqw0??s-ZdK?txzD74V_TMD%Kd@XUK~;aE>zyyGRth|{vo9FlEY}hG zlsA!iT2?o*zp3p9%j*98bHYo_SNTWm4jwIX4^f1TOPRYp10<@eJ7R+GCW2S-TPArI zdWswAYEe+F;~w)Qxmjmj$ZqfjevAIFr2SaRu*Nssd!b=tI5hul!8UW(D%u#d;7IDT z2~!8RW57-mqU6oE@US`q?t_8Bu9BMh5vDKIE@1JxJx(b`k;zBeEw~5n&NuIx7Ek*3 ztaOsYJ{~7cA`J%b>ulyAheM5#t51c+JP!PbtNLn@j-3>c+P91O0)8c0FJen5G%a-a zxVVM;VfdER3FL7KLn#}6jFl7S9+LT8F#Ic_Lz^g?n}3HV7`jz8cs*DcDJPb=uA)d3 zDlTS;$wkdWA+u9B+YziD0zu+#VB>_Po8SK(IuWeC<-sO`KL@=l_?}3E{KAR9KI{dg zO0JQr=t&)~6G-X&ngXJhU8mgrK?&F^p}+1T_QNL}mu+QVeZYbC+VQ$>ula_Nv}Yr4 z_c6VMu+aOykK0XibVQ*@x59jOn$S^%H|-S{K5>J&6)xYxJM^B+8+)ayN0NSiKhyF| zs3(gCMcR%mwXH9!YwbOiE%`UHguCXvnaqT>KR@~s<2!1aWqTyb^6-hlSER9{1tKv^ z4(TlY5r^J1T6A^cgG8$6>p*w=_`reM<=zWgx{V)k*iSetG z!QSrb$b3-_$c7(I>mnbVNGZzs(cKNa)R^uD)}OLal*di~G3@#TJ3#*?f&c5ueAq$n zVi~;zwS>B!Jx0<8MIDXj^CXeoUVNwDeOuoXeWC5Yw0w2hkXratzMl-9vw zS$hn3Jb~t@ZN~brx_sLy|H)h4HO8g|iMxPTzfGYP(fTA_OS-rl*wPOv^56q7iC&Wk zR(uiPhl7Gqb>d+`)&x5!AW}NNWTK`)T|cb}nfC{)z+ao+?Edv_*blK#Y_+T8ud%Sz zp0vE($F!5bX+rq^5L*+elftKcA;tGU!GH-dRf&q!Zw^^wcR4xy2`rUv%VUeY;lWSX zUTZ}k%Ht$z>^+S$Y zI-HbjuzJbhjZ!9mNvuqlH9z)7_cDCy;bMJeM#&Jzq%;o*jdZx;7M?AV5{ZArYoRN($r84>k5_WvELq=b`fG#l>Dx9o z6n!!5|G2e6(;?9IN*J7Sen3I6P+_qOjlvTI^rQv5d0+T0U`djj@@_XU?9uN6HoXl= zU*@|#Do*fUl$Ko1s3`ljnjbJeRteuZ-|~J~oG_Z>LjkhgzP4^P#=7ZHtTUO!PbQLT zO2Umy(MI{k_ZzFjJux{mbk}QpHCq3X=Rwt<5-@)l<}E*A2k^KTYG%DQDPz zpk=N>Lj87c)JJXdrVVSuV8r?`#PS`$NC*F8jq}ejgUIR`2qT!9+Xc)Ym9STxRmLZu zC4a-NUllZ*?J)_5o_j2neke*km2wwTGC#55iAYqj`-8c!cooQLh}Xe70sJl@4Eyn- zZLj6uX`i2Ed(!gusof78P1;8qPWp;A>Gy}&n#9-z-c^mdFAOpvO-;m0=~O1|OIj*QWeEf9Djm+RQ>W_GT2?DoO=+4Xo*;T8KUHnvb- z`zrsaFdx_c;IYIK33(ilT69-2!xuW5tgK$k6~0`oQrVwt!S!Wiw4ckSwAHi7lYi8e zn|B?bKah=eS^N%fGkQvqm52%7d)+NbGFTn`S4DfAF$qILTn&HJ>3gR`Z2w}k2tgG* zyBpXdnS&7*BkDz?9zBPjyb@X~${wTtyA$|tmut4a2R)KWQHsGZYwZ#7q!;btlU1=|K;l9B)$84AWb16*C3V7ts6IN9ajw;gD7Pmbc3i* zb^*hPOWf%i6iMAV^p3vC$y7C1{~eR=*uCO_weBEGbhnbdu(s{wKrSMkUBF0b_ov1Y z8S3k@ev!cI)1z5Wb^%8zsbf@xy2k7YK3jVqQvN$TMUEp5^t-~4nEX9=@!}-vUBBk_ z&q?BUm>qBDwR+h8=FVp15%_lZuWg&UOZZ0RmOl(@@H%yL*qXCf-L>9-uefHx9_!-k zpDl|IVya}YJ>E+_rAI#$GAU;89l&OtdrrdjtDr5nSFk*N!*xI7fnHsNCl{S%NVqoX z>y~X#fpGmp)wW7{ZDn75z=2wA@0+5+~*&_Czv5SDz4p|{CH=FA}jiO(->ANv7xe`NW!9l=q1&P!q=F}kS)87vw`k`PW@ zC-vB8J%;2|vYH5v&PL)y?UzWJyygVO;XjL$$^8!`fq(dB^IuY~lj!#qSiw)Lr7HoQ z*H(0}Or=Dqp7Q%$>|S{H%XrNExBONy_aTuljLXi=TuQsuZJ@NV=|V@2-ln^DI(lDX zq{EkovXyUYarF&b#WL;ZN@niWH2dW~{8g)ew|sMj1pBc77AVF~cQ{>#-Ce-&s>Qzs z+3t`O$0P?z5)lb9DFkbH%6K==%F4#h-9d@;7gO*-w+dx-N(CeEst{`DzYa zSwQIP`LJG&b*xz8cQ%KiFB8C8zDW8CNf;+T3G9->*6s~$_|6dfyl(k3F53Pzn_`_M ze*NzQ`=i!l9{`<7ocVPk`0`l}oj$%ESNFWl4A%lA&^O6nMnkxoE)&B39RK%q(7xTp zh_qk*>|%DeO8TrI01}=fB#4`dOfdB)MQ{jKQZG5BUptzAK%NZRuh@SS^R)yb>46r{ zcySF5z|L&`vg`-^WG1g=8-{7~xyH5R*n@w)WF~*(Ie?xfqvZf9=8ahT{sFa+Cmtf> zK}0r8JXV|8pmYwtspV#oEa_`?MBo106o*u=O}-oEKrEg9>EX-kd`ED!o%MtORg)%$ zHD9V^u(;>JGy-)0+5py8^#_xvh0-Bv4-<(;S=im=0__utsv=$`K zHNFzbH9d2*dt-ZB!tK4I_l50r(WUth7s4yushnD5{bb4f`)Ru7?)myi^nbX#xjUgr zhOJ>Kpm6>{Y+~@{Bj`m?>^Kg9O20eS@xud5BIC$D*@_LUC0>yr_F*~9ADS5Ux}A+Y z8fYCupJDwq7{hb6Ky+k2p<6eGO+9{IVj64xi*@f^9!(M6t#UkNRVHG+YMzJ^`(a+c z=R1E7*g~#^?zPBQxN}WE6Tu|;tLq;b_dCF6;&*;G80wqv34wQd(RE51hG&z%BK9|Z zNxk`6XiXDiV|Y*^li-E-W72sU9XfJ?X9cJ)3~XEoZC+s8yG_?H#pTWC~LO@R~AKvm+eq^lVBwY)266Q9Cz)YBYg?(VQM;#P8D5x=*_Qa~H6#%OWSrCXVBncqhr= zKlkAwjaNatqmbRfYz`>*)Dl9FWm!OFcfv_V5NO`|!V3{<;IdQ2tl?xV{hnjFLp8w|IEU z>ibC-U(a0FM$^U17t2rUZ`;dUxNqvsmH#e%r~Hu3-;4e~yzDNU;G2kSEQB5z!`;LZ zj__#@PSNQ!QiYSV+6C-&+M}e$pjYfM>Vorok(hO6DO9eabUPac$eXr={@QqD$25*a zWr(rs2gp3icm5jWU4yd2>&8Xb8DDpLgFV$#=U+h6pv#)KgV`u_Rqei5ezb#=_u)zNrzBz346=mU(iJE)*xJv$-zq)scUzP~|99GHTFBP^!H&#*elFQ1I z-M@i`=*Kq`!muB&+AvB_is>36zigkMTkT1+VPT2DNPE5e*9Sq@TD+qXy%65d1c!{l zT8Wy7MF*5Mw@SzViv7m+w{+~px0Ej^8RV<6-^d`N&3x3mRL7p|8{1D~f$vPpZ=Pfu zc2;J-cSmN0Uk`Je&%6QCP!hMF@SOn2&c=pHAD(+rNIjEnC|=S6=^}VWMqjddD)56_ zb$huZINJsM=iDF7kZLEKL(16AVUDY2mH6~Avd6gxJN+IYNdokOskME&8#wE45e7c0 zawmoDtG=sAHpeBJE6#+Np)Y-7yy$|J+j|`}zJ&SVf<^luTduRBASaN(t#J&omgwt} zddKe&^2+C2r9NS{{j|dWtkSwr|6BeEW$XI;TqhM7$80J!;ZliBzo@l+WNK7W6G&ry!SCAf`{SVrhOvW`$ZDLBlKs`wFm2`p}#36)Jfbm{jTPZk*t=p z#q4--=6lX zwjwv$%$K}v;<6iqFeV+$1q&l7Kk-WFgd}lB2$`GFp9_bK!6=EEmNiroPYUUGWH+_E zE01)5Rv#&8%cDiwv@Y^w{nHz_?(AFvkh4m8EnWEHjQoZwruR!~Ds7`pd}Nxa z1*JaGJs9z3K)&(ds?_r6K~H;nXit%U-|rDA3{(D@9)OrdI+Ph^XHfBYzHtd$Vg*h zYJ<=LenFE6%}6gDDDDMl(Yzgh{ru)`->DCqZRXEz0(dVWe8;71Pnr!2 zS{N6)gce3knP9Qqpa&8pA*p8cC7Gt2Lc}wL;N*qWGX!e(3(a4Yf7JiJXL!tf*Z(8- z&ouO|rw=1j==o7u1Hu|Y882rf-keQ2dgQDhFWFrA>T`i>oe@dGJ?L47EMj-BaYs+C z4d|9#MVt5nZdkf;*ONaWq&h3V4qmeIB$9t_-pBVEw$t-!Tw3G$mH49h8#{uhFTTDi z&Ii?`p*CIr@~fcPcq-cceUH`B9l#R$8a_#EW2cxZyBoNk@or4=p)cw-E;iY_p4Y7W zhe3MxaS42CM+7$CB^-)pcu-+DGm|(3gXHN206!9boyjV}2vQeh2t!|2fXz|DAg7 zs;Xqa32a1%+Vy*W5xHLQ4E3dcTvmoUKW^K}U4|xy2W^gopzYommjE6Gtvr6J^`{53q%Y2gM0C}4PFTEI zxM0Ns#Uc1XVBOuNo`z`KU#b310@$~$kGC(c+9$=RUA0BBWN(c*XfAYZ8cY6Wbl+!A zCp^NBzGcnmo13ml;q$g9)uVb=Lw!fhcXXhQY;qp>Kna`j9SMGU+trdZD51g0n5fy( z^J7nT(|^cPZduNY!Z+r1b&@^N|I^P1@J*YjNn?~;>8Kth69EBPXjVzFCAM;|y)Mge z53N%%N4%TdM#a8Jft9$M;y>5E<2z3GcY#}cLtFIE)U#c{x-ND{@a02DSSH?mpif@~ zJ?({8LX!cTq6cL-!EHs z7vRN0=PTu^L&XT`ZPF5#-aT_}y z28>e>a~JUS*YMZ;zSjTqHk8e?VGh18p%vgmo00WS;G@2E{8Z;lq|Ixhm6O9ZNa9y) zk3y1@C5J!M5Fd75dwpkhw`bSP@l@_4YM!e5jR4tepfQSvIrl{WPVouZ$X5(@=|FPL z0N4H91n_Sqgqc4+81Huk%d#y({}?W~LdFE$9olgc{L1d%iWk*MGV6p)xg0?KstZW1 z0CmtCHg|+bKVO!&I`C3KPaG8dmG~p&tYuqbt=T0jU*5>(6vo(#DqhGEZQq&HwS^_+ zdv{`QVUXGjy~_0wD!N~}HOT6jV?>lPO+>vW`rX1eb2j>5WEGx>PSRPs2p*f!B~^Wo zQ<^X|CY^oN2|e6d{WwPlsjrGLXymkC+DtDQ>;twsGMB>W@Iad+DV;P?HR~UVx41rM zTC?52Rk}Eg6B+y6lG%>&o5=ro1*P_m2ko+4LFV|af<^nkpj_ul#UdCgR_LNohtI?- zwdzq%DwJXt28Lq(*vRgmi~l#vkHzVKeE_N(%s;s7EQns}{6AS9$D%3tYq93 zV=Ry=_2j)>!Re8Zeasm)-d|_@Iw~K z-=j5m1KU?RAJ^t#eiQh91@!saG@a}nXFz5fw39{tz7Iy-Sw*3EOZWbQKC!5$DG4VTia@ZH12p)}l?6+>h-5dIeGw`DkG{j7> z2ulRZD4w@mbLdGw$+ZFtRCYN)rVCX6ZUQ(cDcd3>BkbB@l)g;>_Y=ZG+SqK1y+2%^ z!899_hY4Ut6QN1rNsqiN`NP%lxl!Nbu=t}EXw+ZO{?HDxRbeIuZ%p09E4)dN)w zPvEkWDaN(fG~{wA^K!YCtB9PwIgk8;qb)lk_nNR}@&f(2Ss&kPe6n>JA9KiQy*ST$ zM*Xo}z>KC`G~rQUYaLm?7!PDQuj7E^_0X}yNlE7P`JofPD>>soNzi9wpN^}HNLOQ$ zDk0@R@~A+4zQH`PYAN`pf!f%2@lVTja-H@1hkGs0BV&7!h`oVB@~1@7qZ9tqMDUar z`H6Y=Zz;-g!2n2a$uAL)pRyMcwFFmNSdz$JEMR#7TJ+Ac6I}cMka|@(@NZpm}m>s zRzqLwm~d1RnRYt?tlLgI<(uTKVI-fdAhj1^({GN11TWAz+bT?6cCR-KPnY!{n)*gB z>YYF4yT~QHdIj{E2$sE9K;Ma9$W5Dv>|2BTGAyDcBX)Q`p{;iRo-&5E+zC9)E1{>( z`K`t$h;<)m;#X|nUL}GH%(y=cWl_|igz-V5!#?;hXf0B_CN5yLZVVyachY+WX~S5i z7;{D8mq-coKB1Yb9&?IdynQQ%MVs|2Z|^36*#_NN#b#Sb8%{MD1D^o)iWH5Fk;kQ82fm=Alus{C4cW&=>-n=5PZ}jM zhc*~*WK&q57&++6trdP_HWS6)R-0Q2gw9IjF2!@3>vMDjq|&3L;8g{LksXy{x2UP{ zkbAe|0<*)T`br~fQePz46>6?CteO^yKL#(S=L&jB)&*f)gds(u?_0lim zqbvW>>{9k^L5n1>jbYK=6z`O4(*E;{_w>4O6%f+9tiM$fx0_(TE&<2^i(p?5Q5p-d zf6KGdy21RT4YbNX!;4=k-TTPDB`xd4`UAOt%RjFC?XlXrm%YC6>94_x0ts{?Mju)* z8ne?%20KKyi51DKmiZC!Xh3Um;bP8PVj{szTm4jNQp!g}z(*YlF%hNB&W< zyMWbqfyFDJk7V#Ab1A+&)MKB&aLYeHh&~H+$JB3GR zi|Ph@U9{`zh;gHF+_>S5q`qISF>_;=3}vncq61+cm^pj*oPF*)mW1g?hMAWhaLC54 zU}k4)moTF3vK_r3ZPvi0uGu~{#}23M){fte1h5lynA>8&A(6E-PA4NDV^FB(etJYc zH;DzxB%HODwa^BP`_?qEtNFeBrqVtfJ?f9yj{vEIB_+J6V^0=q*rqcmVHdU^FA#$h?;JwgfEWFr8~}=bvvC z5d9itz1x-9m}0r1ko3JO=g~E(hW76Q8zqKg{S7Tu*VVgQ-`oZ4svn3;_^P1%*ejt` z-y%(ks)X?NZs6cb^zB1AKeE?AOL+$6F}C%Mb2=^SGx@E8aAJP!}k+N~4 z`>ArBDvxko;jX@2`bq4(7P{14}f(bo{q;%G+zv-#73x9{hc`P5FnpW6Sz} z^l$mMEXse+Bzke%{l$VsyUW|P<7g<*F5#H0K#4R84w9p+kE_I}lk&-0leA@C0oG2? zCsh{{OkzuVyMptQL`vz4Dt&*gK1K@%lL*N^CDd&}v>WOHCN=>Hib)#LetKHua__Gz zhlW32j{Wa=mM{7FJlD_(TsVI93g~nFe?H_IXM#C>1+>})e0{;z@tQkiFE8V`>mH0U z7U3{?L(_CSsXt8wU-orU_<9YrPWZCqukL5rmS?+$Z4iX+4t`r!w{^;o#1AFx2DZ3r z$HFnk#+>icD_9b=b&fOFM4!CZ^5;xsK2!dN@I0Ms&RFF*u~ zF=F$9?ZjoV>L-9J-sB`Aj>P&_ilrRn;I4)iaMMiJ%ASrdDB3FrI;B3H*wWVE z0WhdcY{KvAKTG4Bc+$pd{lz1&i?HNvksCLYK_cgs5bNq>#LqkoB^`Qt$XES;j1~i1 z@0ags`H$`IfArs_Y+dn>eiZ$@VZufJrnwFfwFDg=_`FMoi5ICjUMR~WQ$l4kl=CA7 zM=1LyGLz}L*d+{}fAPe`j&*zhu+}En=^8OuLWg5)+t4}0b58h3%JTPrBRjGX`Ucs% z=o)i)MKijRh+fNLwKl$1)P!x5T<1#u3c3HA!0qI({si>USL1UH%ZQ=M1sG zb;BqUcyg6k74j_U>+vZ6Bs5(vD7NpFDT=pQmkd7wG4&X+gLg-A! zbVB$r%LKjKtquDDE;1+s$edoZxDzs`Cpu!8dlRkY>qVhFoKXv7t0w<&5H zlMnOp2wAL^@q;t+;Vxis%CW>pp&Kr99tb@(bfBK6*v>N~uR{8o^r|!FOzwgW?e%yt z<@~eId>8N`mWkk3KcCL%)W`E-Rn-5G+6g@8<_v^J@E5K0<5YBCGF3ZdtK=-%&phWl zfpr4-|Nj0zFt)rhUGdq(u&%e;2Tmeb$s?+FBP8;%#z1J_cVEy7ujDPAYd+D9Q`EVN zg_jugFBz|jbG5+880O)kvICw!8<&nJud66@tU7FUeeBnv3LenwS5wEl?vcAaQH^(Z zjF83Z@I4!t@~xg5t{bn1isFjYVnEoRu;<&?xkuO%RQLZk#jpT;OFz7$4_ZUqvxqHN zGGj}9jBA|>?~)#_gd(rm9jXU-pZXoTZc-6tLdcY+A{z1fga5T+(1SY{VxeB>4TnB0 z;2W|3xo1=IO%%+2XVrJrM zgw9WTs75`)eUF4ntrqhlVSanks*>o>N#VJfUuyRcm!mf@dEKd{L^B_vuF+_wBd`VY?aC#kzu`H$1e?YmEl z7CUcKup`2&lC9$~i^0WY@nHiKVX+qBf<>IiQm4S8F>a(F63l@Dw(RZF7%Y`kPO<#p zjIz564jW~kJ^3ih;KhKh8n|WaJAw)-Cx|brt`lZCt-#}hK2vl)xT8{ev9sjwRDC@> zJG+2K^4I+SFP%>V$bPnk{if51WMFlF%&w!fBPbKP7_z+X&Rp;KDv~&c3E)w76UDL& z$>*WLd>$GkRyMAztFGWk@QEVz8`M}tm>n@z=k+QFsS($lgZ=oN8b(MA?;l3{JTe;)Ew9zzvHwHA7)zRArVF%=0CH>r^|z0)Jq zYcl|zK{Fbe2`1wfL<-bRaC$~#-dH|f%g6yekCz;>F*w?J?7o#qHyI4G|72p5RT{g1 zqu)q=_by<7^}XLhi%9w&HHBQM^0H4MORjEdR8*oY^&x}mTl??n08^T1Gj zMfkL#zmFd6nosoeo{#_R4A^Y=c7Odl)fVVpXWh~MD~1V+dc@|-FicgOw@hy_7OerT zQ@J@c`xG>I3JH!MV_^h$)dF<uftEaA8OL1cNQwiq#Qn3b5X&%l1ke8ZFDQq=V7| z^d`QsQ8gZYUZ>9%=(S1~OJ%!mM$4Yko5|m$UBIV}asJX-f7RHy%bf^({*Y*wePDOm z$7|umoW_3w9M*$!z5Dn06DELpa+mM;?d%Hnb^(j_C5d5h62YSBq6Pp#m0U!~TO!Pj zgko2K0IfvuFwiG#srlCdbY#+=y$<>~eYG>fB9_*Ly&=V-?pcc^qrQr;bw|J273}pt z7BA<$mX=yXHh+HLAxGnzKGl<}5+b*bx# z8xbsxn~&(vG3T1m(sf$2at;M-?CKPvOo(mhy;*;IEszl2_2uv?5D} z&ls`yDdCf{WmIuFVIzt7 zr|75l<74;tw-YAZ)fS*%!dN<4SVj~1M1_ivsE*J-qKvbVfu6o_9z(oI`uZeH6qwSd zz{+EfY;w4Rm0WQ85`y_)f3(LWfB`P-r9+Z;l^OrSyb3{*SjP-p?~EOSwu0<0xf|Fo2UZ^nEGKPp+Ui0JxyT}u*OpXDbPburx)tV=o!eD*WY=K9;Nd-kxXgHy-6?h-mx)r%P8 z&@tulBDZ?HtPLs4<^*=;Skfifz~()gwWCkiMJ&hnl>b`!ZXA7mtcuV1ZyCidti#GB z?Y?%-nvEd6%Rr^$v9YUDh}`(v`cIrG$|B}ImT$?4!dm(^G$JA#Yg*0;)O_ifASCre z{G~V7I+)?kwN1Lb>194$)4tI>q6uL*t>TItyPOr32g_?_Bh(41z(-&6q_B&6#o^gH zc+0zhmA+TzsmMz*!kq=9dPd~Tp~17efXC2^PeQkjUb|-OdGeRf64u&s=d|C)e0t7u zYqCt^u0P(_algm$L4wcx#^t5o1kSJ5SSUDhPuA9XnwD;}%cv*xDM4p42`G^V#{4eu zp%-ZzKHDX1WqvF%jxzwV`1O6n733zqpE}o>fAm>;%a}Z4^bsqxE!;%D%fOq7-XPm{ z_>b1;Utdbvy$NDiUS0B~5!^@y*O){Hy(6^EVPGax(5^am8TUHIiRP}o=jgE8ND8|< ze#OD!F!WfIlcpM#hCL@HGd_>@Ei)D_<&$-F`W!G5!UNk){zkii&l>v#G=Q57K7G7u zd?zrlhh!EnUTx@8y8G9a+ZQ>cGN9y?P14unsRZz7r}$j7*$F&(LJm=x>mKDOW*Gim z8Gx=(P<9nM>mrJ==_q3bs9~3^X{+O_xUm%&d(D`a6Lff@CU~g82oB7xcy%n~iiy@f zf(%C2Yx||~I9&aYj=X<_r0ec3VDOOO0WpBoLNR)(iz2?2VeGU_0LSe$D1ZVRX(z=U zOO}f}x_5oAf$zrP+rSQ}BUUmI8)|Fz zA~dZkXdy``vA-)cu93t!a}nj^Jqf=Th<^l`we^%zfqUHuC~aQ$`@bu@fnPNhjK^Ch zu78zXX0Ln}{hgXac#Or!!9~X`=<8F^{?^&6W27zHvocS~cm7tpfTK@Bdx_yAPB$?; zP~|RQaC%`rZT62FpMvInc#SU~{hi-X4y4@v`6u2P-2DtRFFSJpqASYki6wQuLX4_l ziYeJrzNqWoP|JHE<_hUcsgm*IX`D=9on#m2x~F3qQqdDH*#bG?eMaTU+Tw(nPypUY ztLN-L`W|}gINdZ(;!p$k%s&9yz-}AJJ0)40XY96oU;F8a_FuigyszE=F4+Y&9{Rn~ ze94ln$B-Y)r=Ty@@QA@N!23afj8q+qPd4kLNY-Zf(k{#{DAq1$u)SD$zqxJgz421* zV7#k>EuQ!gGk-~s;FQ_?&}H=(R2eM`5>{?wLHjXsmdV$TzYws0=Xr5U0|!U_ZS4XE zI0dg45kr6cK{g-tC!m4FdBve$PP8b}mzXs1tLxoFFx%Zb6TPRu^F>~o09L$V%j^mc z$`?L}`9e^D1G^KNiCi|*^qB7_?=2lhzE8oJN}KW3m%{voytpiLo-&S(g1%m**B(?n z!$#MJzIx~K+JaBM&T4GAP0uYtZ33fegRtg#H=VYRN~dF!^=)G=LxJnh!Nsn0b#TeZ zj=#2k)A)W2{yE@48dmlRcjjiusRRp_daPJlPi?E$o_?5_Sk{Trmxz8e_oNg{Rt^DzH@6b&6r<9Q;RrCw*+}pK;3lpwR&O+me!766EBcGEY=^#jt?Nh|!IY~y?C2%Z&LttytDAsTF~;XWCf+@{mQLhd&|4{38Lrzw zu8*Sts!+y3ymGFCwGDqYq5>h3HbZRV_kW8-a1>A!r&%`*o>K65C1tU2*;PjHlAB>d zQ2EKj8z6+L1IO>$db@yms5ap}5w~{!g1_ID@2s`mbQHXNFbD4L0v46(=xEz+m%Ex> z+%>Lcj}+9HxO`H&a}$Dj3Jtt!So1vUrmMe{mRmtE)&r*vd8MoQxB%t=44c!S)*brW z>2=Px2Tz-SH0Y_p{;*SOn`i>* z0UPi@%jhA@od7oNza^}1nV-LeKGio5`FZv(^(J~Oz{YMTRhq2uRfcP!#w<2!EK;N6 zrh29zu+YKkwxIx^xBd<63A^CwM7~OstkTgf7W%D+i?a}w)!$Yq^^hkzQaLo$^}(pK z&W^-aKA6}io-fdWoH^s;As|cyL%j?5l%CJWbWIK#_WsnwgC3a&{&B$Qf=qeat2Iuz zgO!qcl?Zlr2J79wY2tSrY4{G3z98k?x6l)Ou6vfR=JB#@$7#`)MZZ`kg)({Uy_9(m z9WP3bzk=D&>mo2!uovxlDM5!PYB4YNCJON-cIAE>bFh0o+(1>HY%GO_?$%Dfc84PF zXv-HxpC9R9VLB4zyE}UcU{7ydC$V+I&aDt`xpvOsT}w9&>!7Q-QNM`^OYp0Tv#H9xyH!H8AB(cvD^3c zA5n@q@<|j)<2BgByMbqw@pPR><@(8cM8mv_YlonG z_!-~;KYWJ(2002>8AuJukau{5TW5dGZ&m1J))nBw;_o?B@1vU|#O+%I$v= zI{F)sF;LALH5D-YP2lj>5@Ppm7SY`X7i`rLmNB+oJPts|-wN(I8Yj_JCs6;YNy)Fe zmMl1Nm|4wmZ6S0!$R@t|Z2uWV@bl>B^v;LrM-0X@K|G!S-ql($f4k|_$53IRlEa=n zL(rDiJErYVyefSI{q^0jyH1MWcAbWiLcGXY3qgMZIkl2;cR-=}4^lvltFOTWos%3W{$ty87D2cPe9?$ zUvxDSnB((766<4cUG1&V`QO1G`-+d37j7c>l>HIdUBCe2+jyvw!Q-XJ^#tO4<4Z<( zhA{!KMl}D1YXCB`y+klwvCf3>)g$13{y91hJ)IoZyb)HG%KLJfVNxhvZs=Q2_%5hZm~q>oTRp~6m)P-VKj3vI`OkCI5^%W?robUUHTxqxMZQXLL_9DQ|hNERQv=Z*t zT=cKc#Z|%uVTI2@fQ$G@o3kF9wRhqu)R%7A-t|<~#y`kgHVG_=!nuJx2^N-{POO|&Zt1I&fTOf<7u&5_iPL#KV0RRfD9Abmrcibl=KDD zI+z~j5yU5ugZ8q3T^{RVcjqcIFUC47L$UKW2r^9uv-rYYz|JS3TSoxb8an?t_!?`b zrrMzALY%hEh~qLH2N$Vk^RTqu-RE0d`Vu?`mNUCb_IlY+33mZwhnLd&QU*6=YxJBY zm&QdYk@MdPv!%qG0!bOUgL$0`7q|+L>5Xd^x)$LdSz5hJc=Tg?_KC*5jqV5cw&syr z3^}!#o(?s!9cwEd_la^7sf_mYQXPs#|L89vyeWUkQ@G}<{by@P%uy~F-6N_aG;+Qe z93ehxW|M|4R=Ui7>7vA-aBP#lv04;Mbd=16PC_T9YrHIP?R>!CZ=t#M9JE3!ZIIMk z9ZMWvdb?%kUy8o|HTNG2ynH?!jNd!spMySLOdS(N?gF0C%$FK;%}f0fPV5FYFR@h1 z@yNaJ+0>QE;ekA{?havgq+!?D6?|cONn!I_!lDee3{e?One_l~ zs21x&i>TnWtsyMAKH!T=p{36dCbgUk!9~2Kxk%;R3c4$T-0lPMwoS5l)2?H!OL^LA zz{Lui>tTfHEIL}aU3_9^sm=G@)$F|}R=cjrzGJd2-15CW1stDEBRlgOGf8(%Sm?xh ztMP4~hog3P5*DIO;I^gRJGJ;nh{hnbV#S2H3|p9$WN)e$20aB{ zi8-7+T|b$8H?Wi@J$B)FvXZUxhAQ9r34wi;Aoko!5vx_ZfE^yUFFpee#jet&#PCt5 zcm8tuyl5H^f&wEMQ3!tqc=fW~gB!AcFjcw`OhAk-`WltIVP`7U{SyHXFvz3Gq8( zlMDaoIqF=*HQgS89j8Bn?gvg`rLEP1Ek#OcbyYoKZeX^Fw(Z{wi#BCX(#-+=%jvgv z$I7y&bCt#wdAL5Z>%QLoVdXA@8#+uEgCp>xs~M|xTxReK&`4Kz?18HWp2fiq*HvF!_#WD;Uhe`P9>yP3 zohNrDpgOGGqq^%R3hw(WJAl2U?#VtiX&4E2{idIT)<+K$B!y46Q!dKHVkekQOvy*=JmbENFsNbz#8ACnSYQDg_wRN$lmbDq#FgVP3%)p11zYfc7d%$}oR81RMA4g>qsVd8@#-h)9$>5>7w+mP%ckQvW8+f*6;^i2E znG3xSH)nKLr?$jrfq#Y+28^iw-QzE7S(iKpR%~mbqt6kZ0IRq<9#-oO+EBSg&x|M{35Ms;o&Rqhvqx{&iw)ZLMu`pTw zBy`q0(sUGvXXw?ThKO1~uMqNayp<7;ucOMC@uHHtb_=IHYQ|y<$r!9z_CY zH}H5cpV;u(fI+!n+@JL>;8%-q0*T##Oej3+n4`2GH`maGC0$cJuz%m8m^X9S*MGhhXt;P&5!1@t*c} z;&)ST;Z%B2@1DIsvCb8y^tZ53(Cc8+p+VyG!W#h^y%5j*RiAaPcl;j7;^-eczE(Ip zJAzmEY#GIho-+m1DXdd252qyA9eXkUoZmFd;O_=@Lj(57=j+Lcgy#^qvkRCtk|%>7 z`3y9~J6+2@I^|w6_=#P>rsqZC*pqSFoR{wg=54ZN-Y>R~^i<7_NxyM9@1aQDAR_;X zhc5}yr6`<=-L)0#C7{Fy_QGoj(3NGa!5Y#7+#>$M=T&z-`rI3$Jf@-7FmU`U57mer zB6Z@Q%!Qfxk2I}c#UD5Svqx>egJ@ZB6HJ)~m3j?iq5jNd>RGw#i*ghF?&`}nZMst0 zGIWpLe69G6w0r;jPFjx_(`qWXmQWq9sE!Q?mV#Iv)tYdnV^G;Bu&{tFTfiK*KMBp$ zw~mT|nQbohrUWKJ!NUcqlw>aQ*b8>r%_|egr`Ppl{t*`#d98_SNBMk7@iH-Y0ROQ& z_5i<ELd-=<*kD zK0Pzl_4N*|+|Ml<8#)!TO>L9dz(m5$ICq;@wP9^|Zu;!#_D6i(_Ojr%&+;@|(O*=I z&#IRCrf{P-7p9_B#bre~CfLUc=Azz`pVc z2uP9?<`QF8{p3k#pW$Dt?(BsSs+BvH@y%%PfX+)oAvpMm!NUhay|XTyjYlHUm)&gSvj z$ve7L9PKBd?Od3R7jx^Ib^~{;MH*(8ZpVO%rQYZ3+vAsl|4Rb@&tP2xZ8jDx%#a5k zeY5EBa&nY{dtYg4hu&iJc^Vfg%R40ua21u(g(q~|J#M-f$PJe4=i;`Boh5`0b7@KP z7g6i#@w2FVnf@H@bz)2FUE@RIaMz4m^H@|1V%BD`JvDlt{rzn2M~D{r|L7mBQ@!T% z;Nv$!dz)-g9YeFp5$93c6v%?=Py`kf7E3Iu6${43VBvn)Xy(fJO>k@&nzDbZ5!#C4%8qJf7As4$VIGcLATT4(KfD`9P;0C$ZCz zFWjL;6jn5jf^`SEix1*!refFc!RHt%dAE-4?qG#ual1)lBcH9(^1)zeU{K&Ygh`8n zqa;c>&8hG(W-~RHqr?|P4nhIOrNMTE{S{q~Kp3bVD#rcmLyM()!f5Y9L zR`=#(wx+#jldUo_uuNLVuuq|qlGJfyRS98)xJ_f6vt)3V=$&$abl0z1|6VaQ6Tyxq zcL3)T!R&Ja7sKoC*bU4Rp)lEbG92bE-{?4>w1^vZ+CAd1iNG`&?DWCGVsW?1aewgs zY9)g~{Z6n7&(3S7pW>MhCV>pBPnx@dJ@>Mmu(TUkG{Y@N!myPJ9q1?Xv-aX^d})@8 zb1GOWdiOtU$s8*oPn0m$@Fl8_iQE)pFRmD$UUm*+ zkF{T)B{-S(LKV}K<8hnT&iucgR=Q(v9M?L0d3d(Qv#CTCno8XQb(_d3#kjITseDsy z39+Y}1xza%j}kq^?@0gAO~PGyX=1_^H_e4 z8PyTiJ_%hmMF9O?i(-spkNvn$51tGi16D;uqxl)3DLAr{sYfF(cVqKyOkzvofS8cu z&UXyTQN zu={YC{AK!&C4?#29qcBBLGrwqI1i?dzYEysIbAs#nI4_tf#iLsF<${1rnJg86ZYAd z;4{5iq=1+!>NxbZ{i#y>TA0x~J~31L)*CfKHNkZ2xZ& zomRyTfd?r%=P|n&VDnP37summ{6``6$MNwIf6&dE5^YFw7gVyS7Q_Z>^N9s&p;9^R zsc)*iX{xQ&Da0Go*2Fn<7Ev}IIxnSVgXPxH` z;2HP0r;6VJJ_WOXE)=^zIA8Xmq0h-XFN>Q@c@N4z1O1YdqoJ1|^AGSBaR-~NHdG?@ zU^(*W7-7cCpDY1<>PMf9F8w(1d+p;qK`KVx_t6ow{mFFfLrg^VRQLwM9bxB!UrM_# zDm*O=r0eyX)-`OD(5M%85lp+q7hne7X;N7!OL(C~7GQn}hpoMg#RGF|*Y7JXt3PE@_u!xLdF#F~5$rlw7zWfj6@Ii8 z$SF^hR6E+9?FNp2i+SK1N0tPYT|Ibe=k|+zBN?nBob5iwCNGbE&UgOy62Egg*eM|U z#U8=$^G9|A+j%D2R`(VBA$Lc0PVpRVeiGW)%dR0H&XI6O^hsa;%M3XcT)}i~EED=A zeT`1^T6NTZuoY8F=CBC*{i3l|eVLentEh}-K3=jkh~r;+uwiS&bX-~V{o!py+d@Wr z5q7P=FM@w`(32zaE?0d;dZ;<8FX~p@u7y~ws9W0i^+Nz{DbtS64^GwZqW?e>e>kn& zO|T{2yFZ~i-hrZf6vs8o-LffIC=~K^wT~qu;Hm{99~4uDyqu9R zvuA8&slMveE#E4v*UKCuHxn& zcdp;lsbp}GZ{On|;gX9nvZBoKO9^3hEEB+g;9uD zfb9JhdO9GK#p0Tqqac#0#@1B{_LaV&2r<9N;~DxRs(1Z@Sx223u(b2n!E>I6oAzFT z!j-BD>bwz`l(V@(p2&Q7H*lK6)JNsSBz{L*%x?m--_^a^Im=7G39MRv&5Ld_IFgTt zMlT5~WX^8^LpAr6p1}uxz2vXkg;Cl(CwIC5-;wQ)SI1Qf6X%vh`v2^~H{`khf+!lN zf-9Iz9?3jDD!9@22vZDtb&TbetKUfHDf%`;wf*BHCtQew|4R&zllf;3L+`@Ur}b9u zMDRjUbSz2xwXgm9VoRraIZ!LD!72Fc^H zt3AAHbYj>!LSzFxku!)P*CJ|M*WRZH`)u5@v=PF#r9FT8{((LGTD{XIbc;*0F>agC zzQ)aWX!}^H_UOEFCd=cqc5z*~gTz?0VXY6Wbus5I#_O-Qa9t+}W_=3x2Y6|gt=PY= zeJkg2BbtqYx?+CrM$#5%nzStN?NWFm$ahduC$WmmDnXIx8d{XRpPSD_6zuAKtR@n^WL3#akHPG6RT)1x0wdy;|3}=0{63{x0OVbX<=U=amLXGh+KPsn` zjGwP#4eN8_y}kKev~%WX6p!AX=5L{=`|&&6_l!^rN-Ys}^eC!QsoEi9@0J}Z^r>TH z(|0DMW!Z8lv>pCG`cA?fK;vrt%JZ3sqsTcp8YUtYW=1){#@b(O`MY&#||9M$7e>aY=(>d}i%fneX*Y8(x z>K(VB(<1U<0OoH2lbTn+o|n~KN3G+T1_TQfplM{^lc41S=NH~8upR1`LRm^-V$%QaO>E>hc|?T zz0-h2ou|eEpS;rNebrI@2yMsrP>>i_-eCU@{Jrb&_WH9es)NCecyYrBwuIQ%VSs97 z=^1S4tk@T}yV!Vrnqhm;-HDjo10JNvGYYG^D7! zA#8sVI*G$$QdNK= z5qxUPLy!2yEwHTVHx0>WsCBt$YBtX2kI{a+-bRmw)H6hFIJHF7(W4U4aFsf(1WZaD zXklYtp00BCM4y?}{fYCBzLlQ5!X}FvEYXDo`+m7(EceFeV%73*?WnluttFd;)<;JN z+_-Wq+`yWrIyi^Wt&ZbS?jnGq4wL+*r8gI$h&jr}py zyx{k);*SRKIDO7jhdTEwSi`LNUA4F6zp1pdxLKlyI&b*~zhL&Z$@RW|yf+&LaRtm^ zo6F}}x-%6qM&N?6wcWPKI>y14_P4P`6iMIYu+0&D`a1Zq1#`Q{c(H()o|y>EPe6|n z@->x@eI>{SDhM$i6C-2o*@DjmdckN3k5TkWb_sFC$ zuk-LaWnpuSBuO5NS4TNH`LUh&<$b97h4cJm@W8M>U-=#2KhJq8SuC-9=kLI>m|Y&I zM?2d;{w`pVMz(!^XE(5$@V#dK#tvX5cH}PVq8{*2?DPc*_E85)+Bwz647nI4fH>H5B-v> zE2P}^+O~VYY$S` zm3o!%<@iQISnzHf+t@P}KPD^N+5HPb!%r+r+eYF;Tok3=JIIXUGmjBQ*zdV!_~+Vg zEZ_HT<8wM)E7(RpLpK*)-HF?wt)X~&E%#tw-!g@L_0D>4Ka4hLcZsteEB2OeMypjR zxZp0PvF*5_fuF!8W7a$$FfR1y&hIow$V7SR-b_7@6gd=YS6QXZ@^u*NCV<4jc^uJPV9!Cf%fVul6JYf8+a5qb^~7v zKheiQ;#bMUr=J&g10SrFGT+S`C4=FN)9(Bgv%v72&pZ!|?jQHa+V1TJ78rkSd;g?B zPcc141{;+-Q!N(-!lS_d9`dV$tH?}%NfTKTIJhbJ_m%*Y$t7#bd*{bz=2rwiOYBKG$m{bqXdDvRuIhNx|<_-pAb zK#iKW{)~>OjVX z(l}XU8_{^rz@(l!U9TUclWYC0{)H!hNr1vT9C~^o;+{te!%f79w!rKoFl!SET$ADz zb&<@m7FBt3X z#(KGzBi&fa^|xYl+ZS?I&l@cKcMRN|@0mKSfSHnA11Cn#g)!%rN#0YZ!Va5+W+BS0 z%|vepL!8ed4|nleJs~p*-?M=UKf(gzRf*uLEUmOx1D4sBjwjEVHr4{8B z!%IQDXq+%D3LP6gnTweA(>q_4C@1pD-GD1Rko{rltk@6Kd+z!CMK zYe34R)M7P-OiEpqUKmwO6iT4z09fpuHh|z^*~jMRbM)Sj)1}fsdKcM6;M2xSR%rn@ z!gQ9hrTR*^xL~FcK01REw3|rxTPc_c5f~=UE3~2}Vxm09f}p3vUYU@rJs6MC`9C@{yGDFENqDag3IG zN!(VMrs<`Gu*OO|w|5T@J6R08{f(oc%f4ctGF=8$)~RG#S&~u%(HV_LM_Y%m1+QiQWCa;bjSBoalT{*9Gdy1N9mZ!sxEw`VDcjucKt| zn2E%LCF{-m)H7bDqzrldLlz}=nNEve?f&(6e?nMD1RrirFRueHUkYXUvdHudPbYhs zi3@UuknIMZ)i^0UjjLquxucf1_qpA`;C>ofCWD9T`@4Zb^-F$a{h@XP`~6rYf0>9+ zQZZp=^*Xn}OBAoz0AJ|ISFks1{h_Z6TUGoUP1}%Xsfl0r<-;*E0QTVmGh+>p5P({m zKZ3@&xhku1j2jr@a~D(V4AoQm2oc<;-!oI5r>y_p)-O*C+Jnw%4ZaSfdrhXKR#ZbK z=~4&NGrdgNVt&EGw-d3y&)E9gC|ydu&*irhK7jG%xVC`n6P|zYs?>03!(#)muqMUS zOjF>98u9x&y1(vDz8yQkqM60zJ^?fr+WzweT6?9XsE*cTX{-m1*V0pBF^V&ukGbsR z9S^i824keS4yO5OO+sOSL2H7-uvT)g|JEE(vsY|2}$jeBL<vL$eZMdivPb z@2$zPX>$$da=HVIsn>?y-Se=XKTAZBDJ<^y6ox{sqM9R}-}=44@tOyDtY{ajcvbPT zrISCN9EBMxyG%rh-JzqCyAl75iD0_}^!)pB$;%=XTOL+e!6Yfc?nu27qb4!zAJg5y z1IlC~(Ruz6`$bthMSt+l-c1Gv3E=8?fM>sr4o*J*4BcJ8!EWHuX7BfvK@P5;{9WA* z3=myBM3-w=B22N6x-5hZxr6529<%C5Gs+0*^k_4TG88?!;@-zh65kc|SK|`kuK?nb-6#?u2C&n8)f;(;F&o;PW3Z2sD$t;`QHMzo$dk@?F zd+^7DKi4@8p2R5h@n?Rg{Rp1Y${uoki`xh*@dXEcCnq`^*y7z{BawR96`1D zl*v4qj>gEd~G5)Df8XHBZ+yn zz)5Vf#Nb5fqm#x|TZTpAcL+9i1IO*NpK|V$`PjwDU{L)ec~^@Jj`hgWZs6GuqCd1{ z-N#TRe*v7SrB9GCtLEQMe}eN#XkOOy8Pq4ClLR$5Hd9~VobwLmYcg~xu3!yrCV&;8 zMuNI&iHZjRrxLhA!%6F?HEJK3cYb&mjq_rMmm&&396n!*=pcm#2y&or9+OWK zH3WZIlMPWK`+04rG+qb(2$)Jcl(MLUcO}$OnZi7bo{c+1TcQ#{r4~IZOBDM2bqj5!R-67Y)y4X_(`0P13c-$!lk8sXFV9VM+_ISUP1x56iPnnu zkcq3BkDZ=thTL`actOEHX7}$^I_nqvcrd>rVYY}F#PLvyJd%TF*A3(hb^Ua)BFJ|B z&Z=E6czoVY$t&tAS-O+_jpa1mb&|DjPYBDl)<+V3k|usRuULeV!dRQm+~8;e$UO+l zP2BE-@O?VWztO=Gy(^BN(!O`mYWr^VN2S`$5PotOaDTD5U~3!jh)PQ}Y4@_r);h;m zjNZrQT3rD4bri6>zR0H@ZsY4|iR-xDgg9zgV>G+f+r@zQy7GZ@)tKK|Ly~96D;{V{ zz@vt8Eo3VPa%hsXqgSKy&p^Lc;3q@c5^~1|Ud1W5KR=8xeka%R0U+4@>+R|t_BCH) zMWXUlr6|!`)h{-WlFOX(i4nF$>tncoN#xd>tCuy8-xlEzXYNqSi0)h8!uGr#)Ap&~_a)ZxO z$Nx9Dd}outld87AyWN!378LKrP}-uDMHN29c3e;&Q<dmP=#DlD8(mDo0^f*-HTiJ?@Qc1aV4WaxaDLbxo$_-v9lz zMznSi{D2qMv&65!+VT}E$+dzkdveJWvLYdzV&8O}iQm~zygzwNg2%B=-?WLgM9V0S zC)iKLhUfiLv~2vX(fm%rO-z@!Vl(6aM4C5?4L!H=2K9Pn5~%3njyhx3mfF5{mN)uc zt{t~^k<>vL?WmgfVOj5WfW`Z@xd+?9L2`nQy- zAU*NbF(EjL1I>^@LKu0hd|gdk4nVA=i^P#Eql>~FUb1ZWuV_M++%@ZEJY5u@eSWc? zO9YGEs4TI|cS(ve$6N{5nRcAXOZFLL*28r9pP29nN#YmvXO_Lhv==XLw^v?E0Gs4? ziUrfZDUT``vsyhbanvqSoHa{ zCO5>oGm=NbFOGqtf;Bj;g?i|E@o!{;7XXx7O`To9c{?ion%wrf^#^F2F&{QNFkcq18c6QCPsQoEnkW?eJli9-sdwt zw{pYIlCZ6EZ#LqR0o(xKhQ-~SoIh;X!#4!wKeY+hcL6uWD+%^n(n;3dPyTk>(_hba z-Rw`=8|U&0pEYQ^cHBPNItvPC70{tDU__^ z(pa}Ure%XVa@yC==#Fk=@Ih<;391%UD|Tw(#+CC&Tx_w)v$FN@8+YhMqsuW=OXHT{ z9_>sSbahd!O%1AVb+fHiTX2D!OIB#L{jfGKJSx_=o;b?ak{*sz`T7^{g9p+8F~irH zW(*C&D+WK>b)tfieGXdOFU`_{dc2jyudc5oe0kZ$GTT2a5zL9MBVBD80#cYxGFbB| z@Mn0Q0N$Mdp4!h`bDp#GytN-lc(Z=X*{5_rLHG!yi|wtI8tXjI#KyvKLuHZld%zv- z6@mM9wgm4q)vP7E_$ISEHkb(=c55bJbCub_m3`c?hgA`}i%@Lh3c(@8HT*tFyZK$4 zVhb3c$RJfo?fg|bV(0IRVHT%iR_B;M)iG)2nhFJ!)yAA5B~!Xu_TuSwzy7mHUp&E* zjUiUA7h5}kUk@oseIz^qW>OH=6T$Phc$>R`5riy0f=KFm31s`JWG$QV2_8I@0LHW3 zP6D`22G5uW*6s#o@n6MgH*n(!YAiVC1$%Heu#^C(zJPECutR(p19>G(WAO~6Hm&^2 z2>pt=aOQF}PBGA%9MlZUzr>cObs^NPI7JlAAR+&xrn;~f{O9qPuh8!tYD*)a2T7r%SGutgaAxU<-}t=a_QBL1#kskUM7_Bt$k z2YNG<(h&B1VOp)#H`b8F>se^MV@ucI^*XJ52V5>Yv5!mr`hj7?r;jV0K|k@ESnm!> zMfGG@Goi@)VvTQucqLtP>K=TpV$jF474yMzb_t`7KqZ9nD)R9s0VU>VC=Hm9olH*; zQLk|inaj_Tz6$(G9=@EB0xoy&vf>HSiC~in z2K%XGy9<}^riGa}MN;`k-qum}lDkQn&S;VZ@J#S_cMb>fL$DjzXW8Sxv7=28i<#I; z2#aw9KA+}X<1q?2I1Jt=QUKR^Ny#P2liTJcI*!kViLcPIu3=X$Ws-wNfSS**S0Ue; z!^khX4yP7E5ED1DNw_JygYD8d!#L}_W;LKfpkG0GN_=Yej1Tbytm-~bxg zd(`Alpf_D~pST-haO~S^YT|-zY_SSK0(q0p0A4Go58Z6#<3PtLHaIcYm!tU$X}Koc zKqM>JEpnH=9`jnC1K33HLT8d;6UPPh>9~8%*f|sS$eVNi8Z+wAbG6}xOap7s z?mB#o0a@lDq3NA0(@*$jpMpN+qejgBP%t*}@UD})5#LSvo_Mj__w~ow0ek>2jGy>D zW7T!0f5@&sr}BuMOj#sQWxUHuS^cJSebsg8yHV7N8l{E z+bP!xV0Slgm@FPma^fFF(>Ip@db@y;&)CTs?P?`|&wit8W`QsWyvpSW_jdr#IeLwQ z=iK9jHWLyPR9nT@|GxxS7Mxlj#{?b8&_gSLtBQ^|;9+R7_Q~oP$(MHpKVAPEjgxIx zp5jX71HdOgauL8cc1?Cl;X#nMO2+;odA;U}(on?8Q}1-aG1^N2400avTKJI1gQ=yt;2 zy@0^rS)tM9>iMNmcsspTzRksTwzf9QWXCXHF(5ItYZM?5w&Kj0cXj}i!#hg>Z@VrM zl~%S_W_Ge9ZkM-{2!=_WBrz#0?+2a~*8Qe^8Q%);t;cwKj2+ANP+?V2YntaLYA(HN zy~6y^8vurtPdUATS%(_M?;e(RS2P1%y+0P?Us}R8Gge<51G#o|g%zvkW3g>JT~WS+ zU4DnE@Vjj&<6i4%_Z@D_1a{P*-&fePRrJB%#l0mt4xYd?ZbQ3Gp(9N(WEAm-N;AE9om8$2))- z?~l1U@q7IV>UefBU9wp|;-fVAd-}cl^ySIJ_*bJ{c}3G1xx7oq2IChlQjH!z4kHEakI!gE>(ohgrY04MsX zoHHDIZ96W+$iLu!8sgM2kxQ182YT1_N6W7Jn&k;TDq|5Zb_(;NmO_j?VdvBWtzuV< zN*Wid=fRoY!Pqk=wfICvm+=#*bMp3rZC(ZzEdFfr+WULkMUOyM{E=lX>5E#aOI6Wg zDYTF>X$ceG5ifStxI|9ZpG2Mjs4@Wiw)bt`L;Rh@9y;J3U4-2$GCqWPNDV6iJEF&5 zwR;bXp-v;NpK{X_{piUu9`ab z0!nHbvw(qC5|Y6VV4-Jiq-B-3<>f8jN+LLudC68cDNI*; zCeLp#H27NI*witlQb)UjatzimOc@QGznOkpL%$y_E=*dXy6?gr)^ULF;Vwv9e%C4W_a>KKJDF6Rkf2x8&qtg<2G zP4Z%@;t1?`3SSc8)G(2wt1)79`RBF5%Fsazv9c3#(T&a1mV|B{3;C=nq^@TX)WLm> z#;MkpF=i##Y~Z5Iabb+lYjhbunL4NOS|cipKfX%>irV#a+e5d+d)_D#ELpi~r7o>= zsls+_(som+f$n!1ZHexftz@(#`)w2~XcEC2eb#EVuzMBTyxRTNi~f_e(g&EaP#zI| zezJP(g;3j#b$87e z0uPQ7R`{0DwN{klP`#7RG|^G?aH`bl2gDjPIJl0nIBdjKPiidMXoMwvb<(WP?tO}` z9r?1rpS{EqM8+gdfm!9~{(CJ;P*@hjb}d^_ILPFPRLX2J_^?Z--X(ker`N)t*#Ugm zI(VZ61LM`B#IQ^RLt_`P-c>qzBB6NsmNK~^HAwinNzy>)6g-;%##48c*Al=oz6d+} z54^URq%gMHEXM}k~plWlt$LOt#JpWf}L~#CHjSEL4Iel z?CP#{{KM1v?Q|@coJG@{G(UG8y0aiL1-H6n?A;CDwEyY*f?U(0T0B=rzgqi)XB!%X z^>M-#5xQjz_!%p6{FW|J>)@km7suIh+2}aSX0tRVhQ};8p#>czT=qLSUL?aO3$lF8 zGs_B6ON64HC4L3g>bW*{lfS&YoE&DcttN*r*HQ)!{_@3SRvu8)UtT?0{f5RBwz;-J zPmrD;-+iI{TWGN#aksv6gI&R(ueWlq!9{*0%X|rOqrqLAf!^)+J$>u~JxEq^7 zduK^&>Ig8$2D+FDHFv>ycWUt!VQXE9-8CTgmX+lW;yA8u><6O8=t1eWG`IWo zKhm^-^!6PrDlvmb{Y~iO&qR(@ND9D=kw5bc01ptde#pBtbY#LCVl;6?}!c0coq`ESA&k2b&~k9%af+? zR04P>>3fcDD*=3kZrW@5E$XM)ImJ;C6X%Ix-j5lN#m#+wWCw82#?VjYySDQO{pHA_ zf&;Sh@Ln0|h#L8g%6z(s9^x*^FsNZ=6D1%J8vlN(`n5fS)e1 z0-I4vl&*Mlpn({{=DUGw9pm@1$tGjF?~mhN$gGzggbvddDu6Jz7^W}8xh$_QPO~RpJ@}(FQoqak~Vyo zu&B3`cAeedym7By88)l{p7izBa+>y3O*{ zpKH5?ugRs7yJOtEWV+wa(B1`nMyfm9+BC9=H{!b3>Utn|02}X~4!~@$YZH3$EYZ}< zEPQE|pfEA$YUDC;QS9@NA{rh9JdALP@?F4a)mH1)OhCF_dF` z&kabX(I&e7Fv9nZ%J)%p+HdyRBbL6Gymsujfm*C5sCu=v0In^y^z9oss<7m0OI+Ed zO$-#6AlSJOE;@m>L_IlmlpAgQkC9mDbkY_h=p`Fnw_XIsqXi}d;1SJWj&fJ+TPkWM4f_heKKqD^I-RUG8I?DYKYC$L~=f^X%yHfKbm&h9&Bq zk?*(hDBTTaTi?9(i69x{y@U&0FZv7aPfX?JILaAL2G>BQ7I7qN2&B1E9_ z{3-i*^^Dr5%oC+R)<@A=lpzJPcK}14_`OW)MDG!>Q)j-NIueVSCxZWo-6Sc1zvK5D7n3-2cn1sIPdT3} zJdr>x6Tn~+q*+|N4dWl{j-=@5i`BACqVFYu$9PwtgO+Wc$7{}AD`kFE)*!GJneSdMBEcouj8pg88!2p6m5#1;v>z++7;oBh$ zOBf=LTGIjWkaYWPf7tO^^wXsWpHg>B@oCyKAT;)gl2g)~UcZ{Mi_cms>9&RMr?uX= z_ado7ebcn7Xza|FYp_Od?he|)R7ydXwbKVA|5TtoIdtUDsC4w+IMu(Ab{Ao%?)4C{ zr>+XCiYe=_rp@+#x|@EbKfU%`(sL{K32y`5;q>F=FNKdMXNn$cE~WABr5*Y8^bCy% zIAMW`7u50nTD@u$YYR$wo?p$|t``a2p3K^GaZe_KxzBE*crht#;=fqrmpKN6g5Ni; z{qDr#Y)Ua*66c|)d=EVy0xs6y>HB$wrVehZde_n5+S=v4gQ|=erd7toSne9A8)%mx zm}6Vah4*sH?|M`pG!$`B*zDzv*cl#ITkKUV5?e+hGcl+&ld#zr*qa}nenR4O*gvWt^L4EG&N=SYX*z?M{5^NVo&%kJF4;+_W{KdLMD%wCcaPVGI-N9) zb_GN7sp@k~);0r22jWc zb+Bqdj8Nk7EuV&t>8Xdp%1+l${Lq|RxK5vTgPv-6U>X)qHx!5W(f#v((P*zlw4(X; zPTHhv%o|uNHpWwOWeiB`ZlVRulk`)ssY`+V*haj+8K2T=8fcqNo&)ZLx99(je(unnWoo25 z-cNX-t2!Qo4?<&rahvPym30G|3MDeN?h;D~@_7M9c)3$|5|doUyuwdO1Y0cgf5|-_ zD;EjirQN_m+4QX-)wik=%pzYqcCtkEDXjj{MRMK_er@wNQL`nuq4Q(tBX=}-Y8P$RqpppFDz*|Re+!zTA zm&H7GDnt1AP$q;;5D-%(zp3ZM^=0hH!mI_d=%RW#Omx-~z)=XO;xiR!#p+6Q2{VI` z*?d;#XV5tJW<_uk94z^`ZYrhTnA+dmUnCAK`k5oFsk(UeH8B!=JFN%w`6iHCT(`1g zXHM=~#hwTh;mFl2nVql&rb-f1VTalvUt9!C+rqz%b{9h{mcx}=-x4-2_RY1&dueCw z@gE^?ae!!agf|dX-&Q&U!#4axbhS_pn!0RP1hWEw_5)Oe!#T-a_d~ z{3LzC$(!Fryt4`V1ws2&4f^s_IcJv`^ll|+=BFk#fpX!jQDzhevG z9p@cLfepBebJ<`1uXv1w*!7I-r=UM&nrlWZ>@GAWoVD0e7Y%8hwpb(kyLA%`7EyK1 zjn28&I&ZNtW<6}B-4)$c!p_>jDxC+phNehccZK;ZG#QLe+`dXCd3#TG47pxYW5H)@ zJQ@+s9jBUq;c0r+$>1x=3r^w}Sn@5|1w5M->;gXZ+q;0Ha=7~!#O~EUeNN<723lIBtQ>Q9~Y<@gaJ_`8AK9m3a)fao`Z_3qyOQJtC6$@I$s zt>iD$@)EvaoXWB^$=p;sfW7!2u{U^$Ux&U#?6NRxflPAj-PoeRzrYGH8mv;ZXa`sC z0tOW$T03a*SsgtGC1)Ms!*e|Xf2=6cG_nj_kgwV|NDN+U+z9lWs5((O{mhnCEx;1} z`F5@J`cENpi|ZD4?9d&RwhlG9hI1>!y6Sxo(?jmbd;WjYoHxt1HAqAK{WR7koj^9P zaCjT=F*0}F?+opCEdjodzN4RR`HY?sJS6&hKWTEPlV)x8dZOjQhy5Xpty5U*t++wG zJn}KNKn_U1i1O@Typ6h8$z=6kkO=1G$fohz-YKlh!zAm4Dj5utgiPtmxu7)zo0Q4P zmEWbcb99Az&*r~^n(rnGCm+K5z6X8+UBs z{GM71q4BP*YJL;6GOK1+G~2cyDCZTX(ln4(e`rMGIc0B5Sgq9;oY`#2iET8Yo~sq6 z>R-{+s=-*C4vDAad~RQh3hmW@EU`FK1yN)#KQ)+vB)Q{_83k)*<&p?Zq^Q>W#~(K0 zMPfIT$>nviqu21Kg!6|7V*aOWFY(KH$k&Z589eIOu3udr=Pljb1% z<}yst;*pI5kb!W_TJjg1#Oo(_09W!2!sGv6S49?P4HFxIo_oP$MgL~xV@CxY9t1q> z;5-cY%|%CIZ@m~PvN{%WXi^*ab`lS&qLN?>T;-76_YyPXg!wsr^%PZ?f}h!9Y9jJ; z@oAsGi{xT$-vVjInvIqYHQi7nh6bkE;p@~4xAakf3VpgO-xmHyDOop7QMhW`a0Y?H zwJRAW+()kMf8Q;!q&rKFkI_zmKe165R(=(IHuByK9WN6y6XPE5z8)O+u%)+i`}a-K zemxZi(l+(iN=OQ&#g+O~$=wy~hZ4d4GK*nV?+Vo~Lr`w-2=;hRg4xs3WAmS(r3=>g zT$i`Ku4faR*DF}aJ#JvR3G4B2$wY|7%to@RL|=|^0bC6XC4u8n?BSCLb8hwyvj?b9 zjvBTVR^q*E`+^QD0U<245nD!=mxE$VdPBn)-B}{>Ic5~|eork`n97gpL`;07kzn_n z>CJ?ddCs+-gb8?OEgBl|`;{orMtemhXJKm-z9=wb8p^dV8$&0to5`*>5l*re9G#i; zy^Q#Lnw~HHYZvOtKe3&}@8BJ(r)6TXp-k?+A`0tj7cd-2$XPyu49gECg3BWW31AwD zPym?}_L9E5eA>dZu^V_W4B`?? zp1B-cNo@v@rkTCFJAjK=Kq=(_^*Lyz3TQa9hCW|j7}2O>z!$~xhKJxp1Q6h&nBwMP zQ3v0h0+bLaZpav8dbFx*@dUM%4u)f2o-U;oZR?azZw!Gs;%}qql>Z&AC#Q7Ke`EAK zB5}B3p~1{8P)nzNxfcn$adyI>hC(OOHD48aa}aNgJ$w=?newI1!x41TOKtTaJ8{W!A@Re3yR*7{K&3 zsJbUtsC@lsz|Yd}xgPEku3_4pfe%1h{|w(3=od|a99?1Hz?izZCQ5bzV-P}UKXZ!( z08Ig04F?y;IV+u8bK$uP!$mFJy8G|(`nOW9^Aao59@p1d{>M1sq=}HOqkO4Nh_)tFS|Y zWK7-s{zz8#H5-ox|$zZ(;_<&KrPUpH5kCu&f zCZ37Q*K-iQ8g-RvBm_tKN(2M&PcFYuX1DL~tJ08J;uqu&T5uDe#xcbs0esApx9^-`94CN4sIYSjlIOnD1 z>o@zi}~ONJc*QIbyL)RxkN{(w9K-5-JP4h7iT0t%xE9{G*7j!RY>k4ljxgoj?Q+ zHO|v@qnaG76#Y)a2Bs_CrSEu{l|DBk81orr6(rkH@TGq{MVF%A(i8Rg{*6T9u&FIM z%eKag!zC3cRkD|Y36}~xO|2zy*#S4rtCjr?TGW5%`s58XmsIx`SHjjKyMf<8(LyI- zzm>X+q<{4DsX1<+TOT&Pd}_Wm*6{D@o8v*9oClio;TD4^R6zBLm)MiBk+g|~El)No zWcjS1c`M0ZVL7X}le;TeD`R29RwCGul{#-NlO30rxKRd4)n}kJ-m{UfA3Y0y8};8< zyv1F^Y|k^^v!&Nan4;G`==WD0(^%OVJgndeWAdX!6efL<%7iFsaDd5UkK3&A=o|3i z9fJ`JTP!QV3L9u|y&r_$P-rf$;C*3Z#12HOBXAMV`>Zh!Oyz;O=`bqHR$_NiZdexs zE7wrY@J4@2rS944db^3RRIdQe{=-B+$PnvIaPsxgd_%#P<*y2H?*xWTD6OJ4D{~@p zee5K6FEd#1FC#vm=4_)YK)Rg_K47QiXczERXYEzHgrnr|OXhs~*-Z+wq%Yqce0|1Q zBzIX1UP2gT;@92zyOIDtd1=zud=|P>9&>EO#>y|;LK)>4xSxY&<0lipU=zM);T^ky zuW2>rBs&*d$zMhjd6q6467=Y<;M+8#{+f&o%xaj(0{-NntBNi^N&tKRVhrQh&)iz>Yuaps8zP!^h19!n2Eugyj0`GY)@ha`!R%d#-qV z;m(i$75cl|`|g%KWq;cF#CYubNcF^ecp_Ks$9YsFHOl*IaIZh_yW|LM@;9!N6wErQ z`zFblq+jaE7VWUe7O;trC{dfW;b!^pL9*}lIJ}^|m(-*PlEViU(z>4%ro3hq-a@al zD>xZ1c62KJv`gahdk)V8MuNQV2ixmemY;1I8E?^&;zFYON9YJrGwyWxaSaq$>cKWKA zw@D?RBX#1pgTZ7z)pCqj(3|hoIUi%BLig-cHrJGTHcM%+dZ31N#6XW6?7$!c3l2yT zUYNavFqJE&oBIUxv}fYp#u$JLN_KI7xKJyMZ|d8pmVY@}%!L z4$L{XXv|-dU|BpF>K`c>&{^?#AuY&R=sYH%Oy76&<;laFihoy8Rm(@s7sZJ0nZuA5 zC4kXdOiZfwf~D3j;FhISA&sD^yie+(R#m%dJy+ud+|qUefY?lQdZ3Uz+?T(Yy#D&> zy`y9PwWHa&m}`|vx^5C^NpE6H6>^ITlfCHkF$Nc`_LsVX3Jq$gcYw8l{}@$^q8_BD z?yagmPj+pxcm>Ldtt8s|uEnz% zzHG8@t;}Od=YE&nm(TmDcb_H&ZY6&OggDFAZ|v;t$%=ZBxZRbxxK3HLB`+VPs62@j zqrD^8sfU!UADbrr23k6+F{^hhVQ#_|^ds!6BvDiWVi)MK^ZR8Qzx(P6T^Fv2mBjFA zAWz6+A*$p)#-CEbySI?pOF$+OLJ>xyS-#ARSOI7)S%#g4!}_Uh{AZTe~C~>gZ z#{!t$m@dYJU;`tW6{Z@GD)F1!$i}?WmSCh7PK>2ljE(WQf$)eHZKBUUZ8?h?qlXg0 z7CpLKtE4c=!nBNBMbAUKR<3vT%0-ihA4%JYKgD!A8GN-H56jomFGvRS&kXZ=nEX9u zbdHT%#xvpgNA*k^n%%+YXQKVjJ;O|Zs)TQv_!T>Wk2dMu!NBdG4}2pj{A%S$M6Z<3 zfn7}u0|be3=p=yQ%Efx0gZ7icAdb7cfUhGP&(5cyGg+2=WymSkyqAq?2XMeEX!^Q3 z&b7f~`qBb(1q)rtDh7NPe{s>lvPyL$bnT*wN%C@C2ZNbBFtDuSfLb~~kIw7rTKwa@ zgjL1Z-bD>_lqmnwVR##P7o?xuN53jwwvb#>>7drk0`2ILI3&3ErC=?{F^kPi`+O|~ z>VSMPHGyxZZ}ji2o=TZ7$V~lrsa`pKntFHpiF#;tuuMCCzbn?ik?!>6ite%>p|GIY zTqKvUbKRVJzh10d1=yiZZZ7%&QfDC< zJawY7wR_angh+M-yOWH_PN{VL=-Bvi^uV3rmWjSHcBO5T`mGGEwWalL1Cm2!<074Zz}`UkAn`fhBdv&mqQ?2YQ@wZTvRLL-q0GwBO6DXc#eT_u9Ij%Pn) zyMvGZnZAyG;(eBs#~c~|kU*EmTuXi<7}8`q@AKa0qCxHay(S0m%S(2Q!6??pfoDhO zgg-~#+U7D98@N1P9F5>EJzhlt_zHK)SV~{qWp*VE|J4L7|1x8VXx^|MO}+B-WZA5K0pS~XFxp>9 z`cm}M`{PH&OJSv_(m^lYD`-oP*xwb6o|HB$h&PpeEs3E*qZ7hSx@Ci&iQ#=@7c>cM4$fAM)vikvBJ|}&CG>9ky_jds`PJ&@P>lYoJ%F~MM#~RrX;j<9Mj+ALaji8C zMC?0#g-u+qKe29f(BH~>g2_)6+>|@5!_^q8wstOF&1cKFlWMh~R^7vw>^sjVa@`i` zp@p!8j<&zO3;54p1)NnK>l_oi+xO6qcL%fBiACV>r?{K=J+(-*8fH=z!0h@3n+%>w z&$uoUk*7U8=O7hj@p)sF6rKrF%bQ2oPXOx#sz~}yyGMH`?~t$W{yn;;vCEhD$tWLz znwUlWFIN*^|6cGhXR^faX~JNZcLR^(Xsh$C@9Y7viD8zm_IT2OE;6yV1Gw51>{;SQ zo1dl*=qs?QeS|RdbdVn`U)$i7=pu~{*2cy!KGu*2bt>Q(aq6BXMzEZRWwc%g{A?O0 z(1y;Ku8+6Yo{={V97YKG^ovGtn{IdWhsht+Pdcn;(8?T}O6yu=3Vp?vN+%{tt0A-F z-!AmGde+)1L7Q(UsQ}ZR7W_^smPBpL)0*!lY;f*a`xNag4Ls#Lc8u<=puUk*-0RIC z_~d7y|Bzz^wAC9c=j}~<7jX1)$r&NHoe<7}ISg^<)(K#YRLk-8 z#ZF)O;XuW-yYg{e+>V)Cg8+Y_;r- ziQh_oFkAXtF{;)Va?WavM$)j>{H;L?_I&ESZi{zi>-Cp`oE>jp)jYHd7=R^&j|wtJ zT!yT|6T-a&uf=ozdgAwDSl*w%S5op-Ic=$Aa8dVm1s}uF|0Fcu>1uYyUY|~Ocn|CZ z9`|0!J5zfrwv7XF2`#|i7Ay9Y1N*(yDg}%z!yy`#Y21B#)>aZ%MRC!bWh^9w)?~6UH^VM zMdP#`JM{%C7y5O74ftyEn5gGYza$I;DR5*Z)WCF!Okoe$@!sf>1!@QUVG36~59oGQ zM>R%xVGFuXkzJxZdzHjx!CyoVEhH{83--=uy8kR?U3G7b_(uFi&kkG}hC5m2zX0zV z9#^h9dSZVu#pp0YBn9_nQvPVLNdWKd1mS>>tnj2y=<`}81#8ddJ-ItD5P9>}W zgVSh-9}ecY8U|)mu;Je@yQ2msunw;5eXQrrBZ=W8*nRz;{8cLCql%f_Wn+d^_=U*| zEra*8E{4B;(F`%-=s;NVheOFM%7b0NryQAOQhkSPAsr=zX&O0oFyJ3{=`uf(xhw+x z`DTM8Yse|6paM6^iqo`%ESt-7p#7k4CkJ>Jcoz{d{GV|=)5 ztjnXM@FC|(@iUowj)_R@vQJry^4aga{2HCB?*KZxgby8^^yQdp=kQEQ_I3kbXTaw0ILmY3mFA*Tg$F4wnzk?LibHzGn_& z9{4;AbaBa^gTVq4U<##u%p2&rhv36n`SL1O1)c~V^pS4K&YyMwbk{(68h_Iid4zP^ zep6eP#)UFF-3#?1kty_5UX{hT+6iE{_#UDLn-ZW9bzeEI+gLL34to1Q#I&+h`4owm zz9A9(7TUVFzGsp0?E2yvyF0pX*EzwTZPx>%WbiFB$ht+`6XjeKuQb zQd(`(lczY|<~~UJ1q-&oJrn5ZV5)>2x7n*1=2O>80}#X7yMPVLc?X3tCNt%?1#FuY z9|L^W*Y8LUza%z52suvRp=FHJs#t4-;kbnwgRyu{V;O;~)11v`NHw|`B5-@S5yZ-S zQG{Mljqvh0>0WoNfodeN7}U6r9+EdERhjt3@{9y~o^=NidoUqbnb|$+trLQlp@!Rq zt!%Qh)+RFDqLaeNJ*bP1Ncswg7$$`Aa5A;9;t%(JLU_IlNfN&tijJwjuQR};f}r!^ZNr+fs$c}INb@QA*>zzj3>2i7|dygq`U zvo#PWe+>fwy1Rk_d`3p+Skbb|lFu(=f!EOI&yyGeJ_hlLD;AiF!}BeHf8Pke7*x>l zdv_Ob-mGrW=+vT~XwZ&o@HBj}1G&Ky+)e~@)}R}`u}|$Y@g(dTSZ~d9mcE~KTYh63 z*xIKLep3wFQLKiwa&xlF=>S4XBgcfe%+sDI0Pc@v`R?;g)8toDwRm#mlI1V_a-Y~3 zr2Ayo4A@Y>!q9{CZ8uw75;vD5xw$(=zLTDa+gWpcmiB%B)?ph|#oub506o+r$t9Bl z0Lee*kRi4mz?7tG?H8epR8fw7B? z+M8ShI!1xDsPpLJGBC#l%KiBg3zAuy3^%hHwiy%oUH!g&zYU$fh_3-A1_-FQ9^4~b zl#mfn0F!oPUz*j1HG3o#jcc|RQu7|!dRfcy5)}zyCaV&{G=rK^y!eAb%!p3khRe13C8%6T-ly508b~u zABPAVr(My(+SvF-$MOtL*8*FD=d{gO!yFMxbJ!-m#VCw3WSl2!TAS|9i@7U0>0Pb8 zP0e#U2Cld4IZF@HqVpd+w(aqJn}LSeo)2;vYw@*jS|koBCa8u?YG8aeHDaM$oY3>R zL%;EFno%DnZ=uv(-gMB*kuV0nV=!aqx374(FGjD7E#>SUzkUDTq|o2Pfh_$4x6}7d z2zSEX(tRl{XgbjGsit>Q8fL)1I|X8WDX}=k*;quke!6*Fs&L1Nv_T2LTQ1DoCP^Lx zlM;~dN6J5`Scu*8c=_;ImLX5-uH%aO1IgUG^_AFqcGZ|<*eU@GmOLJ}>&x8Y&!)xq z1*wU)YW@!7dh4+h;#^2F`>=_`NTu7iQy4e>^j-vdEAfkUeC%N5Ats-Q$q4jL;0)0{(Gsff{b)eh zFsYTz#ngR9Xd^+brmyX40iDU|67huaxjxp{biO|3*L20d#?s{<0ZqTj?%+Rc;^)O_ zjV^}aU5Q`TI6oOYd08?TPw-e{>Ycz>LKmnmvz@@=^UpAM0^^x9eaBAVD>*#c%96fE zVs^;S=bxwy$?jk0v(R(AJIUZV2Di&RahfE4S>co&?Eq$?cbv)l&aU7~fNZXUPX0oC z@$pQQdgmC4urepLI8*tGgmo>tp&2wGckF(sm}lvv@36}r&o4R z48urpog&M!j(m+yYvWvlO)B2D^11(9>_36BE6aULAM2~di&Lihd&l=iC$!DhJ}6}N z;~R^u1#r{4yX`@sdb}%|mDxAVI)|F8kd4L4rjn!SFZgmDfOjT><@yI} z+&wRUA0-QxowdxK&PaHwhm_U}IbRM>>L ze#R5BLela3G`a61V)UZ8`_Qc3yqs$ZSqD?^8P_jFu&rtQUTiGh#i+MA&1{oiLdkk_ z7qGS`>X_IcOtdyIf4PM#ypn%K>iYEgl`QEkbR&_F#BhE3+}!QS*!g%f3%%(Ujz@?K z8RiTQv1Mr3nb~0HaRwNim3}Gu#7L8gQ1{=BIg<7RdWR6RC!fOi*w5M!PpYmRo zvpI=2NS;HKfilD;K&|jcgcgWoo#@0c>a~u(hU;}QstEYH`5H^K>t|$Id@a1ofARPn zqpJbelLgL2p}*_b=i%j+u0~6Y;9M|KhUfD=bKbwcv;I01neabdg1&|F9_V2ZTo(n6 zi`Z|V^ZM9t;Bg&-?beMGs^}j+&3{Kv?vQsfuebIYE1#v`H@?-4=P9$a1{Z>Uj7xA5 zhkHh;!Mf4a6c;S6gQ@(zX@k{*J!mty&(}S*FVjxnZka@HE`v&zXsk? zc!S0HuY6k%7mMql_2y~64d|0J7JK4f&-#vMLOiw=t^;qyzNwt5J=B@ZOlGn4mi1kt zBdjNvzv^yAp`N_%l2VYjOY-Z)Y@Kytg}i+DS}H>`;oC^yZefG^5m`{zZ8r7wcvF9A zT}=jx>*UoJh_d@GiNnvL_KAuO&JgTgkFbZ)?=3LV*6WtDjusTU4@?wTbn6OP$DrQ{ z>Gwft9n4r@BLR$FyxyA5<2Cs@>`^e?+K|=B-#`wK{sW0b1fga0!pJ5|sAl7dn_;yY z;H~xr%+9r-j414nYJ6+Mdr5Oq@Nq6=Wo|fvv}&&tVT2gT(L01T*6rW*?+e12w0X{j z$_ZgHFFcCx`&cT(=T)%A`6D6)1ZX9O_2;3poxpgBSQMumnHt{-e2od6{8I;6gDz_; zeDMir052h&?F2>^u;$U_7OzH23}5@%Wbjy5j%)H&$1_g)@J?WuzbGq?)Y}O>dHXwo zUo9Wwr=XX1|3V^DWq@Wf7_P)NI0@qGTrxN-yLX)9$V|_92P?^6t?#l@lK5ln zf&fb6PIC<2>;w*P=+_0J$-l(@9o4^)WR_rg7qC--qGXSPUeb3B z5Oi0a-nsGj^rWU|TKk*&Ovn1O;#|4x-!)BZ@Sd6vr&UE-5_eld4UDN#t^Ei+VSd@h zo7=QP=R(6QX70wQOmr=F7D7F}xmdkRC<>pxa$!+_hQ^xuBede|T0`(vKe|Si%))wm zTRI9f6RnAMAbM_0;y&BA)!}}Y_G5Tc^vARNix5`*@P|p{T#2z-?)7rKbn&qyt(`n> zizj@qx+5c2*ef4#E|nok5~q64)Q<=rw9%-q>9a$X@b&JQ?!0kuDXrokrsO?C1om$C z{t^Jy`x4I@K())%yb`(U=*xXC!dGkc5Ojx|Yz-8V`K-uocG!PTqFTTE@te--xkXRO zO8{#g%0D1VjE}z1>Dq`NMBS1F?L9H*DOmH7B%dCccZ7~-Q4Lxlef6eb&^Cm)2FZEi z)UZ@@g;;5{`htzmN`J%y4n|UR&epu}PD@?FN(Fm!EA2;Y$?xcPK)5(#OB>3n~V4zE8j!^6bJ~5LwlfyjeI})MUPT*I|@oYcgInRRK zzbY}TcmKj@?=>HIGI-3F2uIiWRiA?%JD+)ycP3>S59DrOn-tzXdbFGTb!4N&-1$3z z<9PB&m8^JAseONGV|6XMX;^+Fj%|o*p8%{TfYISW!0Z3ZrcSS*qlZqD1D|&YZv$R5 zPLEkAa6M~Lah>KJ0#+$Ik^VmNT6@3GerjLwIKL-Gg0~X2nS})G+q{lz>f^r!Tn7UM=FQXOw~$=C*kw*7cP&1+8`woYkO)Rmc<4&SZ2g`0vK^8O{+T@8 zFtLDbsVLCSdT8p%cCP5|_|5D+uYW(n%)(Rc-y_fa@$lXW>Q3zKJ2NHv-hJ650Ixsd z{wWi{PIA~SPy3@xEYPJgBuQRsCU}2H@^?q)mJd-^<8A7a^c;zB(^VoRarii8=U)HO zbm0t6jAB~v_(1z=1QTt&b~)?XZ??nk64PLdRrR1bYeJLdS`L#j4JH0Mf;Q5td3U?hoA)35aubC?+8939Mrth&}0 zHM%nw1Se^@KZgrs*|!M!aM%uXqfh%8Ao@o&Eq%n6?@;atT#Iq=t@E)zdmtQ4&c1!G zAFqxx2r{6eneau0mk#Qkz~uO3^FgnhPdS%OJ{t|XBy1WW%w;sK@x@NynGiXc zfiQn>mbF9R=9<5=6WDwP`qXig!7n}@Wq|kZ1b%f>kN^%6!D1)yl|W@XfyX#9-Z2~$ z@BR&T0>4^_0M%!q^$y=_E{PZcc#;=Htgx62X6Z@M6(c{pN&} zh#t^bxBdTN~mot>4@5b z;_o4E?VTNc1NqA0lIAd}v$51@nOC4xClFfp`Xa0B*PHnB`Ez?pN+IMy zepDZA*@U95=0rr!WJ~dHq}G{KzUQ!V@AmEUFn*;XfQ2!1!+_tr3s|>J_bYlE0)-BrQ&qB#pl`Qe?Xif+QO1^s+mktjEu_-Jzg@L6h#~Ky4Niyqp zMe11N9AT-2p~M2QgFDexL%F2rGf|5#j0=5>TNpn~d_*3cN%{CJ__nxw;7wFXzq_@@ zn09GT%Noem79(-y(Q`Y2(ecUFR_tWJi%CK@`IqFJCbN;3MP$}i_~A}qHhaXb-!rx| zIAVSI#7ul;QED9q#a&;7+GyX#}Tf#h;#-FmW1 z4V~wj6D7b#^WsTKRtwDdOd@kT&g;a_6^rs3Zhf~2d9_4Pv@A4*>F$-^OIu-JF_xgLuYhAxZw59b~dOpT#Mm$8+6SSkD zz6NdROe9qRU1-{U6Rs0(w@G*DLsRQlQnIGG0KsC;ETLMvhNV7m8w<%`)H)xd-*IQ! z+NBF)DIp&Mn4qEC!y26h(9Vi!#eU3Y{j!*W)jjI;@B?&O>l$r4FMyf&!GBozRx)#J zJOKO9RofH@y!#-3DKqO6$m^s)+}?9ZUs=9F+ez}$dv^k#(e`zovh##aP-hCZq+fMmyd<(O!AUHuXpaMXkVe|x`NyHGu19&?3jq6vseT0>qeyCJKd_o z=M%tAe|mfiYx12+)ZdQggv16XCmF~>9kRGeia7yxmI>ok#bY~x)gt?M?*u-zki~okCwB*P-dy{y?gYlu8Gr83$zVDYa0p;`{Jxr% z?+PBtSx}#c7CV8#CWpI;;A+=zwG$Y?+5OAPua>Btz!we>+}*$DIMPRx!TxSwk@)3v zeasJdb{DWu%nyKx;X2nrxpb1hj+&K)jHaxx6{&vx&o$%KFo0NoES9z-eIfOXVSKYf zhbMeT!C9nc_jsH1psA9^q`r%}umrc?o2~c@E$|!kCp#4S*PRcCb%VSX)zQeh1H(hPM09#PlZYiznQYlEPBOQZ@NFiEH|uBA_b@knNgnUZ zrkx}KOr}r}1v9Rz{~)!_VO&A8HxlSb3=gYUBB!7*7ToKf*#(TTZV<3-tM6lCF;+$| z22^Hr1JU?jPx$IOrgd+C?q_now{$hyB#1}y_rzQ~fx&(T`s$Ud!-4?9Ks~>mz<;Dz z?F5E2*$bc&ww(kpO9WTDeveN+gW!p{!S4RGJAubM^OC`AC-7kyOfs0&NBL;CBRd9N zByl5LXE$)`)6gdKt9JnlQF_uJru6P$Pk$ORCe!4vCu4ngfe#I7`xrxZic5qOThK~Z z{&mS@0a8AQ%aed$fcLPXijH;k^*!q?e$%fT3phB4ql)?=iZPO4ANs8{&XYF6QMm3k z@IW-|=JS*KiwgM<99l+E^w`q)scnm6tVZh%M7eM)A}SfNbmfg6UqlwTQFuoWcWri? zMh#5I&PVAju~dtUyZTn{YLsEfMN_KkEY7<0iFG z3Q+C#1?YYX`kGxj$vgUl$2{iX6T5-0q%7lsP6~s1C}2rp;K^Zote++(<|>VXqdqYR z<@?2s`*w7ek;(qh7bI43h0g(5QQfI9(=W_}dqYonQ1Rk6KgYv`aH75_`shg-ll&dV z0^5(e6gOM^XxHsfbS?PVVS7^&qScJWm@cLuf-%Rnn`q{hquFXqhL3Jq^to<~E zJ3X^vzpuleU>+W;*F$Mx^(nV)JMWm2D>VJ*^Kvmx0pHLC@AAi19^|Q!y_e5FqD~4# z!Y`TfWuZ~d6Tl4HN%HO{e9PqT)w+9_JG>y->+y1oHyJ3B)I`C|qb+N<2-|?p`GvCN zz4t(^fzCS<$Cs0EbQDe9?gDPK!|e7$CL$}PqnUeBwGF}>i05EEjJIRR-qA!~=m z`EeRsE(2pszzpMF>Zgf#qOW|Muk%D=7#qf97bh*K#$r+KkM$kf#Ti$0XlH(U0IVm_ z$BEK8aSGjaFPtG0!y6$lo*2DeMlh$G{)cn?8pPL!D55;qxO5rP@y`U@3E*q#2H)EW z+)W76iM6~lPUP!7OfY2XRWf+a*Yi2xOfViH;i*sY@Bh9WImzBvAa-}~%5nOgHYD$v zoxn#u=qr%P;KTl`k9qUz3F7kz=W_yj*dS3{?FNP@84TA6<$W6ZC?vapZ_zYjTrOca zn)Me+8;R*u=6T5)&w7$4fuoxn1`x}S#nN`9FQlF^bm9^RjD0J6=1KrDj7S_ zp8g#TY9m*y?&GMT*)Z68uX%gX@&p+lG1%L^zm{NYdwaa$ zw>B}6<6n@b!CV>R^8pOT!>5C%bDnkVE4w@p#`( z%qbBt!E+}FX*fK+3;1cmCLl|khSv}bD>XlPOx5on^m_^n&>s7;pz8wbM}Uw9XWM|R zrr*`@Iwmc~q>d6_?Ff$RVA{N#07hpdoDS>oQ2tT42wU_0>6yub^B|i$C##y1{yBsY zgy$g9Q_DQsX$}ct(!K$y4CoSe(6M1G8H3&G5D2|sg|Wh_ugody8L{p|L!E!Ti3L!O z3l?&V$>f*=H_pXOx9HW|JAn^O;3~AbB5DGb>18oogz#jmqxXU!LtCTTzPl5cVGe(4 zETf&kC$p3I6wwSiFJ3}8`!qD3SdT~3?bY_w?y<_f!pYP-sEZT|!(+*G}&k>(IzZftM@^=E4*K0IbtP~wF8lHC}$4LEo zJ~qJxFY1f9cZz;%(}tZfxgVn1oP)S-EIv9A-6V2~c3%HR5F^-+W&mNN9~w;t)hmAVsyvJ1Ji>VoF}b!=g@i-!7AY$6uL?lPX%)& z`lgrfC%Z^;4+NIj@Fj)MYr!1MXUJHp=HkiT$LAkY=S7DKS1z~&l{3^*8=A{7KG83`b^cd1yxI|r%CN4>stv?;q1HJC0wa0t z-M=1BR9E@4;y}-;+u6R1$v@Xk*`+us^Klg=IwsJ#<-^jG@n zX9U$BbHd5`csp{=g^?Tx-R zb-|)}8y#DHBmCsh+y z!vjR3$fxl8`ioRC@bsAh+a~XH|KTBDMC|Wxi8&%Ng)Qy3sZq(SjZxR1IJKySbE=y} zNiF8KeZOt7@i$U)1(U+Mt01;0>{xLZY2QO@*FJZ}_PwO;<>{KByS`Y`=T8C{2Gnw` zuq$m@Y1&RV*kpF!&{BKOht#VwGQGmB6H8{Z$6lcO7P>YCH=Ij(Jnx8B7{9J=7(=4F zA$fR87Y*dq>C(IaB#;!?Czw@R9zhc5GQ&QV04{d_l3d(?S>Nm=gx|zFLwq|A5}rTf zzK5_4NZwgsatAKoGqjAJ@76poSkze!{W^MR9V&kzjmi)DK2ZA5J_C5kUvKB{p*Ys@t2=>*u1%7T#-DxbBv`{w zL3_N71TY=SM=}%SPUBaLSR4S@-U)pD<94qQ7}&A28dg7$Qc`iPs%wCvbM%NA{N& z(NUCo zdrtfoT3>Z_G+d0sT=i}jVex|P;}k7cVC{PN_G@SRp5N1(i|p11ndgzlL+C;ef4D|wkRujhk2tWC3SJJu94x#%2=iS&{IKjt*t zB)yVK{~1o}TtiM)01pTFLG)}M)HMzp=`6VWuR3J6~dgS^l1^%$}4fq) zYp%(cB!e#nSEfJpe0F$w3_l6YeCP{AB4QYos!g!Z(&>o#`^v$yh>6&drdSO0v7_GR zdpA6E(H_!TNZN=s50{h^$^AD%AE9xcY@vF};+*+huXtxF_F!H5Kx63hE^pCXdi+3o z^y0l~jCz7545~PmqNK2r;5t4!Q8e41YP|zTP2BIN_4SQisDzk~tzHcVH(rtHv5NaBJ0QNko9Qd$e2iOxO|eW3+J&2N$T zRXxR%X}kerjft`yG}h#t&(_;p31Q;*7;<@sTeTDTC7OLPceftxd_Da*313f!b%Z`)yef0&s)&=4pNpF4<`smJ4W=UQz;fr$TboN>3quc%Z z^(pA!)6jl>CUUQs&$>+9Vj#Pl3?9YaF5n@mjxkxp#j%B0OE5+YIOghrQh!OtNF;&l zi#g^Ty}0|WqlH+P!*?*$z;x_%mAbZmBH5KixWLF(9vyiH z?l2e4`a0j42*&Hr9&T|x^Km*KcCH6d(BZADI1;Q{hqSCJ(CtW)<;^>=v3RK$9w#;A zLNXq{O}|&6t~RBOzc(XSXlG8|@l^rHVY+(mwUOpuV!8y1uglyqu{g+yjmd-Ez?K)6 zucR-@mbQ|?TXhvbe`80mYDjNKFtFDy4i;4l(>iP$=IzL|-nj?QAbP=XrtOAwE~`zILhlkhBhgiDCSe%hppsjy?T}Xt{`q zJ`X+B)jZ(qIXzo-_yt5+4}=V zo7NzYM*MK|=d$Brx_zVgkVEPTo3Osi6&>OSDsjt(->UJP_CMm3Es|nR$4-cAY!|YN zuw&u>Ny6{CMZDFoe-q!hweM*wXlvCnJ_$4)g3gDEIZMvDfvkQW2U_rzy`Ejjn$c>E z5WAIoqtK$wwd0*JmV7P@=HhtJFU)a?hiT2NhqP9R8USi4lP4yE2I?#&WnyauRwmGK zGW%+muH`KxfJw&m*4@A%&(VDXyUH(;y&g7&gq6EA@9$gZ2>KN)@KKV>vw)3v9w=47 zO#u7W<2w5GO)^sX;tjEJfg?k=9{C5;HdV)@b%FgUoA$Akb?P#nLzdJvzO8c#1ZIUN z`E^owR#LtrbD*A+6yGjQC96X63Pac8m+(73?N}!P)t%6Tft{`}hp;d=xdGgvROb z9Xo+X-&J+f*8s(C;W>};I!g|-x>ugh5eV1hE|bANPZrsTo&6qt0{R>&bJU-PzQVnH zF{ic_0Rw>b6vIehW@ky}(3eoWL*n|5hR3*>YQpzz?(<-QhiR$O#|(%K10A|MelHp` z`a4V|3jDgT8H%QdTDeF+Cz3Alh;K()U5y?v`?Gt~qs>mCp0Ek4m{UT1yjY+v5G@Gu zaA?s%OT3Kx?@?#zv1iJ)>@KXF<=Ac_80aH|UHyynMQiZms9Y0t77aJnDA$6W0QBdY z#G>EP@v8~*0c`LvveN@XXC<(k^n_Ju!?V~KB#-^M_3kB(aE(ypGPoZ*H2@R{{0+Zk z^hJ?$nFJr4GC2W}7Lt<;zOfs4nj&VC@x$G~61TPc*W-D*5%1mnC;?m~hReCOf$R~^ zYx{yqP>Ij1>s11VA!rLb3E=R>gl0RtQLVO~d>k;x2($Tkp;pk4qTzSs z{t)-%(Rpf0$wa|Ieo2bC=zzQE9$T{kU%so`((JhlG4}j{yOzbzyB#{ z9PL(RVEXQ^;Fo@$_&tLF{`<4&BzvRZ27a}NC4!H37CV7O@^|#b=o{TvjXFU2Dd-nx z4ivOM2Mu`wcy!@81Ry_-EECOHGWgafp^s2(vNt5_gttQ&6`1;WfhFr|VCvg9H2rJo z7gsZc&ZQ6&e8TquxW)5Ch7T40fnC6DF*Z8pF5Pf2&VYN6P^qswPxj2-6BM1(ZyzQ@ z^dq9^P%%0`f__7c2N7%&Jpy@5>$Yub;@;w&Ru!If{UQ+x+yx9X+axVuWfYecdAs&6901_rjVVL<1DE{x{^V^${2V?C|AbrQia4|A4Lx9Sd#(7+j-*r|G#WLfT9i9-qKx``+d-T7F|jV4|^+P-;`!*D=U-B z3tfLeu%hdtf?o+UVHXu<2&&z{axvCP3Qv1%-}?o<+t*11>rXua0r1@$TDfgv^~n0yX;;;;D_CV;)2zr)697xHAV#4#fbcAu;<=2{nG zEx|Y{Ii|MLY9=24|MDeWLPy2pMK>r27e5FUA02)_o{-n}*kUv-cV%n#AE$8=)y$si z4tm~m-a^rd^Vy!KZ}nc$n}^^L`q?pV-i2Cf)P=3Pj@}lA3hOPUmVjV2oZJ3yfFE0P zd=uSTH-*X_t0zW5UCquSNvtSap0{*YX(a)yG~v5w?S8)RV6NNU4=3Ayu_@%uC>2cm z=%@;8pt!Pp>e!oNwv+jMFz%A;r)Gx6=2x#*dJ(dy7EvNKp6e5|UcQcoiLMPpeB+tH zOlRwx`}f@Q{z?rnsUqoeu?yH#Y5Zr5TOLd1Iy?`j%hNn7Kb8Qt31MZy=Xts%-?g%5 z3u62s4cwHBoWwo1{^$3FvGsEhOF?j5LG}K@4S^w0@e~e~sg(q{b1M* z)J}~IG4?NZ1g|83js4jc9`WZAz&i=yvoK8n6qr~q;!VC8u4VqgaNKDL_(jXq^HbD} zZ>i{t!H#i`0k@%8C}Ck33t<$yK>GtAtzjw+$I2pWFou=M#8fF_VG{?Y(M9uhFC>@% zhe5neITjOXJ?UG?Rcnet!+KEHo=;Dw4Ww3Tv$rfSEG4MA9*``M0*HT@%aN_!z&De^ zD)B3QvBVh<*yFSHIihA@Z=pobPD{0=^6?{Hn)BHU2 zc;O&^AJ{o&iQwv!&@grxgHs+cy2;;BpZ#?7y;BDO^Si*Hlf958h^x;*!x{Ie_Y%NS z89)`rFhTc|&}&KI0n$gtIj4jho3e71TE49#Dz{ zMqS&~W=_dQ*yNNF5N!8NZMbA_*?K$f{nTEEe8|qz-rc3xtwb=u`GoWcRWEvcrG)*) z3*FlN+bW)qU|_A-MZ!H_uO~BMM<25|Q+5$p2fNa?J>RqaU2^-UV(JIpx`(F)7bclu z?J9~EJ6_jUoVci6%;pyVk}XgA^MQ7k+{+k=gZ5^TJ*|hZ>kyBcs&#fvj6<~}(U;14MU}S$#F|~hQx9MU4 zn^;EULt~9B@b{|oc7108_;$iq_H}Q!udbg3ufD~T{BBYht!l!z?FeN|jvn(YDh;rG z;#0M|RgYIH7Yy{}iW#=)tY)$6b0I{vt!f}kwvo$#u!QV+3`}$66JA(?amKYv5|8mgNy-V0%NxZxTFTLmj%mNDGD3Wu1 zK)sZOkrqu84Jv4pUb{DrOG1)g=D4CcFh50hld zIQqeq=}hP$=tSzPtUnK(B!I(2FwmKdW-@5({^jK{-qhI%?9>6BWbQ#4bN-Ivx{X4EUnjnSDB!;@9Quft z75e5OXwmn_NU%-R5jX8GzWJjKow;aSBWekjT3cXgRpCEC;i|*OEG$ww&`?VWl66#% znphu(*Tc*9T)}wVQmC2Ig3_vHi>`;GSLIwGuNxW94=TQu6@!Bf)KjY+oyM}H!ai-! zieFEuu#sH^{(N&CnYqOrbI+d_W)=Ek)6(g=(#fU%Y7mjK;%|iI$B;A0lTmDguM>#< zA#IF86PFKijC;IMAk7PR`}%SZ`~C#5lZ5qf89%a9$ub>?bq=Y4KT6_$`>L$m)c5?w z0xU5e!rhrEs;F=W50xo50c>n~KD3fe@p|#hgX8y|31HKItWBytp-hd%TN1#iVtu53 z79w5;BY2*!#C8+HKL22#HLE4y7cEoIO6`f@ik%$$aCCm|Uq7AR~O z@yAc2AnFjy!NQlTR9J4|zyz9v!VW9rgpJU8^Yit$2oS5jOeW!;TwiB%xy5uQQax`& zMOA#7F4LXm1OiYt`fAZhLdg0+FImlQV3g}-JWA3}miTpKLd@nvm=Hex3_E5wFv@dD zCVx2({N!)KJ4~r$BjEKccyY4d1y=Pa89e&H{zfn>(?|+V>|y^5i0#rHN!+bOFktJG z(A7?0FEQ*VgIOKsuIXcV&oL~Mznphjn)7ez^UyQiYd(fDfSun5o^i*?;OEF1Hyn}J zF0{}7AV&;m=8OJ+(!fQ zN%Bs*=R>2nT3@9fHUutxPmG&)9_osl_7}r==6*>{(M@k}ySe`ecYi58xhjioh0C+1 z`*E!xPl8O+Y0CD4vebV7)S~##{_H*fp0$SdfGFr>>z%B}!OQco%hFh&V4p9&w)vu!Zy9 zdAvOVjNGQ@15F(jNXNtLk0pS?xBseV?R;|r_$|p{w=R0VSw9P+{tyqKo%}_sn(%Eq zLK%ns%M}v~K^(T8hXJYz0P6mk7_2d(U>WGMb985HE(Ycp zCy20B7{sG6>8uFHC_29^CQbULLxQ&w#<6eJ$C%4~Ts3p8Qd48y$AY=`qk6DLq3HS` zmaF7k3eYaK%iX|>NntVH=c3NrB|Pn*%Qp#OfP1@vgWm-nys1LxV;i>W;&+4bh*czm zuVm!(;eH}oCyUkRh>uQ@NnHLVtobzGPXyPWga%U<30^wqz(9da7yX(XxS-cZMD}kXI2bX>YkwJ6=N7x|j z@LC|l!^-8hZ>TIgHF!k!SJF7?WO2g?buwXKoQF0}p*dea)`JvO{`TVcn=1dPiP7;j;_UbqoSpt#r}LS%-?1fTE$(#w zB{EOwk7L}!x>m;W?jfNah;vgXRU};|!S@W4Fs9I@^p_*HBrX$>7r>AuGd~03YQ;ah8E4 zfY0ZUk$hB1Vej+M*YUM0mmR@R2+O1|IG=lA zw}5Bgvv|)~Y~3b+dHwb$p)WMo(M6pUX2<`}-j`@Qj`G|}|LXUDW$x{mLBp01j+1k$ zzhYILfV4F*7_b$bt5=Gjf->A)l~F#)Q$YQEqVRby*>LZ93+_4zbL@dxAtAgPwUiw^X zRi@y&SUUk9Lf(MiOSg=OjjAVqH&t!^gm2YU-6a&E_b^Xs7PtToZM$#P)vORS!6mu7 z2|Z+f)W8GjY|g)jUN={@t$J!}LssmHicOW?*QY(ZZX&7Fj4>9s*yqx+gM148>@>;3 z`7uI{TtP+cY48qg@9NoJ_*Oa@bQ0#X83U49LfsiQVbk%?!5i?aSq&qzI|Yn#0bkp+ z_FJHzet!VCD~;tSm3ueAB{}(qypwqqpq7&DoXItrj)jjYz}gH0ZHDH}+dygm%So9P z$JYt^9Rz@7+1ga;kbt02l>?Q6TtL<;(8vlfd0a z9{oE{dnUo(MiZlHC&6DtlVg7QImg&x0C@4J{z_<;^)oV3oHS{E_9m=;dY!=0J;DFcv?hZmU_Y5YaK0vNw#prCUA(*kQ?R?+WczV89(gzT{7S6V#UJY>^_voTeMZdA;>h zzzy<)ytQVVpG#_Ko)4CYDjso2=kjZ=l zb}^Ii^#e6P48sCrz9H~NVOSr|_zo6pN)=pkySABZ+z<&Su(iQ)K`HNr3OH~rZHvD1 zLhsT(+>O}_b3drNRR`yi6^VfTrEFgy+1ill zyu1^ylI~9!`tylOAebIL39QSj9WJ~s&)!wE_kzE@wb%H(5fGjM;7*0StEq$?m7$0{ zG=BE?fn%k%-WYTGYG_QTy&8Iv{sEr&37rXfe2c~bF1q<)Km!^}i34iBDDUi_rw3Gg zT;s>mHE-JN14?PHtqA+c^jw=*twcXn@z2!hvq?^w=f2-qU0PbapAHnYu<&qtL-%uT zwIcbG4e>ve+=eX295OwwyvaMo|4?F=!R)<)?Kpzny^fRuBeyYgm^I1-y{7-*hxo zZY@SN2ljco#)uVakxqnY5%Tg6)v*aamJagiiD18P`k4SQZFnI!mwhYgMHMcw$wLt- zM^bh^R3fL}Nfj?N4vC!d5%-w@unp8IgPq5~Rd!`IAgx!o)1MFkX480{xBQkZ6!JbE z@I5W}j4Gegj)J_p>B*0q9o_b=q?7U!h!U#3ofBi;zu zAw8VAVltkaV*r|Wn7qKL&>)2aF4q|LpP;0DxErsFnejgvFwW)e{2B9O+&p!C@xS!C>?o!SdAa#hYJ_?iFwWU=i3I!O5%Q z^5ia47!W4=N@#t*{O(EMaXNW6u_*T%&bAqYN4Kq@Q9by(iah&xj!hX5?mnS_@Jv@z z0N#}7KIVbpIN&>YQt+MR3`|N`$4pugylza(^-DGGeva-s<4Z|S76n)V`V;AMtypZI4_W?&OQ$gR80JO0Y)^9X3 z6;w-*$PbSZbM@0kA0nOjR?wZ0_qv8;X&!4e%7gP^tEdlXv5pq=B<=1gVB?Pu04HT2 zAL254_do24Os|~j$>uDhnJk)u@duv#HKh0YVnlthMFa!1bfUte0!CGlh7P@Vj zm1SM#y-n7Cygf$s*aU~O`UJt?Yb92t(G8BO+s zXv-5CfNo(K+Ey9@1@^jtJV4-_NTD^(_zo6pN)=pkJ51E?IR;uH+TggLl#7Ht4qrpL zh_AyAll5`lWbXxa-{bGEr9617~eyyKrq!_3H@pdnWtt>0*^M! zg2AFpEZRxV-FJe&11rbMz+&1OqRZ#j{XBmqbl0Ub(D{*qYgS`SbHMvM}$!9?(@4!5e#3+Nf9T02^bxef4eP%x1+e1w3yj zdqZnK@2HJ^&lGtcMr&%>H3N*Vhir`Qtlep-ln@K9J8r}!IKfrtJR3}HxuOj_W;Wn! zTbZ`=E2IVXD)~(t;Q(-fmeD7V5?;%kD)2v<^nx0%`|-n)Pb3bBoZ~tME~`kMwI&Uz z1b|tJcf5M~K)){l+<9R=KNSG>c11OeUBml$!1uJ=Gpd}^%E4bnyXd-q6cy8*4va^B zd!FV4)fD@__iIUG1soPkzW_{B2ha05cFzKzI0bC4$s7YY8w@}*?=eY%Q=vi1`^6Wz zfaw&@_!^Vel=CKI-t~W`owoyQcgpy(k@IJ+FcuYuYq&gBE~G*d&m5{ITcoH(O~L-z zVDk>hoe&Gwz`4M%+?@V>QA<^NMB|vPX9j${C`SfuGM>xR);PNAXtu4zO}|bIe<)>rjLzYP z_{mrZiN(|}C(-D@T>?cb{DAjpL)*6K9W9k_K2J7JfZ6grQYP+sU`N)la6>ktJY6G| z6fbkc@R7`pzT?qZAAL&0zp1)T=UAps2ZA{yatRj&9~elT27uW;&#+~sG%ah>p9}y8 zys(~00GMt2NuJfc9F#Tw>S&T0jm@g0nUhR)?_7xe2WP-jQk=;1=F1oPXc{;$nHn;jTa6s>y`b!n7COvsQv4F^=#H4ql z0{kZbccg1leG2w|IyCe(zy7BGPO5J%e_Lu>!WV7-P37d5`Ra}a<36$<+L#Y*rp{D0 zS-0FNGQOOC^I9a=%FVi&9&RnHxgK(|P%rGrDQQRyv{W##5MZ+8fLOAk&--4i^ph!T z9OTC4MD!aCc`zRDw|U90+u)04KN}EcW0X-H*z2{= zE*vkjCn^vHfOQ?>V6ZL!iqpWL{mKDfS^s60Xe+OTgK|V$^nC0^v`%+4UoV|?PB_24 zjOGKAA;GK-MlLlVF`StR*j&H>H1i&l6gU+cq`Xgj zkqekX^(FJj>!Am&+a1q;veUpKxVtO2?w~AJUj;4m z>$TAOw}M9>sMkZA0I)jUOO4V%Fqv0EPk-;61Rmu!ujx^qedq$htjAIleM|2Je+PXH zzvZi>u|0iWJ`qgn)(Wv*+%qYy159Mr7cO-lLEs4Hn?z$^`6`BC*JM7ejte@L#`foR+38wNiLe>&Uf#S z-&DKLy;oKY8wD~I4AI{9EnC3rVh6-FKeHF`_Rj5SiD10QU}`Whuc3pP`aH5Zoov)E zv7XT%&!w>6@2Pq`9Vl%TM7yb=f$Wnfg6SF&q+cyU3$049+JmfnNC*NN{J7&7;%S)20 zvqw_ycbwG0Ft*NWE(8*8_xL^jJ@8W ziVyR*1LPnmT*M(3SFvxU?I7++?k zFeu!FfYc>6x%$+v*c$`-`Rk!IO|H+ax5J>d5l9SQ4eiU^L|c@D!8(s#3+;X@*z#WV z9@M4vxeWx9eI>N?$pnRagI_0sN$3`BW6C{8Bu)p<9+P9H$zH4ke+ONTgO$N_9l(9} zM6eAG6D^-sN!l}fDW>rK6n4XyIC=1(AU`6-(?cg5S&|Lov_PTA`aepi_Y8TP(m2E2 zPE|(;Zvwb1joYCjNAFen_7|TXZRh`T8duh{(7)+_J(X9BUzZ;7{Liiachb>@;$fW~ zcL;yPnN(M}F)8d=Hq3BKwyE4bKR%ca)*r5Ig&s+jrO^h|LWvXB(V8s4azkB^SFr1D zjQr00`qaPQi*ZUtX7epf-Imqo527A6Sf+oY4_Od3feQCtX+*gej{w2AlUZ~8IM(D_ z?~|z&C>Ocy5V@AN;X7V~oU*?OuAPXs9s8hSRD`yTnwCm;`2$DZN`*zTKLA3jGey$l z=3O!Obz$o;JXV3yWa(#uZ7;63Kp-2o`K)-nrHF4 z=O_?-HG??`Y+ng&^28$8O-;WRdi0AZ%i!p!RCw)>L1n9LOm7e&e?H>n^0& zqggvS_Hpm#(^kMd`DxZB{xrx;BwM5~5I^M|oe0KxYvaFi3iz>Pmdbo@fD!2u%f9f$ zipY?_TRwcsfMhL=wPwx!Z~!>4Qw;!T_MGjKE&dg!fz|x40=^FggKgRP8JB~)Y%wv- zG%LCKOC&pOvS76Wx|d;qFs2H;&I%T8-Bg_Lpr-)r+d9cg2V2Yh$@3sV?Gr02c}6Z=rd-%or% zl}x|mi1wB4T4e-l08g_Y=B|&q)6>Luji{Pj&x6uZp?kcEy`JsO!*uEAz{;gp7dgu}GwNb!bAb9wtPY3(c zzu`$>;`J)!(J!VfgTEu#y~a~-bM-3d)jzj_!b|O{^McVBaQ-#X`v5QtJ<*Ba73@Vp zUMV(16KZ6@wr!gWUZTE(9N@ap+IHv&ibprP6te@qtx( z1o=1p3F-6w`P~uocYOX$|BI=-&p6zWc=(MSK&rDUjB%-SxNnI!G$+1Qb(ZA{vX3Nu ze|aVx6n`RJw^F#2qIXaMQ2|>;N1ujTurqCKu=g-z=jw_3N+% zYa@A&mNVWj`ah?qoq$8uJs+RZ)Jm8Gy-z^fMolBy8G3A)0uLS% zpvj%<`^J*xY}ga1P$0NVXU}m#J3xEqL@+PI!aXLV!s)TWoFkoo%4uMkCkleW7em%4 z4hEb4&B`S#VZV>cpgyP`5slGU_P98}8_}JU+F$?Sc;2t!a>OC}<|$xkCFM(4TnAit z-tY}NXa}W@L(T?c_3$2}GJ&lSH^^~cU{W?Vtsv)or7rKg3PxSap(`ATr=7FoFZQnA zaQgvGv~jt@B<#{&=AGo8nSe(oIYI0Qn}zucbzvKvlcX-znYE*8QluHZdcA_L`<>Nn zLItbpho^z>1cVW7nq;xZjf20hdoEF4uYwNBo72CQ{0sokz%A+D03MF_)3-KAtOCLv zpaa1~{&cWE{hOZz?rm$@UyiS;P<>F9pl*`1Y)be)Qb(Q`)7T=oGK+>Z z4saaEZvEa@?4AOy!#NemS+F_ZOuKEsxy&d0_}@9DNf%WC*Y9I8 zPI^qWn&w?99rcrFAR81;+wP>!)yAJU)B$2LG95JAAp2cl-R3H+o!Cv1R6X6S_Nx{? z4(L;Diu%Mf<~2w8ja*xqR)OUzDA9K2VghXiOiZi+p6#>RDp}h0=^OM-FsfI1chc zDB0Kdy)0=wSLdWqRg`ry6k=@7zTrr zz;NqdWKK>6Z(k3sP7AXNBjyyIy0V_3Y?$d>VN67oni==m2r* zcS4H6-@%(QO{yS&QkcmFfYnLgOYZ0tuy#TDqG>B#Bx&d*4TWB@^CJ!Zx@m@NqiPKH zH`n7+{5oikX-w~@vJNiN2rcxCQGtA>HuJYs7k)#wpz|p`@8fIAo)|;_#q_;)`7c`f ziL{wv|E51Y;r>19^}``@0>3}{VoA5rC|S7CPJ$WQGzzM?17ih=aM$=;x;sg}v-$W& z8hc}%E#VJX_Y@jLzway=sbIINuKU9f({FbvYml=yF5hcG-9X^;jxJv)>kGrPQ(iaB z&041(#4TzmmGQS8NaVOHaY<((wG+t)C%cAe&sq4Kugw*nTm*oP52w;H)*s$%kp{P2 z`)S~bK2>XXm7u^a6y!t)ep=uImtI45A~*}iPVx^0fU*9-mN4k*7u78a1`9hY%R#=E zr)rt^{^%3Grbm<0z&`M-JL&mgFs{q;CBhILo%#g>UH7M=`T(IoUca#^e9&|cjIK-X zD^VU0#!?(-h#_zk9U*`+`XKBGr~JhGfN#)28yFWN&IV(UmB6CPxI)_giyZeusCA1O zM9%qw!G%UiMy|i6Q+N0tU;n{;tth}01{?YLE<@)2)MY-I=57USL1JG;Aj>@(@a;H) ze@dO`vnGJ+w_T+?civFYjGSHP+w{%O*CR+R`+VtXkiI_+ENR_}ocjXWIB7aVi5(Z6 z4$fW;-9|f%`b(b5U-srJqB(k9GwIX4WPUTa5(p-F+PBR<*C&CcKjI`X^?+~iTIe}e zc#V&qqZPO}eN6;{!h2ugQf44K3i>w3?Her1H^JIVQ@K?|y#|CyW*B{9zdMuB!@s6acBKL&Fd zxd5E<_?91%%_kDEng>%jCVor$0pGrB@_W{Pb2tA@f0Ik`TK$%==LA?teiIw-EeiT# zTna~f5l||nWbbz-8ah>w589RJxH`~?&r)Uf{L|;xu}?n3bPC#!&(E=Wr+YqC9HWB& z_uVk^MTfR}df|&BC+t7Mp7dL1&)JZoeCk`jVS^?>A=PV!<)cX`&Gf%J6}@z{g5m>1 z6?IhwP7eL%?+v{lOW&|@Hx(XGK=>Ai+Nu6s~ptt&S_xhgXFpVs*7wZS%WwjYsz1^}Eal zr-7e(Jv58!sa?Lz`Mr&x+VI=F8oC$^o+oiZzI-ZJ{LU{9_);7Q?lx_JSN|rk^dUM4 zJnA<3y>&TJo=wLjU;F#wu7(%mruTL5`@p@I)*zGPiVlLm!pB9wd}N}S<^sa>z=`0c ztn$0SgEc>y?9V`(U~i-$;Y?*7YQp9nqk*7mKu=i3;~krB zDl*kBo^n3~P~Mn<+ky2dZ}=cCsG=}i4;172mE{$7+aPZxrTx0IC1?+kN7E{nZcpM! zI8xsGwcot$lNp27RK*9P&l&Wwlj+j?-2 z@8z&Ark>?suqeB{waKK+{75hu`>3m5C@Xp3zmq{2-|jJP>2&f7plO0`{lIhz{FW@p zDF%c!h9U4O#JspJ04j)iU@`GLZV!pxY+^WeY~?ma>@}ZbD7O{jBsp)`Xzb(|a+S^c z<@!h(hUG_GnVq$5z<$S9A%f*lIY#w^jA#8O66`PzN>|!H??*y8O2TKY>){! z(UQ}^7k_EA{VQ!;JZYbMplvW4Nx$@*edm<$=K{j&H1N1DF|UGluZCXEs4_b7o4yvh z2Yn}=&8gsR0C*n=Cc>wF@kwBjAAXX4E%a!lz1lxu`RD5Ros@5-&0_Zv z>z~v9@${zqKUaHf`N$u%Mn@VT`AuvmZGEm*s-jOY-G*hiLE11vecNVA6{Tt3gk+mh znOXr8lK;G!RK%!m0yZ!Zy&KpOu3P2ga~6ia+%YAyaU2I@trwKrD(S|^ViOx|-M_Om zoc4N=rHgjMhba=v`k^DSB7fsIdr|H57Z@xeuKk_#+?v=9sDD5@aQu(0W01hB~cw>vGCd=cE8wFEb~aRAtsuZ%47G8C7q0a{yTWl^Y2^gF); zTe3c3-LRc2Yuk@~o{T>(Z|z?UMtNg14+h&d%XrJDUD=J3a+zFR22Kaxl~IYR)0QGl z6LfbqC3NpI`8Jg-_L?Tew2v#Xr!oY}?5=*wvA}C9%a9++W^LtNSTbJAMhR~*9A^kp zq7E+MKtFJXkp#{$ipbl3OHt0XO80p`U?1@g&3apFRlLl#SM`{pNVfFX<0BB|IOWu%HudUk|MU!op4= zAZ&xbFA9q>)iGn<$FGXkWfctWhSJwU_xt~bP6&?xusI#<1HgG8m`DbFiRAAEkKb>e zr-DKL)k30uQ2A;~-S)4xQDdJ;H2&(JA-Q`U^b#PFo(nbo&%AZ&-T`yWTPt$k`ZDWtt7hw^GsA!#;hrifQ_eC%>n@&!u~HT9dR*4MU$# z@96!MPoGSi)$X)PFZ_wn#{N6$EjJ#pQfaSDLO8tEV{?_PR}%1*GElGjb1x-}qSfDV%oI+0Jspx* z^8Pz%vuIVGIJ{x|ED%hD9*lf5?e5j1`+WGQ0?C7Wa!ZQ?!dFI?ZP1Oi0##<@Gb=5x zve(I4zbZ4dy|v}?y)2rB3wExoZ966n;WSvvf&C|f!K^!rRO^_w_Pn_KeDIeE3~utL zem%~PiHTdx4b~u#D=${CTQF^IM2KMR!we$zA_q!99sCE7%>#>x*Kr$3^k!4@*a_go z4u=I`3t}WXZ1BVc?=h3eIYuxzcT|25)W*S>k(8vpW{Ed^oVka=PElxmH$XdXQ%xxI zk+dIP9^B$x(`qV%Xz~Nh*Fteq`ZQ<+H@!S7SsSocL1A7xE_LjDA7<>_uTBGZfz};k zZJ^&1H{o8lBkC{%!iv`Ic3S0hFpryWQ&ztl3|1(#0(CxPH@!v>LO zAhCNn99Mme*ttF~$E`f0?VNM$KJ?R!eEBkNM zR7c=F{h;x|7)=!9G@{LknMm}Z8?w=%$VO%LEROVV;ebN=)f5&u(YiWj%|)Jj3si8Q4HQJlJjTlIbGbgN;B z@Fzl_sM8HiB`n-)#5;y+OQtwcO!i!XNUSR%6^39{QI^IUD>vU2iF@25=$*|_F^&Cg zopG$`4di=L*NtpVYzw>YC&RY#ysq(HWeY}gF)JhQWAtZo(s$##XJ^_Hr}o6$1J}_a zuOhKE-TQhi#FayO59%?Yhl8aZGybOEmgYK@FIek&N{$;k?*@X23ZrLUPI9b%X3VL( zblzbGfww${WKL;-`Ya znNPd&Hu$Rv1OvDRQ24j$wXQE8Goe(Fg{l*qZ@9BrqoH$e)xKWgitw=pxK2kJI@vAs?_x{bB2F;R^%;q;@VOk-mkkp@bnU2R;`#ZHrC$blsrM#B5OdSWW{7asuL4f1=bbQ)NZiw5s5n(a;gXGDKV+AJ9P za(zqpttF}=HTNbp?0SkZu7!eM^16g$_U?%A0ac8$1m5zI%E~LD-RV@t=CnK|WH}4M zT3=b{oT`*^(!p0IZ8EX#bO*f1j%d1iFnB4f1c2kv=uMEb3-MudEwB&&W+bvPkS_WE%=M`l|DGQN^g9YS7bga}ULvUXdD zu-=}JHS91PX9!X_v6Eu(xY^?fR+Kk_yI`+|SNo2SF}!g!8owJdrQYwZ6-5ZHMMO!* znndyrP)yq%$Wz;~!~=~?zu3bx160&z^@D4?HK#|lR};Rm>E*WMbl{hbz9!7|*@?Ah z^OUeOJp)FVt^>lCF7C>wBn$|*;pU~g%kEzdy?zJyx;I0?;LUFYn|$R}&@^z8XFgpZ zm_)$(#cl>^;Ci@lhA%ObZv&8{ z-)yjV*?>bJK67!cvy79`InJ^VGr%f6|86z9w@>v3wvnbEH_CDmK_8Ww@2Si3^KW z9Bqif_UL377G&a7zj9;QPfDP4-7M{CnTked8>tgto`X;PR!#%g4WL!vKj$l+tbs;{ zCxQp>&9?0~pG0p2gCoImdq#qoyz^FaqOS-nOnPQnTPfEAblCLWTbpW){;O-^{w;TX z)$azgA?0>zL7pD9QcG$czcr~J=)Wrqc1~_DaV8(@DmXm%CN=CU2|pC6B5PACP9}%< zckWJ=Gd@um3>z z%Y3&2^B0Iw-rBz%4931;UQjmeE`7B1K>=G=XImfA{h&0tYeM5x4lCO^#&J!E(i5Oeq1=*<5tz*zKGLzVu_R{Q7Fy&-(D$md2nrttfMFnbw3DJ~A6|c75GD#<1-*j56ub(W zNRJnu{l&9KSKpB7e|hz-5!3X*Zu9%U;oYOb&!x95d3i#kiz3n8mkElfhHl;)@2)vy%0}!XhGm~5a)oGkCIPoZ}Lt!b+ z+j7%D+iy};P+)_=C2Y(%m%o#E&wbS1Po$d_*p8e3PI|j8k%HS3e$`;XfLcgbYNJGak4}V2OLcAl3%fSIWGd3w;6W+bhR-|IXeFpiFwP0+14{)Ifh>oqjwGP9kpV$2%KyO_u0;-j+q=R5) zqT?-pYRwdi`NE;9O;mKROCoxH3CPkkoY+WxWtDw_$&ML~VQ{r?+0jn2_3By_Ivou~@Hb7nO6qf$6 z;Pt<!_E~kChjB7$j2Cnn?#PqEoV1olTwxS zCODQzHz!SRX^<`eXa=CMf{MnSJPq9Y!OOSxyRhSLleU}D$x0^+@Hz=P@NN7fJs%0W zoWF|!;dy&<{h74VZv~4o&$HS0Q+T$Z*Wj5 z%+Qs@pHr<3lvk9ZBvYz$!w#Vba-oh)iL2w$_rS3B4dG7x`W~g;hHlf4x4}su_~i6( zNpB}_`wZf+u0X>E2L^nlqG7ep#u9f$%`@JZ1OA{{`P9`$k5n&K12{F(x%KP5tEQ)j z)qaUmCcoi@Mk|e=UHHn#^mYQvioE(nd{mTES~FSy>mGdvgRZImnasdv)aJ;=cl%c1 zyFjvGXc?tNPHZ5*ES2Z~V9ip~{^) zaH7lZ1cQB0H{)S{YCO-c1b=-n*wmBkxbtHzr%`;Z90z_8mEH^puR;K)EJ*1zGgP4r z<602;uHb}cL^luoa1-%{Z;@QUP-rJHAEQ%@B=8>Nh2nxVP^7`qC-jiAEpf z!hLTxwrH$?eYeKizbTFA#enb{J|iH!XysQ!4*^r;5f#w-P_21}oh^gtIWS_8M@@b)7Cup! zES2f8Anxx+X}xsIBJ3-yFIwd5NX^l$v$`r?xlzg*3z|^P#aMqm z9Pso^WBHZ3ziMK;n`#sEb}f{o<2hffc5dyLw5=Cg{f>HjUsAB%_Z#E-D|sugMP2wx zQg^7FVMc*5UGNx^Ii<1wla#LvE4Q`(QGj;oF@8qD;3-+P$ji8{orqe^h1(vq6!MUE~9Yzv4#~23F-Vu(3 zLyX7f{l_It-oE%++F~3oAHOA>g6&$Sz}Xrp>ba{W+?wcz4=t7VGl< zv$xsW+RHIxckV1c@Yrk;Cu~W8oFVqX-vASw3%Fiwu}C*_g=y<71C0^+I(M zvX!Z(PwM_g;qYEkKc6V#}>rd+aJE zCwPL@Z13Jl{bFDHo~49+aj$(~WYsBuJP_>q;RL&>TZTjXE#J|gnCnV$ftUEzGdroX z;r=)2^t#Cbc?dXEcq$qy+Q7EK;JPXRyQrCpTfB--3iDoT?oB+TAE?n|Yz0yA*o#JF z&%Zt-S_EamE1|7Bh&oF`ch)YOH(lRoNqHOWsi<_8ett5XQ}Ww$vs4h}ExKZ~=vY#b zO3?PbeMx;m9Cwc8H*Bbj#9lE^r`^)h+Vvd31xTffzV8e>)S%szRpg9 z!&TuN0~`pe3(d!laHgOaQG+hKRlT+v1b+cd z&g(NE%)(~tIqn{*@`dAmKhW1dh z>GC-1aQ>O?6KOq%ocgE9#G3*35Krpk!}etLcY$la2^^&T;&DTs53n~9-(X}5E<1X( zU4rRwH&I;nAX2KOW_J;J(iL={mR^y2ja zYO#x!R((BC<9&{pv~Bpf+tFat?Wn)4U+-NhrD+D)HY)z~^wj)q*Da>ngM%qoT8*g= zcz0h?G(gn%2i&sZg&p{16&uKA(T^^3QVpnD0d1=5-xM(s|o<; z_H-~9*iyO^yHdBy@9LD;+}KzUneRB5CyGz}>c0I-FxZW=Jovlg@5{>{MMI~46Pj|V z(@QiWI(hxG?Q2ihJU}AdLj>AGuh%BX;P?y6N?gpakaxb(c)gza$)4+55HM=Vh`p;r!R^OM{!Kr>X7^!G$E@}ujTx#GMZ$#gu-ZH)*@+&KH zOyRTH@;%+gvsoMEWQ3+I);-O<20o?tlR%%fwGRRpCu$Eb2$t6cw7{Va2x~Nx_lSW$ zuWbrs{%}Bdyr`amFE8q@`8f7MVn&8Hx-8)gpM#&%^>nIjVt;?S;W)$WpP6nxkJ_u? zGBOk?`^Gha%{tt-Gr4@LF~S9QuWYi=0!$k>DK5u^I4FiUkf5fkw>Ztqu0=EZY?3DGBmQkZ31|v-UuH8eeQ%Ga;UC3+TJy z@5@BBK(Fohh(-jjm6O1)Xy(-v*k1 z+rlC@`ZtB7S}~e4j3n|NV;Je(2q!)P92b-%J%0U7+!;9Bj}9RT00S6Yz(8EB4L3+H zSnc;i#7R2RX21 zWzn07E4vOu;snZ$Ufkq3e*z>Y3%&ED(m6$Zu_I1xej-&iOMgbvPa=dtGf0g&Ve|=qgD%&U>oTI0lDUH|HMq- z1G^mzE~Yzk49gs6d?FYi6FdgOmRb)0+p^FF%B(Z0%-XmrGXY$C0eIfuA8O8 zwu*8lpsxaelC#Dr%jm;72xAT#wK3B9$L}GzOoz;mh*n=wgn9bf7j_?|7vJc z7OzmgZsYi+&%G8p$&Zu3Dk$tu0{8W|M+J8Q;AT(tzA7z#ADGyk5Pr3a3pQVk*hIfF z8QScs(avZ$&SJ=|{udt_JCh&WY=puS!a{C*$>8J!FjIb;rOTG<>1SWg1)M=rpI5)u zZhzKO%c63y11?lfG>%OSNey5>hp9ir6xj6JdpPVRv9K=uA#DQf7Sg0wq zhj}jwJ7nNj%n!VQxK*rUF@$%3$5N&Ke@@~O=?3JNCoWL3RGM|q4BSxZmA2T-+6$RX z0^R}B^>@wsEs`xVrWq&`Dw?dyvg=te)E8ny3yP4`t$v-M{r-9(yA2XM$86MrhlDakT(%JrEpN&bv&(h{|34k;EUuMrmwRKY^~Q+G>T^f-j9sAkXdW z;b(YS_HOkbY_?CSw=GcuhvcA_dLgs{7@BmWrkO}t-5N+uon=vQ(RJ5lYj*+Ar2+pT~CXx@gNf3%L|5j+p1j&+SWI2&Q|df{8?R z4WXt*sd_i5tAQiFC~?lSD&9R2%!a7Toub^wWfsQR7>7I-)}$?Ef?a;Os#-R+Gd06H zC3-=Scafuf*DCV`+ggT>gTX#9y!TO$r!1g*68zQmo!KCmuAm12V&#sj%ffov^w2_) zR-%-yJSB3_H~^}oY+K1v$deGr0c~Q(Mpgaf!GH*|Rzx3?ttZ z9?wfM5UW_sKuX^AwKzOO>`pv7MFHwIw zm}f=wtDadF8rtPA``-ti^T;|S z{Hh_-ya$=X!UTV9`?<6|6+D3=-~2q;6xYav7l#P`0^Dj=;Pz^#sV}*q7~NsPFn+p~ zH%_{zAo8=4z$x5Be6kYf7be!%M-HJ=!IJQglL`#(Fcm#J3|C-WM%GfGiUS9^sw%5r zP={Rrc*oZlZlyDWdIng}-U4o?v<@5A?@62%i>zOg&OQ8WoldkDCu@FB94l!t!mPV( zt20{#BgnXtTE-3-f$wZLx-nj|#P(*3m4t5uf)%xZog9ay7~ioCJLQy@0=9z1Phu-L zL7E$m1apR~27;?YH?SNwm1i7u^`oispyAtT>^oCN2)b*b(*)~16XulXsXKs}`IGbB zdExfW26?OTp4Ow4TqU45d$045lo5W}G>NF?Da%S_=-KMS+v79VZ6FSRJGF$|s(3w9 ze{xF1YqVvGLTP@F@)jN{3)Z^FBzTWWyw9^IMQhSqFH#A1p*2sNwBuE!{WD<TOx=K)}u-{?NF^IR~PH6acF!#tIOyj5Hn z43_Oj!bdfpG|vZzV!xdqXdE8|e{DI%BsA?I>2cu1(e+iO& z5?omKa7^bNwrO<*EMt+d|9B(4uc4_7-b?9>m1p=&;`-AJmc;WC-v}4K0X=Nwe_W$)-&{pE9l+vNfD%ZTj)Bn@@cHU5Q~fA^oy+&%@teFO+s(w|nBH#e!=BERZddU`VY&#+BBv zZjOk-LfikGoNkqy)NLSG`NEV2v6F?)s4)ev5ii7XyWeStlT*HWy-)i8ZR|~4rC$vx zsj(5O1cH;X0W?AnlMUMT3anPACHH&E)Y02WN1Aad-?c;9`xd*mIthAk?=IIZ!Tb;w#G}T9dlX@c01jf zP(L{d@74^iGgIt}? zodhJ69C4P z_#DTHMC{7GH6)Zz@JPXz6Po{`iU}r>|01hPrdez}c=fF>3 zKdPgik%2#$H^|!so8?E+VWD`XllN(Bzrg-ZS`GFq+&N13Jm%ZX2h+(c{b-6RxPNGN z0@nhq1Y`KjwV)?lgBJrr95GaMarq94NGU-+ldaUM7M;Sw+HS7RK2)19=?SQYJXad%d<^ zk)3CP!NNwGw>nh?9t#Hhz%byk9ubj`)-UI0R)=Mn0 zFj&17`f^eQhCzOv1lB>}c{*4GaB(1*=KWinCy-5Wn0!$A<(|yb3JhO!&I$&CJH~i*{6+1cSFu*HvhVo`3|1z_X2DeTZ-RoT71cKmBX~cx*qvB|q+{ zXsga^e%IIgs*Zel(9Msfs*fAoVsH92lm!Nk+E3d}Ujr@GIvbK;n7xR`0QxOp)h0p$ z2d|g%ZS&20-y4HBW3@8;@ib>!H0`4GL9MwlO)5~|IDdV(MbodJ*0yof;Z4?w0Sd0W z-^=VUidtXjv>?%Y!C)yOQ$?K9;zo=^A(puih9Cad0>HLxyy}10fLOV$9gBj)EF59u znA8u0u^BJY&6(f7KNuX@y0TGzDGu}U59<-Zz_#UCo0{hXC;W-vukAyG=`>aD0Q7te z9?f8+eu0A1mXn0Q3a(1qFhf zW1!kyBOwGDhgp)>GwqSTI@oI{C+#H2T)`o2T*oI`iqt$E?^k&fS!gJ`_IOaXvERP3 zSEhG%Ku!jQo8qZc!or{RO5#PA_X2X>QUT$vymci!tKaR23N#!1mFtc@ z9X#r0$|xADUaj1*>J`x-KLfxfC_GQb3ItD#$O z-qmMK^Z}FVAd=I%?vp&(E50G}O!0k{_Qi3T;@2<}!T2S4?9d;#gu_tcI5XB?+I@rn6O<$2ewUpInDq$sGV01 z{D2lAP8X9_E3xj$pQPQP_wk3T(_xW^ys-3mxSl!yM^*rR|J#S zUCZRdHCi(qjBNobne4H_D=n%*6$}VM>?%NS0_>h0*KKZK3fLESY|?NeI_0NrwjRtT zesne44USO3Q)jGiWrJOXykVhzAQ+kexHg%sixyzMx!^U>GQY9`VC0Q2Dk%H9c^5u_@}3jm062X#UvdKz0i2`G2GZ!p808ZhW zm0%>5pgp2(Te&^Qf$T=-Lok7#7Yg8+QUnDLgJIyx&~QLlc?HWb;5&H)d}T?Zvj8yA z4Qy@gIKSf{HXqtpDxWFa$<$-tbY8 z3z$PYfRTpYU>|abgGZnwo- zuZE_nyH}m$w>s^$)pW_#pkr`jU$7(cTvSBp4ITYrFt7L<^-g1do<#j0w} zaf5q(UVVz;a})_7CLjEb?@fi3jZI;zr|s*Y(Y(()+$pWHFfx`6W@oWLYX$?_@}aO; z&Ng5`XL4GQeVj>oUKMT)?Q@JttcO@k4gecgmbgHmUJjF-JuKoBL59Gc#sXma# zRWjWPYAx^Top(sdD{#nc-Z-7db}s#j`5zzY>~BQCWD(*zacp zz*#U@8Kpc>Th|Ngx2apjo=|^j5)SP1M8~gxc6PlFN``gsbI`x3kGcU2Z}|XydZes8 zo;3fQi@`ObG;L^_&fW>~u!wH_W5*_P*AO}9A$^KsuAde-;Yk!jM^N2dyCzNI2_m!I`Y8SnUXE{R& z3JA9jmTQcqt<%9mKd)eLcaL}}ROK)jJc6>leOzGJBt8ed z-(a|tz@u)h-?@#~LQ@nB<}TA_XEUujHglgb+G=%*9^roO@A}8zTibT4wZ*8_g+k8z z7#*Ztb+!T+6sRHe3=%$<>QCEds<6(DO2Qe&zz?Ic7 zbaBV7=&r1A9>WF9&J|d71`E=(i4`-@VWwqB_axrbcDwS*J;_bv{umuIM26ZGW1cb=>s1j6-iF(69W&v^vl&Z0p z{A@6|d9uWAHX&YB>?>{yrgT%F_Gr(xS@~2j(b|A!xF$Ueb`m6Uwbwp>G!PspJ?Nq$ zzMur@{F9cB-;~CF`<&Sk!#jeNIo!FtIqm% zeOnJHoD0jwB#X?+YoWD|z*x_Yxtn*brQV6PR3rlHZ$q41t^j;Iq1hNdIFM*5>lfTe6K zmRcTDE1mIJl4CR2nIsz)w?*m&PUNrsnBVbPWlibzf#JCelzl8;tvrdlpfGElY<>bx z1g!NbVZEkZ_b9`c<=6Jr(1Ny32d^5t-wqyi7+usa^`57KNxT-CP%wD$sErN(G2}f7DZSk+waG(ftL!7eQ}H zTBV%kw5ot`{(HcIONuv}{_t~FEDZiyEaV6bhkfVt=tdP7K~qA!KM98GXM_!@ydZ8HXC$Y*f%x&9&%sL-NYH^gLa=w z;lTbg+`p5au2{=e;w4mbVd4`w*MM?Zs^_a*lEsh}RE5%vWh_xnLw=EW3Y04D?DJr7 zi_vN^TPSD2=p>+VQVUp&-_(ofGr{1o{>)82({AU%8BUnm;=~O>B@j#qrPSXH1P2&b z+Tiy1Smx)_c9mZIx0c^V`&qQ6=_DO%ZEOE+1wZ5AvwA<}<0o}5z21VN^n#}$&j48E zo0w1+J$h5L^SSBk!TJ=x2Kp-0UJH$V2&+uBKyXJAyuTozGetF>D@<+8WLgdVUP3p* z`67VT&{{p@U#wOwn2TaX%z}YdA1`OHL0xwI7y}8K1Mx(qK1=qDT-9-uSm(HcW-DsM zc>HtHcCEZ@A|Lp$JoZAk%+9Zjclo4TEe2|(Zq@>OJCz`_tQ*WP!#+RJn|!9fYD@I# zNidl8h+Jgdw|Xo^!C(;l4gBAgWgq2bVOL;u*&!f}t@9~O)7d*kUX%%llB$uTYo4ja zbO_Q3`-*i%b+KZc@WKp5Za7rR_sVd4M6z7KSU49jHXBzW)MFIII0>r(?X{C)5^UTC zT*DcE-*1b+$K9aFsJxe{dlOUNOG{_NWGtb;dSBCk{3YX+Hls`z+391j!EScm7n}Aa z%S!hMm|EY4lp2QWl<=}YcB9*XusD@DgTID$xg7s?Fbe+KvJ3`avD4@zaFFly0Wv~3d_|8`7M^o)%ux^aOd zNQ+WgW$^4W63B46QZb~DvFKhcYZm9X!e{}fC(!p^5Lg&n8P>F1!UGR>4QMIj7>*}v zz~BHJ2Y_Q^9MDU=WMQoae>uY#GDnPC-U0NrX^gZ3S-U9fEnrOsJt*z_Fu}%HDZ~QXHrR`;H#{xGxq|m5!3Jix z^^#L}^MWuP2ZNj29zfjdW7oDPxVA>j$AY{8hA7PS<^%nA@0amQHQwYMz(-FQl--^_GyxT3()vjqRMh20F545m~+LKv?Cg zh#lJcNhB6hF5WA@IbW5(MSf=IGqb2zyov@EG?TT&&3fE+^tFz)fVY!%CAXb$1SPZf zEuo=2h3cQ5^?3f-;aCV5d0Aqh4g`jFCxTi3Ny}`6WS#P0D)2cC02czaL^@c<1v{rf zW?45`mrYRCwON|C`royi`+(a}+sgcVl|r0QnD`HUy;T-I2%3GS^x^P$Yk? zI4b!p6Zmk>IC>{WA>rs*TO1o?P#>cq0H8W6odG|pYog9%fwNIN0vjR5>ERT|nSw1& zgLn@b>Szb{ZC~=V6M2sT*sVvq{xMwMf)hV^kI$(RQ0$lwrk4lBElx`sf}xIvV!-p6 z3)bC$J|-)|W*;t3d$M>KC*?-KbXwuH)71gti^oFw&?5uVLmCEzC(+ZcedanHJocUQ zdC#3-aDPCv4GfEX>)#T;o~yA41P{O22ZxhD@b$JRn$PL%>!C;8)@z~3oPKWl5Qlhe z^o1FUuX@1eW24u^WT?b1cGG4cZJMe|=VLMbJczjX}R6KPcR= zF)9+QKj3-)PzBPZ+>uVOF{vh(rQL+J0ewAjG=P3h;xoi=s>*gYzw2zfuQqwe#W|3~ z9xQ-y#)J^oI{>>az_P$5{5BbsflAagwe}wvYlXIfNTo{qo9{cc{k$r9y{f_B$(`-6 zX#*k<4367?yQ+53w$)R?L`jgB25yYA7Gee}`mK#ofPH8%&U@I2W&_Bn(lX9*AUkOQ zt3Xu0+aE~3c_q?m?4BROHqxgu-%$1Erk|wD{o>|cPa^)eef4Xg z&A3vpg$9GJtMR9S@oS-1v61;I{^eDNh3zZ!7j_`qwAOAw4>h&|uq)$m7&i@3t%45)>m2 z-OOyP~ti^BAl zPf}c|>K(?Rhv^g3g0+as^xW?P6TC;L1p6P!{7e!H7(-SlbH?IbHt!)A*zW{`orLmN+jrh1*uZRxRWh?3 zHf=y8Hp~0=;?A!BUhw`@@YrNt*#I~8=XCQ_FxUkO5vSt>qjI@`?#~2gIj{Zw&4%>2S+Cuyw@{y?Gi>q0ny#jyglUGK(;xSlWBW(IBlm@57>#Q)#UXd^8p_6 zEKx0dei4cmCPNRFwIZL$u5rlZu9ftc(qjGFgoa^NoMCn=#DNgo6329F@4puSW(ix` zwZPtHCCI$ZFTmPvQ zosun1!fHT!{cLfwz&R$t#_g%OTlbmw7@;X1uK^abw#1rG9a#rIpUhS)O}+72poe9_ zC~XewueD}2*lH9o=_g%{+}z!itU$~4Mu6nAES3S`uAIBBsjU+hy_dKn9Bujfx@!^F z*9-{HV6f25=2g+$Iip^^ygdob&E$z>(0As|E1~h};4C0KWa0quJQYk@zdY@GefZPB zo!l{_-FkDk^~6qe%jj;MX%6vl8V1{*!xY_|0E(bT%n4$`H?liH?AKmOBSy)|0YwfA zghr1qjIZB7B~5B<_Hkt}_18hySB8M9xd`LdVm9dc!B(9c_X=g=BtOpmHjS@*4&|nRI8;FZD7=e1r3;R9w)ij^G1EGH*EcWtCZ(Emt>s$PRpJV!|*27dOW%a#9D^d zI-~`!u4|pk*0{Xdnjl9BpM|rJsP}dwfq~^s#vC#cKgzTVC2_nGJ}QFD%`V z8CCZAF3rkmW$QEmjDx{eZ@~r^d=GWT`gPkCgTYa7m9zFRd07WqZB`8cM|sa%%Hdcj z1cQa@8)Z=^R3`GlU_gV<(zK(1b`^jq#ev_5u6f$qiqW_*w0u)TstF^kjZ7cq>Eb2|Dj>{4 zw|stt=yQH(qRDt&$8GI-S)2sE%3V^{B@713H|$({>A?#F!zQmz2%DfUS=zX(i~_>* z&JVmEy4SV!qr@yN^5*7Hp84EU`8@$5<#btRGXAQ-*)<`q+wZhH<(0#o=DZFR7w z+niA0w9WQgOpWR7Ou%#Ph#UpxbKS+m+bioN0UyBc0_V>|qxw>KV>oLXo7prYg71)OdsR>T+HVyijBNYcuYOjTV7!S2 z&wm3LZ3At=yHJR^uYO4|NXYM}5@-IVU!TUF{jv0@vR`2+NWSH2ZCQN{^ttAVZWKPp zvpl8w^(-Jc4eY^lZfC{EuYq1!TOS#$Hpaxq+5v#dwfTp34?TBt-kgzJj@Z*q__Y+z zwt%yln%(LG@s`NZDk`8t#)u=xDD3K%uYsC??F<6Zu3^U5^=sWm_GYSI-R25NZ+AGk z_hHL^(d?yj?Gt!YChb@M!v@6Kl5~TN*Tz5VYZd?w0;xosfG4o?cmTMnziP|mSCLcz zrIf$^sJw7OG#BY$ZUtxQ%eK|${K|X|%1d*lEZPy3iE4q~0?nu)e~lQbc8e&aPshob z5RN!HQTROPH0XPg8S*&J!1^nF0Y9o^%(vOt(V%+d=DiRHYnZ9?3Q9OB!5gOrPG)$I z^FlE$z;E4>bHp}rsXd&=Y8zq(WsIUbK>W7kw0X1>+3W=!g%feLr!yNo3v1E!E}nfT z75YNF_zlPhCxNFN3*O2M8P>;w1$`;m285fi#lp_mJC5}R1cO(=RM=}#Fj%}cn!CMT zXBik?`RiUz=0|)K2EW*CG)ZrZ ztDg3743pT`Q`aR2fk^qavAgyj*yFp4lqr0A=2?+Q2$s}HUCcPdgm^9NN+2NX+5j}l zB31TH8J~2yQtW=x!0?xdf(Nznb*?>m$ksiY+fJubXPg^uPCH z6vT@jMT=%8VhPGDT(0QUWd0=u@~v|Uv>DDP#A?UWbzxy(>F&hC_nZUuj} zNR{T>HlwRnfnprc3D$G6K>d)f$}^zK_7~;_`T{=7T*yO&ptc;z^M;S0A!6c^mROXH zn%VPPT;2x|r-gD{EVykeGkVj+2SHNCjT~b&;S3)GLqfeKXNHdSZoqA`XXT_|_s5FE z)(-jSacn#>TR}OFFM0h;WGGp>g;y+|piFm~pcAcUZU4eH0b#KLwI_i)qF?iZroK!o zelNGXvR--IZDqmWw%T=`1mB^;3_f=OV0$Wf1;MWXunhLD zXHeYxg=w9vX0rDMygGx3TD}YSgKNsA>F(B^BOSUPj35HTb>8LYsPe(6umJ~^k^3GbLcspnX`-218da@LI&qQq&nAU>{D)`r~ zHIcPewZO8rW|Lnb8sMTN=I4euxa6k1C#S&gPN|7$8|1a+P!Ix?tior!MKB@|6R1zI zfxFj-fHliJ?4+|PjCGE8NVZ~HgJ&zcr1>wWGuEkm^JP|Mv;*59s(Q@GEorxtzZ_-i*QUc^ydS= zaUhhjgt_)v=NPHN#=RO&c+gs|)98)r8S0L+MhEA7!3=7saJ*AORDPUS4S?EfB*j3& zwr1AI-Yt8*?%>5n*^MEL=N$kts=lYn%Y7ZERwhQe_ieR;v+;8vZJwPESC_XyZ})_? zmNY0VHtlj~OWS!s`0__Sze4mG5axBa00Y9)Sx;--ckYfamE51fbnkeTM}6mOqj}YB z27~1*qCpCg_|O#liLu(wAfU3V3^$NuQqDysZzJ|`cIP22#g8-Cf-GA{uzqWBxe;% zrZZ7pwi9k#Mhg^YJ|#LC18V_b2DTaaglNES=r2*|r}hi;SDWP+Kn-y@sRV+FE8L>4 zcT?P~Uvi+FaJ&3AFF&5zX^oSaGD-5=aG6f^u+70BQQHBs&Bc0#i?51)A%1HxSZ+`$+PsYQ z%wSvtX20<}Ga`rin5_8q+3!#j7D3?X#!L4*=)QX7Nrjv!t%T&-_Q&_@*}cWs%WrQR`p@GCX6AI z=%lsx#telk68^qO?rZ^j(k;!}gU?A^U__MJNU=sLE(=N4b+Lh3X`4}ATP+2EaWFWs z!PfUpJzzf5|84--^z}(FShfqWDd{>d?OM9ARrSyDgznCp`9j(K*0n1u%iBONqG=IC zv+bGD1i`b(wWFbHnq+Z67X`^;0485AC(VrMpb+!oIs>R8hR=36kqz7Q(1uopg||2j zirU*Fi7H^+=A7dK!Ae_Q*+K<~;1@4)^;~ZolS4(QzwV497Ny@x(RJ-pvj5!O_9x>+ z%dTe`4yvBvSiKGCGW(H6YWHZzivEq=rK73++j?*@o>eVq6ZZm`F2`KE9GbW`ofWT( z9_!diFxZqwJ=ICzzG3H?CmO%oEAu@3H?N0oJbaG=!6+cy+o0P-uZO;Dqia=hyTD*!q`{{3fths|)Oy$=q1eo6szPfdB(X7?qJqcFO!>DKECjc4W^u z%m53DRxtc_qV_N518Rs1iWNYS%@xp5;w|$bL+>Wz%P;ZjFl|T1&2+E?9U5$$#)R|`Z%N>k1dfHy@Fgd5Zk!v>hBY(x z(|KcblFqFqyB+jg+FMkX*CNRk2dWUY+GE=?xqcy@>9WT10pfeMla!$BWumd zwRl%F`=S!y?O)cUazIzWf);u-@s{v6{Wnm$ z#UIuJs$2;G8v$jDwRggI4)ROk-vDrsmu&=W&XUVZyAgfkLxEQsPY;xz2>yBz{5&iT z{yLhb3$|}B!(&V)py_7NmlK}PFa!k%4x!2^Tz4er4MN$;PxOa6I8zlXDd7aV0OUA> zage~2OA{W~n{*uPE#jfKe29-hXL3g=_IGQcRd*e+rgS*9a5oENIrfRobG2r7YDQP{ z&s^mAk|%+eEDPSnSNB5dxn*1K_GctZ1qcZ9r1ocU+-cp8*l4xot3A4->37J=nB5dpf3Sh_Brnx2nT@2ykzrNmajIQ?b*!pD_M_4Q=^E*jNg)b ztea_fo$W1YpE;6a+TYg@7Q#5m+iMacWG#=9{~SFZXy4Z98gC&caeHBy{I^w1t&7z! z2Z8sAU71nYF2bf92V*gQeOSIt@=yICdhlzRxIkj z8=q&v;N+6G6`0a*;%@Xd5*4(FS^ zzv(o6x;6O`$Rlao72O8cUIQ)jo)TDV6a;>+oA;~GsaBCI+d!~TAqrDWWb|vGR~<+^ zxKQn30@lh$8q3C*H%;x^;uSYJPAueyIiR403S6 zwU=KD4Pm}Z4*H;G(m7|2DC-jH1cgGS>8cGR|=#mXCP2RoCvbZC~W|U>l>(1 zM5f$9D66iqB(ZBD4C6$>7GrhofgEQr4iXAAL*qM3Tt+T$Fd zTXT6845n_ox96}t^vuS!{n#jfC3KjcyY}{)T2c~9Og%jb(BDZ$5pY3dqR&9*u0DiS zOk{E@c)6wq2XrH-6x91~`UfjJTzDv1bGfdgis2H5ahVOpIL@FVC@}48Bs4{3=#}>) zpnIP@ug}3LA5cSFQ1sMFho&&z0p3oy**<76ONsp-^62-b;|4JT=?W4|?2yEP2-yBH z(2&TX;c#sbIFzw?Hu(K-1B-bME!+WiZcL2crZa@vR?Vko!y5U@MrR9f()N!9gv+Oc zbLXV5DUoctc}zlo*4T?-pKT^DQ(m0bnb{^}-?@m8oX8L64Q zMF2lDp3iel3zZbe$@Vl~&}-pg8R^~76gvy{Wy04vK`q^?8nv4j+9*=kql87kOuz6JkvWSP_TdXvn?x2#qy$QHlq13D@5-RAdh7OwO@7z5qUwy_Kphw9@gGB&}0(3R@trJZ%Wya97Od3|61s ze=olNVk((^A`2hnJ{|{z$pn5mpkH!p_%-o*={1m7kn=J)?63Qv@nzUJKs!54zb2xk=ecmS zeGgEA{BNcQ8|HNFXP0?a|4*lJk8un>%-djYsFyas2Yfv%kZ|)_=n1&9zJl1t!k&*s0 z^pABFOwd=`x1O>PVIy7(GOK)~UpT|U&$GO3Ba>(0gUOfe$bMlvu554@*|Zp<_>?cl zxW|IQdHL;NuPtlG$gl&NZD*y;evoT7nZm#?qEa#N%K=>?n2=$uo;}?ae1FAIrUa^p z;l7b6;*h&uBUI3a7z1p`+{T!_ZuVMp#k?1!Seo*_OYjBF8c}Ft&&}JZ)FOOCrjROM z5_l?=G`A{?^ENU2TD0AsS#zLcVZ}3oCi!4Kv_H$z2Ma5rJW1O#O?#!*B2-n|}r8we&H5M~4CsVDaIz>d8?fj=?V0(d~H z@X9VpV)P01n3@2rCT(grvx~M!V}MfPj}*ti((9mkO9{#xm66VkT{)QrEJ-NS&ElyiHzHpvKMXv*3KU?fa=g{AE7rDwrZ7EA=|n3B!GFLlGUwe z#wKz7#W?Ast{HH)X>AUlV6oY_jhwpT>EQI#Z`a2=TsN#X+Kzwe7~{4DeA%w5O-rYK zTcmO*A#1iJ8=Dt~sT2r)jmvDJ5GwMX!7|8ysp~xPxiBBn@4^0C>3mCbrt}ocTf`%2 z+~*uiH83unviLR7SHAw&YH+84&FhtwE7RRhoevUN@b&KltH7@+>$;Cqx+lL0>>pTP zI_K|c*&4{{gnf_U`Dr_AWm^uw^MK|zNKx?@B~Dj>a0TfMB&}%4ItDG6wc``7h2E!A zJRK~Ki7YPe1tJb3G=n7+1c1F1fhe)jZ}0Jej_Cr6yu~sSAc933bQT;HxLpc;zDvS9 z#R1@oegQbk+cq-!S@ij`9iCF8a+03d$uUH`V6gNDIa%2GIQT zIMSoUu^G^a{B^hJjz+$2MveCbC}HS`Y68b(mrePLT+1E}7C%T6hjRs0lD3h+hk zDiIuu6J#;haEib=E>W;+C!NjP?1jh%LactbNIW!8PB3LHm}vK-vs8U%o{!g$%!Q?#|ni?PRo;$l#y*QmjRl!>1o&( zWZ!5ADxGZ{(#=@VKL`^PQ*17mI&hRYWxT2UG}*EAqiNs1Kkws>RGO6oP+DQJfVQGe zH~cw(W+!g39HUu(LM+~6)f}bv5m@i60nphLPU|!t88%basB&)I1%$5;S??A)L0J|ZgwZ^6+z$uSE+Rn|UywSg#i^Bo5?y$39CWb)Q$)_LjYX@HiIo(BWKQl|j# z^SZrYerRQ#FWX`5*Q}4Eb8M%EL1QKNQ>cvV;CQ-S(%>*GE33uwqSivmcZgbNsn zB-A+=r-gD{BD|}=F4iisYct2?Jw9$?KEtO2`IZMYG^gBK32G_rZIuZ8aQy7EJYNdD$4 zlP|w&Y=Xbsk%}k!>ta88H{_BX#)pTEy`rPr6mwV9!MO}lj3joJN6Y7*p6dn z4*H5Q1(eCzHqxc{U)VpMmC7A}4T!0(h9g6) z(AsWkNZ%$oti>_V+d|(Ljw$n1>T8J_jGHhc&;s0ofNwVjILhiFAhs^H8(qDd)`T}n zalO4NuT$Y=TPCIfS3oNY5Mgp{HmV+cqm4T`JP*aRzO{)` z7aN=I044nQV}7&W5+L58b48`Ou&nKG`cOJu&G`UZPyiX9?(cCEatwauJHgR?-Uoj5 z31B5;PA4zfesldQOXUOt!9s-#1WVE2RnTJ{D*Yxfk$$K>7Q3~eH&IX5-g3>^JUGpk z6Nnp*ha!5Dq>!PpX2rJoh*C~q9xn(ajd3Pb_N0+JFDo`kDo6$>cepE5jxq3T*VV(+`8e^hN-ffhLaH|D6DE6zIJf z0RDO~*tC-wi}!=QvQCR;#HkYeWnWM|3sjJ6JIMjpZW#DgBo64Z6JQ3Muz9|bUq z>#qekLU5v2s}B=1*5EOL4Z`h-(Okss74u$@LTT8PHOCkcGZEh7JMW94V6ZN)Q?}P1drEk;8#i|Omxv1F64}$fbg@bO67UEBlmA`d z^?GPpvAZMyykfkv%&V=Np2zJtSR+9nve-`_>@JRzv$v7@c6d#BJsdxSthm>43oZrs zO8PK*>lOz%AvmY6hTiZp`Tk`DjnM+5G@vg6+aR!=$=Od*>>Sk#3f|y3A;KW`GHj@_ z%tk8kVNwptufM=g_KrFKun(B#Z5xe%u6=W$ZBW=tU`$X?y7r6O zj#Zoa4au-%_^RHz9n)7pPZ6f5{w;Zr%iM5>e8zR128hCw&fXC$PD%2OK=s7oTo#~z zE^#6MQ|S>Oesf2#`P|W0YV@GHPTB4?&@!L@ec|9Ml)H{bVh;>{4|wviZs+azrnfOL zRb<&p@*a22WxgGF_$eqSv(2zLa}CiYyOD>tSY~hZMCw)wR*G9n z+zme&T4YSoV~>#jMA1&2hdk04Kbsg7*do}|TmaZh5r`5S{Y)t9NG$X-S{{MpS{U2m zXuh|3%){s?U`xhNpAQ4TFrT$gF>l&}vpnni==t||1c3EeE(FU#`TW(;!n<90Wy!XO zprkZ3-dQ_nRgk~#7TwV%99_?<1n9;?qa67eh?A+Q1G3FU`X-SR9$}rL2<3$CluwjD zUkouG&sY>7_h7CSsNg!8D+RGZHm?ZR40{geGP}Oq>aTEjVioAi)dqUWW0AbUI)$U;|%K;*V1NFkSvuogV>o4d`QX_znm}DUg`s~ zD{Mt{*XE)f5}s%t&-2gD&6twbQ-^Fnh(tT6Q5f` zgG(@k+6C4Ynv?PP_|?$4ZZxLDR|<3FFM5@=#<-v`J4c;Otw!QNC)xS6Ij=~vXDx)j z2C+*Y3~U3O@p$23olSieJFYXt25rOQPw92h+tb0kc?~qH_R{S{WPcBMV!))gK1jk^z|z!TzzJwJBo636A}G_qXyOZHw##mAoKG(lfAE$uHcFbv zuyB0dy0oROErO=AsACf~e*J5JMwGr5x?cb;7!1lX_&c6Qxl_R+p9Y0DfnX5@UWY8z zCwKq!>EKbv)`2SEJ8ky%6oGXa5MKSa3XVV8pPAf%IZ5)`pM577f^wiPOiOo*6*Pbm z)vtQvuz~RC$(n%}w@BD`z{%}pHqs_!IbybT`o|gw%e&qsF=#o_$EpMXqA0QKCqvd5 zP_~YM0I;c~3!<=eE3>72!}c-F`(-o+9T5gG*)}EbvKiwT8?$P1mBf(nrqWvu@Hee? zljjz{?&GH{w?ig0&&eh(AlP{zfX1hQg>>|AQ?BO&Zx(p=>ZoZ!vc9{UxF$A|(8VO6 z3Upm1^Efxa;d-!P<9_6w|3csEuhG>4Tv!JI(OADtdQlnUjE3INF;2V30s?SCX-nH~ z8m9Zd3Cwfztt<(iZJP#oUc~QG);C8zZ`LOX_J*f^dD#P+9cC6Gjw$*U-mE_*30=%@ zCN2_>Ek5Vs2NFL5*^6bRTyksWu#cjLkw-R9Qj0_oL!bIp5C`vs*N4x$*EI_T_B60N z0X%)UZtB;8U0Gf>aUfW@8~i5lvPL9-cX&Ol81h&}MD5dgBzV zjgWx07!As))uACd2X?HAKhLIMmhWWC9XHw!S~>^frSbDk^&_b=+wSK?H-od~*XMXf zd3SHdCdSwGU{e_K{=nLD)wi&rL8xFC}v(k(M zz|uO45^Fl_FPIN}Il;+#Z+XGE0_A#e*Oo7uY$IRReh4iFqiM>7ZJ$X7&s=OfEGR+E zrY`d@P#maGeFfdG5rZ9YY=v-#lMR51dfcj#eV8AYdg*c`rz@G=YBI zI)9>lvKbL$c%`t>G5~yr50I69&E(Ix&)GQ{tqd zyyVya_#`kR6610<2eZp8mf@)dHRMUQl2CGf1IkWG8~|o)r%+b55zX2JwAWwPT!^mi zFvUDWf&3~aK$P%%9i4I@eUbB2< z8Niw@qH9>K_YJUPz0n+!od9NF8^0F1Be!+2_3*ly!|4T3+SKg5|5;#j78~&28=t<( zCT&P(R0kpu5Du^`EE#>?WPb8@K8S0umQxwe&WMA(_kRg6D%0by{rs zw}{QmOuAlb#t9zkW?Pz=)x>_m1Di*yTmzh)L(NiKi!{)4Gp${7f;A|WF|WI0kbDu+Xe#w*OuJd={)v`9VJs_g&F4qE3(WmPX|F?nddw&z#J>_ zc0|VKIm|nItm$C@*aR3+0GKwmwXfd`0L%7Hd7HAW=UG|URUd3HnkL&^uYE#-Esb1% zX%!4;+Jl(`JfH&B1&eb)cNQqgE(%9HGVbt(Zx}?hiTQ4ym2u1>eJxUK0~^e7ioiK8 z5KgNe_Hka3tgS<8!QrHd&+xH=?O4cUV@efpZ2UZJ_Cg$7KTw6;XCpXE8HU@l{xF5G zT7UgCu?Jsd*6N$MJD75;wxYxClBooQo1k5Ecsh8_;ceYAh+y!5#=&4;9(9?gaCy}! zJPF+Ev4Xy<9x4!gK`9Lg4|=~RB>7tCUY95UtbZqYvw_*9dStKqY{9N)?qp^A<^x@bK>Xcg6qa_DFVulq}cpG9H~lKPLLx*>yx5C!`T zy-V={V^WKivm|)^AFwBREJ-q>;M1B>c=RnuHX8tC2b8UCQI2Sn3Mw9MUR^&h`XFN* zWiv)e7fL7ks|i>64TZ;sSr7wSP8o=JR1~T9L>W!38m~^aab{v?SlW7TkqWLpP1T;?*5_GiN+Lwk# zI`B)4qQ`gnYoV{Z{n`oO>Z_osf9sval80Xpea)pWfx&R!d{>nwK6({&IJdwX!+mNt z*AQr=~GV@Q zZ}4gGmJ>fhjZjCPCzB6+XB;T=1%!!U@Yl41Q9=4DB5Nq14=y`8@-t8?wLl(Q&8RILgcBYhdRMV{ z)Cf`&L#oatc2uu&hS?g%;!)pjv9KngOwpUg**l9VmG<7I!nn$0U5*YF@eR(yDKONw z1fT+RsDFIXfQ-eutH1xj`(`^AwT7N0v9ZyVyBA4b7uU^+^l9j7A`&R)^BLys_$K_e zr-N-U_>x=CWv<`T6*@r~of3wpf|J)nxABT+ViO471ca%78Mw9iNQjl)aVnTpJzsL8 zJ^AX)IU>~Sphx4ib&WRJJ8ki8+Fd*?hHvjDlrL6>b3+PKxO189UP!%ftv5|&pishx z=#@6t;e5Vv;j}yZY^btEIgV`Uk z{hsy@x6D~ec4cnjbcwfm*%F|bK}8QH?cC!aE$~@fZH=JbmgwX_Uo$wV5BZp$05%F+ zU~}zVOg{k}s8z44#Asb;j?76Z89+Ny~JZ{?a6Tn1nEmIG-n_5KGYuRL5$V_R>v^u9%__fzU zOJ+;TZEG}Owv_wb1eUNL)$>QE%6!RNDBF@-=QzP5g?5$5PQAfZO8L$KS_M(rneTZH z@)pkly>2V=w64$S78(7F#OL8(j`P+A8`ohVRpo7(%o}K@aHA1~$m3jhlb?qV8poE_Lr?04)Bj#}k`?FpHgg z({s5zg*@ol67(C|p@r=imW$>4%t$*t0_nOn$y%>mr8bAzHTcyzXp(uu;9p%j<&KGkFL-9#kowFdNWoNkR1=={ zrd^H+Ts9G`916iD9ntkFVa*EL>)q)W5@0Y`k zcQ@U$$3>cEv;3rY>dH5`H?19tp=Dx=eJ8zcGGBi%E1}4edi8UZr^ILDNB#GJdwB+e zyN+>VZ^}gSRnXMG1>bdDUp=RFCbuv8yuKbYzrD@FkC&1@Hpdmt;uT`1*2ULBH(8K5 zJD(Qz%56)Ca4nSIy%svv8}M3Yb1Qw8+NS&ysXc@_!~cewzmvGmNeI{VdorWiAl}O7 zGcyPI3`aL{Iv}Ymv&?glxA@5SV(@xxQp5bvs{-1>q{}R3Z?M@nx6FqIP6;nSGYaIq-_#?;HrsIAuPkU&w-hUe zvgiXnzohDDq zEAY$B1b|1Lg|D=1q4LmoDR58ENETU;^q^nogl&SOQO={{BH&CWcI%Nl1oH?EntuFF zPeo;hxyuW&N(gKCXY$+-;!ry~2;PKv0boVo+-B4mq z70T15C{K!N1ph5d?N3J64a)%UT!@Lfy+QUaHkOTjc`LDK`%k27+;2H|Y`v%LgDr7r ztQm~FC>7@hQ`_S+v?-gKXh{(2Mfj3)13EiOtR6V z`+=1n+aoGiYWzm)OcT0nItdETItrVbGq%{>5oNP*p-9m&C2KNBe8KiwPeCUe_qihyu#;oo~>^Xg<)CS|DC1b&Df{|XPsa3I_R6Pf?if*eGP!odSkzU=3WEcWT}PwyU4kw zr9-itcRpYL5vf|O|5)`OnLYV{e8-@?=7l^Cm=*)6g&-{lcI7$>o)T#rttCJ_@y=6gnpN4WcrcZSE+_w@JxNYxPD{PNA7)MR-Pn8HGI}9)l z=(=nv9Zm0I*%*<}!_<9LlYO+>W4^|CDw6@>MIU>1d5yQOU}(0dekZM6Hpg=lbpXMX z<@2C1-?x5$#=RaRh(3SI_cEk{!mS_a9j9~0;~E$cru9;25`ka!TIg99eRg-~8H87G zSajkH2osqHY)yL-_D)#-w%Ln9Re)1Q*H0MQN@0XBKeoem_UndUZ$C|@#aNkWV{ywEGF2AMh zjQyVS3hs0pgFr`XGC$W*M+qBkhy#d<R^8H5sHOsv2wHGE0{8~FU@TM~2!FW{>__EjJ0yLT@BA$8u6aD(sr7gLhh& zL{XBzvtIF5Ulf=<4^~p@LrFK1PXpJ=0;xT1W`jlkr7Kblde8-8v-}o7wjI9}0Ddmu ztIM@uZw0exdgLP-)q}sL-ZKZrFAyjH{LU1&fnQFi7sdoH(``X|7YICo?I)SLEot=3 zQBe}dI2I+y3LN5GD8>a0rd`FM7^SUQHEyW|xsA83J+}Bz4byF5x@oy2!f&g{Z^H{3 zVAHhS_7ljm+0PnjiB&iTx|(%C&lO-lKIh7&_tRlO*t`~6t^+=pX=#_@OV$U2*Xh}= zVcuiKW%-KeTd#zcr+-x-I0*>PpxO%jy4OO>=TIttwcBiqrtjUF3iPpR^VO7ccz#na$`Hq7RA?hCHdMf-UD#rO*iF`!x6B*;!mqo*UyFJDbB^6P*NRpv%7=`pPh{*MO~oVWTGs3eUenPXn0cv)T3njNA6G zsRi(vjlHmLMEZfj+-srwU74_c#^x($`_AZ+&e>7uPebbkdt}mfkZrK*+oWILeHApT zdNHmKug$vi5e0mwZ>S%%UwUt~Y_Rf~37z`Y^#S82kw;j)@&9t-w0Kv|N7LtQG5(Am z<{jz0i2(aMuD}b9o-EmS+I%M``FCCeZ3DjwVZipa&^{2nZg{3}zlq()iuB00n4hB-Gd3u$#ZDsC%~wlSm@9jVo85jWil_1uuES-he49bb;@oS11ai?qh` zf!H%PAm;I`#a;mY+`{jD&0N|1Gn4QDl|X90d5@e!nto~}@6Xf~J5SgS6+=k_v~I5r z**F+1e-HSj0hF7pPaCI#FVH*{Eb=H2Od~%7!DNENJ|KL7y6th#7`zsG)JeVK+60DI z8-Z7BPakP@5($v&JCPd6gS?6!B}Oo1a`b)6RWTtKa~X+o@`eEP&;AH8NGRv8@lX@n zK?AXec#AW01&1_t3H<*c@c%iWoT(V9BF&g2K#WRa*MI`9UXJx&*7<24H$Dzk^)w)2eVkJkTK`9s#h z!$q&2JxR#B0!s>~GX6EpT0zr)CL4Oxae5Ip6N zn#a0c;pzWFNX21#i-R(>LMCepTu zio&Gd_QH=q75)dOzGKsTlb6cKIihE`x_C=fdAWZL?BM4wSYxi+=b^7*Kv~+6Z4=oL z5Sg*O0IT!Ep7cl96GHX8wZr>73128{tL2sRb<4wLEw+DV1rJ#WjBF3%5gAc}U+8Ci zqSjGa_tMPSx;g{GYp;^Llh|iZSDK+Pd$89dAYs7FxoAp(*o9=Glux)~As^_+qOnHa zVJwkz9AI#crrhmVE}y%zUZSn@scNX>eu%ZwZ+jiynhH7F=))={@lY>VmkqR-uf#yX9hEiMhB|X z&|{l93JPl#Y}kIxsJhuruu>>$hw5f*+P-K;m$d0lLK|JM4T!>1!fgDB>Obq=Ky74y zTE47jG$w)1@^wE})fET<{za#M$G14iwk_)$2}7SnS{rod4~ez~r-El)TD)<7-PDw# zyVs4!8ham3@lyZCrbp{hX`gKWgkWf0Id%HDl59SoOXG%TM^vO&K~nO(5Bv(fhUZ=j zy$uADcsujB$!1LxL00v8Xu9g5)`I@Kz%)E6z7CqE-vT-2O*yysI$RJoelMJTC3JUH zwwz!^<077gT9MHL%&k{K8->+?@Y>5X=SZ~AF5OO*p&iomw10(nqn)`8 z@iuHLFE6tcTmq(|Vd70WC1(o?Lzd6iDx~DXs4k@%80M{RoHqrRkAllK_-OOtZy6Iv zf!;Oc=>RLVfm%TYW`!Y<&;OFi^I@K90dh09hrxI#%~gaP3czyZP6VA+Yrf8d1_g@TMlw6+AqnNy^iA(kJxEyfZ! z#|1>*RyY!k9ZWP*>L>5z3J^!k{Yg~ad}f%9zk*N!de5&?YCCduV0IFC2GA1OONIfy zDJ&aBO)NyYjUjWj@26eankAZ z&Ym$z94yxHjRqN+@TNgdTqbXAe4A^n4-!cD>y*JU49mTmurC#;^udMr3 znqg~1I3iC~_x)K_Y0$7G1_QPNpR{cQTlpzikc-9*V3l<_Y0cLFeBH z4*Qd#HWJtObUGOQ7DHbT601I(_;_*p*VxrQF@?XlGlAR1GxP<&d^PQ%{PnzT?$OO9 z0F6ic2{q(jrfjW0%0-2O*1`HTeHHA_C2ldfC$EIK)abrAna>3ss+Y*+kkk1LwA26L zZIs)+z(X4d9`lR(cY&Aa!{;eVk3;Jr+nfrXH}>A!4ywSNXzDilIw2|Wt`wF5F@I@cVu1mH&{<H#P^M>Vuap=wv=WS56^z;&vi_#xW+#nZw9LM5h97OwH zB>?i`iLx01CcM{B82C&;>t5QrK|ol2j#~7X7kJ)D{=M265VpbKh$i?W{WKVC|Gi+D zuBhPe!+UBp4-=@@Vjtn2ZyTwusRLAo()GG&FNnroTm+l zf){YK6_{=};R<0luEXKTwCF-~Y|&wfQfpV#IuYeES3m^PJ?DABTKyi%4%UJHcSASYQWuo$_#j45)AUgr?v;Vf%0BCtfI1$e{qY zI^4D77y$@eE_?ZWiXZ-!y1PMQ{j1+6j$VV^LSjlSKC;;Ys z4mNAa#>_odTI~+}L`7z4bS(8^{Ir z#9&PvJNh~&fLj6Kq|RwTc<7xz2hGvg3&vFSDQFiw`~w)bFNSJpROkPWxngr?*7Wr=C7fiN!PUs`cEf7a- zh%W+%`AU!Lzijh)I(`P4sK8BDMY}$8z-qL2D8gXX6J7qZpl@CgACynpCF~1s@_=$% z1`OL?YQ(Y~T0x%%!QV$uQ~#OZFDG3Zzn$Rt$tOZ zfYp6Zm-T5wnKMQp)b!?|R*ihJ%xAqLd?57Z87Ui+?=XDnw^GOEt8EMCnp^<7_ZgTG z@8RC}dmtPE68PobA>h1TyuV?)=f*D20>T1sS$=5q*=mE^VDP5V4*m{;3kKtRoKcQG z5uKe1PCp3^fnfcK=Z6*tf^9&UX>Tm|so(**-Z`C41FssI6Twd%@p*64L90wRV|+Bt zP-LgQeMJRb@LGWpGC~pU^yN<^91CxhQFC>Wc!{F2TZW0G@5x`4Te^S~fEKEP zuL#0a;cswm5fuF5}ueZwlBDw;uR)4P0>47<0G|M}aJ&GlQZ zT|u6aUfF75m+D8Mue8FyI|Xm7{J3khgMBe*@3~MOGVH7?+1nGr;jeD(OVc64M#k5} z%mku+aa#xJp9JQ>#xa0h_{c_P{k833+cc^RwkP%K^TQ2nsdn$y%h+QgUoRs=LEW`x{6Lbk-XVtfjmrunEFmAu^G=VBY< z=Q6Z@9-D4K=6`s3K;W-vIpN^xdTk&4Jewy$)k-8QDn>Lzo=*Qpv??Gh2H32q)}@SJ(ly+Io?&a)Q|CzNON3MysmDixk&ik)+B!bslyi<3JkD!vyzeG zymQ&sGcIqME!Q%;f_AJ%dsVxBTE9K*DHsvWaWoI^{dLHO9us_xh{Aw4s5`8X=M}4a zDr&c!?>GhDBIZtVO5j{3VH}@p()KS}kQ;yz<={#>ra~}HJVIwsrw*%h50qOGexr1e zQl#UihGVe|Q1W1%Bzi0y$ci7&q8-q9;sVq7&IrFyXZ_$H=(}~`NrLUaM?BY%y-}TS zV0Mia!Z0tA6T}&_N)0<;y_uQ{^xqOmJ?PKm8o^tR%i8;@#%hR+d0S za=M51BW)`f$%xxI6&w^|t;En#Fwwo$`BltHxdz@`-{~=r1wXpT77n;2xn;=67uTktAusRPtOWQ5i9+#Vn;2!;`^$z&2Ev+{MXhn9XNx z2{v1j*8?#2CIj4a{&L`_fP1DcM&CYg%c-qJ)j-LtzXs#_v&fO36x7H1I@q?|^V7l? z^|E6skeQoXOcKbHbua4u+b4j<(AB1J(XDS>*tIqTdq8)aP>$>ksf&Pb8SG^~ocLwJ zUwOtdAUg#Ckd0K00cIoOS?v|C0bQ!RP!I4fQ21^|f9qecbO7_FhqxnjBl44k-nG3C z(nh%B?~g3zQO4>}NQr~Qpdn;V42n7JOSC?z%&-c$6Tn34m+xaSQu8C&T=OF9nFj;E zYi5u!ig?zGXnzt~^x7m_M!oo6^UR^eiHEK!+)vhh8X$!Jl_2*qqq58E|Ur&u`hFOmyOQe&U3@5Cv`bC*a+ zmSTp6IfLpEewx$1llX!fakV7ptVAii^ zhOKnR2Zx;Gxsj*XzVgxLW8XHI_4R!KIDFrw%HwZY-Zb6aY-EUB@OP<3_d-N7Rgu89SKbkr&$XQIin@E_fC3lG{Vba~{VA)S8>)8+A%b52oQ7{-k3H@Z5(o?}RSo^pADky9M!L&XX z9S4LTBf}4-&prn|ClhtBfyo~PVC)&)$!@zHjJ4%9^uN?Q_0fbwc6(sPBP)R2z!iA}MCuo#Wdwm!)2d+%yOWxj&TV6Y#g>PO3kJO*sXAUC?F( zc^Luaxz`yj2A_wXG@?1*FtFyFvgb2g_jfQD5$!49F}JP2?@}cEB=ip%D}@2o^->ro z8lLWxo+ucm5IxuCoh294SRmVzNg&~upy>3k2H4A{9Lj(PW$lwKQ&1M~izZN9>jO>l zc0JRD@~jkKodB@JrE(A)M@Xr~O|F?90(o8+pag}2c5?WOlkn}2mR6PnVh1Y_=S+}& zKC^Zohz@M59ORSJx@#{QwuyJaPI;kh#xHOk0Oa{9Z*6iTZ*^f^p@fIF7rEm3^49{u zQGk>ENnhLZ50t4T``Z6A!QZG(p+&`Wh#?)>A&0!%S>4G?6#~AE*ymE0I8rk*Sm3iv z;!jaUirqLh3Q^OWhgv$|^*CpRx<`=!r_nME7`}$!7o6MFM|x*7-PN!hmAGO@9q;t) zyOao@jf^B%<9!EUaEM<7gz;J90RZ}b7Q)lP^Qr0iPt=~*272e*Z-c|pC!v4Jyq}w* z;BS`C1Hn&~Y(RKl$pwD1Q^B*ID!7fo;9(3^0RSR950qS(~;6=bN$ArW@{I0CWSwAjD1aL^t8wC+=(ZtM}R) z4f6}QZ_-z-a$I;b@$ym>LsS`;w94KwmD_B5sc8B_X^HhEI)wvNgC+Wp4?qr9=QQx1 z*Nv~(01TUu!awm2g6rUa&>uEv-3_3wzqY-t4I(<877pv7aqNo&+ZXhl^1|Me9Je$i|1wc6KTNk(@ya26UfY}0LiEqp$bNfl><9_;+>Flf=r5*_-hlM1s zg1JAJ%{&Eqefc5a3femXOcb38{>hC=dyS%T8_rX}JnJ)V_8knKcQb?9oD&!qUlPDw zpHEwL#(COQp0?ja;HUjI7~{O{*F~Q=FXod}&YEo&Nr+qNLbt-xzd+l|svOD-jZ$M2 z(WGQaAd}N5p-GFL6bw}&;>hN_h$y98$+%8>^w(T7T_~w=4l1pFal-x>j!V)Fv<|#* zz)uwqqzsqKziR>ZmkcWPfe#ys{R)TURXlt5qjw9CWl#(9(O(7t4t&#Y+y{N(FLiwe z0ghBc-k%Bpvu>&(6LqzvM$gCYFoiZA&?d(^3dN6tznsubE;c`i$_M&nHeDm>o2Ot{C2vVK9RJadbKu8er0S2rUnIaABX@ z|0MJ-r>BC${CIAIygME2PXF!zZ}vIpC#XF9crGhA{DXk+mXZLlD%-ZgMuiU7Gef=y zKkv>*e-vMB8dBhs#5~2)a$f77O=o{T^SGn!WNu9c@^N2e>idTfne1N^OId&khE*PA z@_Zhg1=skHUmGC?+dl7XB!S*e@VT;6+Ek5W%j|xYXg973Uz;&DwT!Z7Xcc{N9DVbD z{-%7b+>fnBzTYH1D%|1Gjq^P_FY#KoegXTRNUd5#Z>EYtD>h&9`V_y0y(K|uA(Tc`?ycaWJK#uo)IKSGx zFFPCTo)`4#<89NQ0_G{Q9GGKW=J)qSNPyS@lSMt#_! zd5WjTGXI0=D;1psi@x4y3$lUz*H*E)40`5@JkC5}{>0~i%w_&3cN6q&2AQ1=Q;$3t z?1Q~=z&S62Ci#n|E&GO6(Y;?Wzm_5Egr(FvoN}6puM}FMUkJAHm+>k~|Mjt9aTz@? zM4~3`eF^SxxHf6z)5i&XnFzQX0}^qt)%%(2y>EK+*Z)+s4MeIfgMFX`-RSemT=^bs zCYA4?%O}5EzHbcIr^|nBrxVzo8SP?x>UnWZeU3rlwV#W}v8_*3=e-%Ub!O)$jukha zt^X4FwKs*4KY#iJFh@ULI0SuDJ3r*$^e;b~`0<~j>wmKa9Tw=9U=xqGH3eH@4;sjx zId$SMNTgeYM7M+ZdAi;YT=E;+pOa)RQ|@mre0h<69*SQH1;iFuVq!d`S8UN6|-@A@l96=cKOEJl;)7yzcAZ2b$%3e_68 z%56MKXmX|>badA1SwiHGPem`61K=Xkq?P&o8h%Tw69$zu-sSW^rW>l#a5Y6ODIFY2Mo8M&9b;T4()ZqZ0%^6W%8Q zU^TAT_IEI<8Uf(wnbN&iQz8*fd9M53)JJDIrgMN9VpP}~KO1YDaXG-6xe#nhabhfP zv#`V|a>38*p3CHhYdK3gJH#geq!#TP=@m_yXg7om)}duO5J3C_#-+r+OBHc5oIhe+<=w>A`~l@B(1jXx5#k>okA;%%`*oqk})<6rxP2c!- z6vl;p*%=2|3;%|10{r#Yw43uisJj?k4agj{N_Mu_Znfxyw zpSjU(gnvMylfO(h*qiYt-+1g~V%Z)imTO4yJKZ;l-^zT=B~Gq!v4=%%z29ruJmJG% z*M3(PaSL*xr@07@)){{BlAl;tYvX{NUdQj%apW zSx|B)dnYU{I^i7Az;(h(INE`3EK#0~d6N*3f|T^8(0M$7a6F%ICjMMFlYle2X0q~g ziYep*3FY6E?mXkSd_M7UF7ki<9FrdpaC=)Lp`WwwY;l6GY%x1MO7J<$NBA7&>3cz6 z_)9il`uXH^v$03A?Z!qjEswuUuAf!=vTtwq3WWXhbshkY+ey{k&;uxn0-{(aP4fl= z)5I~LA8#QY*__Y~A$H9v)`4N7uz|=A4Tl>FM`Y*VV$es7^G|D6=O9y+LZKLw1`BYbuJDa1uZQvvOJu@p?tn~3f%HDg= zyGOKt0-Tq@;Q560^PhRv`CBae_kz`_;8~CPWBu_b3Ix;otn=f$=p(<6K5d>22=7Se z4*aT9!LyDeEU0Vpu3Kvn~k^9W!W$x#CZRfg@ScZJFz2S!IXYCeZ$r* zMn~SuTw2)!yaVo8#}3@-FAGq6<@0#}79{wPXPD41PX+%X034nWei0O|Y~=OyJf)VD zM~)1Wq5$t*(08CZItWK_iA*k+%lGtZ-9kF&HgO7suVFdlyQ+R>E65g7cd2>-Wya?# zn|sGX(EyL8JwE6tJ=k!h9?iuQWSBA^b>No+K9q*O4y<|Dy+I27T84aZ_2;jt8z1;j zRek%pHZ%ND6;d0706RUQYW-5G9QvO5|4^_F%VS#+R}Tqn^y>ok85jx$$B^+u3}3bL zAfE!lKU6pVhey_*+4hq7VC>sq@D}~f31Dx>kE2%5myL~*&J>^g-SkmPd4S|)oD*8s zvzb0gH`;tb=aW*Rua|(kx_#CX@9?<8HA#@tAX_aiZS81N_ia;l; zZ~Fl4y_<~4t$V>_gEDSdNUWkIa-PIe-W5scV?~lc(oUaAK-ijzK9T&J0pNB}_zfM3 zQt)=M=10VI*qnhak${70S~s4F`vH3 z=d2L;62y9YSe8^*-`k30tEtcs)1YoBo>lTt`GaBa1ADsuqZ&+;*)IiNr6}VX8>Z4h7pFR||18yIf`zd0-LBE1Lsz_$cG z%QzzEaseZ4LqpD+EorNwo!iz=eMK!JN~zhRnR<_VnqTQV7JnsmP0A%i_)gCkqWQw_ zy-e?OzHi1kS&R_gulP*zkYv+=H)|1{4z4~4{qt=f|cuVNYB^~^c? z3GNQRNAMT(J`hambI=$N)-Cw#^K9VPoeCcBnRwQb@Psnf0e9e!KcvjgNyk3lS-)%& z3V&|NPrI(FkjgsQpZ2H*c%U8QX=-ZogYBjCNs$_04c(r8 zACs?fDhvEItO@Dvu74oKi_e`3f9AV=p*9Bd;g!-*(K;dQC`3U{Cl7dGt;VLSb1K*_ z;P&M*l$I_B{*``Be~g~>C*&)CjK71Yr@G{{u=O|S&(!DCuUzIQCrWVm4>HR%_PL_? z4fLrVwl3nlTo`O@=z8potw;1(XjXO9yv5=r5c`w0x#&ABKLI>S(y__cCxCg`*ESxp zW$R+S3?38CDc)}&YZD> zn!J!vhGH7gRRB0@n@ZldcixXT!CyJbm5f$ex7L9(-(Pvb{VBhZS=#oq&<-9ar2NRS znywHRAtDo5x{;02m2MhY&GUSaIoUsL8E*KT7l6b-LO)rr*{pRRecsZkW8M}=GJwxl zc`EXpHnrJtK3|8C! z8pbfbfbl}=yPCJi;!Bxvxj<%A;dDP{??Jm2jpjT1qe;#8Uxw4VL^kNV+{5RXAF;gw zRT`~NLXY)9y>m3d;5qkW8G@AVRIvIKv(EcK@Su$v-F^<5wx$jM2l?$QJkgE!Ph$Jh zv>oYQQWTq>)(iIVYq7EY6(G%eQh>jz!=Z)KXQ2=5ujza%oos*IHvp`#zh_q?@%aBQ zB-F}~XP;-_Ge@#9ANJ;ZVqv>k@wB%A;D{F54)Q5J)xqC(AXr`MvaPqFhAWV#M}7pn zJlkPLB%+``Rr&@-jmsb5IMmNwOECk^A54G18f;jie*@N+VqM(C+DVjAHn=gw;IY9| zvL@Kfi9ZrOOctMuE(61bUt;RThPg281E_0vPJHpcKISxEL(a|7LPVDww)TlgpKy+3 zaC52g_ar&WiodkP3IY16joPk3rvz=y>C zKXn2)vtfTlI*7?BU-EP|HfTPyh^-1`!QOc%Nw%X#nK>X6xmeF9>B=668UBg)Po+6=r;I5(5Pdck&q*0weQB=ouvCytAyPGiavB1t4Yv|v+fIg)@zl%4+7 zG6s9WN#LudP1A3J!lud;H9*+5QQIZ_NeD%r4+SaGeQ;iA0tb zuiKr0bMmJ>FQYKp@e|-7|9goKj5|J*)UyJ|GGqeF!D7e(CS}Og8p`L^JjF1wz-v^$ zEC72pRW0G<$K+V-L>8hh`Zo!c{qx@xJ5%;JE!%dl`BFMbSGn4uHK0 z(=Y5S1$2|+0DBP99VqAbC>J;PIWVkBQmwdz z_$+cDwG4U5Q|F!ZKa?tl#6^0cEqy$0h`PO;990zX-f~7`Nwlm)n5=!VKa__*+t!nr zJnMr$9SkFt^$#{DJq-r06SK4?DfdZeS0206F&BpaHt-=I27>Wq@bEnj2&<5B{5j|( z7)(I`SRYgvZNOrN4EF!s-aFRZ{k8ilLf!##$KiO)_yP`6kKlP(>ez>v4#q3GhBI5up<&0 zRH;oj?xg&LqbZ(o);Z{%vCg({kohY(6gBu4T;w*$x2o}5DGD6vS>OZ!`n1pB6 z*W6zp6o$Fb`h4(v`wn1YdVP@-|I<3)Mo-&_$28Ol7rD>?OXG5A=iVz4B{`@1z~Ccph+5IWnJOPNepnxaED| zSJb>0{d>Rev@esH^JT4-jI`0Qj6RJCOV&`D_ajJ+Hdu&DWZsN(?iiR$Y~QquljaW( z@B6tghjmD>Jw>m3!F(CoA?v619~=nRE-Pb;{t-S(lE?>=fnO`jWCRU++V)RQR5cdl6PlyARj-^D})Gkd3^`-u3wRTBpQu-jNynGHW5>dm#Hx zh`lvdTE2|ZO5{`~-*90x{~RRFgS`%nPfFW}?! zk_CGmpGooG1c1MG68I1lcJ)f~A+~ELV`DQ2C)~IUD2kTRp=T0##Ab?h$?4w7wsaeM zHQwhq3h^s&Tmrnu{!U%L(o~S@_e^tve_68XSz<@@v}@)rWeHi#OY0(CxaOa|U0)St9|{)oY~a~Zmsj)%2>Y4{9$?bp_i zg2KjIUq7fD8yjNyt`63JTL7?#LKkf+K|k^GQ`2$0i=ktG+FlN5^loz$4b4gW@0-YQ`wh^YX%?WQuUW|H~QR`Jlk*k$2T?M z&nx~fN%MgxBdP!|CBa?;QU-o)`3K7eg2!BhFNfiWQ>+CkJ^}oc_B;~>eW&wtz5-m{ zoS(mD9Tww*-n-+k<#!|Dvw{C%j<4(TnvJY1j|~RkE7@#vsRpqWr9?w=>DUbk>1zhJ zRR$vxfLq$Hp7f1qL{~v!Q^mH9&W4Q##hPCb1OMuDJ0;4#oxEWpivBI&Cp!rGV!0ZQ z!-BIKeG>Xu4?4}EuTefuhi$+si=SdDzUq8-e{nAy>r;u(FZh{zH(La)$Bn$z^=;u= z3fm_1jg3g&80^aI1yfn&IB0smdjVk6-`3!f_Rxc&Lmadh2LRSbFY6E!+`=AUC*4OW zmm?}8WSFY=EuK}?+v;W&Oi0XUnZ)pE{?SD~%JNCTkc;LGbvb7h+6_S?)@;!eOu+2n z^sW6#jhBfJd!NR}D7t4P1;+OsK$R3#1%=N6;n>DQ#(N8%ec<`!(Fslg^VnmGwkyM_ ziJS(5U3uW~z{j$%%b$Y6_TL4@sO}61uOR7W!PTA$ruleS27vwhr08AJY8^eV9LAPd z^+1OeX@H}DMmYQTflW2qyx5Qb?ex#rwc&vg`HSd3iisPfFi-o&pLp{RYoI3wvtShL zErYVGrXPv|z;#d>&HclAu`$e7STAU(Uhc=YtNdO-(q;o!sAl>@=F43>KpwK48Hn zX5F_&Ponx%z8_8m&pA(-0z4ZOCendlE_llaZL~fuY}*ww_F>+=L}k-5I1W;ut=~Jt z0lttD)V`WdD4nD`_1=X$tgzcqHL%hC1H_9w1>7tPk@ zO&U+NL^@#W#44tO(Rn9!3jFG|EeZs?7sBqd&x1B5Rkae22}{(rXHK>Z7nrkxPeM;k z|KlN6KCC&Ncm20GZyVuhU$}UzM#`e+Nh}??xmJVF zhLCFB#gu^d{IRt7M6jmapY)ArnSM_`r9)8)UWXqMB$TOjx?Da`t6=am<-pkHISmOG z=4Gm>&qBNJ8i4Q*SGb&Nm$qHMYVLNi(zG)v(&}LD)1Zo9Sw^f3kjH#OM$3rcX8|9{A(FDmolT#+S z#0+##ZQ`}LBh3o2 z4_%Bud;d3QFER9o`pEZ!!Rwi621=KsvJa<1aCn+m2MGQy2f^Pd_fwzs31`}YU9y4T z5yV}-jX%ZbpqIu7R_oKiG#}&20C3bctA$u_7zM*8852{1AaJ`Hcoz+cPElJYujm=~ zjBwKIApa!kFj`3eze`yg|3x>+%wB0Q*sXwOqda*D?SZj0u;l{4aWJ_{Lz^0xJ_-bH z7@xOxd^ryC+Fv@}(2O}U#tnr-F=M5a`G$_i_)OoTeFu(Hu0hi&2lw23rh&`=tcKfVQd)$G-nit^bCv z=6~xuVE_CD1Gc?Ydk(Ta1@Y_i1yfI za;JZp!wowZfnX;Nmd7(sz&CQsr)~BHVzN#0<^PXdvPd9y)z!+;naSHgM=l>os z(O!S?#>PE6%thh93(TOZaUZUpFxKo~UI@8g@MCf>edREx!~?W74GCT(#}Lnnc)o4b z>TK*ya$sGQ1*0w>(2Pe(B_i31Zcr1RuaC}#jiwYN(NBRtOo+rG_2JAD8|1djeqMPl zsz8CjMu5*i*MVOlxA)_qfD>r-S%idi#s6~RAa~018SIAT`P|V*&-u8x^1*>LrhSmT zsh7$#C;7?_7nmI0FA-16vAM9*)gpNrzd2t2Dz!&otx733xxP2U?~M;q~G;9 zX0?rN>0V>fd#Quuv0cAl;{(x^uD3n){UG{Jj9~Qqr-D&7+e?B!j93!d%HI`{3Q9k8 ze4ZIL1|6$r1D?|jfxz#w_V<4`r-EJZS5|#w1#=&46$mCx3j@N#cOCd$4EUNd27rAq zc-0jGz|ra7)uu`R?Q|N9yhh(@Io4{Npg8)d)9nq=)j0ccedGQ4BuBzks@iVxUiM2j z{e>18X6Z0bxR~HTU{0O(sW8G}tVME@Rhh|!?X&PD? zQN0&*9wJi&@kY+;-RndsKYw5AYn-?ZE^)Xe^~#@{^l_7f2+|O{vz)xAB72JTh$Bjw zxYB`7OU}f`{$AP5zsno&7YTh2{7z5f4ssY6hFzBP*PIAG`G5g6E_m)ibovIitbYQ$ z<)hvqk_`$kuVIcu6}>sle4Q6oxSgQz&tKq&ey6^RaqC4;xaB*CKHZ)I#&%5GFX~IU z7i}urU>>OK;H}Ch=w^3qJ{WLO? zE5s1**=J$9$6&v|xUzBM#w~D5o-br;F>Xu7X+Dg8zXd-?S&+j-kGJo(KotLbzj|$P zCxF*F82vEGjyqXTN_)=Q^A!@Sps)KkcImWs;l0XhQ=soxc-UWRYbFA7Sa*CJ#__r9 zqsIyQhjgOq7vFB2p$$w4L}yCMPQZ#M0nbm85&%kI$eZh)2KEL6#KS4!jGkd+Hk))P zO2OO3nqLsFMOqLX&2+OSbI(C~IFtFD`?~A`p8lPC5>^vSXqYf4{G%z9Pin?vE{H04 z9r~2xY+96inQWt4))nJY=eXRm`@8mx9wvf*@~3^!!D)OhV%J_ekNY3a#TqG5aAfv* z>kr$!mvvC8>~VA15Zecn});YvhUusE+_Cz0lc?Zk<+zauHm_|B19#t}J}3m6CK^maQ^3&^$yr@5*tXvA*n@m`XT z!hMVX2F(pnZgVd8K+&8chy9ltTN1rf*jWal2zLFbSBwpS643^CpqTcC?dPxT_8Ihv zqfY^|qAhKH9GTYKC!(Re_K9imcfTj{fnZX!<=3FOfbe|R!?ZS8_Ehi;Qtu~76Aa!4 zpuW0O)_bMZeyXQd`@cz#>!ZH29*RfU2%a9v0+EyP6)RLezEXXeBq;SPzW3{QYxH+o z?RL|EiR@oTCCF#NVAetp&25Zg+EQMsns6Ne=D^;h@m(M|#HX7;FhgfPhL6^bKgwy6gkN_@JTa z^BxX)v<6xp44wl+$W$?r@1rSF$XTNB_%4H(%32X0>JD6$qyM*Pj*%U zIuOz}WisvuI5`ZPBEHsu`{rty3>PVSEPN6%NJi0r4I2|wpNXm~OhDmTH2tRDGO!T9-V7YufF zK!x3E5=K&IdrNw&Nm_`Ict0cB>aGG)_)CP*CaX@d0DY3MdY>WvPLf0UcWj-k{GJzo z&3_U&3|P{>v4KEtQ67IdSj91>t7$N4VQ>~~_5#2`u(z_|a3XAqgT7G^Im`!bY4X;d zZa*e)H7)C|;ouU{LPDxz6Ce1AbW=VS<}2L6%;LpD$rEc2Y6PMEA z`>X84tP_pNzG!!CCVh86SVh#>dP*pex@J+hG9D z`jzk&*R~;GMxR+`ouN}uc>B49+}tSpRU&~;y1lTSBcBt$(Jw{Te7~t<|3aC;D*9yf z_~1|IOXx=wq}}$fKM5UR?f|nrrMvxn|LJG`&I#ZjCv|`~qCM-fz<3(ojzF;SmIxjg zG*9z!*l8J%3#ji?Pn$o`{msM~fir;?$|oNDD^qmJY;XwAq-`p$3T%lckKpEv+ad7l zPy50Hy`d?&MQ5{(5|}TU^tNa+cFk~7XW%Uphkp;)uB#ND1cq5@8+|0yC*0su(%a-? z7qGVnv*L)MtOr6 z2BZ|F?A&h8=aTvCxqqO>P7q$S$p(_8wF-`={ykvzgBZo61rQaBd2sz;#mtGWrUA+G zPeT9pgcI`@b`Tusq#FR8KLt>f7Ps683J2T{1C6VErQ5BW4SaGcd=3cbWt8U_Vw4Z3 zg4z4wo{59H{J8)y7jAIc-%()L)H0!y-VX%4#M5F-b2;9*7%TD6V=c{@RmdG>azH*R zrmdC87ED7fP^sTuwLYIk4&=5$FU=*lhw%mJP9J~M-o>`2#H{Xt-rk%1?IG%E#mJui z9a*C2SMJ3j^Wf0PxoW!Nz}U2XP~xlYH1N2@hON z%ldMrIl9S$S0OJ%2xYwd${po%{iAD_7=_epj2e$K##Q1V_bKKdNw|6)22iwEZHAPC zNt-#y!s%cRfvVu})megXQ6rH z*fwF1RedMAe!Ww`-1v8ZGaTHxvN`QLTIqBG7=HQ5Lq?zWoeu=$XuEA@!`Rcm4-LBp ztgFlBdH(WE$>h{`iJPQ=n2Q(EJ^q$?gZ^z#zL7q+vqGyvFj;aTKZcUf@uhYM1+<;T z0Iv!5B7{2utbKU(Dd;ITCUCWKj+-Cjw`_PnC0K-Z7LZoEP7F4;nS?)9Qh38TQTDga zqb^V-RcnA?g)atQ$&0;TaQ+89F?ud>kZsH}QWk3!i8)eYi&+Pagaq^`Ane)}A$%>` z0pX0U+-}f6(Ak$$=SbEC9pY=TB_0hq1C)IRdisv@;&TxVj`O9E8r#{w2mB-EF!R4* z-N;YX(X1KG(Gs*r^kW^WEq_1piPf}QEbYDBhR$;)c7i!3c7Odq%zWWotYtOzz~!A^ z4O?0~-wSfaz#PZrE!YY8S}SY`WY;L$Dq+x)1JnbJJ)o&u-Uiut^kc|onG%uZOW3Uu zKJc>sI|qMbftY2iC)k`j6{|6yWgL-nxq!hLBOvEZmb6vT#*u#4=bYU(xTW73y^<;< zTkU(3K18`uzufOA6TzbI_OgK@8@6avBLJ&FRKF(y?v$B7So6C;92e;)q3yFx6$}M0EW*=&92@4XpfSRp0dkWwE{YfMw4= zr!;%Y9&QdLv+p=NU(ZIfu=Qs{=!nZ^YBMZVR`MCgQJ9s!5 z%mMG`58AD8n4VW(|oqvnU0PP25YPpP*D8XfDb;{({X`%n%99`jqL*%;R)a{=u29T z<~_M4sXfo_3BUxG@aANbg&F#A3V15Az2{hcfZyEPtn7&KBcJcRjVaIWmgjJNi0ptB zgKK1hX(d6Zlc<_!B1EZ8Ik+de+v!Q*pnWqx6Z%c?*X=nDbX}#)pP08VG4IJ9JC~~d zs#w=4={q|wJ%3?eqvk$Q_pklz^PY=rdC=elun^ucqCX3*nJO<_&*jlvuSFBZkhKjq zrupAbMWo~1a?TF>O||O1iw{a#;acD9UAUG-A~*7Ns(p2ce8SKq?}uG$DQp}*lG_a#!ufKSsQUNc!Qd8;1N430o~h+TuDax^f0(3^%^6`d*!&&XI=ba3OxNPKBVXc6Jseh z%pFYgkAq2uA!wIFsI}Qh9zuF2gVo(P6_=iR(G~c#tF?8s2~l&EHX(b?0i`M^Osw0# z`nQAsTu}JQh@JZkrT}jbDN%E&7)<+8K(CiVZvK?z4Z5;@4*lKOs#noxK}qAY8pOsY z^arOG^KU<@wiD>dRV`LK#2sTWpCp}qC(G&{18Q@Q{y~6Qbbunnf=&1ukLFjRddzTm zlDIkn{6I3PQN!`cVzm)Y0=!w!clQDl3Mu*|s4;+^DFr2CNGUZ>+-4P$n}5LEnz9R`BW=5YA2jo8Ybju{=5dd%t1OchVW=!C`FN{Cf_7`F7n7 zn!o0mpRBGwo&G)Gfdezh@=sBiK|}kpMYJuE?1rD~j9R5gS6GajxPk=}2;sVZnm4jR zB}FMu5(0iq@7y{a+Fg?$HfT!WL3RDR>>eB~<-z0BI#YaMt!$3C~(Eo&HVW zn?UdnMQGnk!Dpd6f2X|QJ&SKpgCA_#hIqsR#zoT8aL=wy+@yUA;A%Bhb3G-v9OmnH z8isV@9}*||mJoA8h6*sx!+S~JS9wJ_VkjQ_MvF;M=0n+!2kQrOM2%z&f}uq)>>Qr$ zmG}f8QIpmIV9wnGn^{+L(sx5>5v1e+!rfpVq4+4E);Qu(u0MVSJsP z!>e_N0<@LtklpIkOHBBbSe^Lo35$0uHx0S)(q_Vs) zp`6iF4@Z5J@-(s9L{d1063B+$LFyGoEy5MEf%RTLQsP$_ef*P%l9n(F!jc2gMRVY! z4ueuEEJ1nJFv>kGW7U6_>Zs9S^P%n)&^w(9R{opduYf$xUEVi^UIv99pSC?k~CgnOO;+#JMGx z^6a1v1HKk3YP)c<2+VLG!bS$Ha9}$$pR^eVHrZJT=s;k{CUW~6GRn!Ez)6pB)<<0M zuzFnZ9JS!1R93-&T={SyWOa+=Tce$i64Y<6GscboIqS-Bt6i@#-dR3#-WlpEHup4L zTj9IgVjpNB*ocW07+L%RQi#a<9h-a7SX&f!zTp;VkJD$PwztcBj@5zRXTlNl9!56c zkDes&b8b!nWBv#8ArRS<5G~g zB;|T%-9D>rFwuOk?+~u_10@crUrKx-^6E@)op6HU{qpCCK6fHxz)B= z!@_WnAs>RgtQpV628Nd}tbE{v9{DElrwR%yp9Z}Z6c(^#=#SpXAMi=wC8eFKa8n9; z47}|#_^lgfz* ztAnp={4KY(+!_MaDPX9R!okzJ3j-Ug6ZjQUuHK#iwwOQ83pV+Ywro)r@QvwCZxa%^ zh{bfBSY3{c_|*yrawR(u`e)NeuFR*&w`wo-3&a55@P3vSV;G8Y%)wLG0McU6TrGCRESX?`29Q}Om>fOqA#2atb-k$O&e(x zYkomIb>m%1`b)nvm^Fg?5$@?{s^UFXVIG?Mk1X&z?>7qOGznH$ zx2afZg}ji&7qXS%0*~Elrv*83hBN8PG{^3w0btI|k@K339Y-_59MLIk4-^b*%TfJ@ zWYIZXFr)XnDp%pAl?%wsH9uC$`nnU1Hskjpo4(0F5!t{!gy{R40gUT>32%A@pgw`|NBEGdD?vwam0ERNKCm*-;x5n z3__(ZK0I7PNkWyj>=%;UW54y~MlX|ng22&+!_XN2jB7XWOG-@#XZM7{+h%Z$u;TzQ zD_hScwt;ovo!K6R_@0K?tJPc^<@xO@>$bA^N<^0x^+*G|1#kP6^KCH{kElJ;(cURB zeU32|OqX5hV)k0yVkD$@xNp*zEa8U6oW?m?m@nxVH!Oi?!AlP%3qf23z2GIh3D#!7 z@`hjTbJ3ZB9S99cCjnt==NJ$+L4q|-=J;3! zIs63k8h5M6zK@%yfN{LIlkCc$EiX`BBhZ{8=7qU??sx*&1e2oxZyNM9zpUgT?SG9K z(;a^kLb+{Qr$jVVX6h%tq{qTNlq<{^@_#1nb>@#+ol8_;KJo|y!u4N^A!I+Hs{d_a z84uhjV%I_${~0+$pQ#S`#(Z7WcZdG;^X9uXWwFGMR5oja82^Z+gn_*#g1H1Tn; zHQpnTuZ=4GN&uJ>I_bSY;ArHlYhRS%YQ%6ljh)6$+C6lBPp0e34Vk#WM@1wRBY`XR zTCX#{s2b}%01&-U0C7GE^kBYX^s)x8wLi0S51ectTUcs6r*Jn<^F7Iu7p0;>acmww z2fg=~VtAzAOOcph@FTo`Fbwb}@H=Vhq%hZ~g7FkE4c>Y;^|y~nAee4H2hF4czAkV~ zk_Lmb0I*;~Vz8T_Z^P%gL*m`{k_bd6f>9s3c|RwAw#|Q)gtpoD=09~DJP>m|YD)It zS!&%TDM}}DC;eX#_&>W8Y|a)f-JIBUua|;_*Rq8e_eoV#jB#vfL8axdMMU3k*l|qy#5$2R-x;a4Mg)KcsyHdF731 zKL(6K)*t7y}KF28x zz$Os<(?-ppueeX_WWUDb)emtj1_I_p&~|i8OpU~M%)9lGo7|SCFQvbngm8*F`r>2? zrN!z1NBS~9QANSN?E%#`49$ys$IR)t^aL>3z^|33G2jhtOgQ0ZpWy~L1$@=L0ym4D zFPB7g5_qVx!I*6QLPi1+KVOS?j?Cm?);T+8u)@>odcI#dc8=3|0m~h%sAS)?H z=j0#}HODMT2(j0rD|(3ZK|nablduJXdc=()1{LbF!;7kA#0L-7_kbW6 zyk|0U^U~bgl!0K{*xgS-@8B=_H33fmi+?WAb2J)JaAZ(5O;4PjZ<&S^p`IY#r9KD; zr8h39y+sp%-}3pT?8OGHRJ+3DqLj;WM~YMRrgaXAoQo4hab;Ixv;HwfrZ{fcsN!5z zEO+Ri4FGe&0}qRi=Cq?JY*ROWDr_np3);g`)Aq51!i}o?=v09cH_-Jz#VAs0WPv{Q%L>qUcB<^{!#oO#a|*nIfx_MAHC%Zqmt3|4{P zVN8O+uP*`I*74vV|tS`?dfovZZ9PZ3Y`XPv zNur;wKfza7OO>DV*1JF5Yr2S=A5O>KDJZ=Cu)b(q9rPW(LjCj)DY2#kRW`n;7yretDHw`DN!kCy`3>as5oc|SDttdOU=5I0bdcOUku=ZoEy)H3y7Li_FU{19p zT>+{m4D5hUCKe*qO?~l1!L*1Zy2hyQQiunO=rDRu57H9Pm?0j(kk~0qx96xqKFB@NtgRyA}?Waj=~I%JLlwTVJ}}!%8dUg`~1U zIa9w#he5$-8aH6005FGg35^n)Y-|Mjix^emr;xPZq0b_}ayTw{s8@{NWGozp{-IqW zjK*D&5KgyrT;!up;X0N6){0Jx&mwQUXj<9b%MI=u1z1OaJ8{sbZtt2ng13!C zl58%L$kDp5CYIYLG64_wE|dg==?R`HV?2JCzx}X*-$!o;hj|!~Vc_pF277yE2i>C=SNeehcOm$u zNJlPTNqWOXD4GQ0%5}y8T4FAbv#$xK@_Qm>xK5d>_NO3;Ib@dm5!atiuqleO| zFTg8(5kwcPE|ED2%%CGc-&x+)S>t0pHMR}lntC}*cKsIza!nh|jXa}$iveR!76K9c zBJ@#Z`bOY)yLP~PDy4tf8qj{I0SzBG6n#DxFr|TDxTDsC#x67y-wU-=t`L7WePrur zJ@`Nz?uKkoWRGb07nCl{a&aRYhBK<)l3&NB_A%2@InT zN<%X?oSQ~4_O+L6DiAz?UTK?|=(&%mucIw&ybyDEo zA5MJ7UPIxX*L>ixHqzHWR)hP(2|`y6@1x_cF0CpXVs`gtZm=u!0Y2$!Q|8o!SBl?J z(gD|4@i`8(xVEY^m?%(F6^fev902Bo?iH$WT-{sc!4XeV+?pS^Zz@w1_^8;@aO8$Z znp4!3?!777&aJ_r4CiQbbCm7jyA@eCOJ7=KO$^T+<1_cvMe7vcUkg@+IxLi2Ob``yh97~`qn zb@CT&7<(&7{8WlYd4|aratDa7r*A%eNJM$-c>N&R z6@G2Fg?M3B#{WAA3^NC>A}^kwly3vS7TaF?f#2T=1V{eH0pYC^(>&%mud#?e4+KMx zF}X>{(+$symVBEy6DV}7Ajjm35roHv11$pY??32kbt*XWWo;yop7}L^!l1DAGxKq5mK1&bVe2+~Qpbm+@ZgHn&gM@I^nx{7<}1$u(D((20i}F`N7gRkznP}^Bmjy z`MFT56*q^38cRgqbyFn9(@U3VwCaXsgAn~1nDiX&1z;|w&^;oxo=0^^6_vrRrcIJ6 zM)jp|PE)Nam8e8wKuGFQw9RLyeLW9cyyopo%+sW^44r!QZKr22tahC`C-z?2b%;L) z?dKU0d?wBV!8%Xh`4lwy;P0e`vDslb4E~~TWQ=P7s*;y5lnUYu{|6HvS!Y*4fU65e0X@X&`u+l?);+3R&8S_gDd`@o zf&F!jv$Cu|@9?nk1AToCns&H&ahd#SV4KJHMMIsj1>pq`_G0kVEc(lY;ofsZeEw8L(=>ra<|We8TtXdCo9U2C6fe%Ra?mE z6fjZ$v(UGvfQj%#?@!SNk%Is-!!I?reOZBDWj72q!!JUEz%l;%lIQqbWoT1)hha136nz0>VOm$XmQp7nnR>>;f% z0O`|bR?%JyQ!$=ae7t@=M~_KL-6{^(?6SeVdZR{#3+_2_Bl&kR7|xy^{1dU z$xoI}JUam~i0LDE!Fzk6_bpFTWBx4o`csg^myR8I-_x8?M5F1o5DQL0&vZC3!S+TST@D!*r6hIpo2i8cr@DE8{&j3dBLcxf0N2mwYm*$fDBK?N1 zZ_??$F#D~Wt(0v#w;zn||HHozjO${w&s}>ScKgma7)s2oM6gj zJXVD3>m24+kzqn$-3O~(^0KlCEJr)gb+P6ve`0cxI2JwXHH5hf!$%T#b-82ZY_G=P ziuv_)f|a&pB4HO`5+^MY&Jru+*Mc3tx+sIggC}b41I0mC)`1QX^eaJO@Lh0Z1a#{I zw$G=hgnusKDPdCW_w!d9o?8~!B$Db+eGpTJ;Vp0JCxO@>-;9nLyMj|MF4e3CAREk< zAHaLE9QzRjgn!6Jo8bQ)mDQ zomrunMQqy@aJTc1xQv&OP$4Sn{sW-%OaAJB~fKzqE2g{)EqOu@X^sf9ah6s6S3TzSFY-MfUFuugc{8;L@4M zGrq0G0nKNa4!*c-Z?I)`&ECy%VlbhF?XTDtc`z8-AhZvDyM|3WuuijICF^_1^(3binqN)QCSC~)DbWv> zEw$iB-0CV#dm2OnTJ1$wR$5YlN|}Cwz^wm}TXGn$7XjbymFQg{xEb&*gU5%f*GsU# zcdVDj>gBDijh&abI`kZ3V`7CA@=^&S9x;0IX8}IyHgNbTk#o0xl6!@vAm_ zpRUbBi;$ZBIJbeiCip86WELPEf5Hcx`7&_c*M|4QW%jn{dAs9WWc|8jZeEDV(I1~U z^hF1SO>kBD2J~%^vQxrYpxFDh>1*9vK~Hx65_0z2JQA}0?abn>4VD=fYHtOFAQ;L9 zOO(Oko9QgINmJDT|IEp<+>hn;G#>jQtn3KTBO7tx_Q79Xa)BM;3XFr3utAA7YF{lH))&|Hyb12%B zRk&K%@MDVQ^b?oGgLGHzzas5f`U`?>uq|qXygulggD)8`Y=XZ#IPvo<>0^G*D&xm4 zMOn*}qCKT~voKx=W*&+H!gyyG2FUt*fwoq{*2MHg@DDS?pVE3xc2=C<5`kwh5s;@H8 z`tpJg{;G;8JcLmO$+r#8rOJj@BWt?u)Rg(F>04I$n$I8PZzN7ywH&A7+#)T;O+(*6 zH@HrwA>MMA146U~=Bz@Ia6mtZFcPwAW-q;#8Ygr^7}&^*oG0vFRRc~m0(k@K_onG#bj>+DmqMnRmC-9!H z_PYc8kxO=x3?nVr=fW0<2%a%C{o$=2;enC&pM&=0C;$BEoC017aQZj?EVOBoA9Gs5 z2ZIM6eEoWzyGn$zzMbVvgKlgS$~l~d0Yax}3dCFdTiD;=SJ zk_M&Am4qillh%cz`oZ@HCt_!3-|iz7gMypxT!lC zR~CbJ!ETUP$%-0iYT=Xh$D0^DH@gW@{dwkXi;WL1_<9o*HoolBxY~zLP6@MVF9F=_ zl(3r1WcP@g*VcM1(A2C>0}If#7zB&grg1qt+CtA^F8BagyZ+FY22TY{m2X?m`Cu?t zPegDsr*-)c?c$F!Qj;1VrvcfaTia+`$NZDX}0lij^Lv@L9OJ=(j(S8`qHK4P8sN$9&? ze3;b9uibyj!>_pP(W_IzOIr31iuB2}AyMSBKya%}q~~K3*tK+>^#NgQ1)N7`;y~7D zrmE_fy+)aSw2;;w7(xzg)}3B8yS5IwUG9#kV88aX&Ty`BtIQ=cB|K95iKQ0%h>Ghs(|=MgDNG+kHv#&5-I z49uojn54;;JB*_{Fa5p#(W@ThK$iF+D z8tus&Dy!8bYzP@IWx~MlcyZ1~;p;)+xd}r5<_6B*_u!Ycul%Zfvh>ua**4a%1w<|= zypR7Rn8=yQN zJ_{`y!Q%e>jr?YNvRJlz5XjYajZgJ%`Zl2V~#w;g55MQ+AG?uHqcIibFqYEj?M=#O8i< z+J#!wS#nV2P~N@M#IGh)PW)WN!g-lsM$iQ4=nSgzVaa#rnKP784G22ZUvUyexbMTVZpgWPM?Ej_-)G$ z1nWGR-@-f-;iUW6F@$5I9|FOTZ*&XXeF|D}ll5brGFGw@O*RysGLGgd6(A=%s=P{H z1M-R0rK$5vzD)QRoHuBd_)+WiCXefKz~-AW!TO&-cAA7E$}2s~;6#{)$raE2)F@bsIKue?{vI zQf(7EcxpdD7RJvveA@H!*jo(&w|iWBA9j1xC>Xro*KB}iES*mUKd))#evi^%Z^YB5 zfGG|R!^z;mF;^AF1z%|ggK4)TO51-Ec%4wT4JrDGQt<<-n=#Do8wAb{)~TvWpD!F{|f^D=O?2U0Cmj+z0|ncfM_dtZ1vz; z1#v%-?FWQ;SJBZJCgPli^(50SJ0J5DR#XO>p(=V%j0)(YNg)yZO}RuDZ0-tPv-nzf z3h)aZmpkedLflmEV11etZ1noHVHYIof1~#F$$O(znpI>DE;7BAK2jOfGzki=mK;a7o5vQ-wcO3M%WQ<gxviN(${&+BbSYd0u@t@-l$G8golHPb@u$GDJ>EZI<{2gUl z)!kTF-ez}swT=t!#(`i8=!&;)HcDiW>?yIvU4rSLBMCVUIDb0LdHr-vG=<7{;-QJa zhJCTWKi~Wq(f=M16+vHX6tTAOb_Hm7GPsfdnGfK{Pxi-9yG*3!`Xm@^=iuol&ViPX zwao*)T*rXnevq^yIZ-Qtl`gJNp%AO!slXWIoQIxT8u>h}4A(6RGx;~Z2 zzdgNnkz1rbMkjv-c*E=A_LowAVU5FVL;543+|!!Ql?ErAu!`qC%i%bo2SiFN@wsm+ z3C^4&oaZxWKK2ne!1#g?w*eh+%Su$mvx22u-Z=b52~_wb(1*H{{KowG-uegW4ke!x zsQu$e)W>yQGK(l%?R$CeubdRac4%20douRVHmnXUJaiuWto=$O5B%EllOI$OC;Q`{ z5bP4oC!*24hp_^~B%nO_>sg$3$XEq~(KOb7*d#Is2}Zq{Y}-RyRzpdgv?~SRE^#1s zDt|rQfLD(+y1)fb zQA2Vpi!43~ROt+VD;NsgR?W5FrH-OD{Ohn9t@+YUXNKuncas%<(j>#c7iD;=G?AOX zu2-G`Z&Go>O+U{}?}EbG?jx%|$2>EvT{AC$XuL2w`PB{xXY|0&Ei#u%-un-X-V(*9 zgmE3jQ^H|?5(FTr2*|>+tpdV}I{d*KBMC+Tp0^KDvOHfpV(_dFp9O?pd=?t)Y2O3C z(^J43?|cfF)@FHMo=g-x#=!A&Imr$=er)TYu@3}4F7g_PO7Exq@OHl+0>2~2*bQ^+ z4|y?xVE60SfVOHH1cG-fpdoT{p3HQe_=>!kFD(6jn)Bw23e4$pUUQ{fD1f7DD9cD* z{2xGVpcoB>0bvO~3ti=lK=3XTsX5)sEZUZvo~rrpC(Ejqg!9!%xK2cE1JhlSwr?56 zxgR*pt~p*8DG{dB2?kja$`N*-#Te5}GGUbIjm}#QqbIeLq>7oko>L`;rKBYdgnXZm z_e8<(%1pwkoeJWSb(<8_+Wv@_!5`9%%v!5WFj&n!yzhn?TwcLn+0WIz;P1{aJRbh8 z$$tlb_mm6);T80SIn>Pi{ZB#r9}i*9z-kCG_(~>3b2zbwRjHre>iyH{cYXc?EyC*3 z6z7i6y%TQ}yNqM%cPOzOm)J-PR$o5p#&zVf132GeWG{u-;5xb?m~5m}u~P|-s2!UR zlaDgk?R=K03;n)dth;-FZ-g!9)$PG|YTO~+IntBn?@uRv%O9lAv|AReiM4mkY{Nc^ z^*SyGBhUUN+4rt3kpC1k?QIaL*(C`2PP%;uVgtYU?j$OUeHUXQuGyf<((m&g% z_!RK+$o>c$rRL?wtO>-JGBsf0Z3um8*($Ja7o>#nHIWAX^D7487Z z^>_+U9L(iLka;@5?fS(&d0tcpq+wgxyuw5bMXJjRVbKgd&TPp*FHD?q3!Q|`9ieNH z!zQW8>^8@_Ezj~uuB&(7#ux05G~__A!MYS1C(|lWj4#%*0I_VI3Jwcq7~$f8?8snH z_J=eV3^ta3-tCC?eBi1Yf&S@~aOdRk({_nJmOk`_Gk*qsnT2*#d!OC*J|Ij)!D(C` z^yNkpkd05X5;W+n1_%CF+spgZubpKusRo*vLM7M?Yn>gm1^WX_)!pF>^fND-*WdQE z?*Z=Gz;8U%+W}XtquLlw0c-z3e)*9Fjv?^7u*C&%f5s~Yf(Zh+40@yCMFPFSYX^Rr z{KE_m_^nP#$_0HR+Gtgdf5Eqc>6<_l%F(XNP2!5geWiam%@b8e-=;_@YK6uXbXqW1 zf?6p%9js*L=n%>S!blYgK5t5EZNfk>L9jz`O_4o6+u-mcf$I*DTd(yLL^xuzVDG-y zql9Z@mdLR07%Xbf^o`F!`#c7JXWoAT zI-;ox)7<~=`Fl7+^NONT&=&-fUm;B`Mb?pKm$WPqoz~3*`%5Y>Q{M`|)cdu>eYVSU z1K?Hq9P+_*0R_-uTAGE!8XxYf5;}FrOYuc-6m7#t0O40|RO5dOLDeTsc_ zvH@PzFeEM?ePK?&Pr`;(4-EYTV3B7NS|Wum@NcE?fkg`8{Gn|LsSH5d2c}Q3V~Zq)4Uk7#!hvq z7Z6_UmC%$v)ZONnz9Q9SZ3l35k#xt;apMKgj0nDGjWA~VT658na&q$9kwJ^=e-XPfQ zWoQ2-zv$c*A^-0H?|UHq{Bx-1e`Pw`xcZE=J54<6DCB6JKyXfn_9p1EP>$eEr!Ai) zu5~X|kMv(ldb<5({UIE@WS`mWnnH{@(B9cWdl`Gq>V2aB$69*K@SYU!zL0b3;rWCTr1oDxu zw@qRCNrK!db-E$Ml}rYS4UfhmSMjAnkVCzd%Q;3VBQZgaaw)OR?xbL8BMyKC_m4vk zYaE^ehG4Mz95e;<-oX0k6fpUDb5FZ_US0Ej`xDSG_frsV81pS0ch+`D&&Ba#qXCtj ziJvGO*xsRAkPh}I@}H(ZY3u)`mH1FTDnue3nU@{TO&Y1^v|6JoSv~e7xNt%@3P;yC zGhY+<&=z#=8_q#In$N^?r5NX|g27!u!@e7J&5_XrUZ`Vj74pkB za|&3U7?%2T&`e-w478!Q+=0`>Hh{YVwxIWvA0Dq>LOpU1E-LV=euWBch}}dcfIDd} zV7wqh`f5K~O-2SZOnlAwFG+i9w1v;`uAqjneybmV;G^gq4_#wbfH(;V+tYA==SFbi zQ7Zz$bod-Jt`}xq&~ijo(ANfhQ8#FTLo+c77ZQh?rGxLt9#dQ(N;@v$473RV<%DmccR_I&V{(iDfJ zV_Z7O-K2j)^V{hgUfg25w#n)e@;**{E_20iKswG5kV|zd>kD?!4q%q3GN6PcK;UR28C__^FF&s`~^q&3lfbX z;uXG-u2I#er`iY1M$el6#Lu z-BEU*Bq27QxXy?!EB+ZO7C+o#f(WXdD8Sf`c*zYZsWgCk9b3p-P|CnIsb^N7#!28g z^j6r`U6&k3*PX5c!4RO0Xcfk$af#av{ASk*L&@33bzQO&Z*!>^bimxc$JT*)v7=+ZM^7n(DAy!W(pL&ri$`T zuOkItCtSy}bRPUtVV||H$T4>F2Ib&}mXi$$sj>K&I~(pu4pK^_ct>$5O4RHI31#Kr zRL-B5!ufB=h%U%cW)r&YbnLIY?(9ME+RbPg{E^LnN(0lHgb7eBG z(wy##Q^0GIH^n*U6DLcAkPrG=?9ft!m>_w!Pl-&W8uL!Z3Vz4W|Chd!*kuCV7j}C} zhtD#vEHspUWms`pyu{$Mh#_z-6)II-aF7V`vSP^OCL-6uoEALccAMC0gX?~OTbFbC z_qCm=`bgT6ka8=Q1il49QciC5`pEk$&x~7KJyyZ zPY{^bpZv;eedc{-e_cv!t_#BkPEps>vY>55I|hK8fnVmVZU^5LPuF?LqF#_kY}Mk} zj;0Lejx;QC#0yl88wjo$2W|R9hpsOr<1PrRZA(GhRWUwxd|B%z z__Efa-5DGPVxxdC(GKt}6nYa7UhXC*fd$8h==n4c2oIm!rgjJ~HN43MTwrY*HoN5N z9N-hh0b#(m_0nsBIQ{*(X&+49ei@%YPX&Xmj;e#TqvZwoc*=O40$#G_6fnHdUE^>D zey921SD>>yv1{~00FypByu|3E6TsWe;ol>_{lWS!D^NG|8|bUOFe%in$Mi=MzvNw| zX9|tUiU%*{-^2Fwg!D5b;9Xy3kFgy9h`qCc;bnZ`ncy#+3|@W;B>`Rt1kZUy1%|Oa z?mosJO~L(W*CI<_JR%?P#Wl;dfX<~Ic<5Pa6JI30$L6HaiO%jBHsqS6X%{5dIoUJ4 z@KI`2aqmJRa-m^L(o{bMgB4`$M2RGrJ_6n@Ka=o!I@*y~zFux5+w zPatof$su9?)g1*-=tQwA86idN$yG^uG&wvE1qc#KXU}uupQ*%YnF2h`-Zq0C+y}Cd zaR-DgO-h02sZPK~Cf)k6_mFA*o4`6qN{xVUq{ku61zMvZZVUE}&u zNn84>80YuI3K*uWaCaA?bc?>ThL^}CNC%xW^HTg)z&2f9pCbuw!SJO#65qJm^zs6) znWTd+#bP*0k`q*rSU|*_47T(ADKM;BTfkGi4<|i5Dg4MTB%m#SWaMBFMxTxT^?~~bInNuv8$1GOBVs*QG4j8P1h%Zmn7+c@RrvnUJ*yntNzzjoCk!LngATD z@w}RG(EZ{Z1#P#l7;qM4x2Tu%J@PvVCJXvx^~{N6tD|f&Ri}n|@&)c&JO!-A${3%k zuI4l^6O1YSE6_)eY27m>V4R;EUIw+TpYp@oO=AMTw1+?#A3xy}-mDvv0 zK-%*^Nqj;57p6Hq|6FYV6|P(8;u_iT?6Y8uX9L6@Gzth~Ab3JO{2AC#Cx?quz&bFT zl>fde+VBC_A*2%#;d-=qN)Y5Qv8{eG4!<}0L%XHu3)_PDUHyHh%Sq%Bqt9mtJx6_l z6k@c%g8E7(nNX#rzQ*iXrtAfffkGlvq9qmY$)?0>kxp_jWA1S>xc?Y(Sbuv87yzw6 zQom_YFxUrw$7{f5@OMo}a(N0^odzB+8NYZMcwdW8K?}iz%4Ofck{A@|s2i0?)jrqy zH>BV8=-*f4BAKE1KbziGE($Kf9!AsXOv{A0;87uR!hL0TFis*wl)fkAgZF%Xy66Oe zIqHj4I4$JVs(t8SnQzFQ%Dx@O9OSOlzJb=zY7Dp|MqlDL@xGndWX}6$dmoMc;$e?h z{ky>PR(n|cc|y~-v<>>s*}|Rx#=!5=?ZN*Co_jD2OilAf)AHz{Yf)=j%S+2J?zO%)Jc zHN`#`oEHV^j5lfhdFFmVc!96F{(3;Tz-J??>gBK_Tmf?&>iW+$qu#7!prdSM^0Up? z;Go_q;JH>J?ilwfh&$|)3F?l#J@vcVFZku?s@odyq&L(q@XHi_LYlx|irZ|-G-zb- zpeeu?V35`&C--^cAooX;kiO3%Ka9-rl<<_d*Onv6H9q>4vz1uvh&~N`rjf*-Hx*%+ z5-|81^K_Yrl3*|q>Gft2Ja7F~?F^$!qV`TGXWOSuwI}xV$Gr_@T zi+fJuGL@_p03EV8hUCUbjn`CWW{V1R8w8)H54T-< zpVYw|cRnoARY3UJCdA-B+ko{*fg>Ug`r1>$E65A`a18uz8_#BGn}YW+r#urypMXC7 zTfoMB+V77Zjt>TJ4S3VE8d3ifVF1iPJ&lFg#1b(+97w;Af=8I6my`d~TtbvR;)&G| zkC7Udgl@FgeGy(mD+4m(NqU1uZ6eMIz>Vq}_#{Q$woKk5>m)GSD<{{rt>7`Xd5>|@ z!B8?Zg)wOsXGeyCW^kw%0Iq_<@zvG|{5)KrHo}P)Fjf)kO<{iP+wvm1GPXZyRmy_F z0Vj|#c3bi-MlNwy=?Xy`T^BK_SS`l8w!Bg3E%g_TEh;>Y`T&ewQK81!{OcrqjV+P0^Oo=>bVYu)g)9e`!!M?_m~ z7qm?$ApEq+@LM+!H2*&p5MChL|5a7dn^}zk;Py;}rjncOMhaatGy!8(WwdZgvj&p+>44qJiF53dUR64Z~V`nPPB zrVH@u2L>kJc8abH?xA53Z1Qs5#6_vAb(8k|rt^I*TQ~-PPq;$W4~D~c^WCdbzah& z>EON}_1{a`$a~{cau@Rn`u*L%_4~S?wzi~&N?r<(6Ir_Iz>wOtZ^}tbkd|ypycW4h zA>5ottQ_w3ell?KLSU{N6A6!k3|TDWk66~)KISxf=YDD5#e9>U7$bPKT34xRBj`h{RbZ9`rQ|AKx4q+jzdqzd+zOy=pW zF>cb{qn){L>~W#SLGGQ2AbYS)ZVwmpYZ*Q`bFNpf=G^qhE7inC*W4O-E!I`!dzDWF zn^1RnHzTUV`K$v!Pt~){XX3Kg@i%PxRd#%=%p2x^|yFTkjAEkPX z$w{k^C$XAPrr^i2&?yhOZoBBg?Rar8*a@w?1P(-PA^~FF7t*SamU$X{e-7G4dnveD z(x7F5U<_vJpzYQf)JY7Jbjnd7bxGWzj)@`Fk03-L%6A;m8k}?FW7o zwEfFHxD%!yf^*OUfsKYasiAxUI(A$$AiVno=hEqyrmr3r=xZ2r44XRttHcFPeld*# zwpyOm*x&Sxzx~mh7Tz!lKf?BYqi@wGp_fna9h+Fs>X|Ul%Yc4L+kR^$TiEO};t}LlBETzoM$^lCkI+zhbO4&B^qZHmXqC>#@cGvZd{yC9o z&&YtuurJm7MAm`fu^;*7=b(2SSkyeHsve#K_H(@t_Sz*4=J{;VD*OPCmXft;*rqcb z0jLpg)1q7_mC|=mR~E0fA}z#g7y9`(sipskkzeVYUrsmveyeV*E{6IU=9l@@MA~z3nfjHZ7VtfyvpOxew(86^P%xOr+|t4 z$=|_-JUF(>VXW0fit`>VAMGUPonpL|`Z381z7SQ^RNE^dk#<=4`y8G8LvZ&_jS_BK(N74m?r4! zuvGqS0NB>U$d5|rfn%)isBsYV-7o*&1%G>iU$a!BzM^R?HyLrtDP(h)+gju=06qj_58RylJs7y;HdX4R&xlYhI`g+7AhX?CW$cY(CdXjpaSP4+!HT zi*ut72;+$h8xYo~VHc5RV=&PC#)1RvEzd^Yo&xqkTP@EZ9|weS^KkGx2kvq+Ajpf0#GY{8i#DnA)bfIBekL&osJ z?(==%mvo@l1$hSnXEbL2i#=;A#5cYWK|ez&N=#L%xxb@n(lgCPg+lH~R=hovvZH=_ z?Mf?;lE5&^*nn_NtMWu*f+Tvb$;!i*g!YqjL0?fdjW#E|Cv%GWOLykiM%f|BFG!ve z*T_dh=X<~B!{;^n6hYF$NnVb6C7hXzYuPPvmfHns$sMr!#8jlyN4IyI1OQAyn~ocs zK~C+^lfdu1>>vNZJT{m%_`C8y?6LnDdmo*zP5~24;88%>o&px~pDQ&hfGIv#A$KyR zxthh?B+#7MKJ>U2pNkKj*Wr##Xi%sh@reLuJD%i@KnK@nl9*=6DSifJi{4C&0lP6nx?zae_nD&=k6m9IQ zww>ppc0sBLEEqF+7yMl|#q4*O(o?|W$JSVX9}Es`4&TCmgP54~yCT&rE^)=jsw1J0 zP1A|5lDM)QBuX^%Ttp!n&;yb6ge1l*u`)s4IUC$3>MG%V5!od0D`RldakH5Pz){B zi?0@I=`XgY?fjk|Sml=>vM+c zs?gq;pl?Y)SktDD9QqLjgwcn`{_ySgbg=R3LEU~}KzN-R<|RdBP7UwN^k?|F+-2wn zS({IsU&279T7Vg*PXTy_AuhXKp-sddR&g*Id~5f|8p!X>^d6MEuG;3hFCxEjW4Si) z66z|SmR?|Uyx?5+o~S8|HP-bX<$bbO`_MfFoSp8CLvSjb;PA}I9|(^a|@|4H&v?=A2k>r=Dy3TkjIo)24B zjVGj{x+~agPKcrRi40aq1$Sw@=0{qYe{lN2UtK0b8j;$!9)i9cJnXD{E*lsU7dR97 zm!{bBSmG|dxz~RK!>3QsBrP=Lu_PUN%ajXQo$1Lw1!>8Jgp2lh-wU^z901r;_4`)W zKQiQqLO?hH1%EzeL7w`+z;d^$xTo$E?r& zNXPLPQiLd#fYfL>2G*`iGU+J?>ob|R3hHrTjuA0*BK^aCz$bjCGqx4x-Aes$0u#Y2 zp)jw5zYogZKgC-k)eb-ALk_e@BLEEVkBF+SibiSjj1al?PAE*@(#oE2bU?CW4dldg zEM31t&GH~E6k?a|Wc8KcSyY0jLy668S4HC*_1qU}1%oBw`+pOd&2`?EQZ-e z2S^QVE3csN4%lui7i3K_7%)sw)&+oB+dJ?S?yRrn5$(V&CqZ9^7F2nLHfCtbJt>?5 z!G=O$1iQp$0v$)C1U6E9sJb+m8`twr|W*6L;6hYf@vH# zvd|ju-Be-S;r!{;vv`*3+}oWLOd?-QpCU_IXvkwpI`Wn&7qU9jlWpqAkx;qPkvt}D zP$M+vpbc^F_>+NecbG57Fv#0ez=(ee{yytL9_S4N!L;{D+2^3^)4y12CMtuoLiI0< zD9{^t=xds+awQ?F&AfPTJ-ws!cvhfO>nS<=ttp-WHq;n>C|!RZBLPgh1}46Qtz!S?@&$^seK|f(N>pD zl}xh8OK}=FyOO?2);FStu_2T7Xg3LZXET;2socV?nmFlS|8D{lTn$MFg7;=34Ej3S z)_-yniPg) zCc%q8v*_~D3T^HH>mw^fd3{tQlfBO_m~4ZzTMzAu+SNWXKFBOE#@P9yKL@>iZ~ zia#AaH{U~U7-P|L9uH0muMP0nijVvc!cP?u_sPQ@KH4JDAzCkaX_>}Aa2)tm?T!yJ zBe%=YoMh%l7ckyDdve+0ki~kFUaIXR-vBw|hh}Qq7(}J!_4%AAA4ON6@G?a1gfEF+ zlbDi#!o$o1!H)IQ!?p9O-+(4x;luOHe`9T1Mc`0(Zw@Vr{hO8)Ycu?;x;o}`GO zY+2w6A(6>>m4fDUe{pmD^uH{0-kR~V_QeXz5suK!*3NY#lEQMR5>Dk3jC`2pZUP~ApJbO*dTKT z!4WajH_vDi@8`s2Xz#yp*&|vzjfido#7`i22rbsL581DWnCv^M{j2;q?`-jO)Sm*5 z(t7`cFJA!TaZw)zP+&tA-s)Kkuoxx@t4t=qJR#)O(0QHYiWg8M=OHKiU+GGqDOp+P z^WZPwhF95FXG&6^qER40W@p8F26m&?o17@{yHEd0ruZZC_y0dZ;Cb(^VC{OD4c9dn z@ZJFMo><~Q@RoIfVBUEZTt#`6XV7KPIWBJciRTiB+V2y0L_W0ffSd@i6+TMXrlwP+_*GkV)xOZgZ3E&cK!_$t zR(ea^$+HQ*J$%)MI6q^M6`(LEyaTY0yZ{bUL4OetmN~hveE}PPcJMbp9ZZD5*~|y` za)0QFZI`tH6OD~N2>Z5>h2Ut9AH!#$J@ppo2fP6=&_{c$tbSnH)fBxEo!x5(2iV(V zf{i`z$I0fN=QEo`;(Hl0%L!?zQ^tD zq{iPXg6wDYG7s@AwODk+|L(&xb660h}}V3JYgR%bHhQa+cIqkKucY>)2^R1RTT7m*5q z;(g!fXI9ur6Q26rb4VNv&Vs)*0>e@87xPgdc%Jx;Xfxov=UiF)pKx&^kkOIgGf7Z5R?NO0*-L$%~Rp>36vlp%SRq5zrg9gmmP4sTfd98og;$W+II^@y*n})h{ zJQk1o@FifcBDwxcoes?FF_I-d5?ZWZv)SU`n7+Z{Fk7p<_4PuJKNh$W``6;P)!vUE zwz*eOpVj9p@1akIDih7E8}7i&wyea#wPZhNY6V_=B|Lc1JBSquR&*wK*C@sdMSIE^ z2*w}QqbYpzk_MVd_G_;n-riF&yc4j2-v0nO);TDc|FRbr(X3>M1pM72d^zoa2j;=|^}AXVS}Ul8~| zFmVKnFb0HqPXid(Bt_G79T=S$yku6R>Nf&S>bxL->$P#deXOV1>`0;a2-n>0!<5Llgz?mBf(2k-r!@nOW9}<#4Fv^eYHp{#1$Abwi_4SBD*}re2Jc}MSTp)Pl z25a}=PP=~srj?=ZChiDHA&3#qR3`#x&5WMf5Jo4eRqeLtZm6 zRU`Bp34VG1EfW`dpVmUs!i64Euf~XFUXG%J^K#TFi4B|INg?Fgvj(zzf`Gk^e)+NH zyJ&t1<{`U}W)u8<_Ij6r;Q7}Cf}eR@)@I<{>0eqF;#0u)r-41ElWugNO=hX3?wREl zfDTK2B3P~Gr}}B`p)KYbPWP?cPN<-M^@a2#9}}+AU9X+MpbjCH?Z3j7SG{oS^LF{~ zPk*BR{EC(H`32NvJ=tZxkeXPpP5w*pzod2r{;GT5j)__PcEw)cMJiGYvlA0PhX$JZ znTypvS7Dlh3Iqdw9F!fqx#(R727YZB1Hmd!AXD2mg)nLVMu~~=YjX98;35Dlwe8Ru z0<+ftb9H($(@4s?eNf7tZ({oVUlkN1(h;(d%LYDNKpq35qLqLq##eha_Jk;oX$Y8? zz|Y7wQFsa%g!7ZWURKMCRWM#ZrTuRL&v`cr`r_ird)m>#Q=Q)d(m2nEhd3tx1aW!g z$>cF`+YbPL8u;D84iyOIRYM}gL)k-q>#skqoYA&fP1|-7o{_`CLAsy#V}ZUeoECD? z8y)V+wc7lqy$c`TH}9uCEJq3a^jmB`no`;AWA=$xEwfMfVPH%p(E}Of6mpgpkpw{3~m$D zow{&0z==TAJDwaKWnEWU>sb49(DS7!^!3DvP77mzca9@5zZhjw-3ftZf^pUe7L)$% z=+i6@{c#(&@#Y~%&<~z9S3y`?$EZxC9~D&HXb*QWMzx&r#DbW#F&l{wY&pw>=~fTq zS5^oP6m0=H{eb|UJ0|ml0;p81Nv7rdI|3Q-)h<>*f(rST4?H6j=a$$y>?#f zoh9W+IAi=);=sI^n|bez1jQ&l32f#U_4NVXvqtp!dK>(mdylz~c-DnIH&46!hdg6Z z7>-VRHt351VLy3=3k)R@VRMbwp>Pq3NpO7=WVUM;6+z#q?w&iV)mT6aoZ6O`Khtsm z7z1z8m9eiVzQdlwlA*^d|jFN9#A*q|*QG_TrfMz1IGPev1~lQjE2T{-v&y*Ux5*8@IKtFaFq{%_neC zbQ*gKuql(x>bX)TpbZ2o2ghEj$gh98>qm83V#3qF`U>xil;7m)^0*gRSSVvh=>wM##6HegSRbTZqC4XXW{gTz4U>5g;6j1-*!*#yFbr9g)|Xw@GWNB@)wC7!<}pGzNr82W6M;ML^h{%tb$! ze_OXY9qatyI!oY=iI;7XKynMG^@buKmvUR+Fek0kJGKUWi~AD zc9`pr4ScKME^JIV@&WD#hQ32t)~AKD&pbybfS(Zox+FRXJMy5m+g7{160n92{*n{| z!9$W49+Uw^;RWT`hb-_twDrXy!;mNw%zktbJ?j5<5@k=9a=|3{OLO+(6gjpaqyxk{ z$Q!*d-$ng$UCFqBFu4A1nJUmr=EpfS=k$kv^Ev46oc`?uXtVxU3kmgu z&GY)yF^nXqDP0|RJes=gT^~Jvvc1$Pkkz}A7RuScrTEJ( zrn>KYwr|p>t#gMLaeE$o+s`=tmIV&8ziQ`J_NdBDxkT?P;=j{)du(0cH>b~|;+IrD zx<6hjw%%j&HRH;IPo=N6b>XHf77?oe!P!_A33D_`zUECnl>0 z8409-9*D$&U{Sc}a(xbjBjT_K`)C*9mOi=nDwSYde#=SzW&rNykuTo}b_<$9v7I>vdN&G^Oh7iTVC$W;= zSQvc?0b+bnkM52f z0p19k;*oTu56a2>jY61N){;b$xf; zdVye_G|iNBSCwhsHvut`{Va(?T9o+`JCW0*Q4sQ{f~TA+8obGqJ$(IXU0y37g?Du;ee6#Y;2ZUpv~a1b7-t}9G2{yvM`?OzX7Vw=oPxiY7sTL*Ms)fdv{`A2fUpS+n`^Y*oUUq( zA^&qGdv%>taN`u~D5?!Shx!R6aV7WA{J85X)va>P82tx7-y%KlLOuvW-}J)0mJZ0r zy;tL(yEEQT)n382;Ii_z=PiWY<8{W_*``rz;=DBXJv?@qe`n$&;a`*TKDhAtx2yL{ z`0vK^htBXXRff;Zcb5BF{8Fa1o#R@89gE>2N{21PBJ4F!0%7&uWze6CDgcd$>xXP{9YNaW3>^uVc)k~HYz{uc7$88&z+nZ5$) zR7}F`Q{utmyyEkyyYm=84X}9Tgz+raO_z_wXejfK3evubDZ zx}CTF&hO@faz?ZGAx~vuxl*^y_)6k7^Ue?3=4auwRU^=L`-ifeay<2eHuzZG>=>kF z#4nP2CAUS5XamDhPWiVLTpBttH41v5&d#c4yx81fo1}bXvrhOT0?KNGk9F5U-u;k(SWjLKGF7mcv7MN9uzb*+yy+7k_$3|GP1+1+Jso<+ zRv<40%suM8mi6P5*kxWOpD>E^IwMd%z1erc-~j|>8qddk>uAsy@JOy=I_ay4Nm3yg>pL^^FUp-rjYG7lP<>W z>m*$>(&v7`@6RCkYe*kgr3vtjuKu(SQk74F!H(H?Opb&$^DBu{lkxsu|G*M6dM_?3 ztLKkBHN-zs!7ta?z3Td*jS2o@{x%T2W@#ZkAFpU`lwkDNVDM+F7g@k}+YZ9}4+qnGrqfTPK@Tuyx zE_LzT_$biTzzWK@>17)PvHGv2u1lYew{MNt-j6N#BlhR1J2tJ#uRFHV=l${Le7#D) z>DEWs{EGFN(er(fN?dH=qV=q;ATdiUbulECQv_bS8Oy^rCqrml)!!_#Vo9ut z<{}V07h;a}pZv7L&L(0+|NgJjzYPF0_)121w8gseY9V415O(@X7g{*T?ZvMRL{dTqv@6kvs_g-j@WQ->7xi#_LtVzNZ236B#T35u-M zty~op7BlYIpAzOlU#LMqIQ*2f3C^M~0<;~JmJKaQFgBpMz-#2gvj3n+_oj)G zQ9w6m@*2ZJk4guxSjU5PQpu@`V9y1%rvg-|}_7SU;>iMC%2EZyL{v%+{#i7QSh> zoY`dYZAJ7Z@4_vWi+0(JeoU(z7xnP>XfNWhMm9iu(@ik={*dX-UFSP@6b!!GpwvsG zqW!sEB|k&sPG>1Q0+mz5%6*#i##n#V7&RN|yPiIfE}1^73k8D7(?@;8>JPr?gMbSV z>o6J<=~f85RIm zCuS*eZ4N0IK>QE2nB|<&nE$4Vh~Wcl#mVjTHKg>tDl6{^k2#UY`f;I@?8nO z=UySw-|imk%4kVl^(rX5h-UkUq`u8mF#`>PH%DYtEf@~Yz-DZNjh(Ebmn zy1Jc3=iT(}c>4MDp0!tf=dS@jBKl0#TLC zmpTSd#)S=i8wjT44dA6Vj&J zQ8~S*7Yw?JQYjKjMJ4$MRK=igq)FrkUjl8YfLB>pd6gO=F5;TYB{t zuys?&OONzt5cogVjjbQ9vksUHh!4WfoRYosO2+0@AayYa1&x_TyZ1gRv&| z_0ad=Q8Gj6%RWxU!DKHDVZJsTMpgh;2UW7kbotLy;gr#VJ|3p+ z&!HF^dIpfs)WXm{0S zC>VS(ZnfVAgXhPZll{U!=8GGf>SZ@>v|b%p8S=AlZ-2Rx-Pl#%NL>Evc3@XmSI@h= zd|}doV6y>sfnXwbdO(18_57-b6`3$~du%Y*L*F$<_5C5y#ZbQI+HvpSZMA53?bbxOvSxmv zb>C6!oifl{uwF^hH6c0WfsZYb1HC`pOt0Hq3o#<9(mOSiwD5(V!1&%**%G#lA4}*q z)hoNfGU!V%2cW>OreE`sdlOjh^~{w`@b`9j1b<1*u`|#+-*Ua?Lc`Y+zoPZu{x(7g`IzY{`Z?Bv<-pXFG_9vrNOY!dQU^N~_kXYFG!Z%J* znyAeLep`6bZlA3WH|?&lZjz1Ab)w+^=3H z!)L)?R^v_G9Ewil<|@NOeAUdPWawFvFSrJIbR&rRG^9ffZ-9=WucUK(W6ho)P2Jzu zOhV=bT|a50uyL+Em+0JesWeNxrDbVepBL7wc}3uNUEKbdiMGMtq1()>E8h(SkD%|0 zlBz)PnE8b^0pU7Otk)BLYjANjof6h0Dg(PXc|m(ESZjInLEp@dV;cX0vp^Ztzt&&h zhPHl43_F}wBbqwnXGsfa9VS11CM?n@jLcD zGC$!Y&yPLqA@@;2YaQ7oW-F+k1*S4Qpz8T$;LTl}b;RrwML!O@Xg2B^`1)7Twm$`J zJiANPCJVXgeX`6*{A7`RTH~g3-~?@z^WRDmJ|Bp$Vp^6cCO#uTxunI5B+;fv2+I39 zOU#<9m}{_T#jQSNg(UH7W`zKCX%(Y{2-M%HQM9N}s|tS;c;shEWf^(5ZV9jUxwJ2XP39OIssrs8}*xbcXc?{?ZMMq)4rVKO}i)M)p|Mu=zxb3r-WMW zZ=b4m`9>yb)2yGc_}&}t?r8KFJ4u`tYA9t(hO|hbec6J;T&MADnIS0b0>T1#SC*G8 z6A)hQ*!hX%%O{Dx@OU#f()^pjGdP84t0L2f`psPB(_D+jTqU+mOrVK%qpfX~pAU;k9Th^B<13KL(%77PxJVkEIs}R+qpS zO;fLuUuM`2Zsn6Wn(---#Xt3IeGYF0>kX?ar#ek=S+%Rvl?92{-Kcf+1H?9Hs(drt z3`29wiE_i&Jq`Af>S7{WlcbKINy+}bV^CI3>Y7B_sG;2=lJ_l1JN{26Av06g0R8j} z2^!j6aPrsRcGRp9v2%-R|&35XjcQEQUNvAG-W^=jcdKLI^vX1_ny^WcZg>x-Cn-qiEz%4dOKay0+; zuZsHW1%hxIy!!ilVCW2-yRHdyjZMO@$bP{DN zY@4kIb(XhtiM|Y|?ew!64XdB_9(8>t*hWo}w7k=1_Iq?@yPdvJx*wzOrA+TBy`T6y zoj=tOUzm98(hGHYqd9($4vgs8{^1UPYKMuw_w6^;%}tnkrPvDHdo34}4bb(hQXp^b z6Yt!;H6cZ>0==lbt%24z_d+z=VC_fNerQsM)5d=y`(&s7@_+6W(av`(qv88{cOY3R z#Z0tR2PwUrq*^7xi1~=T_)qTE7NqV%NWU!OQx-ueQJo|o6b?EG(QG@;Y>>(MQ)?Jk z0t4>oEnuYYSF(Q-m~HU)K7OS^Toe3t0bCovJPZE9z%g4{RmwAx{SPP5q^7^#2itZCf@c)ej>fNqgR~8%=dBYF5&4IHO`1RwXiHGEe ziMv$J7lb(L>bc|s;5kd*LweG)yz91Hiw6n_n*gk$Uu*t_|0Wn*1&Ggr!HHjf^Q3*c z>+ipL$J(E}ol!72(0m|EEpjVB?0q7;kr-q5c2pGYs@q&XNpGY^&t-qd7Y0dhS_lTC zpe;FDvp6kJTOTi-t=l;=8$>5R^*Wtbw-}G6pIBDvx|qN%-Qz6k<*UJM+wrQ$bRPtY zeIWRk;UcpG>=1Xq&o4JK`61%1c8Lh22x zp??LGJ3j|)1H3dJgja#!4^{L5c;)p^LYp8j7Xe?#3v0n>BSFF6Y`#`J-9d9cv3+KB zxC95`OQTb|S|pYZ%?G&j6||A^75lYR&ngL`>6{!;yGcUk{Um7t68=`{o_0bKc*7D^ zKLNcy$pwBFr~C{Kgk?=-Mw(9thTN|As*DJ*)TM{v{uSh1a=+ zsq0#2@byH7Vkcr*s3Qq5w~~`KDk?o-t#<)ggRnrV1KX;S(PYybZMPFhH=7V5{e=W_ z`<0{A?TcHxb-ODXn^-fpIGTN@c4`i#%DzpZvvnDBlM1`Bv-?8A8O-%*+2$EDYuH@b zx8Gf8m^g-qd25^U{(PF=(u0&e)%{Ga9`8>Q)^VV@c~4N=_O~)z7QadyGCg>M`dgAR zT?Tf&(AXnn5Lf}_9oxTCA6{}}KYYSpa+NLq@ah_ZzYw6yU(#bi{ ztLyg{N>Pmu7=)3w}m>U@cc7b4}MXb(6ll%YOHP>hsN8z#jLXzy2h!oi)a`hRtLcg1AE}1aP(3x4~bYH`WZcawfa-`~v~t__NRc zYVj9qTo-NH2YFRW%ci)Lhr~eUmE_g!?%>z#9c>93IysU8u4vmXX0u!G1%B22R^lS+ zQSUzys`m}#mChJfoHZxjwln%RjnWsDk zFXfW}Up(#h0s6&jfigas4b~+Pg43clY&V8IP4oum&dHi*?VHNCpG;133)_i>?xp-w1Q2)5@)orT-=}XASv$_pb8=1cYtnV#-51GRS zfxxl$Fe~3}quPaCJ$!xb&lj0mE{$$DSl`K`L7QUTk$<-xohDJz2@gR$&6FKeoP$H< zPHz?bHSqgW$UdAWS^;JiH17T!^mqUp1Ht;?tPKR4w|~jKa!v@kq!K+Sr82mi9|e#q$pLXMxLF=Rb5P`D|tjHs9hs1)@#YyXv`$v zv|odBr1y6;&{wwKcd)wi%Jyb3-AdFk<}Izy27VXEf5~yM#+MA|exMutrF$Q8&!^_^ z{n82wS3%zu5glLj`6eLj){nWk%&!`S>&e@|YYmzX%`1NUci9hD5D_k1QPN{QTVISK zw~o4k8(GRTC>sRu_8?}gPG_%B85lL+hIMnt6N6w+6Z2QU)|i;@IpF=+1g1@!;Ld>N zcWs%QibCI(clKcIkdj^)KS-*3Cl`HWlcsqL6?3ub(M_=$d%ueDNBZtZqBqKDwTwpki!FR8HNufPc|`{WCuYJ*O^}VVEbffhks# z4bv|8o4?Jw@C@-xBly8=WwG6*gIu0Romtrgw2Q>HjlfmoraxM?$lJEp?K(1^raMBZ zDR>C%v_4DC4fk8=vA3XI_yBA0yxD5(`hfR`i7*Z}IAi}%=9QH0P-E7@S57}PiZXV7 zii?51xAdf`c(?vx#=P~A1z~S23d9bdjsnDP<6}2Xmt6j4uJS=|m{x^o%zI*1DIHK4 zwl8hgA98_NR<^A_SDPr>2-w|nSKU_!!oTI~gAz*t*}oTjf5>csyUuJDNuI^gucE5f zc!PJ)mHaG1om2#6FPZ?I(^sI=$SQr)pR3WyY=D=OtEqrr<O6gT6B7tik`V%ls$j^0e-+MtKY^GauY_7$d{Xy*Ggg@kg85zR+0HMP9Cx1ZH~z znsPMg(HGBl5?x|xtph$Gzm~+@lP!S*-Ossg{XgA6Ptp(hBpJosduUD;z!~$F(KhCv zLE}k;0b#xigSEb-!mCW=g1@(gB>1cIGIciJ{vCG4661rclToE9OfwLx=le9!yVhZ7 zNijUOaKXxSWOvHc7o&0okqPUSR#KOcq2UYZHI^!=d{Oa{w+zc!PMBvR24cqgy6Xcr(4_tB*r^ODU&?p2&e*Dk3{TH$_G3`50}}h0O)l zvk<)=4JOTf;pDM~gfOpVI|6?CvkITf+_MpI7wxj{>wIBA+0xHkygsPE#q1^1cIUdv z{*|(r!^L8C&+WcgAohc7?k?>91)T!wDZ!UI_$!Vk_y5Hec%oH88^InKzai~_kL)a* z?KLd>XAt;52qnF}vVm0-oKnHu!NYO@oIqp0m&WIB0((5u5M+fl!CRLP@*IP`RRH)j z5WM^(@_LjWUjh`K=8ce zfnbuzo9zt(!9>1Yn7;if=&K8~ED?O8SJj7H;7s?|>fteNH-TkWSN}JJ)d;q_m}vX9 zF;g03MrEWESWZ*W_gj+Cx(5J7XuUacGd>p$Qu&#k5B3$^JnS9HH0{Rvpf6qbEDZW; z?!ey;#(kxFoS5!%bhZboy+4}zpH^+jgon9=?<7B3H|E2 zS}`+5)U!)+1UIvP+mva6W0@VX3z0VYo%@=hv18o_0`dZ)rQ!Eae1D5H=T()~>gBKu z?q_vVPYG5lrsAW3u-pZ{0c%=X>$2Y?uP-&?{MUhCvH{`lTfe)Xg`SIySy%K`Eg&X1 zjNct5|MqXFLy?Rxqn(`o3F;(U6HKACc+W-M%WK%EpJDBG44}BiM6P2nMrV1H-h6ag zX4Nr_$=N>JUc!0{8QuD8U$L6KWiG8FZfB_0@2lLG@hEKCRBJ$IPsZX##t+Ca7LQ;? zK>ulwsHC)T%+|nsBt2=jZrXl@fcOpjoo+lAf5neS_i%d-Nd!C8L*;Y5ej-fI+xS%O zr}0l^aN@7n783^Em%gZ79{2XZpS65_L_YgHp*XpvY4`T8FY90|S@oo!5s80jqU|ZW zW{X9jrsZqF{&~v9V^ek9Mt^A%X}Ba7kMahS%lqfLVm~ZeP(d6+0Cvg5F&u-T^HTsu zf~7jiZ8gXVXrjcX9!DYss7r$wZA1=(o_p+RQFrk-fpI{0;AZ}F1Cf@ypf&gITN$WlWSX4UOJZyO0Az5?xvyYZ#KWB zsi-?=)S|h#g{k@a{=sb5=Rn>wxS=cero-Ink(9(+2{R!OT4Z#Kh{rk8D+qft1#lS$ zgY&nB@msp%;seCL^v=+i+ss^>F1z80Zv|g94Q~pI+FIT9vv20gZToJk<@B6CwC(AS zi8e8?O%&e>CbDnn-ff@*#A;_arw+SYob#92G>rLC)PgwX$s1?7$OIY}O|=YsTX-~M z^#-pEtgg(|smH`EZ^netIYywk_%Sb8A{($(Htvz_?1~za6A}Hr%jm6P^F}oY1TULb zHvOBx3uSmFXiB3_qu(NZjiE7CbRbx{soRKkkW0NauOhwDa<{q<}mxP&5Vv9#9U@J*j_DK?&_ZnjsE(@WjZ5Lz#(}$_AWc> zK|+_>KGay0-WYXQ$7xVlR<3pH|9{r;m{{P|2(cf zlv@XUtu#=V2c>*HK!Dq62eQyTB^5%_R;w?pg_25_DTfWaXkX!$eWTOT^j-m~N@W9A zukj5jsNAYb^7sCv#CSs20k3Nl)G{V~C(zb>7|$Iqy)89QmQAC_w}Vsh)BsH=K*FVP!+1UG?Tm52Vyy2|!y zZBK%DecA>kJ|w|l5PUbmVArNz=Uz`te+Gg7!_E(E1HpRn(6E*+?R>B zzVSD5n;kWmdU#{T-BW?hNTP+SW%hO7CvK7SNMcDjPKOwPzB44jEF7<3u;D)o28XwF zZyhEOd((b_mJhxT4BrYy!C_;M?^+w$vL&-G6>UZTlxF&7G0=Ip)2arcH+XfhX|a_~CN+kpkm`o0b7)06 zKN8*_<1R0|PEPxb0>NvzD4&#L$^)ZsYMgDS@7B8}n)k6YYe=RE^l)@^9hf!dgQq^H zL!3+~u$i;W~Ucc5V2(+wF(3DUzi zndkMlLXYInOuR1e%;$G%`dg1L&BO;~ zzPIo^v3R>SjT#y*AhfPg!p+KwH#=cO?qGHbpQIO5Wz$)yx~lI{Tl7J1wHlgH=o_D% z#QfrAUt~K?wS_Crg9aR{J9mW@Y{{P6eDw3zq2O4L?@Wo7 znMuGF1n7nCO6ASF^4ESu=l6Q5O^a;o<@ziiR{(88TB&TaO@o3Y8>Vbzq@&qq#S$ej zg=}t@W+>PR+I*-cQ zPDtDc{CeF7se_k>-CVGL>$P+uk&4PYS>9lHe#Ce_NSvIjj5f-rMsJ@;|NM z?F+4KigNm@GY8u!IP4cr8V{Yw`51!3v^HsgM#Bfyz+=wy3)2k3xDBOw^L15>40Sm` zc*AzvnoVYDE2{#l^rcSS-z4E%l6%?PF1vB4Fj2SFOVkV$E4~j(*}6~&1e3&I>Z&<@ z(|3N!KyC7`F)<7=;h%zj_Li`JgWh~QRktsbd&^gkP+vRUq*dTojUQDveQ&WH^?(wz z1b=cAP901)!3R!4^EQc(a)s=x5*K)Wx(EKm(V*xrtjs*Q-~ib`!I(9CfVZF#XF*?? zUv$ad4j%VW2nrL;eI=W^dQfx!u= zMVzejvFg|L9lCE!4hdTq=Kf+CyDp2yb0B8HUp3DS8**E9^Z%1T@CvXJp}z}!{xi__iXy-^lxzKn0be&d z20<;45wmR8Z~p?DAlQt;taAosS674q{w7WaQo64qUnioYw}AuCqg2ai{}Xq6DRC$nTt1hN!AezZle9T^ z73u3 zqsje$cE#Q-9t!qm!QiA#2SR+EY)vH5k95YiQJkCh2?Eb?ngxH8K;`^20ZALgo$^dk zhLu+45I}APe;L_WST=H(jLt8-ts9vDFq?L$-uexJV4Y{v0%?1rvbO`%7eU`Asj?wk z!JqYzGit*uZ*g42VEtIn`4NNd@`it+>pbZ(4yN`eGkIvB^{`r90CoX%)j~j6v?LA& z&qFzTqxJ!m(ZqT}9X9cU_Tl3U#*!$%x&2n~qu_7|#CqGa5_2Akw%uZ)10jw^;)20) z`{`(NV>s@AwZL5vOB?f@INH_3%EA~7KKMq{7(IQ1*Myf@%PO5_oFqD@KNl|D46`1p zN>=%-y41*F-F{5gfnW-J?VYx@C={xZ1&`;E!?rc)Fz2G@!B1QE`m7uZs%jn4HW&JA z`{}l>>?n=C0>~$(7;rY@%*n)I8&HW^tgoAdRtI>3;oSv~cbN6&S0w_1w^tzse{bo} zrwIh#x|Mu45bWQ?qFZsut2464ZHd!^rr{QOpG;rBmyY#4aNup9|GM-8 zHF4{74vF0Bb`^^=i@Cm~?Sl?|79C%12CEL!Gcp&98cL^4gbO1<&C9T&CRd%np8 zPWP^h5*xcjn|T!wOSy)wGx@v%K~J}lf{w-)fq-sn|6m*)_<2&L)6sIF4jatlkHk-H zJy0t>xQyIbgIfYd(vhyrLO5Z=7@Li(42=_hD6+ng4ZpPSH!GCbjE+0o1pPDBr(sZ; zAOeBCEMX&3lxpfZN;;g7mBs)~GD7J8?`apc`da$>6}Tm0Nafy=Ji3)TI*uUIE^@ zfGFCe>h?12I;mE#PMj=FN*3}9{82i6!Un9IYj~bGqcp1C2>|DOE==)ezfkdaKH2ep zmbhy|qiNTsX!7m&Fg6Zm9!zipV3$f>e6wB;S?kmKCY*##0ema?*Cz-N$6%~Db9R$) zeFM$=`9*Np2Vw~ahi7}(NaFHG-RpcG<^wI;cqx}Y8a+(~4#m-I(e>!BHgMlQyz0C{ zidw-XZ91RyCexPfJYf8=zsUaz}4{R2-WYc3q*=$1468+2b~|Am2ze76v1 zc5M)K@M4fy^J2jEgVsLirUCTO_4<}k0`?8yi$2Hn#1MF+*S5jQ-wcM$9IF+^Fvfb` zu6+B5Ti991Qy*C+p>-3);)bmF_TgQW6IxHWblxhrfneGMe@9*g4@@AK?y0jM2)?So zl)W9S`5)VId)CTR8W<)u_nG-|23^$L=;t9A5WaQEYT~(W%*Itv)fHyrFH72|YCIV6 z$4PcBvc3buxYYUOLkE(D>Uv|8Gq7yjmbw0>=(xw$dE$CmZ+` zvQYb0iJ6DSygb+8iB5yRpLz6NtMyyKW1l+Ka32&-1Hn4zJ8by3cf*y|!l|x1v560& zW0A7g@HVg#iox9Fg#R1B!P)QzG!?<&U1c%*URz}2)to>G zSRxtf@d4J#Wt@F`b#QuY=r-H8F)gVy@asmNjT!<}B!;nTEi3nma+;z}+HOkMIPt!9 zdc>>3=A!3#ls9O}ux|O+(2>FPa?`o5mYgORi`7U#__zQY)6AfMHFiB+V&aK`N` zbvmxva1DaGPy2q&Q9Vg?Z_p2o!%mtujtu>@SHO(au*r7M1~F#nWXgexXR`K10|d9K zu6;!rqxH`qp+iHPdx1o(zNp(0*0UwxFi~BhDX-W6=Sh-00t!G05SB!KW&>7zN~RlF z7tIs(pDpD_^NS~hyt#A8R{_4sWwX9I=f!8OV%WBjFMv-Qc}&~!fyzIF!2fY&^ju*A zk!F==3c*Yhpj1KJ8-oM0I;e}}7~obpm0>nEXMmMs&{xZA*;OAW1FliV(o8!Q`6X)0 zwrwqa>c|pr5&eXAdWV7GWV(DkaYku11@9X*g;cVU#~ZmFe{oOhXLY_bIx-97jYynH zQ^~P|y)S4fwKy3z6kie7lFQ_k4qvmkf+Y(OJ6a6PQKTu`OlyNsm$F-Lq>cU-M;oq(jn`~COS|gF&hE(EwjFIW z%4&|ONU`JQv87W??MxM>Rxit<%-c<~u6z{;{#ax;RV#k9tM48gB-Oe-a@F&0^VNw{ z*BTj$o&CZru;*a@l@~>9JZvxvd zD_ra+Z6^a)kpu(-dHm8HWCbPcOtoL`k zZs_~o)?eVhyXd_{L#_BODxk^U`U zYX)GHMeh%IOSw+L`vRfr5{w`Zaq!pJ8Er+JsiL^QFk;TxF@B83rSukDewiJF#e*-e z*}Vr9LF42z$XZ{5GS{N0{(c`7VUcg4O9d`H#YQG&1*!#pQK}{s&@}v>9)R=I@6o6Zz{X@b2S~`*V4DS zodP43+dF1RJsqO*oN_zulj$Gl0j^<59rj<}uy{ZB?o6Iwd+Ds17-o;;erM7PO~Mvk z`VgeR48lb+8_T-;Jf^}_ z$+D>r_XEH@wJqJOYMB}cr|f4b=Ul0OBSe3ef?CUdx_djB0l3BThTrA! zM$Ysk7dpB2XVc|SUmN}diIoF-GOi@Xg~8-*a4KjfL4FCEt%i%Jj0ZE2jsnE<%?0a+ z70+qQUp5;qvXb^dtX($SBgx)(Nvk|16c3W35 zVAW&O1=Y)ANQn#3P8;{)IB9n~k+?Xsvtu2b>^VllVDmv$rPEwEl_k@hp#Oo~7+a)u zleKTgyyL6d0Gp(I(z3{3)DF6?fKFo;0|8=++iz4mUvD2Aj&W>G-T-#@sdYW%FI$wJ zMdGcIhopVIa^(=|>V%LRTP-U(39Z(TMME&WyWsH-BPtkRALMBj`T?eXsAvPVF!&4K z0HM&6Qyte62e`TyWCZG#64K`T(#y&MKf~;5&GiP zd)ga_q*Z*^gMIny*7&^GhTj!h<(~@A8x2ug-Vg{#{W{6$mDMUF(eJAVi8Z zfnOrBS+$N0#zkEk>{_SgSBDANpm6l7w4Ws}K4m*Cp{}NW$gm}#Z31*nFXje{a%A@l z&Jm|7xMueaNxPk34M*j5P%(id1jExoX&pqj^#nT5-iU}okj2!`^TxdgdLd~SSR0Q` z%RUhNBruG8L+#`1cHv^sH~redhSH=J-H)&^2Ak2TXoJeG8oE&5&gTM@57}bwDAK)r znQ_pO;apK=0Yfq)04yrM*OOZmmEoSUAiWI+r*n2c5PZ-rRG9p&*iYgIGJj{ex{Uk|&o?PBP`g-jsHA7Xa)X)f6U_o&KqC40pNK9JQ@=}nbv)3C+Je*u7|)U zmZOqB;A(kx-(l%Dg=PSjNYPP%_3nNs7>1bHvIx8+7Cszrhy zWj!iw_|}J}jmsZ*v>i0=_BQwa#kTFzD*9OxTK7vVZpeynAKpbdA@c^X0+})j*eXS7 z5aCH6_@1Ltpw|b5!`r_uPxp9UdAjEi92mY}ix=j-IscNve~dcJ-L#|qK=60*CU_=R zIkAL*{T)dTZC=eIJ)9M)F)2}UY{F<)$JOJTrvKj_4E|wtCNZ5S*=gY#J;;p7sowde z8jTf;oe~@$Xt0_K^g1p{XYY( zLc1!eTK)X94+?AJCvO33F9_L3Zycu;$K>F*3Topt>K3X?KB8n=!KqyBW@T(+7-XnQ z@W%xEG)fEFU~9lQ`ysFje2Dqv)4H*IfyyjV@0`NDWBO{zMSnd{w-<#ju=Vuy@cp8{ z$mh!B>yssqIg{~Ys+5LfDZ}V}YDH6RoV1_K-K8|DtM;zWuW{c;daBCZX38IQ3*MMr z2yoX!IUn4`o$e;p&htx@dpv}t_C)0v%N`jjSw~2@Ke(sNNp6DMNqfl6=(YhIn^bv4 zja$7Nj*hc`H}zyrwf<1j>xCl=0+01M!+eNX8fm^tH*IW7Fw_)h9+`5_Ml)ECc_y+< zKT!HeMkTR%X<}ugYSMI@illZ2K~UMyB==F=zXT-YyINe`by+~WSlYhzW%+NjWEH&~ z3=U8n*fspnF*blK%gV2R|M?%|_n_~@yFf6jY3vT(vX@sfGiL(77cKmq;TyMXL^)P+ ze3u7i_!t7rE^mUqwEWdH6Eq%m)fjap)jqJz$mw!4Bp3?r#v_ zj1SWMrW6+9Ruk|S{0+}{v-(Urjq1b=826!-{Dw4>YzYl(tcFZ2zaG>l+CG~KNj5Vw zH^BnvcZtlyaZe9#-NiucHHY{>@U^|{28WTo>(;uxm)w5*+r9TgH6I-If#6@sVL~97 z0L^UxO`yUG2io160XM^9Q}yZ@__HeoO@(|N?zUsxzwIzR4CWGLe>d975~1C1JvL2i z1q>deJHCck>w1h5QS6U7ezn|czbp78dZ3W&T~}|b2ZctxhaVc-;P4#Rx{H+G<_%z~ z0?9G?zSnQRS9)}9P#4)bCDDV1+z+Jh^{Zdox^k~;Mb_wlqbs=hyOkc|AkQ%b`Ra?q8!bl$-rk|YLUP^rz zxwr|WEOJYVfjKuWCHgd_SgcNQ2v(y>^sb5K95CnHF;7nX!tH{;bI!eey1o!&=j$2h zB^(s?L0=dM9=oV}?Rfe#(BbExX$5@o&p@wrbxxfjDD1uzrPj11HytPcMzdN}lb|q2 zg)OBK3lu1eR2~K~D}VPc`F#SB+bP)Ul3wc#B-qh=g2Z!VKH~%IzD0=4=EdZ_qMqj2 zKEOAkd>~|jYH#o2DL;)40zqNblV0&%_JLj)_?;JfZ^W|gWQg*5 zp=q8(mp=c*7>pi>X36;9l!KfAzvP-p>Z`F#(Tol7%E}Uu#EJN zM*heD`>+4{8YnfMllkqvADHE#f z?|kFji6vd~t?pjytC4h%`|z(RZpILIgsqw@qx>k)T#a$g4j#$?^ngod2lSFesbI}c^CP17{YO&l`ct2#mUZp#Vg63l6J@EjbS@H9W?=Vj~LZjMHCa7AN3uV+`H@>#%o zUXWkIGlRfW*L!cB^T;~-me@a@y`;hn_lUkAfEyJfEE6Evq8Fy}O;T!s-nac^dvUbbW` zWb=&tM&%Tjr|i5L<9hbZQvGF2QJF0N$<2Ty4kC(*v_{&>>o1e1`9+9?7ih2mLZmj=3xu#gf_ekvprZ&zgg8m`=mDW;Lta9gTv6ec$%sK+gZiV z7k=~T+E~$ySiyfSjqz{~Af}U><*!Y@{A5`8JEUaM%ZUv+$7t2}6ko%n6jG9#r;2R! z-8Ww)!}rJ(tnC*YK0vTx_A%gQ0e16)!ywu+uV`av3;OxoPYaGL>5NsqMvkrC4KOZ5+O!gIzx>_3 z)qB%B{oLs@)c54t{xh~Eh?yn@YE6BUw}mIvwYRcN`tRxnFEvRwwXXujS|u9iS}jG3 zm@%vG71u7}^x``z+K5>Fy;1wdb9~R4;83jW`P9L7X(>GerpYa^;_-`6yl_F+#+2hv zx(Tf}dKJUI5v<=fxCGj70lPf8w|{4zndX~pKCj;RE#Lk%`Ac@>e;E?=_HW=3*|&Z9 zzHQe4|Jb?Zt;|hR^X+KMA*-{a)o<|Z51m+jPBuGWHlV*qVn6ya4LQk$Fs2~L)<8ze z+wZ73rD>z1=EZ*eO4SdHr!?_~k&KTgDy2fZIhCDICd)=vLOwxGpx-=A7Z{r5vN;XY2UmJ>}^e)dmGqeP5nGi?t2r|B(#ffe}CgN-wsaf%!2}{ zExA9hKIa1&ZxyjD`|AO9MpG18oOiO|^Ni-&(;?#>r z-I<9KvQM4`I;#V=Fc>VFq{~^}V|Q0FdbGYP`;)=opn`w}LOZOloL76hF?L~+n9U5qfa z=1$t`vifS;DlnkgZiuoB_1zpmgY&^*KXb@fU8owl_9zhA!PPeza$DWV-6+S2uX{S< zmJ3)WuP^$>&T-%7cGzWpW)wn0DU_j3G^7h1vt0GV(R3!v`&MW!<*njeM|M-1v2SgZc{fJC&nrL5eC3zbS@FvKN69OEOMHdCC;mg9zbfCKWjU`$ zjWHIFX-?aQrs&>x1++Bab5IpH*bhSoipVq>RG6}jJU1|jjW%{8eIiJO$7gDAU(^Cm zSS?heD}oNL**$#Xz-Cm;3-2(##5FFZ9JT#nzh+85|6b5F6~^LLJMSvqm@{}&hAf7o z;j}q;Kj;GKtrq{4vNLFmZ3-@j{K|+6`iTx+4w2&RU-%aAQV8Et6;uyYuj#U6r{W^n zbGO9C0_~UZAgyCtK-LC(Ra`ho+h8!^JVo2-&p=C*0&#n%&E)=l2Z8yIE6(!yp!sl^ z*FnPuXz#6oE-Rc5=B|U#Io2F_Cf4IThVDAxJUJPI8Fig?9yoWs01l z?XKfwdwO%WWAN{B(slkao!<7}rPa2LRc&6l-~r9anvwB1@n?)7I`qx*0TojYI`gF8 zkH*Lx&t)Xt$%kbq zu7m7&;6|T0`9|LB?Bt_evg0$`FS;^=0UyU}F0TwyHa@eiS|p@zml}IU4G??)I!%&i za%_Q@p3nxTN#)5bLpXq(;Q{#k01T)}sKL`CEcClskVK+f`n-Csa4P4l0Tr_s~&Ist*doN-?cA4YGXdZADW*!De`@w29 zx#hIpiP3c>Bg5#L92k9m*^7Bq>p+|I?L^zh|Kh}fb)X#mo@X;XlX@o&+ufFw2KK_- z$d15c+kC{&)3e2EZ%d%eQGcI|EoL);Vt(RN!b?4d_zX*>`>FlZCyNO`*Zi4>^v^Y4 zdaN7HDQ^q3`vp<7UsI#lpW)5>OZha}nD#3Jef`)Q^hH}2OEVUX!kg-71aOfyD;{ao z7!RONcS8xa2o^*bM*?O4%g?4@etJ6Ad%-b}hVS|(qNh%6Y}Jw|{$O&}26?L+&mxA` z&*++a2zI%?pY}dz%U(|3LugppKF8^VhlX=oYl4UO0^j=+s?(K}l zgr9=`)dy4lQr`MG=#K;LCrRb%QDXhmrPsI?o#(rMWsQBG|9CKeHXi>h^arKJ>DY^+ zpN1Y~^V37j$go|SXqP*rjf5dF=HDtc$DagjPf#FSa@+=6 ztC5PFkm~!fRZA&sH)Nko0ij)@%sK?_YCnGEb}g>pugH7qgH$;`gdWZ~P4O`Z?!8 zTZY3=yBfS&Qz@|>Dra0JU9GxxJgP#6)&V~SZRJf}-2OoS{IUKC*D0g+2af4gEt>iH zUyU*#TKf%Mr2XNbAksX@#3;$%f5?x#lcpn8hE7qT?MZ{di zD>gP_`*(uDu_0psIH-W~+0RRx{z1C#{6GHRKmY5GKNtT$%r{f-04?9;E8u$nBU>JU z&G=St#QL{x_iqd%Y{Uhhh!Q;Ab+q|~SJ=ioQP>us1$2*3EESVHzj;ZMD!?KBPGtsa?uttFc>k zU+oTKTE_tI#y7_r@L`#J{`?yq%rR71TGsDjVK*(nnSX`7pIbnzE_KfwqtITmQTb zkiYd9o0+ZSy!NS^ulxl*D_-VD*T>4Y%2()n<$qE3bK1Uqf0kuXj{@WUM>N&6(YGxv z6v%iNXce6(>=TU$LwfK7@-PwFy8S5mJKGLd>hT`d;93KxoW71W<3v|O3AacsE_XVv zqHxaLjhPyk-9rsY-eW9hJMf&$VMjk5YY)a#5bWsS`ku_@i$>2?M(GVLKi`%(;j^QF zqo%l^<&uU0L;a@jV3%+If}es`=ePA{u-2)(=)4g;%iv6Eg1=&k>YojG5`62`w~r}H z9{d$e6J39a0pBX{DP69t`k4nO8Ui zXeXhwbc+Akbg(gCB!z`IRmMpZkMTpPZANqpNqN8$A~w=B26+!j>R658jn0nT4#J#^ zWIWEc6b$t`FBfFBmxO|KhdUN8Ma7ny_y%ewvW0uSYSIIIMS^ICwd4X)`e4&m!geUV;Snh z4*{_zU*H0_Yj0GCtX{kN{y^d_A@38D^*52lnO0#WI6D5I1>QnIDPngl7G(M2gS35n2wuhlZ* zu?Oub@9f=ipZw&h;5W~GoE8#FLm~I&^0P+|gXUA4W$l)gi8w|~1o1+9I~^0OT4VuQb8 z!7y2#4F-dDF=Yb5UlrL+ESa_p5n&<|_|=ZhEI56Ot%J=A@Z$C%l}i}T>xBI){R#sA z_a98VzX|Nh6Gs#R!RA2n4W4eh4j-@|0^#7V!>c?IQ5N*oIw#ItFTSRQ!^s5d4ewks z?g?6LMkYL&R!n_gN~#`jCqbkBR7Vrnuff5_0In@qh*M<@nmF5lGP+RW2ywv!9+Vg) zm6sA?XW=|D-`af?B}_ThnUQkwWQReUO-3PCK{yUZ+IG&SeYwQg@f5~k4JT@-Jxbu7 zgZF0xcSPGe0y~Vk9$(zng&SGHiJs3m*;^IymH)Vn%sjhi!(chpV5G zZ;P^gImT7_rrlmN={5t$QIpu0T`4)6c|?yZCAWp$sJKaxSdb*e<+YPaEEUP|(vp2^ zs;JxgZ=5!Rcr%1c*GbRpW%4`|A^)tmTH>Cdj9Ua8*X3fm6#nYylv=V?E3TWg3}d)9}H zuk9Y>m(k!~%u^W4`+T&yAT>@wsM>RM{N3}g-?@L$Ux@W}NbB^Ze1CFP2K7`l>mzIR zX+1D4)-P!Ee#XlJB02|YN_{o-L@Kjg@->ue{?5L`m3k7TeHo~ssem7F=-4?vpl0k- z9bB@xrf?HCD12yvGaOdL6+gnMKY$wJ+(IitFFg`V8!ST=0M3e7+u043;u%sCQAg?b z(s|>)83c|2K@}L5v$ubTjOA&X^3(Aps|%>dLYy7~Mg1Ff&^$72gY1W2s#3Aw>@DDk zo0_~6jbN}oRNHpNd7pqTBC2D-mSDY=fB)m3|NQ44f3ClOd=~v*^Vs$A*#eB9WEQL? zMGt{pebDeSif&~-5)J+L7A3Jo-dB}3xT#m^8`vo`e7devMqSArIu;ZiMn>LGjFv`P zj``{IpQh7!wtKtUG1rrlK6Ib_I+6kMY^sD)<(#zV7$*@wE=-gV&e5%@Q*Z>9td7F*lfjLw1kB`m>D~9J~eXe3bEX z8h=CE64J3%-kIDhbHj9J}r{?6N(%OPJ*IhmHC z+qls2BsHe)7nSeEJ|{di4qacb{^20QUI)7o%^2tHaLDKS*yK%yst1o}4=KGbHv$am z*aeujv>KJxUdqP=IEEncJngrXvZqQd)4^_3+$2ccTP`t3pY*V`84LpCx3=g`ZBqD6 zsXadV44$evKg;>KA@bK}KILLQ;)T1KceMTd)SU}ap1n+;nVk0|V7}8k>_k zW9<6e!aBsR$)8L7#K6xu#a%k@BGmheQQ)sU zX}h;?o<_=VbPWd{P1Ev8-oUa6bofUWkfNUG(&S7_9ECcXd z%Gw91NC&gmXEH_~w59av%ph4ny=-z;|5NGmMNkbRwFwqj>E7-}vR9UqC-9{lEv?9zaj~ zs`|`dp&za-5%12gaTNPM9am28ETQq}?Dg_apWffM@=KA)8-LII(d|?Es`8OXusti^ zAa9FRK34T9%5fidf0pIEPL%nM{7>s}LW^|>Rsx#=*3uOl*To5QJTOH~)>dFb?p`U$_4&eL-WK6VN`bG{b@e)jgSB)QqBA)5Cuki@$aK^sIul$h74owqx`>d~1cF5h=n?>m+*{u-cU>K6F#}6er z#Yq<5~(8GmdU`bKy_h7r=uWwu6fI+yWwB=zf%T4n5ZS z1(sdEOk8gx*`s-G$6nv3{dm#yS_NisVE`{^)Sz)q<0ej{U!(bXKb3u=pWBj{&m9nC zRzrhJ$x}K9RNgXr9K|ukh348W9bkbEK*v!Vp*rwsOa8{Ry^krMWB@(PF{*4?i;u>G z$Fh?P<&Dnxps~lBWz(-5>dvkmec7ofY09)>aY7H74Q@+(DM^nbH9|_^McCt#gKfw1 zUX1)9cDq}a!mlm~jb6iI?ScF0vzxJJ+JV4 z%TxX){_o2BJ=gtNmh(CxjQ1DOI0`puu@1pXU^Bp4x?(w$uF>mFR-XS}644LaXvBP(Vzfmx6WRkLC zT zjj4$_tP%IiSUsnr42+hA?>6K;9 z*!D?3JS7P2tdoA-WvAck;H@lIdb}S&=UrBHU{|M5cI|}r3R?{OR&@8(mQJDla!ksl zEw>i=q_b_J>?xAVb+G+FZ;gYV?o}&>>~W6Slt^SAB$4mre=Icy-l4iMAN56P_h5>n zp#+0(=1iV_u9zb{t)7?SnJ5b^RXh`@(|P8Crt%86j_UmNb?RoVTR)!|_=-1lA==On zXo?qUy3IKFXn9oY-(rBi_Vv;O+c)?ziK{NoKBi5o)s1{G{%C!gZ|#^d9<7{_o0gFJ{X3XIakuMi}oeqHz>%(qbKg6+{m->vNpm9#MF^cjd+A zWl~lURIJS`;490JqY=&1aPby!`uB`SS@d~XXy1we0Ma@nhjF_MXlio(8|TrU#Xqwn%Du@)Hj;p6t1?$lP@alNpnr1=A$Q6sh${7n9?v zR7v#aY)*?@6YnBg)pcJ3I-regwsB#A4L&$};5If5ydPvlPax-A`&v5r+34>QJG(f& z+7zApni~v0-cq?NH_V_rs?g6hjT70%_L*>nxtb2nQ`c-qxVpt1!!|cVP~QH0;8Kmn zHk$gAxvDjSf*!^=I&Tf;+z#6eg@<8D($+Zz?C#Y^qpB(OPDLbUwoa<5(3TXnm#Yy@)6;09;7wx8@(0J`?I;0?E>1; z+f4MXOY)q>@Uk(ZX!@B7cs}E8X+4@Bu@tdi+MXB06$M|#i-6mY=<(wPsb-yl`CA+FHn zIW3vmCTEOuRY~DICB5hYO?FSui41pe6z7N(1IClZa3)wG9B$jH1m}@jX~*P|?9=r7 zAK`v`^SvPZg3(S0Gh?6=oCzGlfT0fpU*S*_BevVN)mL% z<)Hx8n^u5WY}uZv#blNDs%*^a59imqN_o}CZtKlOiZoMo+YG@&EuH?_a}r!O3SB)t z)7kzr2>h=*2u$~ekw*a49!z>cU3+lp1$J*6l*y5i<1{?BI!0OUz9MGspxBuI;Su8uxV&s@FIYyvV1OG5>*OVN@mUo<}S=#Evbp?-z9l{+xT7L_h#HMhwQMQ%djO#ep2inkq zXhUyh%Ri4m_V>O89rIfFHJhCeuhxcXPw0p1gOo>VECHI9FKmarp=lqwdF>Y!&xewH z=2yt~{n71H`l@VHyokfTLEaXd_*@V0xnfpK+AE{|S!GU6>v?)`o;|{2&>WNsPtKVihn_Kc*{NQpEXTb?(*MYog z(@iUJ1Eab2lH+!oIOV*ST6=Nc;d^7Ff}W^-oHpY~vYVJI8BE)q-TayD_@|&JN&W2e zeYg_Z|2<%TUFsUa;E-4K@pY5rTum$38?mcAXxX{j=yrZk;*)4uacaYL5f&*PjDO3| zKx<5?n?l}n)+Y%3-~asM&uQ_GvH8EwEh~U(4h`dQ!s{Rc1Ht<6;APxFrx#%U2v8O{ zCa8DGkoR@s6lU-zjwl{v#*EHpK{9zaW9yB*(XjwzC0t_KPFUY1$xBl30Wb0~X3xel zd3v%L=*A>80?SvlBk zNX^cTJc1W~J#Ef6yP@A~@0s9&cdECe;K|tHFUyW#r=as%+cl3<4kf5a=PlOHxtzI> zSPJr#r?N$o@xndnwBQ^yZUfPMGEHS z0NVgM(#zYlXyz>gd^$=Wpm*~c;&$#cfHC(t7rb~eattKVyy9t9nZw+|$_IA-Xg^fm zovjhSqRn6+I@>?IP8+OkTvz>^1k;8NUh4!qy^uC+A{se;iBCqaqd^~?9bnhjKVCr_ zqP}AHEQKdh@tppfw#JgOsmE8aKLz0!L-v({$;n|Yq-I1tRr>nz{#ZNZ~|iWlxp z?MuRoID+j~`P#>RU)Ey)m3MVYeQjehn(}o^Q&jJ_t4l^V5bt;8D;53`@)Ht#7W$H6 z0E2U6(jHaH+V)}MC-U6qJajJUb=r{^>$J^z>re;B+Xlt=jOGUxZ}@?|Z9VFbqkaeS zx1BFXWIEpA^CR@KhFj$lW zIgJPgeRY18ZM{YvBm|v`Rurwv>66&znLlDb6mtI-u!M}Yv+4%;3iQ>_KugB(DGMV{ zDHjC34++;fMg?8(Nf!r#0bXN>2nK@r)=5m=0JFyv!qM_2@D*?7HGW{hH6B{7k3m}` zwK(`0o4L37GW-VhD?c!wu$z1Q52hz0p|#L%`NeLdl>4E?$BY*HR}ww|w{_?D$zBQLas1}c>$=hh zz9M!A%q8Ju;Aa-jq^oIU5_VqtweP%MY%9dRejtwtg$0h1dj8u!fZ$Y@L6vjrqGq`6 z?O)ZLsgLa^X;2R=ae>cx4BFmd8OeJ&y?=A~AP;1BWuzOHEfLdP8PYhk0H8-g#lsk5 ze4Da&; zdM#i*Ewt&Lh8{Rt8oM>j&n?=Xx2Olqf)sNKv{*PjD@&vo#g@<2K0VFdU}jysNV569bc z2)=J1Kj1cf&5^^D0vqG*pu@tw^L!*d-LLPTsiM^N^I2Q`Z#;W!{~oaVNb~dTB`GZn z_-Y?K&s+^n2R*@Cu^!{OfkKB0-1JMH1V+fjocw2 zC{L}QfsS|ydDE=Q)}8+q1pe0_e@=yeT;~|3GU!Y9p+eINnO#t~4DK?ALsA;}9UDWS zFW{%XB}0=>WDgL@q2#7bxjSXw;v}tOmKPr{GS4S=lw?DQe8D_)10}qG5hw-I9)S~g zI0qY4ypizz9OW~eoNk0U(6DW?9s_#xqrl{%dzqsoed=zoYU;DyNIN~*%*Y|Vm5Ni4 z^uZh5@6I{zszigCG43f1kgEQdop77x*)L#@(^GvtxpbQ1orYotv&$V;Y^=mQ-U$+##@ zT)|98XL}dfHQis7&cG0vAXkfeP` z@-8^W-aer1gS;AlcAd`!Ql7PkX}8~yHuU*t56*s|S>G%zv0w5wSJ*~Hn|pttUZ*qV z>2cJg-L}WD@#AV;s%Je@7}$K0Py7h^%&+-jeS^5%kFJkj@wBhxd{6v^{wMYMnX<1R zC_@a{x*?jcx;d>(2n2xPBzLBy%_$D#Eprxqw zp>nQ-QZnxpc98L&FMzyJgzhEH53EE0i#IUo&DOGzC(yyAhT}c?U%zvY@he@!7w)-( z^b@Ti=taEj?*Xfi7&vqRTCw_f!C-TRJaxW2d<(b^01G82>B$j;a`k!8`AF2(k zMS)j^2pP?jXkQ`HAT3C(pMfqvJzf&}CkXuCfBbnX{V{`}%v1(~?@f6c(7h*RDCZ79 z5pzvrQ8(RsBbJP9qAC!aAEcJ7zE+2z%OjQNNwGR^a@-t&#mDg;r`-b_Hvl6);bn5@ zCNJb_+)JsmA*?oX84v6h7|`z92in7SvdxMfYY>3#t705Y$@nUU9d|%5NrTd5I!I&i zNW~X4ZBivc2d0a(Z^PGag=Eb1Z85;2d*L8`IlcXuuAkPgbe;F_Pgk_C$35QeP{%d9 zL9b^aJJiwKd_IryrkwG(1M)22mSf-3RM4u?@xZg2>2_+&I}y)kJl)R)*6+ehwp+1?gYIm9jYl)2 zfzjEb0ZPBNjqrSb0#$#z+j&0lcuUwaBDa{!Iyf|MK%Tqj5D#kS4wk{%>FwoZcE0a| zOlxZ6HREy3pX&Yj{FCL^u2VN_yZTvzy6R^jhBkB|+R%?}#^)8@Zsm)!TiN&5PJP?g zODAk&Xy4hsX>07P{x{=k>U866nzv&q(49|FKVkik^O;Sb*FMI)_ASTdesq1@^Kc!i zI^}iTm;G~@lQ{~PbDymn8;o@t*Vkp9(_%YRbX12Rit0QI9O;=qP)TSR+Q1!=Yg|j; z-e9Wx4SOf5pNj_DRiEv(aK<>p8yT)$;07kle+l0f13$+U_eo;7q?BXrVQ1%+yVBEh z{}Jbf^z~TJlWb@|S}$Z$lSgCTk+sk`?v}GO5(G@8@|G*k}`J_)IXDr z2iWut;NZ^^6pD~|^#qFTqwAqv13%mR+Rs3XBp5}2dW`-%2+V&5fr%*ug7txBlofAm zesDnd;zPM%hY~p+n={W_GL-m0#-`R|^F$VJutTT0-ZD3B%H1i;lh&ciGfNb(-zNdN zCSRna2c8uABx;gg;fvJUsBpO`bGjKooAQ}MLY&#kb+J={>26~J@B~P$&BoCXL>sh+ zkM(3vJkcv(@^i$=28o*%5RMbmS z!4#MJ+3SXDIR|-e+F(~sX;wI;UH(NHuz(;b{}lH)XJ;NK4Xj_at~PXvB1eb|rq9Ajg*vS0bpx)$x|?W6u=|4d~{ z8XKnVhg~zaIHg(Ntp25blQ%S=|Eql07wvn@D|mskwpY8>KI%58{H*-$@pg4NFIV$b z>VF3l%0`+f#Win_^e>%PLQ_y$by#0IUcL86EwLEjxzMi}VEb$DE0>Cop5?HQrNY&Aues$f+ z;;b(UmDrQ)8Zt_z9qe^JjC_JU{u$`-32c!`DhPZZ8hjv_Cf(7-KeG5NSd0U~mLD;{ ze9ZB>tmVk?@1?`(qiNI9s5taYy|d_KZjxE5m=88qFU(q55O?xnww6V7v6PBuN&+Qd z+9P7>EXBSUV_3j34E`hf-`Y7aE__p)aR%_(o*EPGv2$?U|Pzx$=9S2w@*ouu->RO0K| zjA=vUEqZIW#JxO+a)Z*5tEJ?%Nx-P-j)~%aV&)cJ@7p|%6Q3C9Vy`>w@p7FU6=NG+ z8X8n=Gy{6X150??M5RU-a_-1iDGhrz#%j8oke#}G(=14`v-Wr10Iv4f(6!!%XKPtX zZm#^t_B=nGTBim;Y|g~l+|=cN2-&K`#cj!8x@B-XVw^u`#Hm!t9`>uT>nih1v;cM5&EzjVv->?k*t?K-wT-OJm{g7pH~5a~Gii>_rh_#7{`7S$>4fW8`4V_Q8uDFY-eZ6teJs^jjd&!N zt1F6U!if8YW4IsaCAp$+xPh6B3bV$3$tQe|6)q9d7$g$MIgzN_mY;W})6-A#fb)fJ z-XgxXChs(&(fNv=-A_TQvZxP}*TGLA3c*-&z4Vn=A`tLJyk%?mw(_hGcrb5*zozDp zVcB=Ry;}bUux?T3lH%Loc({`57osGHFGlxL0or7Dpf5*Tf(v9R;Gc&4_dovo4g!w@ zh(6@B1Hqrg+VT-*Y@(`QG2|1l>#*1S8)e92Rh|ZBVrkX?#;uE|scBtrR@iUM{N^ZG z4&sV*o~p4m8B#u!5`d5Nctd!6-~(Q+N>=-^~ZEsoLV`U@vZF-{r z={Ptt($Qa^h+a%j_4VZ%=Sh%f|2zP~CzkdG7*a`3;rUWgXq=y%BfTLmbwx?q`tm}0 zVI8igtcIt)6B1swzgi?t?U#EqM(b)^zq!&2*LbwPpVO8q$lFoL0|g{`Y?IEVz17pK#R6C&Gh9kc4PI}GJKzR{wWutt>b*d zzOHkUSMUO7ZR$oph^Ut$#?lbcHd(6tfdTwcos%RN~S_;^a;e;ME z0Fbc?ctAtS_XMsnrM(p0v%i()NP3dEES@Xy)MJI9uOY!Q8I$*V4SR>#wfP!fobjS6 z%%|vAF2U3rZYQ11d&p7pn)W+!X+cqt?Jr0#HPk_?JkTrnTy+oxPT&3=d=X>oX)qWE zei3ce&iG=gwd#Z3j@6AKcfDlNvx6uK1nY7U{1vau7FS#NjKXey5*pF6pMie&jbL(l z33U1OCh$N13<8r50?&iPeFzu_7sb2I9SW~8l*nlu5#_<+4;S$1)~)dtOCCeYPn93` z2Ob?x{B_D)Cf8EO_Ap`BIyZS|8!W(kmUe-&JOC^myt|WRM+dwp2RUCPZjm|ej7@=C zbhV@dni(gGg>B#Y)Jwp_isw51C9h3UYX4CV!3;wJ8| zOv#S4UjI=^cDS3;G4ng$01p0iaa)5s75((p8Q{x-$L9bpVcftKm@S_v^@=eaJ>cxn zz6qvq@RjY{#CA?ZnCTBXUn?4ivw6;YEATt6Gx&Ht5COEI3(Q`{qJI6T{f6hjt$p~P^_<^#=5!s?&*Z`2iIUNZ!%gQY#E(5^{AtbEIdJ#pFUX6Llc`4(&s=XI$U6=O zFYoKTDzB`f+dlWLFY})eK8yNi>XM#gk$rJjf>#P{VqDq01snuOS{woPl?8%H+l5AC zH0THd^B>na#syyKK4@s#_;*ioFc8e^0FwrS6(3=U2Bgl=ifnMhNhORA+v(xi2{zXxj* ze?jZ}aZ3L!=_if1$MbU>P7m&W-b+25Q%>e}W-L+btN4Ad`5OTDh+S`v+xmrZ{7gk> z;P4TTGo1|9w#`GotH@ASriU|{8=GC)Qq^4lv$Y>)G2fDEOqKWxS)J0oLUYiiYrJ&u ziXshMXCLsFzX2>w+{W-1Yt-K(HD`OqgnHWEVJd#cV@jt?W{Br7Jwyc@=0SG1eO~6^ zuk+CbjTrem^U7bvKMVG1J?~ugbB{nXJfcnj*tA7>X5sA~_=4<}Jnu(Q_x2U*Hhly! z&h|}<&u4bDXlLfN9MJ$rEGr4;kIg%~t$boL<-82)wJlRxKUj~+$T6uL8*YO(J#NtanD5dP-znP6dB&@P?@C(?_5tEjVn*hN zaz*OY=YZ>>$NmgKRB9<>4CMoCVie;xd_OOFUWBFEn75X~eE~7ej^U%hu^q9mBxw^{ z%$8hF!#k&n&hy}m_l*ZD4gyZ1;Vt0nW0xPY<~%opz!EWyKyUa-XvkY<%`5;+?zy>U zjVPqe(fWyK|IbJ{0+FSbZRhaf^sK6zD|`#s)EU7xeC;sIlgkUF02sQ_c1VdQ=+7G4iQX*gw7a~g6HStZjv9NOOBAV3EoMaCO;Eb^ya8~ zP#-yF)Mnmn(jU{GD*0}$`A(0X{}|K1(^>%d>q9pe*e_edc*iM*=#BkCoxU4=r_vF> zKRCl+>|!EfLPq;B-rHj^n*4qb=|_9O25qUz)_mk2rrRt--U%kkoZ;U}Qc-r>_g9VYT=nyYh|$kYh8;{BIuNb4HJ?vt zd&&l)wS4hcNEHIM4IGRy<$FSQl7M>hrAZe1v7=z{?opnPQyK6y4BNYeTyA(V3y( zD+VRJ;90?Ur7eaa##w6MDaQv8Ka)-MZApD$%{_Li>i@k=-S^yJ6_^jP;xoE5`SFI> zcoOdEEO9c1fwtzjE*S?1o1X8*^ItYxr1zdHY)=%2rYhb0XR>``Mg|?Re4J(XQ_xzK z+&`Oqm*oxulcH_k`t1aT4Q+Yb%CD@UiOy8b3C^_=cv-B=OA_t_+V?qn3plSkusiaC z^5lZJT7tZ_K=%kBL~i{383g{y^(L@AFdPMf?+tqy2-b&?408ce-In(O6ABoU z*QN5Lbq%_|4&8To%3S2NwP1UAKpQ5_*9l=ylb$$IOv0DsN6X{5#ryCLmzLfU6 zDh7+b&TvJKOmmapja{2{_p(`8)K{C-KAC zr76W_G0cj*Rn(R$P zEUP+xD2@=n1Vd?^R@oRiz$}{g6Npf(kD@uc9n+eAe?q$T8@NQqx4;8@X9sFXF`B@8 zOiF}am-r20HLgg;GBuQFv`Av}rYI?lfG>jDh=L0)2&@km zqs%-H7Bl!dXzJg>5v4(3he5b8p+^2ajV4$^7+SuP_G{!PjqZ9Y8qwNxt4ZE$4w=oS zN#|$QewbA8c1ipIL6~H83YdiuEl>;E`iyjw+uZq&p9yhADt8Qa0Y{tMY;)_Gj5oGX zKi8XTFeW$(D0*Ibt{aBRO-D-np(H!f7BGqRIO;7zlE{5>34MQCb_evihSRka6&~v- ziHAA}wS5DBK(CUdg<9XvRx5D-7Um!wt2+|l1L_`iG97VB;|ORf=90%PTuSrZ}X0M1h+7Abt5pCFb z=F8Mq^4t~KIlf}uri~!Rwfjw5<5Km#8h9`e?CgX*(x-js=IhX-O^+KirQwL4_>MD( zWPM8cKud|K4Fb`Gh{(K^X|h{DJ{2gS2T~Bt4=R-Rc_iX7u)}bzF&nqK1ltHV90M)Z zRMnZ>=mUIzdd!w`GRVJ8>Arryv)`O(91cmGogS)ZcW` zuep*B3RC*lFaFtQQSP4RRoNEFwuq3-8tMN4bkr%+r<+`W2g#8)y*A$hCNLy)54YX| zPR{ZYKBNBJxOjF90+)eccaVSq-if0~3%ZDKAb9bR3>oY-t!lZ&r@JmTPulLlyX(ZL ztI6}6&gWva)-|`(B(r|S%hI@^x46!mzPG{{0B-yww7$WezWhcP3Q)|dh#>WMSzPWK zbRXIb1KKB-()`W>>t=^3^#a^rFkw3RIzT?;{`@{ey5t^Ea-tzfm!AOBQ7rW^&PFrd zOr3qhPOu7izo$A8N%9;b4`j~4axQX!sc|p1^KVtp2iz|wsm;9D zlV!MGRG!*AZ!xc?_FRmZy-r^!zczLg?+?$M)474?R1f2aB;nxCxO&VbZPL;DPIeC( zO=UbxU)h@9#USQ4x6n7KocA8D3$ou_W1JPQ(_$%@QUaR(YSKarVJ!hFnYhwx0dhAlsqFqZPJ4T&- z()t!?`g5Llezfw5k0BrJDoEQW-Hy7gD!(Gm^OQeZ-p===e1a{@zCAi+vUP%GYb4aw z`ShGNJ#@6_cST<&IH8*o;8{K(S^Fj5mA06g0K^mGmQ`15C%e_zBKHy8LY4Y>E4&*M z8{8}a+Z#th4|BnT+3Y58V>DoRGe$Oi^7G*yS5i+0csG^L3J=c_g0iR24f1@Hv8Nq5 zu+vh5 zgfRz?6i2r%grVCHE2DLe3D;mQK*Va?!1$|LkU|ic)%33?daya4EI`wV3!YDp-~P?$ zNw8O3`LeyEEs6k|Az@*dHq|wW8x**C4m{tVHSLr0{m5Xe;5QJA^zO$_I%befB3uW^ zPYDf9!rYD5CtN{UX^1}!06USrTa+y}2|E)CzTs(Xd6=<<@D zlCep$pTQ(&<@^Vj61Nw94g0?*#TnDD35*!5E{)}zlRPH8n_gEB=AYfz$oX6rQyG53 z=Y-A4a+>IYhkV}Z_4`Fb&mMB{!8%X%-r&0AyaG|SCvR~lX{X@%EVSKgbhZ!$9?$X8 zz%0&wj(_hh;B+6*YKQ&8bktM31=6G!W;EWhwt!jSEv~w!a`}_M??+FVN*PSAU``72 z5$mavC@K5-Eb=0*&Z{~o{yKk08akV|1C-#(+KzcXv92?)3FRYi>FUyk4MZEd9Zm5N zEw5j}E7~C1w=%GO6YQP~s2#wVg_d%zb>^LaDQ*SK2#RC(tYr2Bl~OY%&K%Y4_r zyEOB;kC$#vaKCxlgj;kZ?m7CEee@^$_^&dklCL8+SJSq{l=;|4w6k{D))yd zo;v=K5BN@+tUmI=;CT$){seRWccM%1^8%#$SSV z^>Dr{9#U>~*?jknV0<^>e0GoAnJDxt2>d^P@FxgN6oRn##wZW)jyzEr2);M-c_4Uf z1{40pOz~PjA^zlzjZbyJ8ThL_Wd?pUpv&LrFq0#i2ke##K^=&22La$f6LSj<5E$>6 zwmvN_(k-vSI#21E&IOLs%?Ot?yQ4rGj=0g~bVH5wa&%15vpR9HIY?WiL-^q&Te9qA zU~z}33^d*j&gcZ_QmRUCfm3=5yYZ!^qoVJ-vrm^pe3cG#;r>T`oXo#DYbUiyhmHFT zb~eDy;V>N@dkSQAXgp23mD=*&ojLhSKEaORgx=H96YQ#&IM8x{nB-Hz9jxfoabS` zG0)dG`dilx)Zy#0^LbPD^|IB%b&W-C(x%4^n$mDYr&zx{gGk;A;0446vo(gr6P=O8 zEtv-U3q4KETo(I+H)$^qJmns?8aJ3#wF+ldpll4WCkB4rm|PA>F4~QELmD}fV0*pW z>kd`eK1Mp*saJB+$Vo2Vw9-iFbByOt<1AXJIzw6WIDR6Ef0GEk5u65tzxWC0&fCAO zw}8<()wO-v6+W3J&BqEJ8R!6Ob?o~%w}}uYFq{cMPT|RGZvoHrD+v6rKmME!{umqo z5QL@s`fJV@G>8GR5K9Gyls$(bYoCxVjZyib*UBs!4H~%4gNe@TiQg{*n++jMbCF=s zNwOWCABeu(uLcUI4DC9%>_nN=O_H}-vm6QByDVfit zD{_p<*60McIsJ0Bo-st{>mMg8XP`&FxU!i%O*st=-OSDMz1tgfF_^L6NBo}Dnr5=S zb1s0Fn2qinv$*^DTeg^=eG7QMxNX~9USCa$)Es=A6}cr-KP0UA8Sg0Y3+EJ$b5D4n zUt@IH&t1B#&%aOet6nOw1Nb&j#>rLd`R+Ocj>bmL8*PSZLvNQhY&d($E~2%3@z<*E z+10muz+qdak08eRa?{w1KTc`ZCuK9W5As^)2Gh$E9J{5DAs>Cvby4u5Znm9*k4bw* zpEb@Hw*G8+JKtkn`q)kx)N@-V%H~N_FGTY`J*Q0%9WDA@(U%EM=%xgCmJdjVkn?MQ zASM(NTU&gSy>p(n>f7MGo?Qc$s2agLxya>4V^`xQvbeCEjc=tP8~+xJINp&62R>Hb z7B10Q`yY)iDE-71-(v=+hFil zLE+_L^+xa_`CqNXzhY_Hx!b0$b8Jz37UZ3rqQ$GXS=)u@XF+)zAScQ2W~6NL)!|*7 z&@9|RV5UFJL1P>mm`MkMN%3%?_TEf73<~I?KrlJJ#}E-^L1TNEv3N2z#|9`nnAod6 zl&r&m-iY}Pja%&4aPyQwHaCVI)Ghip0K5tQBAUPX94*p=MuOB$-9t(@JzQYf?*UAP zOPaFa`oq#-5+zjjCn#-f z4tt{Ldohq2YwH6oyRzl=e9$OnOi%G4&!z0^sWTR_$YZ`ujRxCdxPWmYhaawKJSX&> ze#Q!x)uf(j@Ry6Ri1r7DXEJGZ5#el)Z~#6X+;~CWi=@onN}A!NfrA)*P^3)922<^2 zQp}c7k;v~AvUEK6KV2-2hF2e4&b;$B&-GPj!n43H5qx03p~146Z~R#n^Vv>a%= z>&mXH2KgJony%_2w%)eRHVOC_04kH~5`SyVB~UV01IB^3mo zhn@StF%B)ff~w}Aq5hF8_i+?xt2{O+j!(|FyG%eQ-8xV}I6velth!Dd$WJM-h;gy7 z!NhfsjWmZJyn!P9*&D&%y$xK^BQJPIXNj9EP8N2@Lz@BDmmI;dzZvN*bXvt&=Uv1b z7_dPBe&(2nLy7xPoZ|e|6KLy@R8%(nizE+tXt7fk;cDSkp?d^O_86YnuWfr;)+Z7TMqFRy{Rj3vyDeust5UQTTuIz=&C9j^h&1X#9~f8QTKz1LGDEAjLS`2AMux` zAL!KQTDUAi1{eGQ7i|?8N?C`pq~fY0*x|I~!$Qjnk3rI-K~>$~(O#KM^*s>#_CU z)uF&;bvp97$z%J`;HfgK%dXyaoqAW?Mu!`m0z-IwqF~sQy0-L5L{!%#>DZ+`F1IRI%#5+u8F6+zBKE739JR98w6fi zH;pHY**8fK;ugI@6YwpAYI5oagiQuc2q>N3oGWb?QnkQu7ygu1^gE*s2g40cN`qs` z)y3-tx`LKuJaVt_n^QiqNk;}}E5>$_e=h(mc@CKfE(`Eos9X!0^eq-OUqe~j59#93 zf{Qv-QZ3cEsH!9dqtmWgpGSU1lpI$m3gb||#BW(&42&O4KRK)6nK#Rq3VtME^+_j4 z1Cxn$R8sEL-dcKkhM4j}MZ&|&rC;lFeVovp1xg=U6!qea$Gm9{xA#c+hIB^o+or?R z5&C}8*w3AECi8#sP2ko&yjieQxeln3I2>HuA@Nq8cUUg(u<}gU4g4ZHJdMc^3G+^y23b>v>0JKliEhrLv!5fDhZN z)m8S9!7d$(=Vn{8A2#nW9Ul|lwEqfT)W^0{^o0|g ztBb)I!&C=`m9Jagu3kwwsn0uQ=d&yOdceB4a!NC5xk;N|8^0$tV4u*5kO9s!h~zCL zJfMkpn2ZoKHSm>&a|fCxwqSlD$SqXPO`ATUcH4tDdayDj!S%RWzy^;q5~X8@3m#$r zxG07BCc^RltE?eJ1tiQ(rc;(LTDBe52YHlq4fSul+2NY_0%|?uV1_I@?iK z2zK5APBd+9Ef5604--`&m>eHre2{jFOEIME4IWJ&31PG@B4fkjCm&pi)pEgl;jnp| zjgvaRG!f6CoXv5k!QNX&HsxiT)(!8<2!o4oDs0@L4^vnivqhKFjV#*uLK;Vd931d= zb~YO0$?FCBZH@?3miFv-#&Mn`CCQ0gDkB?_ir?UY*;W$z=Mz;bs6zKkS+nQ?@(cMS zz?!6Y?6Hsi6*J$YW%ETfJ|rnrb5Bd(pJO$+>T5`L$eb)zC7{wEWJCI zwX_|Z{OS4jBuS6j8QUk8+D*U>87{Ir`LQ>|^pS-J@UUEC`s5}4SG;RKo3Y@|M~hOr zPY492L-vkgASWaKt2v^Y#A%}&OybhS7UxMSR!)Pd`3YvX-vLES>P_HjYySL%evr7u z=B^BvcaI5TU|w=hagXk4@NBxlRAnQ@l%9h#91e!5yprwme>xyMgPQQ!K#m#h{B8g^ z_ig4YUn-w~AHmM?)#+ZHK|dP^R(6ohjBl{kcF0fr)4Ax+aUT1k{VDUAuM1*)_dL6= zu=|KC_#1i6I@phz2eHxrx~{;^7P! zYYFc|V4&TVviobpEO98Ms`}%rJ|bMKj$pSY z9e~~dp0v#sj2ElIjwJ0V4v+^CrB8N{*b*JR(fcF_d><}m5SCYfH3o}`emW3*&HWgw z^%KC4JTX2DRo5w_^Y@3&D+s}5akIGX(i%UK^U+%fn*i{WfH1y!jM0>qlW@IiPE4&0 ziCI|DN{KcNo>ufZJH2|mq&D0V?Kcx>{4ghb8l@Z14XNOxl*68kZArZ#iMq%X?x*%?vX zGslMj-r$n;j8l2P*5^D+;Z$F4YwTSK`uO>*>c>^}H)A8)%a(+^b9fK=t*JHEvb}2) zFW=yHbE}){A7RopdcW9o_D*}b9kbI*4+a#&EbXX+wVPFU4?MZLx41{|(Dd0Al^2a; zy||W#c_7C#tItdzzrj|t(VjMdmtEJKnqG~5Hu^D!$OL2!?Y2F2bZNtuv#V@WelLB1 z25g1;b+2=5WH{Uxrr%uu#J*hmkS$F!eWGopb~pK3r^%c9XW32h$|sxmm}xloJ@M1J zmDQ*6H*e42zrm4h4?njUp7M3e+tn*6C-r%!?0j}*Uyob{VH}MOH`Eh|P;8Hyp6xAB zy)r)|FY*%+ntrAH+Mj?i8OV*LTJ0XjC3i1>2;v5%V(~+8%PyBK4RD1wyyIs)elXsg zHvkmj)tIVUi-ACb!OT6jo3yA@dJdi)Z4M&nxhDuiB+~X2gWh)(AXCu;AY&! z2^G(y1s!+xZvpH4Y$sVEX&L3)>2Kfq_1B02d4G2G&rZYf(h*Rj89F$r3RLW~?no?= zRwe-#cM?vG75d1JWe@X}$=VTV%5SE-Nk|@9eCf_DE(fGkSK>Yg z#Yse6){$FTG$s+#TPhZTYsh%uP4f=VKhiutH zXs5ldMR|6GYhyu39vsZihAcPqa%r>i5F@QK*Xys}gCu;V4VoypJ9dZp7}`#a$#y&S zq-)SatX`k=es!VOX9z-{|S%nM}w!z zur9m$fc8BuldapPyow&9YP#uSa=FI}yK4)v&kSwg*Xp>Qqu%mz zF~sQn;;r9tC8Zbgs=Td>sv;uPNr=+(bg4Vqsm4aQB2cQwcBJ-H5|^y2l4#od5_Bb= zOb0}C5coa_Tpwfuyw_w_2Y2tu*kMpWcWf+D9N>=wg4Oo{5)2e;tkyU3_jTgHH5^J* zeu=AzmnSMFt)oa`3X=lYdHdJV0!9wr{?#|PDz8sK19zyqhXWs_ire16C^Fvgp^d6q zjx8gMhYRENGStVZez%R_=x63T16m*@JfAoU{6)!)=gva0ounNVX0fnr6h8?7r{A8F zd_Jg}K^!)tO|RNM<#tOtYA3? zmsu{BYrPLm_{I+e*^K96ufV+mHBA5Zbh3>&nsMAsA>ZJX{hI#;rg8t$b}!J_lSFciZ&b={Xk zy+YX-zkTs#<3J*VFpkEC+n_DFGe@!BlWOX>r&I7V@**FLaKY>O6SYxlC#7ukmavZV z9mFFaxC`8nxg_xczT+Qr&0k%e$ybeq$9Z*Mm|Sc8Cbi_MrFhdPWrLUPkq-mz4y#A0 zey#=1OWE^KPlFE`?rf5;Xef1_QAqPSqeV!Ihkci{90R}NXkuttptpm06buG={~R2? zn5Z$Jy@iTabsd6oNfwSgQVh!hZA+MeAB2Z2$5mmD7kcikam zhy9iEpfZ`VK0IXsV={bNe&2Q|975IsWa3FJG>kUWegozf1>P;rYf6`0_oQQ0;7xP% zw(qXo2o5iQ$PQ3fj>T9p!Q}5!*qkurn+21Bgr{xFdMs%rXv5&g)K!drh9#ZBBF~_v zV2pP7C~Y@zucU0u^yP}VkI?-k8AcMX4(`i~D(ONXNA$sUC8>SPZo8~~aex**Kz4J0 z6GpjJiq1&lVAem`obr8u8R?Dks?M)lwT^0EAWpD~J@@@l(Ex~dyjt-`=x4=MHwflyl7 z4B5l+{O0hNw6LKffb3-)R$jpDZ@<6G+raWSoxh8*%RPJ?<6Q9f`|~|Lr#-<`Wm_#i z=Oy`Fa17IycWJO!+uAu@dMj=0!eZ5z6ZTia>S0 zf>3*4;T}X6E;ppRL<~BB9c1{>Mo zIyc9j(H-KwV}9`z37oYAh{UH()pMx)nUM)tlEA>J#l}2J(mKZo__wj)o~;@`)pJoC z3>HAsS84=$b)RHE1?_Ze-yCh0blz6U7gpT~H!*BY*Pp(7jU6f!YacT}z(*c>yE`k{ zaVHoPJ`?@2w^umu?|=O3|GR>~*Mxd+^sf(!!QI8LgS$-}U(;EJA)*vNu@`9iKI&ny zwEUlVqP2)9S+0GcyJFaX@O3mdla)6lMja;uqQ!$>h|q}()6 zQx10!YJu1UaNs~WDKR@4Fcyollt6+v_12}WZo5AHJ<@}@^8ZT8=CnPmq<8sw;v+J_ zeLyY9JIr>UVF|q9&reu~^|S?7kundc+?Nl$m!_P_V%ZZiWk~K%VikgOM11s%KkA}XX~c%Qo8!Bq$xYv zhb-^xeV3mlB5zOoXYzX=b)B@&A>Z&%w;A*u;+%bh%XzU5-q(uf>lpp@_3UF&+1BG( z*(&sPgcvB3(N@N)MYN0V>xgL;J=SwcK%>xK%nO`?U)zkD2-dNr)M{%OH-%|q=Gcc_ zV|7`)1#uI<_08aN43`zixcR-HL^0hKX?G7s-r0S!BtpaGm;yJL+abiqjF=jG^BN-A zQHtW5qM!^!r80Paz%zMCo>M-3{M3D}yL$^*KhN|5-$;894EFLjak&zM!8%{P1uT}~ zTEds|F_w=bM@+_$@vW7xv$DbIeo39i0$^AQCPVX**WIRn|Kq>^=U;#PIT8GEZ2-qV zB4S-;br`u12CRau9yjt-V;2r%#-f1mqjtTqk}D>+PxqeOrLcZ-!e_7t@S9V#G04Y&DeJKV z^?Tigu0^bUb~HmucvxLy9RK#D7pguU+&&PaE|!ni0Lm0gu8fWY!;%LL1z;5-=10LZ961=ku`#*G%)$tJxkKmk6E(E zw%5Xn@V_zf!>QIEB;TE{&-%OHd9sJ}67OVqD{HfyViE#vAA*Ep&jxq+)xn+t{#=4&F`NN_O zU5GaHCN}3Ym!18Br|jo`H|m{UovzgpdA}Ge;w}A?n$V0LZ5#O!_||5~&*68NUrLNk z?MujKzMDEb-620EKz%CT^R|tCdVh3%ls{WuQV+<(vj zR%MsA=*~RFHkedXmpvUShyF*Np->^By(sv!wH&}x6K zH$P_#jtQ=6zCQ*fXO@$r%rESd0I)CnAg#{(0P%XechKzB>dUTH_h|cN9=#C^^2=R9 z&&7rO$=UP>(e7Lx8WCn41RjEoI)8;4CDL|dLlQqEU}UcVYN}-%P6dJQ!-Nj&5XJ`;qNx`fi40N=1;RZ0e%q-GaEfUW{_kz`|kUlw2nCs1G6;~J`3 z>9373oUZtBHqG*h=vg{xo8xwrn%dOYHCy7IHXPRkZeTo#H++aHY;1jCqGF}@mxy5~ z<%oG3a<4#@g5SBj_?4b)EAYHt$;ck-_so!AVEmk2@&P7YCuNJVEZJgn*^F2z>yH?2 zK{=-sW14Gl`hYP#Z$zQ|?4W2~7M%ma`LG$ zIdAAjw4p!o;X_0;XQSdN`%y0Xvu%g;>U6HoiG5`-xc_)5rTKVf(v(hZXUk9W%Mm<| zVBXpDc>;bYo0=E%%AeF8_RpH=`Tqa2_oiu<0J}iqE6x$>oDq}EQO1T5O5SsMENOvU2pxgllBJKc= zRpU$C#pXu7<|>qPeiu0%jX=g;HS%dX&NEpx-ni}p6?a~EI>f0R`wz(>jh*!f#beY-KKjADsl=$(pwa^lw?Yfn56_60>xrC!m=Qm^MXFY6~@ zF1H12BX_mVNR%VQ&4pHdj2>YS_(<;)C0%F1TKzlAla5K&L@BT>j=tgk-{tUdj76Mc zc!1ethXK}#Sn|g;qf5kOTOz|uwE^9zM`Sb*87s-l?0MhHA=hZ33J#V^Z{PRWd zuhNX?!T7}3YVKyTnxF0I{eC_@UySJ2uW;r~XmcjvM9-a5_8EKgZSpN*k-XyJ>*bFJ zSN3-E>wQ-HCjm^+rn|y>q{l*XzvevEe(f`>yBI!KcQM@+AIBg@9j9YEA2=qf!?vEp zYjL10ra3B3@^M!(6<;e$+OpZywmZf4?V8cnpl=hzKpljsdA=>EeQ5kFY~bB~IN=B5 zWaFr0hU08PZ?E5}T%#!Uk8ypmbC-?^kC_;70DW$aJ5~$OqcFv#7{%s7p35@Jtn50z z6;nWQ-fvtCATGupS`W0e8Mec5nDj+CP7VikRRj~`vr2eRBlz7K*>{rJ@8GX`za?pS z0SMlNl7FvglEUQ?CrN%w(bo1W;Fndo-<5h<`_-?rNvhqGWN?sMKBjG-n5qzP_29|gQHK@@j^Mb+5t~TegAPeB349$cRuc8*@y%286u0mc_%x9dMZCd& z5d@RQH%{T{maD7AysW9ZTCKyPpFOPZm}pku(1eQ=;3ta*q^snxOZd){!Rwc*dfHTO zlde3NAg~;6JoNp|c<#Q(&AqqD!4_L(CCcf{w)GBd06QuaqGn|B0P}l`RL=#74{KwB zxoV|6Vgx@IzAD+nB=eF4DmWE7Pr?#@IQ~Qe7(SHIdJsENk_RqaGbvwlBwL3x&pkY= zF(xVD{bfc3uYrKqFO}#Xc1qI6A{c({o^BcM8~(GiBa`o8~;F7#@)(%8!H*6 zL%Th#+EDieqKTXV;+8f6BsLJn@m!mG-sU@Z6#Jp(Gd7}ZS28Tm{BM6$mMlpftZ^wl zzz?hx?H%ct%u1gtVWXP>xgvRht!;7{b0X!cwM=fhYJ*P3UM=#CEE~TM<^&E>OPl+~ z!(mq5-N6_e48mkXUY}_;A%KrQqzGS;qNJ#=lenw4M+WEp+Jv)zu_eX3t?400D0`>q z-}!d`U10sr%=7X;1+CfvuG%mD+88B+b$$L7u(;N%?#T=4xC&Y_D19G1u;%nTB$nIp z<}orc04&uy09Pl0QG%CL{YfJDSE&=rWa#_Avi&5u%kF)^2ua}kA)~Rk%w1ndRSz38 zznQBaY=+El1|>5jOdtdKPy#ql2CE^dC%R=CIij68I+<9H3hPU{!v&i;r{Q@gW1%|q zDY~_DVM8mz=7{}1$KZmx{Z$tZz4fC-lDW}oxoPtdC&fZ4$$Z7KB|l9JhS!lU~5a9ElL9vz_WS*uyvEzo%IL^O#>Z@GPRQ5*UWZk zc6Q?iMbd#@>CC0lD2*eP96xXEPnO@e=IRpXLI!Z!FR?WvnkXUIw@hgPvQdJbQkgV?sp3$(rpmzPE7A z;a-m$Wh~ERC;U0D#02{Fiwk`=Bx9L78!*>(ljt$^{Ok>R6WJTrknD8t4!$n|%+@z0 zG8BjLS;?BVj<3$Pxfl2z?pyBf;kLVFnn!qywDP3B18M3uo>`sJc|g^?H1*Xw&9L#c zIv_?R(|s4>*Zpd6u5Fbp#9FnZ!^Zf1%vBpkv^Cb#Hn>D*AqyRZaXdCsie`AoyCt^m z+a_QVj=PF2%A!0mx3C;l0Lo*y#?}xN!I4f1AL;W9KIxMR%#~h`=b>#b<&4Ad>wp(B z0U3KSQ49vh%|&~-h@=>t)jw9q=ADw>)f8jI4+hHNP=@8LGZ1i8lLCebC{(@2}vfn<9o=Cxb<$+SD{k%Ez?vC?oI4f`W$bR-7r=-fnhH?~ZAi1vhq?q7f+OsWgW?=0`E zjo)GCGo>GA4*&wtWgFHfvWLsRn}|@eIinmu-;@Bkz~krnA1Eqdzqp zWsf+S^TMl-bo0r-@`Es zmXi|y(SqKW6&Q4uK<~XKUj$o=?gNhM$RhTApS15Ax9|K&BL-ox=onUhxVM$!SHSms zydCuyEw0bPSH8*ha)PhgCgN4~1y;0f1R0L+SMf3-s(Rn+1%9C_+L^ja;q%EC!DEfY z@6^K!kA=>@8xojRRNs7IxGm@gKC4V-`QIxFQk@b;1ep0^Wd z4m-=RsSXg^Mpn4Uk9bsgvK#-+B~PAW^a$?9ep>>C=Y?Aic-j+c4Zov`ltMS@nb?#j9@)SOm5}qc7N(1%qh`gd5D7)mr5@Verpn{ zJiwH=q#@NFB*mZfCp2(geyP2^L=+3OjP#_&PH-K@O{yb#;&8gu%V0h4N_Rf!3lBJ* zzzXZ(%7vVhDa;5;?RjMJG4G6a-paF!#ZHsNsIJdCCJ|)nxtw)Bc^Ma}VoJLMzFXbHNw~O03uznv52Cl`eep%4F zyAu1V;8$t&J2m~TG^=}u;;y7@wqLOtUCpvF3E|geh7Ul#eJ|6lFVPpl_jSqTzP$O> zulw3Kk)dR7)|bkXYpTV#YFQ)!XZ!?dIYZ|sADFHBs)g2OgWo4Vr_C;%WY-(F2uF zmr!s|6cr{2;8h9VYJn*dz`DNeE{mxQ(HT#f&>VWd)H@qaH$wUlwgv3yuUb16k^^0! z-NuFerns=HY$5xW&vmyQTC31Pq)|sq=^>>lD5J6aanb5W14#<1gG;R=16+sc{GsPrDhHT!>NX2)rt`-v zhHh_e**z90=JHI&aK3zwcSg?>yvt#Oi*+oxD#X&8^%qFo`-Z)xMkErp1}%he560s;a+f&l`SsXmqnu2 zAz+k~_{@b9HBMaNHfC*O$|nt$ux=KXztl^)a#NJf%d;}t#;dbSb?z7?fWg@&)Ft@} zSoZtB$!X3WxdgwR5^`;DtiaG%As@^gzj90V{bQD--Hp3n34ZiXK_i}#*>hb?`f8bK zt#A85(R~FZkQ}{c9ePLOVemS3Y=8eQA=G;41JFs}>*3EfA!`$_M1691h`Sa?m;_cb zkJnf-7*esS`qKHTP@V+7C|;K}#8KrHJaC2k~Ur9hHEeS&@wU z#yF6Z&j(vq=mBP&X=JJ!Ev%Js^)KyumB;fv>obnI>rGX?kL|0QOQ`&>zG@jvA*0;>)47G>+k*u+SFiZg{< z#z>btJx+;AM9Hg>9>=A2RqYH!_F(T|V?Nw#@QC%1ax{hn6Vl@4J)HCVQF$`S&zACU zSV&K7JsKRz`3*4g`O1Yp)@#oqN4$=98czNw5gcT(ZqC-a4$j$-2XyT9Lh-&rjCyyF zU0L@_@gCu60+>~dT>_X)43%u~o`X2)JKJyh759tnzjY_fecQK`-96Pjbni5elJV5Z zeI57*v4r}`wR6Y0E6U+OKPl>v z(4B1rzQenI0~)@I6#SuvL=VcEx986*{;adow$7HUOZWjruK#Vj+J!(`d`x$&_gBDL zF0O4VvHQBXtHa={?wgnlUXcm9ce|~wbp*QMsK|i#)b=CiCpsdS->OO_d=l0m3A_#{ z*CFNRA>3W3jjeIm9%VAJ@sYt?i!a-Q(AIhJh_eIUrE~H2MK93iNm4u@(L-t%e4~T%(1>BF2G7u8|P?+C&M zg5#zE9ZHg&1t)y!eEea>kN1hjN4^ZMlD{a4m&lg<6UpC(zkE5K;iI|hup1_c8#K=& z&#o=DB7kRIN^W0aGW*b$F%fwyHqQ%96ErBIn<4>Rj~U=|l2uFWNU77%rC55TP_7|~ z4z*#R+x>R=ps6>?H?PLyMoxi)arw;3Bg}E(lz61aLtNn2ikmyZaVFtTWm830RC|)D zaQ0T^qz%85*Wj;rdl8)eadF5kFXp84Se?7;T@`%rJ|BBIx5x~{RnV_k@c>YYt4VosteWm#WvTrM?b zE)2xDt(c#+Dd#JbURaDhE`}IdQ+nJ?XQm&DoS%aPz9TdMb#d>hPy$n1HHk{vXA4DJ zzXI#wOr!k9_gs-nCN7u!6{~%c0M>P}jl4{YYqM_qlY}plIr~sfM1i(M)?<~#!lY4^ zv6{ut{7c6hHUr|NhfGa*hN_+>fv*G1>ldZT-LFNluxD`59x492Nnl#`Z@I#~V6+$H z?PH7bP1~~NOR*7xcSRtV+{`fx;-Ztq<5k7=Dgm6;w|x_d^S5P)w(xxNp3WOo%8g+9 zpmz^#zbWw>5qolSdmu#zyV&N7tZ1+~K$|0PL+PTzg)D-Bzi%#fQ9U0W<1d3{@EWp8 z4wuPa%jw!A3ID~;=Fafz-;D3)w&Ph<8OHVC>tDdTOc7cvPZPir19BI%5J{&&xX1n8>oq#m(_KP5I0K=sp2NdE=!KD6%{!{YYl}P*y;(lFvi`s-EwBpd z>>fyAyzA#E|Hd{~`-+Epe1J9FT5++M8XQ*^I1@N_zlT8ks!^H5_pL<+oC+Yd4p@+7 zfRht>7Y{I2%N=I47!KC?-%z+8+&PvTOPt8kGf2*Fc>wcHG9xA}uq>XFV?8@p8$0F< ze$@xOeSX1-&`)3Q_}3Nqj_)G8%-d#j|5AUMENMRl{S3RW+mXAsUEkdHjbE)=-A{BM zHBb9tJ5Pd~@K_7=JeE*jn8LrQZ(lmLQ1`!0UtTpIN6?AwuX$f5sdIj}vhO|66Ku4J!EsYKi z;IIthn%b-e8>W2{_*#q@ByH)Yi9#Y*TvKLY%#1H&Dq^+oQsR&E~7LcUnfMLiIrR-NdhwLyVsriJpZ^;zm;c zGUcmyvRDLQEh!XL(cF%_z(w&){Gv5QZ$un#p`&wx>yXlm%@c6(dY3M`0ijz251jDK z&XfElZ=%q(Il=~3{~s-(+2 zcUA|$lft$#vdSrunJuwj5>72Z7S9A2H+k0cs3-0%7_u-_J{FD2gW(zo8R;~5ziyt$ z0Wp>*!PIhq{p6x`YU|-3=wEnJ`qHqLF&yd)U*;j6F$Qrc(ZU5?pJ8%rOwvRf=3JfC zfz8e0ny34Z_tnVsdw@5uciig=;)(Aeyv*CKWZqvQqs*VyrvyvjY8iQ-Trd}>V%qx$ z?_+Y`cED~t<#H2qSqHIf1F?>LCUqTykIh=f@wp()Z+Gz>V%0(X0mm7RojqsK*uZUa z4s1J?DBhEq4AZ_1y4iYc&1g5SMzjea(C=Y(oHO2wk9ZhMdkzOS;?^=0AC^U2;m+CU zfjNv1@r@5t2}G|uR^c2v={FDJcd$&!@EXXIc7!#f;%93g;s+3pfML{YU}W)*`7Z~# z%tBImsh_*V!cQ0U{x8U{O2x0}yPhOT%ja4PE$rrC6YKEdps{pb1aZRP^Sp*W zN&u@w1xWH{3la9UWr((+f&qIl>AF&FK=FtzO}r!GRvU;k&@iz$jjE9N~d+(7j7A6g+qmM4iXfv0H(3>zKhcb!=Z%;0**8m=xreU0xRybSrO zWZrKPBZx_Lf$A4peoyuelhI3~2j}LrIle+auY2C(a zVly%Cn8?+`oHD;(HDTug(m>{Mi!#4#Xs&(aWrW+r<@*xli*n!OWtM5$V*yp$C$t-P zBbsc4j(!i5@ixEbz4(z=@nbtX{vIw|$Q*Ooq^9ryLlnEUR+`w1*0?iU=+Z!!3yOxm znUdQ*NAoET-2F=S#o!3NDF$XBpM!y%?>%!|7*;;#V zEEnChkW6wfOOmgF?II^;0o6(0aYzV?8g7b}AIyh&WYXbJiYKKK2<7}MxIZ}ha`>Wuvh3I9H zJTBA3UHk|8L?J*`IFK%jrp|D1mx|1YsgEM|d z$z6$Y`P$?wVNVODtNsVU5#&mNlTN#q$w)&`FU{;!LG|J>W|w|7KC{>0Ck-DvMC(q4 znppZk;goo2hkHm(M7;?%dzn(GGNLB$6b73KyKO$`*L3gkT|1Gx$beI+b*sn_i=LGb zd?-&G<#LBP=!~NmN8ecBnZODii&I>FzR1pum>=vM@v5@~fdoU2udpVGqwAZ|)L*}l z9VCxDqL@5XvgM0+zwnOZ@fG;9J_Z;a-|GcQDl`5;`?}aDw7-g@xVq8i`-Rli+_UO_ z(vHvC*1S0WSb+`Jy{Cy-6~~~i?dUl5eXw=kGKO(n!J?|^W!<+vE)I=UzLWS}WJ%kk zz}Y?y?-Rm%2ny}kRo`fKdhAVtSTbzYc0^OyuDqQ9CQCyMPcbJv<-O%?8RDl&-a>}j zWM$iDE(@BCeCDIZ*8bP;v5wcKLbvGW8O(r^TYRR$PjCPM=fy;p@ZFkY=GllJKosIG z`FR_0zq2*j@nQP_#gyv{>*4*CNnk1$%dCXh%0#ew zHz(vzXhJ3-ETNgS|2VW5jE`H>Vw&yxL&U@=B1R_0CYIsrwz@u;63|};w*#BqJKsCx zhk(6Z;s8xKIbV*U2R2{ue9Xf-_3%&NTtP|T*CGUyyQF#a;vt;=p=d8+tSwUfM@e91 zZ(+byWB0{jT%9gRcBnsFSn=RrwPcZ4yg>rkB!h=Z->mNah8WyL%@abH52}w#xfx`) zNmx~e8xck{`%NqxJ$BAI8{t3|HolRzD#_*u497}_ZJ(5K*O3TEcOP^64Wq7fH|MP)HOPX1C7&%`fUOOyctydA3(nu~l$ZwDFfGPo%) z+39%?lf;N8g?){xV-nZ>wLCK!U4Zxz7T#x|lE@Hv0+l19W7n|$cP-s*wmiRYnAtp) zcjZXm?fD)(FNga^h7c|lxWjTL<)K>ttk3o*WntiBpfg}SE=S`+Fd-uy$*~=#k^>@N zH#@n*Od2*^SKD%G32&ZAIH70Qw7waj{`wV@Xx2`|COqALp4XiJ z8QMC#uJ;>Xza|(QM<%oHGh+K#V~SWwT}?bRxoQ*kor!&S;_u(|KHB?7fv5UK`&_?B zjZixl>7dTeP#mMa?))aPJ7ac!`QN#5hxQQtb+E;two%$1&T%-L7wy}Ww8OU`tY5I5 zT*l(vAv$pZtsN(T$r%IzbZc&oIpL{jTHeLy5c!2g+?>Dda3KTo#J|4hiANQa(-~n? zM5KDN*aD}LUUvj%dghA9cuwx;%vLfY{5UtVo*yjpgdaeJ_hOvf1H{kTcgRW@L`(@!+ial#29iW%jHVjssQbo&i>&4xtCgqZeUT_tZO7&O9~I;Kaj4la zj{tomQ=PX|{=P|I@^WoM)#=8ltHEE_eTnr~6Z|UMJxIB~1+XgMA~kIiu@CD9316|l zzW+<$YM^t=SZw$Rp4?k-07di~Pl!%!aNMjIlRjRK8v(O{;N$_%N!RdTV~qU#z9M<0 z*PSl3A~i~n|5Lf2FNMsq1ceFt=D{2lQlA2N6+evu`M+?-zR`U~Ysy*^BX4mhwp@S$ zcvdeETNB;<5FWsxe+2L_*$Z{2UkL-2sRA<)JfORIl!_50-P~+K|6utkKlD}%@2~1I zW)`4_+khv1{B8F|?;SI7t3N`*mwlB@uCSbuOqE)|HeuWD8IC(HUVt=u-(DmohRdVW zc!!e{IUoee=JHib8z%!yrYeQSSvh{8H5tvK@&{LVIZ-$!h zSu(9o5Z3aTNQBA5@fF#|*OmCa6myKa0>5{gm@?Utgf>%)iC5(?#<#8-x;oQbL!S}3 zd!+7_B?;X(%p>yuzfP83bse0IL&t9GE=E-+Q@8P3Yc2qd(=qzrx`PAG`e%dl zZ8Kp~h?`uUdz=bB#9maD8bhbvqKU;?32h2{q>iSF28Er+K=*cXj2{yjZ}WTJ#b@L( ze;<@5hPNLq!*Xnn8@Yw@;QrKh7;^@<9a<^cJJ)TIks9ctA@CeqVK}O|PMymTKMq_@ z`5cVK_q~DJ@Oc<#!8dEPFEd#xPlUzjsf&ibacGwOU;$;Gr)gMM?`-v-HP%Re>c>>APZKM}cIiZ|c{eGdLXzwM{%ZB@RrQI_2^pEQ59Jz@(Ux!8 zo0Q$|&y>$u8d&|1v&Uv~#ls}<*Fj^5Q+SVy7&1Ic`1>$(VThXy7w9VFD|5Q_<2XXJ73(Mg3!~D;S-YGJ|@z; zsvqv|!EDA2hs_Hs8Po|hKFD~xiVD0eRxfp7H6`7t(Ab13(hAN-hJT1pz%8;$E-5+0 z`1w)DEK3qMAPPxZk+rW$3RiJt59P;6VOJ5rvo0mKuP~YYy%t$s(Da=nl&;%Eah*mu zzph$Xh^obDUN^3uB!J<)ZgjRHqQ#jIjE{k;cqT{;lvd|7q{`c*JXtZHmgl~nwXL${ z=WvBRm%W4iXyH)lp$~N9eY!t z#arihbw;{*AH3dn*OgrxSjTW(6MN*|lSG-ilJ`ZXofwl}$y`ZY*<5k5eBWsf%y>V| z{UUO~Q^M0@Zr9O!nouA8j`5f=serbw`pnc#)wk3M8C7G9!9OQDw)U&ouY;|ujoUIH zuCn2SWN3(?9z$5XZx-tZQdl)3&ADGpb3+p-2*ah0#}G$b;hoTkckvnAE6^V`pWt&y zx!6Q10~jcu#Y3&1OMti&o5;$kBEj2Pp)jX)%Y<(Ib8Ls$nW2){{9LnPN*bGt z`O?Oc_4VWAud{dL7p)=6KnzK*Urs@cR?y3A*^A&oAmB~|oFuxI6)e$7lDOi{Z2?+( zTA&GO=)@$v=YT}_u{V*Ht|v}(XXV-I`^?t|%ZEBfMr!qqOl`P5+>e%QPtvIj_B&qO z?o#1P1Wqv!I|=2WzxlL(`f%yJdo2gt43w^5C00HqN6$hi1NwEaP4X8#v;Uk@&X0q+ z!4G5%oIK^>1cUWy&%1MdKYw{#f6-k%Bni2YS3La=Kw<9lmcK%s;+?^|8o&0seO}kn zPmP)QRNb)d9uvZnyB7TxuFh^`ZkvXw1MBWBVcu$$zONfx*Quy;tm^_|F`Z1N>SFdn zMpe^SQO7g1jtLOTSTDQm;!NU>?c>aOuwrB$7up{Hk9)m77HBanO9Z4;$Z zeFd!h#9hCtvV2*L>Mx%RUZDwy1+*j39lt3uWn~pzg_WP5-v~-AmJPEwy z_k(+H_|4(*(%qN6(E0~Oz9G(+SbT3JRQ;scyoQEf<~EC@sattv*ZLrraNZH_FkS2Q zLgDzJO8LmnpHQ4v7Ren?kBzUY-w3G3vU!-hO*3aUSPe6#usO+WAllfnv_e3>@>q4L zv7)Q3%7=60`5j=Zmz2+di)P=N!osl|p`JtR^zC>=@kSiM z_}NC2w9O`jZF}ni z7$yVk?LQNM`x#`4VQf-SI1e+zXmbB}aEq7GBfQt7w0~Q@8fE}<9VRpcS{$GF<${&T z&{&Q){ZXQqA=0jofS1K+&7rv8<69Z(16RY-ji4|Y8$1CWlzq048Z&s`H%bQU&JK5{nv48CU$iM6pU%l)}>@!X4E=a%3m2J}|YL$&q!WYT3J?B@; zHw@6K5k!y9gmTFLZRMK-`jB~*1YXHnru&fddy~M|vdmc$*vVf)builajosI^=(9c8 z==##vh0WuFwh|;TxjyTgZ!3}#2}cW>wW{4e z%;kBQ=$VhfqvWql=N5V}LPT5mDWV+qSjZzeIztZmL!O^EdEx-mY<~s)42$)mjjoQ9 zX^DKDr*fWNb8zC5zvb%RGxQ1I-G>>LJa!NZscT3uQ+K6(RWXq1Q-vXRSQnGxkv9kL zJGry8^;Q1nq+b43afUj%I*!)^tu(fB~+4u-zI{M;pbAOv~c`Prn{vV3uP@w7atqogl2Uj@JMC9qy7 zAf54+VNFg(KGz&hFo@3#AHWQ0w$E98kTVZ%Ci60ddwW&~({|tBVsV>_%Yp_Fl&0f0 zj{Rzg|74N8((B3)f8nGzhB3~M6f#34@ku4i+LX>YDPyYoH;W_t`WLMOhU+QT}jqoZCC)$sbgK4f5AJ&Mb->=%9A5KK&KGj?^4RGe)0?=gbgDa$go~E zp9*Og$`Ni-@5^_r*zekug6dS|!175HKUDa6-*H|UDRQO58ObTevS%9vzh_EuCv8ka zJm~Aw+afb&y?2NRffy^1W4Tmt!q4^-xnoK0&Rb_ zIvyYBM22yf?4bU!M6fgBO%P|Y<686-{D^Dt6);ir^{)iq`4_>?C!}}ln%6i*nPrFmDX-G> z)hr1dlDiZ!brQI;MTSR;Z}D{5x_DZyY!Fez-*R}pa2Jm*o=^NTT$$a2;L*)c3sTCf zXuY7hMIqEt0vIe5SAF^O|H{PGy+5-!Y!?P^tI05(&h&iOvN@c{!fyT`8Eg(SY&RF2 z*z^ZhM*5L%<1)6C1Z|qq0n1r8K`g3{)te$El>Nvv9)Mo3<=0kca#{017wfjsMFSvoi@;m;=|pk-vKQT> zhqgi+r0+A-W-Um7xmtk(gw)<0FZDXM@fq+ZR@l$_l9n-18i=L73)5~qyOTdu`1yT3 zdhrw<-Q40ViT4%}%d=1>?dF5scDo<>zewq*YG|f#H0E$VAXXP>vm%MtL_`J{(_E+Wht=J&v>NnIobQan$7`h0t|d# z=Rw`;a$(X$?@V%{LutIxj~51RkOg)Kj0zi9MK95nPSXC`{^#s;B0dI;g^3DRxL&Ooon^8nXjGIwulc*?{kCrd=#_x?e=*oYtQZ=|`CCyFHXPuoWH+|c_8;7_*gleX%|@PlWm z-zPL!TP@!#=;~m^b6In7g!ktcVrOu#8#+3q#&LrvkJNr>a z_aR*Q-vz!5g+#DyKR)q$ld?0fvvQfx9}I7%KYH1B9pwjp%ckxpklQH}zK6+RT^H3B zq3~YKof4Jq8|7X7f684RB!Lsm&nAJdr95Ei^0)CDyRUal>w(}7f_I7k`n29O*Ndez zRke^!=xR~8P$U3_a1NSr+lnQoF3<}_3R{~(t$AJ8M->9#UU(|ld)|TA)z&O;$@wPxoddH%qy4+NBYi;7VuSVhJ0#YYI2?X33%7_hWnJLnffP_>s*P@`De* zxWzeij`jxy&XQbW0*Ouz=6@*Knp+c81+>JYvUkr4SfXq>@F&%*mq@2xqI$n(DyRqhdRYJ;W6sR zGMVve!Z8r1ph+R96xtDBoa+H}%jFTOkh=Ekr98yPsi7(^#s~OxDarU!j|}K#L_5+MwiD{mjXZN_Wbs9ty4ny0M7$lhpFV548_RF z5`y^b%|jks+v!1481cR;sC}SqS(`b#S2Mn~%EfSIq>$KZ0eJwyKt8`2?$^8bTIju> zI6+9*7WLyquq>|ix%|en(Jx!~kIC>}Kb~!cTc-QPyr)TWD>~ULrrn~>Tc^ojZQuGa z^$>^m>wHs&`JYz0iSoylm;~O5SE6^5!1n_(#axRQN&tH-5rz7S;g0(ym>smP>%KVb z#fpn)v0PU7UtpE=jF6P4X(tPdnX5NR;Vc0h7hV;esc(#47$B`|qP4*@`h@~Khy!Ez zU=BO`nEZvb*$lAVT=2x+)?xQB=kRd%8%`z*0s7k!1xY$y^}LuAK4ONPML#J2oe>r5_d zKImfIHo9mKgl-XdtKLc!3%>U>x%ZC~$5}gObame_ttGh(-eU-nOqB{%9AyzsRvxId zcCbwz@;W;YamNjnxB3Ro-wOMY!Xwt(9+JIIt)#27=Qrof9M`>97;G{oFHB>b20BB? zj?4V^;&IK#xG2wL%yYiyp>vGqL5#Ok z(3L&E?qss#gs-mavz1O>vghrM6Bq;0NLA;g?u(1n$*>M!Yew?-l=Yaz>V=+O1=#_D3MV!Y zIB6g%s$J6Ar_|vw=AP4>NPX9~GD7b{5uO}F=7(SI@S5nA;emS8eQ~UOL zI2dVx2ju~7pqd%1Z{zM|! zU)Q~H*ESXnS8-Xa>L)S^4bR% zaEYJHM{aCY@=P|1an0z!=YU5%xUSO=CxEl|a@GeucH{9NW%ZjT&!+?fu^%Ys=NNR> z@bBL2#~HIf1%2rS9We=CUl$OQ`TBQKE-3seSR}lsNOLM8q~A=1B&}@QzP)t#6|m(I z5#_#i-T5Tbh;KjN{W;~cbLg9jN&*v3?xG~{riuQ?oadB%fy5?CGlR`2c$p9 zGSv4EYx^S8z7*NdZnn>Ab4X&lN%0#Q4nf=2g*^b%WHV)KuDGZv=}zP$OP{0!WVFW2 zJZrFbMc%=Qt>7j|p%SgagsYKB5{IPztGEOQ=3vstA;6ROkNOMw_4f5T2yCToKx4w& z0)sG1Br62G4Q5SsJdcw^mj?WKqIdloVC4uSDNbOY20CF^W(nT^8;{1`CbE!{p&SYr zTCGlo{q5xwi}#xajOxezRIQo4=o`6$%imP5ibT1z!n-9P5DR4RY zpik?~{Z-_dwAv4xU!%^QXGDv1ZrAmmV8L;k+CB$zwHhnhd6J zCiciQF>IbM;NHiOplTF)uU?0(YYz&FiA7Bcj>m__K{4}%7~G*wEY6GmN;vJe$mCHR zRa|ex{7?B0D!+G}z^OUW8A?yEn9U8klJm&#URn`9uBlnF7x6gbS;vm&+BD{Y_#w~g zX4ujO=$B6RlAwMnaGUX&A_I2$esNdyJ4Y?=@*!g0y~!XEyz4&k`NGDtlKzv>`-kk$ zMynq!yA8Sos!sZvdLE}~3rPIVlfjEYc{14f3o?XxzkJZt_moF-=*RaseG=FwfJcd9 zkO0QXWFRLBSRxAb6$9-$5mTKEzA!I=S#bU0YyV~HuHX$fB&d3WY?eD|cy1A^7CcjL zCV!a#2E5cyu)CsDwBh(w4^QanET3#*1`qLl_|9phn@G`!O z6UB&PK>odN)R(A#eEZQoLT!i(lSeo=La@V%bz27>RpWiZJ}`D_GaolSPlhQd2e^btWCgw=3k_eh~z&A-o-Pud!l@$$^?8d&AHjnicr$)rcU+vh)d&#L{*sS&d; zC~%hK67ynlDL0tP{iJWn;b}`PZj!S;#DNZNN`?^3kb^qV~6(hGe z%|l65O|P$j>o*YKXN$HU9k+eACoZoNz5u@M_*g?*UqaEk>I!+tZd>>MRogc74=AT= z`00H4VEJBuKbHiK@mu+a%-aYvX3 zKbB!Wo};_s=W`^b_f@O~r4Q5(sGaNH3IT(-PX*i=V>0rx4Nh#X-~ka#3cOurtp_|R zc}7b?=h)ws01o%n(^As{<%MfdGMQ9LOE-*+LR#7Rbostj8pe7(oVs>=zMYp{Kej&P z`@7gzQeyhj`AiW;I$-qvjo6HZv##`AJ zC20Uvb*%H*k`Dx)>cF9^&j3@Ink{^aML2A)J41B%oc?ek7@VW@`O4J))`?&O*X{rH zf8ICp?iMenc#U{{P2w@I9aOl_`=;5riGpv$BY)plSi4T39>zd);>ICj1eJ)3@Ivus zbAe;3{a!^cdpyzxaUh0tsDr}K#i4!Ns=QY|(eCpHkbLNw_1H}Dj4q)!=hU%GPBt>& zKIrQK;7{GN|7_t8U`|#df@?2VP`FOdc+OA~0uOi$bUCr7dHjf1V$CyC%QAkhv-B*O z21qaJiyVq1Sq0RA!=G6+-F^wI-`R=(Yp6mee-hd!esw~4C6duAG5{!9;&_fmiCI&Ri(j@Fgar{MedD4~ z)wlg+$kB#FXrl_&jlLwOs(1z)Jf!^tn&ZKil?>Sl$y-MHtj?SEp`Ghv+Nv|SSf23% z_@2orrq&xlMl1fa$AW7wq9u91ofUIt~6$01-Z<$#~6))n}FUJySbD~d2`scj7e`v-_ z=xhujrHZnlp?hnoF1$`y;?+VaV;!ho*x{Mtx0JabIpm}HKGv=9r%FH7m5J|uhidV= z$}MJ-oXP{HG|OeEJlTja+~uX%2cl7#Z)>IlN{;NvM3zy4<(2G@4N4rcO#h3uEZX}gXKzbSuMPTKc|ZA<%y&_hrD(KgEZmM4#=uHVp=1MJO|bN@2g zr#~t3`F+ASnEbnz@yq~gPqKO|K)P@a_zZcR@k$r@qQmyrfai6&<-J&dmthnBOE!2O zHP0c`&9hR0>wSIot_z)G^f@F={_1*`2)+#I#OOi_8M%MIiju*)MPL8ZiDA02{)b#r z`0^tzVY+PGG1*wOE!`&HzMMzr$zWJV^yPDlV9~uPHS}|3v~LL zF19K_JXqYCMb9h-68|;GBmr!BWZEI9ZhS2R=Jap0FRP? zQTO3QuN+`85BEpU^YDo3M0a*epINP~GW{$#=u zTPr5!dkJ7k)e_WkIh#^(aPt7md?J6w|BpxnYYa;6>bgFgS8{`MJSY2}L~!Estt-vr z)8k-uE@>kalGRaT_scQQ-D`?U ze+7w#q>wLu2wn zER=Lm3%E!@59)6wfDzBywRJKH-~LNr%(DboKk||snlPII>3Q}D$nG$j9%>oe;M5)~ z6WG=oZCb~e*JIVv3T=4Kax!+6W;eE}0db311&@mqH7jla$0bm3Y)jkEWv{!geUNJ!G8@o zz*;4AuV&cXE)1Y;>zwENJim7&JzdXY)W-!PbtTZoDLs|_t|HkY?)X0yyR=N1>m|T9 zExcL2=@5eU2L*1Evv|@M`vm-Q;hO8l@??X;2r+N-$4j=>YH`Wvkc{$HV75_I~g4S&Z-Z zB!CHA?HJjKUe!mdL&+$|B(a9E(J}pIZzS;H&&(O_s<_x=or-$)+ZPT7u!o!#W$f7e zz{VRzwjumyqu+5V5A}fh0XXcU1#%xnRqwr{R2uzRBfIA$&`9?O4X;&^97jp}P9MI_ zc?;i38o83%>9~xbr%ivdN%<+AKe8OoUbM7w0eE8A8Rd)zEx2a^dZYm6Q39B=CmW~D zXRe+G@tN2lJ-aV*C`vy<*pxVdrrR%po2yO$%Ua7MB!6|{H=cFe7r^U>_7B~9>PKO8 z4}?DlZSpWGR^3O6zwN~OXOh9uzLj0_EegfHXWji_*xy~uT>rp&astijHS@hQ*4uONAV~)_>aRbnh#x+K!DODVGQ^5kO)&wN40M^ui%F6m9QAqu z_(9_ANX5@$MX^X0L+(HGF|zO52X2Djv{Y+xX)CxDa4Q6lPjJi3+Bv{FRN!S;r7**$ zHm3U0+=tC0$><2Rv^bH}ap8~_k#@t7_o*UDJ(llYnE8)hbDILv=rA{g!KOWr9B&Lv9zI1vo$8Z)Z1k7t$$-f=N~Pa@d)J-Sj%@aovq zl~g-6tBH+N*^X@7hN&;+W_oqQv+!h&q;*c}3BF~AKr{6q;Z&Skn8}}FFAaYN8MSR% z{G$54#nOBwl3aEc)svlF?@^lL2C_4G&VBm40V6m#>)f%>>)ZnAwTwxq>-S<+BKUrLv=X&~lfmNV{p>4XA;0F>#IVJ!|F-bL?)Ywc$E5kO z4imt-pJ3wGuWd4@FUvH4m$m|e(o+1BgK90Zs)eD4^M;;^|Onk1MeH139n=staYuqT^^;;iu)yAVr{+f})(U#T6G_qsj zQX*v~@PL%^&di0}>KzuT#=*e|G1E`R*b)r}v7BP?L~sw%7=aWrk|b(9KLMX$Y|_Rg z0Gq~vV(|Wv57b_7-)}^Kt=x{{p|OH)n&36uXrs=CU4rfxxbM(UqJPSlY>dug%lcr< zQx|IbT~}ZWrKY7&NO~;wC@C(}=xJOy)tTu>8&Ate{C)aiAt}*tBcK`|1NGll_-UV_ z{Xv1*B$t?A56Z9KmD{FW+-+K_El)c}KTy!~5kU+?1~wCxjNkxpX0%)hWiXH0bvcnf zo@n`a?QgX|AJjNJWHVXLpJDY`w$}A}XEZqDD7k+DIY|V6tj<23CJ`Ls`g9@~`2$y? z-4;W0h;Jc7O#bR(AyBe-wT7{ac?|BaaieRDxD2peJ!uz1E^dDia$>J5;#4@b5$$s> zBO1x7aAsc#9~gvZq=R@4u&t=GTb?C4T3Y#O;t!U=zMwu})MsRA&#jvepVgA}t)zXP zzA=%arQKdZCVPWHayn`I3`?osuiD{wkga;4bO3pCe16K#tniTc)Yc)!JxB8B{92D5 zfX{iy3E+HFnm(VERFh5NmOC0Y(#*zdeT+-FM7pTU*eq3|E{im!Y_fP z`2yJe>;~)~E4x}?81gVd+vJFndTle>wX#dTMXpq`-&DAvYjy(Q6V2aIK9~evm`YBY zHc4Qj`YT|Ep>1d5zG{0R)HrwDmsnp1th<=Q5jj3$fKe@|suwH7!}X&HT_u1;wr{mC zW;|<`DE|_8LX&Ux=N1EZ(2VoZ@tfqY{W{zoLBRo-olR?x%fzs^6=^+QH*HlITrAJn z27Ge1yt{chQFwCzggG{}r{~fT7i&XILR52OaSGX99x@r`Ly4M<0(g-nAWrFH82C9! z{+0>h)Q&MgP7B_zaIi+>4s~j=)?27JdowKJ%LFeF+1l>0bwA|1N#FZUFX)SD&7rZF zO+V>FOvhW^y+IYwg&JjRt5kI!+Tr7c9v5e(YHOz7VovN0lqD1Ns()T#xN|M>yE;1o zan8h;)GSl!k_%<>LpC>5SfD4Ovfu^faglSLxf zPJ5*?Zf%O4T}6=PS+0%8#OUw04adlxz|{%Y6$7(54->T_aY*DdS_vlM*x4e!Dj>E6Vv^!?+GGk47v)PpJn*mma^6>%ZynU38yVDCVbv(`nU__ zmr#a@VDUX9Lu_G`2qbX^THxNM*Z`FS>^bH?qPXV-*FYKoO9)ET;^ZxMaGn6><~YyU zqcY%P4M3Xll#0`j5H?2~PfwlGL~yv?HrM)P>#wyyHcj_@uf7D9@<)h3V)%~3{zD^? zzv$$@2fXV<-N%D%GT8p2G$Fj%!G|r=1n_9_y$TPc!%fx6-s>Aog576IWrh#hyOY3O z0=Q4`7D-;liC&oOWt-$JzXDzdBaPXIs)bwq!RByd4yf+X!EndS{eB z?wFi8!&4gM#YD~rlvy04N}UfBc{(~UbpA!KAa#n&1eXkUnaJ~rqWU9~Vg6l_#97Ag zVmeL!mi~c{k^OW-;_WD&Uw4C>#8#`f0*rSS;IhpUx{ZXAyeCQ9kMZmHYV5rKEzjTY zoYv1Z0_vD09bEBe*npV;kzQ2})?aN)j@E5H$AnREZ@gu$dD{0mHhuszd zdPqr(yxz;d5;c=Ok7RyYYy!G|P7}ehf3;X!IVti{d%dm^OPY77zZN!HejM^t|iOqVTiFf)g)echK>zdMc7r$&9_)5o89jySP6 z%zS~c9?A@Oi|_nLjU%aV z7?U-@u^rHcw_|OY*o+Nh8Op-=&IURxN8qHqF%YsjHq;@i4>zs#YJ3;+#i`_lY%d8| z2HWN5Cg59AX7LE{hGH134d_4|?CT8=u)Xa#TY}f&Xvbn)&*1TcIvb5maJ&2|@&x;Y zFNgS)KTZ@UJaxf5bk7SG4Be0Gq(H1sZOV@t(uTD|5xm6K*yoVSki3>AQGv6UsNqpPZTAk35rQdy*1) zxDV;PluLNMzW+?|XTTookB&2WCd>JsT*Ocg6TCXUkO(G9 zle{{XHxs}V;;ItBDn@nPK~*tptd5mzOvEWK_POGb3q(7xT%g~8LX`tt#{${979r$% z=v9P@7;<`Eo@<^LY1!>R20U4s4c4PM#YWv4TKToKw6-HNo>9WTCzbvoCGa1iN@*7x zLbju@<|)45=QMmKOV(cx6TwpKFJwW0BGRYC#~>9fbR~F#GQqeyzY6S=IlTtKv4LttEMm1V~`^>p4R;eq^i1@*4?Y_lzBfGXaXZIId#|;iXGb z8s$k|rmpoZTyI=V3hz0u>Uv$d>HCLJBl4fyl1&Ql*kuVWj_7s}og{#DefH&VR!{tF zHjwKgr_0H_@PPTLZP$nF=>6%lX@>XN1I4>ENnm1n`zv5#m;gTg3b;!AdY-|#>%P?W z-DjK}I3RHqfmKWD#j2x|x?dn(%%%Ts0{G}pLF>ArZPm0sSqM&72W~_hQRslGdH(6i z9B}LRI$PqpeHb$tk)bpFNL!nU-Ei^a+SyNJNYD=d?<(oE8jcyaqmTn^UNzHn ze{l-gUJ9@bw$aZ^z>gsbh)HI#2#BIt8)_eM5JaB0?QHp26(+kU*+DvJCO9c{J;<5} zz>+VA_|NbKwC4m{_njAJ3H_436zjf~c(0-fq!LQfd#SvmCq+X2!;3Q?@(=AjY4fC< z+m$z@^nDHaRlea6(;h!MEM)Oao|!9NgqZzQDbF!sOO<=0^YYFBkOS`9X7M^%;Ts>t zE+{U~16<*1RGv>4eIDqs{^&T9tDQN|_@Cs#bH)UtVg4-jt%+cA?XYL7St6L~q%Y#R z_XZ}e43k|$Y}u7;>{m@!v47U$1govudk%T*ExPlkg_Dk$%ot#=66&#V0pxGN!R9_~lZ8hn#Re=uZPuw8>(o zJj<_nijSVxlC)cr_a(BE8Bpy@B8g1jDL0_9I9%-I___R#lEGB@v$4&SyJyMZFHied zi`}6$dq!#O6qrh$uO}57-Pm!5{uzZILU^E<=;E(^vy&Uw31IHM&qAHGr!Eqvz88nJ zS@YI%pw|Vl{F~H?-t2nYmU}I~^d(9Dnz)3HzXUcDZ0njnS$_&zujgv@R2|VKfOX$A z$zW_d`myx#0&gbff4cCIyhjhmc{gdorgw_x&rCIM_Er1u|Mh>b|0Xa!Oz>iP`lauZ z5wJ(WKQU4L74WvTuDlO(`%pJa1TUS?W87}~zslrcDS(eA>DMnbIq?MV62PK-Y1{tK zB^X~a`x1D^yM6^)L09a7db&Wg_X!=2-E?r1*gSYS9&B0Fk&Rh0nC&6ix1*q~I|i3H zjx)Yza4JWI-;h?f{J~&=^G^ufVyy;}L4( zNd5Fa!r@o1m6?6i1_=fdJIWTivF0L9@3M$G8s0L2n)=efq! z^YJ_pjEDG}NngY(F#Q>)qYGU&9>|>0x2dRw)-;Sm8FHl{E}DAu!a5XMhhXDp)YM_z!yr-nL^1iPKrdZ zpfPdl6?b0<`_qb&jTsLW%B%sCIpDqnt6h_#k~<@`zV}k&NdJ-=v%vvFl*<9KWeYXF zT01ylB%Rtpd8#)m`~&MZpStE!wtn*ir-}5ak0lv3*FbQ?gZzgVRq_|{!c{cpHDN^6 zUu&6=Sp{{{m(UuU|E&M1gt6@ov5D7_No~Wd|_d2Ib9o^ZVIGr$bK9mPax9-JiDc5r_@GF@hDCP50Kuprd;@1VL{4_rF z+L+l#>&)1W;-Y&dt|bnGxRq3n}7*i5&gMu}9yj{R0SyXX3xSmW&8d@4Ul_-6bW zZ=47=^z~b-7H1|+!?O}d>S9^MnL0REF`;wVlSD9)es^GGCKkubt|4xJMe%^-u#bgy z+4q*Mjz*$35FPOB*ic*`clE23Uzu_x)NGIA1gC1H-%M6fWVfPy=m*8GdOU&-w;|_P zqbE7=O+VgQ=gA{;8tLObd^mqd7jFNAn*Uko`c#pffh6TAzEJv)nxog9Xb3^33Ew-d zfn;}YOCr;45FZMqXEfUJly~83`_(d{A{LFM$}df@PV*!gyxi2Svyi-}EVzE&Q+DuM)l%3MD=S1~H^82hBhbhHq`mlc<2*#~71kmdup)T5{PY8>~ zskO}{h3_P-SUtbq-D_j`A^2mb^}RAlSR-!UZo{pA>kvrZUYbqa^i5e$?2~T;CGW5D z1@l1?khsK9~$Yr~A5^+)1+6PH0INMqQsIg;$$oStdWz#Vrt)XZct@DC-th zr$#63lELP|%l0`8lE1pXyfM;uc%#O#Eu6#vXlKIkOuSC)ta?UM_zXTQQc#D=iy>nj z!R!KOsBV1c;KeEqddUusu48%c_q0GoLS&?}&`>IryV-mOj zCsPcJKAMF~{(R9>_nzkGx6J7x5xZ@ys-&+5W0!1Kd3M ztLt4NSox}W#?~+q9AfuZ?GwZs;?g&UF(!@A^zQbR@+jfOGp(`^>u5|mLDXd`ggx$2 z;SmcKzd!tBSnUG$aEBqVI>WxBOF4~o4-BN|wQDctA3g4T(Z(m$v+<@8ra9*VJLrr9 zpTh>H&Yu7q-@nyoPG$(ftgXh2TC4}0NjuoANp`wVl<@~^SGrwX27oqXH z%l&6xYlW*jRi(fMg#wevuizfYDjm<4WZ-ROMyj=2%{Oz5U*HbuxI(bCvwP*E{_~e$9~ZWB#WS#eVqy`cu#po*Nq| zV*BbO0laP5y4YXlIfZ4)_7)BJe?Z}sNN_w;<6Jaw@*KsOQRw_i0{`!S@mCTUC3ur0 zFg;HI--nXvpMkas-pej+J@OcJHJIzVFR@PS4=B4bTpx=?E3%}ieS0;PdQZLaATL*6B zikU%^w-j8-n(@;ng1OTE>GEBp<vxAK&P$s1bDHh1h}CB{jZ4l_&l15zldpeUo{81*{8hINUQtYW0+>+TzRxvf zWDG!IMc%Vq=fOHwJRGEVj^gR0ORnvcvAe{vx?``R#^Dh0X%BBP{RH4 zu%I&z5`K|6Es&;xTnfCQ3L1qcgCws28>k1R@RR6)^Q#Cx)7D8}A${Hw-~{sp@NXko z!dE3qg6xiD$K%|A{elEJw0--o`~zQN{KS}WTmVEasne7%jVs<3_f@G4Z`}F?F_+`o z57Mh-kl(~m@??Muv*4&mHb9ScPAq&^;Y0qG<@;H;s~A7H5KvOGoXfzBe}~p3f>WML z0Ph@$INm*{WnoP6m)!ig*GBV0HrZ>Hu2yWk9_Tm!wZ4#=zCwPU2t7{#ld)^(wO?2K zJhZgqL)jlxo=hDF>ZTCbMh~ps^+IDlDUtDCN#OtUuYWuE*Z3=7vI$`Pr=An}buh>h z#v8Z&h9t3;UHqH(GVgG!!4DI`w!>(0Xb@0~RdGBav#!AjnoMZ5$OYaY0Spt&MbbB` z+bX6hHl4E7p?%mgIQRh!yl2VFiiOq(Hh&6cHSP5TPi&NlVQ9;*8aVkIUVvu(QSiyM ztQz;ev_0S389o%`1e`(}E0PUiuW8Ff+Q2kp$`2>QaCiO;6K2ws(?^r5TCauQ6jlqs&$~? zb#ztcShhOdPLiW+lf)Vd90Zck}C({cYjM2-qMT?6(<2jsLeF5%3oiSe31&*1BgMc!s z4*Q^-m=joiyM(jJS78ru)!}Z`pL53g^bPIx(aTs;N1L?Goi&QQHMH`=^k)+NWmn1* zpeva@1yH=uTSDNx>(4scS#lqJ62zEGQnB0BCP3Q+Dcy=f*su35O*&YRKM<3SOQ^EA zbdM6kCizoYC7L9I@AfBmbK6Pv1vP)$&U^;GB!dGYp-;UIW!g~B3t*7VogCb|KfUnD zBl3xjQ)6F6&;`QKJG(6LyJW6@kLmIC_N(g4mS0yw($}sa*J^Qsyzl)kib1m0)K7( zZ4&q-0qpIC1TWhpuSxXk1CGNjKDbU2!n6a(^~_=p?8#{LsK;Qod6%BV-4 z#tG%R%L2HSPMLHt$;e8F3_VHkmJ9I{Uy=>VAzxq>-zP6d~&Ik z>iCb@+Wh`lY2qSI-l{DpttTF*qKIm9rS%NC0~tILG?LuSVd+H5nd-3*z~DmKet9Q1rsA z?~Pz}IF_iu5(X3z4eflW=>6fTBRK^y_KWDD*|&AQNTi{mQe#wA99<`MlYiRjF~J7V z8YO~1!Lw(geY(xgrQ)zkCK+DEoRmG$kpF>&Qx@2v*Xpp&#k6{`!^Lcy1b%Jm=Lz7L>+9Fe+bqA(#Z$-lK4cF+w|E)85s8wEnP_N2mwy&IS@g8t z(*!VHAn@X$MCALycGuIDf*TRIf5{7rtzwS3{~Uyv2V0B{EZ!h?a!E6Jsz^YDF(7~!T_ar+s1pmN12q{IWS${jB(2W( z%Oo4HIp0)aM-jTm(Uc!QXl%RjnzUowgy^*`l85Bvd}QYs=l4TV*s_)n9)P^gkoTSC ziNBwY@lnxxk})ivSqPxO5&Bl64Pi49fkCVT;{!NKs#Ea*?4<~><&vcUJ znclU1N(6L`39{pZJuKEat24%vE1DsjAzbKm5?;o7?Bnu=J--&7n##7t^kD5SY^A`P z_U9c>YqYd0KDpBn;q#=YrID+OKmYl2UHObkFh7Hb-{m{Qc&JVYONzxwUZFHTDSTf*o9Lk)-5C8cb34!EUlok8a)L1+ znGo)5KkpUS@uxgIcO?q)R~*3@H9At#@lUq*j%C34dzKG9N2^5enr41|6D$`srfHAK zo7Y}N>%;``HR;GFgzq^P{sc6EA0do)O|x<=V2Gt6@TXB8MUR>{r0?uw)KD zS2cf$)xR(kP>WTta2bq1S^a0B!6N5)+6Md&f)A3u{bHf*?iec`Dc0jPgPZBiXfiuq zk^IfS3TEq@k$#{2h58%5IjNnR=gbGbFO!$OzqLp&7(l4qnl;$Po#3RFn>nAzpw!uw zLbhsIECQ`u{K(6F3UH4%9TOk59m~t?_zk442i+maBKnDX^ zVehfOqFw3|h?bAWiz5XH2*)B51c<3J3d0;9?0K*3Tn4ZdXKYec^So=e~t6uSf-)~L;>)+r#$F}8pb~R@U zck+1dg>^!8S4?);R}-1v{xQ^`!h|d00R8)&)Jbl=;1qjTf6n27e%K#ldb%dZ4Xa?Y zCnba|DGj&n===v;Bn|zdtT}uZ=@P?;H%$aX9%VdnJfd)3T@4b$+gQcdiWftK;Mp?r zUqL|rEu-XJ#ntZJrq~A8TA&{^oq?TLi9E<1 zVJXK!ZyCk7Zh+&*u6&vZRwF(50mT8u(@;|is=JT4p!w2c=npBQ2WTfeFW0@w?kjqS z7ZShsn&sfiS32(`gH0%Id^>F- zppMGum^^@lGP>FAy00eI35)|dwvb42LY!J(O6|i2%KpUe;a{c|H>!3C=clzF(RRFJ2|3K1 zrt)54h;&XTMyPt%*YI-oq&a_1x$dVVGJ)%wL|XP(PI=W;_IM8oV8RJuJGX>Fav1!O z{~?_amh~*qYZ(6T0xMld@9w$nZCZ7mHgu~4dTA1z$ASOzrQG6~!-v{)8@e(|IVMQl z9$WDzyDLuWPk?v_u9Cp7L%>b~#{@7)+$Krh?9V&zFN2N;n2lf>`jYv6r}^;cNv zE_2`c<$^4paaEq8G=tpG`yU$v9aAJd6gb$Wh7E-hNI<&m2G?3J=jdVwvCs ztOC1|dv<*!MO}%;Mo2G{3yewqSFr#hX)1sRR;Uf(me-C zmJCgIc78|sa3Yw`WO;%(n>UQ-VBk?a^B~?63>(iW?dR-FX50RW>rCgB-Ni4tlOi3AH1i|Q$X&X*pU1}(}4qE$qmSJIfbl6sC8G(qN1Cg<9V=lh5Z zPYf^Pdu4%q0sI|Eo(!g)NC9%3d_{6#*u^W92B2b;^60AIv2RiALdj8D%ukKoI+KL( zO6VjvWcBAnexREtcTv1WvPh!OFi#QAVGt0$TgnYx^%f!v-mDbKXN zxd`QJ#a-!WR1bNLJ!V%@S6$bifzIk`&6E3=PyEuJPv!qsqF5&a40mBFsJtz z+I%KNYa0`NrVw`;gSWXj#*b>Q!TBm986Zt8RcXX+ku!iC*^(Y6J^o8_j7u

`N+7 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%0A}hR1}3Nxm?WTjF;hHjN~)p& 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�b_F(3GzdM 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

Z3}(;%niDn2o`h|^y_rr9ir`4 zs|KRBUTyz8Aj^cqk7+BN5)wV$yImHYTTwyEOE_JcWLi*pDY{h?`c-wQ_#)&JTfqvd z{Yl6QA5bqgZN%cQ0yZj}Pxx9nYAB2S9o46195^ef0eA!RhQc|bvJ(t`fA z^eJ&}qfamV$Hbp|GrP^L5t?XZM+@3Zngl^6c22mK=c-9W>S^3iDX|1Fm~5C~1YmlG z9H0N~--kURxxyG*O6qoM*DuafehCQ)8ek5sHz@8o;ULoD3@U#hAd8zYu!9PE9OSG8T{N2z^o2F%gB_0w*E; z;@48MzTc9gy6!YBzbclWGjH^j;;M4H{TjiHnx`?Yg_17DBq$ZF4^z=Ch}PJu?%Bn zcHh{g@%z9}n`1k29iwZrAD9R(rLV`ZfjS+!)W7{dlpzxnNYL^ci^RW3bviJSiB9iE z3#qA7%j9&n7+c-u4|!C>D#cUKD#8PmRola0Wh)mVUY&m5l{;JR%Y)S=dk*D_igsMA z@F>*jegBQ6$``{Q0raXle3�sw%H6u(wgRzq8{%lYVNsekNt!+hVr%JaInjp6#Dk z-Hev!stZ1K){-%=y~i3qro|;Ubxe}$;At7Z>HkrHe97QpP*sa#u7IIdbo1~H z?H)rSMB>RGZKH)g^GC}B-|0`8t7x?7d^vv5z|ve5v>g-US`@Ptd;6CF%#4*?kuDR$ zJMHb?C~s<$;G4eTkFoNDQdWJWhu)I!xGBZ__6U+18vT#zEk#l8apu{T(a1#3d zIJ^-+EmkTMYevT%VcEFj2g5!NaV&tI?oVDku0u1+fnU{wY=HVdm(Coc8oro>aC|Kx zw@i0iHOMZZ$KI#RYEt98pwQg9ZAG!34qJ7&y>$FI`C>uT9SCxUEmb(jS8|5DUYBt! zYU1w`wuOErf;;tRV!HMFteXDZJ+tD53${*BJ*s?a3HraplM}K6}X-|nz z3_qLhS?;tc7Pkfy)y;*=n#6K3ON42yzWvL~4~&@PJ+e@<)-B%tJt}{&zHjU2$XmT2 z<{8xjZ#rRlo&IEogf1@dxqcw<4>sSasJBqYQ!-_PoQHA_d7KpMgp)Bd#K3Cxz+jIb zGI7T#W*0<{6*e4UcuYs=YfhaNJ%|=md0UwV8Tt_uBCrr`V(P*&*Z1H1Rs4_W4j*5D zo}4SRAeX@bOPO^#t}+r`imXM%eihRM4M*)s-<`Rn;afH)4;4EP&QIMI`iP~ioY?n) zdxrRZV5u&qMDSb}Y#3u(GVRd+^t_`uc9Ped2tNDnG>x~q#EZvDoep8uZ)a4_O$754 zP)V@2=oM7=d)blPxr&_5Q=4RWM+`jJ<3b);RVhx>AS2iiLLw2iR&lgi`%C?(8ug3Y z@rX~kXR#(0+RNuCY$9-_olJL%g(EJ$oG}kJddfmEh;AH-5f%-O`&^<8vSgwpj0YFi zxuVBhGkU(ik42)g@AD_Yc~YAG3T^7%F|#DH-1Z1ctvsO91YX0;SUHDq_1nK3@8GAs z{hRitZ#4P)JWw!?A4IQHXha;ZQxbU|eUl$f{PMESpC}vTJd{)7aY=9c_NELh31tzm z#}9^m9O76=gZL9nhj_L_2h$^26wrh0plu$p{YOhAE=Ra3du`nP0i)4xy6b%OC(V=L zvh3_Mb@*k4ppC+>@O{s1DyGjdzeFWM*;-Zivg3^7bBV&y_MUB_3zrD(Og=3($D_jG zV@}3?!`tG$j@vl6bzUM z6U1_2uIylE`rdax+ij@k0>yIQ8HG55%8fI>fUbI;P$mpxiS9dXODbrG9e6b5A;Z zY|Q(Em&P`Sy1d+Ta#zT5`P--9TdAUXh-9Y`eC7YMR=U4byVM;Ba)vGSN{-Lv21ni~ zOx_l{x3I7+bZethn_Dj3w$P1pXrn`)cmXr2LMV)CLK?762h5peWA$pSi{IR)K%d)l+W@WBsOD8SJ4e61 z@O~`0CJRK8rT7TQ9Abf}C?do1uO|x165U)wpV&8UF%W#xp63g0H5>NJZOXb$Fz$OS(9yYuZ8k!CmQx zPFZ~GM9<&kdidm_(@*IZpInYe|4CiX4(s6h+B&hZ9dxTm!x1kx&))gRHu3m6oULE8 zEp+J|-DX?p*RizKvl!zG`|vr{#T3e%_!SeuWuUjexqUmWLD^0Ody>0c-^wAksaB!( ztZFJ%1U#vJzYpx*|BQGi0y|E@gb|iWgA8Aiv`990N&rq39KOx4ss!ZMLUv75g-{q( zS6WN7F&y}<)b}49qDhx7<2o|JmN<6IWNyE%2xy6_i*1O)$ULg4m)aeO3!5YWD~Jt# zFl~M;#~?n2_Qy-==@n$G3*LsV32k?e*fkpc%o|>Q)E|1|A1HkqmPD~DgH)D(j*b7L z@8twADX9~ZtVR;RB*aAwujg-j`}f3T@80CEqBn^s#C!9H`te{7{-N2)1pZWRR>sJQ zJ0uHQxbm=AQa~+MDi;G5%gGw{XS?iFWUaLpIf(lF-%MZs zl}G)MWSJDUn0Yj0f&)28IO^}-7P?Kww&JJ7=6Ljf_>i`RZen{Cm5bwLqfnm%?uy(d zPTM+I|2{Cc74%f+@49FVcg3CCU2fy1RmF>z*W;1-?*lJeYr0E&+OQafIvql;2uK7A zHl3~(lxC_42uW?5?M-G*bM8>to*YXVU2YkNj zhOM=%EBkfzfR_lbzwPz0NK+5K^qzYzwM8XPU^895Xc4jMVZVhx`Xh~Y^{`LBY5m-r zuB&7jbDyO7)0yPIBFJR9Y!Z;1>9Ea-iJ5W!w|^gURwg8_*~A_(=^>^(m%jbWw1qoC z{4BeL>FVPBe6>F}e`IK1sgxi+%1@KzA6nuZ` z(}A=|X5e}xiyV5UKADIVeHG=j5%%-3bnfgYb%?SsHT`KL&+BK=zmt~x(Tahpo{D^I zHG2F{)(77NeyNg=Ey*e~KH#up&Tr0wcIWrr7J7V}ws+npVa4(mihm!teH*z;SRZvW zCW70yoL|1$q;wzCRGEDtCeh6AYDw%@`1gSurR6(h>J7H-iVdq;U`9`ML-fqqR6Ul; zo(LF75F$Ho=J#b4zd^+*uL|}%btR*c9VEatWylol4|~*N@IWDJsC2BI_8??sDe?P6 zgu6vXqHk8LiMBkD>Qz{wPvGZu03HzW05Tu`=bA`<-{hUrevQQ{x zx9T15XTF&J?#MG;QzyCeFx2Cc2AK%Moa3K(lbxPX-~MIg2PSX(XA+^jv3sXQy&f@qmd#qBP zD7G{(vD^%{XZdviEm2PuvqF+Z4n0$!Oa$m3Y`0=n5pD-}mTVKD51$zHYch!iahQv( z07qi|v9q{eM^jp^6{uS4eql*Pb#tgT|9!PglF&4t@ObUQ>1F;mNU?39$5`216Vo)QP|FBxDiK&+DuLl@ z&9T#~1i$fD-+yGWWx(mUa%bT_!&pnQg^E{d&qY-{DVQYeEQi>xa+3$ihtR9G2x9uj zM7mbYW}g1J^jXoa(C;WEP8ls9TgHlNcy;&Obpuw7xrE2!$2>`HG=GxF-}@=bQp|y~ zdcsmI+}pCejR8Y)dc~ChCcZ>df{!Z?!#nTo-#u>wBX8`cCx0iGE4)&c6T$-jU`zeT zk}+~F{-K_DII;2xC&fGBY0@+llrjnU{?xAnX_77Q>60!Ng=9mVy0{In4U1UCnlwzg z*6!hH_l)GUrA|^A`{THHV)rBGl{YC+pR+2FSbq=&~Wa~n&aknUsW-%4gpGWA}1J7w@c z*5iT7FTWl!tgY3b*TiFYio|3UaiKfUwPvas;#9iQYC@gqVu*P z+Cjw&S1?YFzLIxa$c=lYcm0t~uUgdS(=2M;bGI|kB*nbt1FxkI2=V6;(>K!EVddKS zN%uIsEcwVC^|)vgo-oz0ovm{+fc!n)z6M=3pUEUh2T2f-kzQdRXQa!dM<8Ww|4f6f z!Q<*-yYnbm$Iz0z0b^`{k2+#=(i$p0@1tbP@kp`)929ac=%m95)C&-Ujxc8?@x>u}6q zwl?K$p~qzG`|plzpJLBHBff2io3zvEoDv~xKpdS5rB^Us92ZB78vJxoOR0#&3R_3~9YD3+pIlHQk z+n1;1b7_x*<#AqGI_RpW#TQ2V2`N`ORxyOEC^eQwV!A?Mo9y08C{pQicr z#CS3Bv5^nGP9A?c_Uy?2C`2FCMBd(c7Datse=okx(*T<1{H|x$($c<}G(yb5X&gEE z#bO!;C>OVeCMl^QC4h;8mdW#&6d@+H#PE=}e<$AR+rPkh$9acO#(952vryLQ+#?td zuaq4L;lqT)XJlNz7*1I^59O3(oPg=DQrv=qSQY_4pCUo=0@EYe0svwPaHt$HWK_|skd(>Kg;wD z>k8D=Ch~n?r9RrXqlcKLdT4z2VI0PxMDWnuLYHggf$sx5i5y(p0(j{*{fR_P2-KmW zL~ySGum^^qwwtPW!ZHFzz0k!IpuM+)x&*;vyWqh9(-pC88niSt4UKGu&)z5vzYXk*V<+v7 zZ-^mY(MR|GIlsS|v+B5O;-=~N@R8W99D5diTm}t^d zHQM)qr3A3Bb8pGn{+Z(){7c^c<;t)jVN63A9zWK3r|iH8yx^O_2aChbdgLd??V`T!{OtZUS3-aga zi(6^i;6KN(f4sS;PV11*_)T=@`RA`{k*qwzi4=+OOEMH{Xd!DiU(d~n#4lQcaEx$1 zzu&gdgJ*SH=(ea#922;$sn&Pm_kl|(W1<{O1kZLyp4`rZPnPW7nwKnIz5R_90>bO# z(473spaxDwO$0m5tx{b?R6oNecfw<_mUdV2inFBHDs$viOjD|UmEsMgsw!)Jaw#P! z1cqCz3pvB82?3jRWQ43}BEQM}{#;RDn5v8IgoM%O(4Au9V#%Lk)zR`a4w@LIz5FQ8 zp^u9FQ!AOHrLqjIcFTIXK;|E<0Rg}^70uO*Iyzg9@MT3wwxgF?20E6rOEM zBAN~^$>!c7I1wxuQ+z=3B&vRf%V0%;UOJ&}LXHyRT1hXi`FFM!30C zI6tJzNJ?tp+dn^SNU?DKWuBQN9^*YBx!ZBZb_$=rok8a^55bhOwB0kqv-yl{>=Ogb zmdWy2LR@Gw4oLd;#kBFWdt8TSkz3Dg1+-zR1f38p0NcwHfNf%Fc>6_wu1Nr&?JrJu z8+Eb6+B2*3!>0`&5&mL;mzJ^ST{9j#Z*1mwfuE+nc*(-H(E3V~WQem=j(p^7XMRKU zrrSclj)gaG-%+(m*tf8WE9?8fQ?2#&KDPc?KSTIsY_GPmxTG(e2rflklQeo{L2e5+ z!b#+mRn%D3A@y-s{eU;>TjY2n*d`bFO{RttUtO~-szf~^w)auIy!qZ7hL`Ha*;24C zUr<$5$>@ix4OM5B$3Qg^x+GKubz}sHSE`A&Jh19jSfM%{VDL3(mr=bz3=(mX`NknN ztSMQOhvyR`!O1C#3EF>jclvGZi{I7ig6=(NrK~@iVpFRncW#Y%o<;#dDV%a7x=6(k zr-aB!Na0AuPWk??rVGtOO@;2XX&@BQcCGrV41>X$SB z_|as8oIT{_ERK=0SfQMf5*v%5GoQwE-IjbeF6>rbPagvq9_e;)?^Zi&p+5LTL|oFtn-oKB`BXBVrw zsukN7x(MIrb!`hh7;U#^?mQ34;r9E$V_Zj$_2lmZGxhWK{}lGyLXY*s)OQ&j4(%)v zBW7G+iZJN|Wv>Y=AzWo~egEhH#Y_v;NdQ5*dq_|6IfF8DRe?fot(X1rxD$nmu!w=` zgGYfqx|#?db@|qX%Bt@_Ju4Q7gs>&wZXbNWS$wA9Z+IRJ(Oo@kG)?+H?Fq7R&7XlX5hEY>cWO;<9KlmdjT{Sc;8^nJ+{ldnp{CSly1HRVS zdLDF*H=r*WvG6u95AZwozt>vc+4GT~a=LXS4E?U?|B25E{Ec30MYJsXKA~9b> zhaMGW0f96lrdTD$uaByOi%B_n#7maS7K2-JBo6J3i+I%sUj;wV)kOGd>VKq86(VoG>V1j)p^{SS-+A`3s1IyX`XIMPAW#UNF4WI zl46jP`M&?l@ty}S7HNJ7!;GFC#uXQpKJ5>P?P!O ztWi!{;Dm3l1nH>d0`zzhU;}g`)R+Z>?U@qvrWo-`5NgqO%%(Sh_cebBUFBD&z2m0% z?BWDBA5Fio$bV!}^2Pks8K%(_ft`>+b*$rLMoJ!fz7&nUE%cai94vMDQ#t85;twXX z4Rxb5Z42EhPuoJb>_d)yANU>4!%%CUlaBylyXv;Qz3-up+PY}Y-*r9K4_Bc=9cp6D z9E*wGRd?Nq$2wsFRn#Rh|I+9wCJf(jZ(j#70CM3^-N+|CC!M~_E{89jAiW5_WBhUfWzK5= z+fjF+`$V7F!BrptnEIjikW`FlkvQ(bB+eiSaS7W$hu;b&Chn0J!O&RMq@M5J`guI> z3&EZ%iCgx3Xf#Vl#vB&kd>0Srvu)cPlAzObXFWvDvW%qf*oPp};sx}0_L>b1L>SCv z)0CLDUCu!tKAX7xxLt1mbcU~6)0{WCBi(wI|KSrUB>ca*;)!(gC9SU#C7~B}Y~f^1 z#x=59jJJW?j3(sx`yAO8dOG!N3*9KYwuOHE;kJeDGq9!Go>_?X8Nu5(`+Z=u3R^2} zY-2r?KC#W(?IJ3CTk9HJ9mf_fy|=z;w7QqZ#+U?E)ZyRQr9+2$_3o5P!i_4$$dgtV zw+zdb|mmnQ4}1<`uevf;SvIj{&BGy4;nT5t1#kQ~HBRoIw)eN=X3I zaQ-|vOdY}w!^K8(`KIlk$8#ZgEv@5PnTUni<|n`tkw_mmC~&k);u5Z41q&D_fg8wuLUo zw|&QLpR~ZD392q^b%WH^nMS76ZW)VD+SWTXv8dM+G>ja80Sg@L&a0 zcWG5y?LoLnl;kgb1-ZLTl>{IOmRxo!4B5WO4TZA(;(vR}ggURhKN{&(H3YFSWzg-? zcwN+Awd|+!J?7I-D8qe-n^)YMup!CsyN~WEv3EXo17c!hr;Ci-?WaHRt2%6>d5XLQ zFm*EG43ZERn&{>D#3U_79x-u_JoTK3k@UBJt@D)RaA$&sHraQ66p6%hkw=F`n>X;e zwubn#ot`txSov&Tm9y9rwt(&r{kj$qzaM#saEaT#Pl3b}6YVDQGxBM?&j09}t{bx0 z1MIZeHjL$?u@PtJuRC`1c~!E~Q$KHB2D}NZG~py-uv}8FOu5M-`|0^(Qc}V_mQsfe zoo~M_^thdJFBiWLT;?QnTfy(YY2ODPo4tvpt&8UT9mm{EY_sb(4sBs4@s@LkJ6GGs zc*X&xBIJ4Kz*74=H(ID37V5`U15 zWtthP9wKL1r)&Z3*VV;2wonD~!9zYEG^82|O1EG$_Rx6U--fP@WnO!^Q{AkNNUXe4 z%wou|U}tx2pWUL@^&oQcFQgh0!H5yYt%YVRiGHylDk$5JA0?80Ev{{$+ni~K84|z! zw$O|{?iAS;nz7~I7P<|&7IRzZ*PF>*tV4Z_)xd@`08aTna8noWA_>6{JnaT$-MsLd z+s$q|Z5zB9b%v3Y>=N(=ypenZ1vG3VnLOLt$|r^oL>wsmw))B|Thu>fcFDJMiE(E@ zY$9=?aNR?H#ZI-%+d%+ev+S-S2v<|Uh9-9Qo=n$1g%rZuQqYY1{G&AzW|#tdK={Wm z@R&VW_had<#rmPu{Tt|{9KZdbiDm$NfJw91L+$LWX}c*D8Q8~9_JPkaeK^Jgn0!6YVc zW0130ro5UG8z-PAvp^e8D5pR;bjgnk?NVI|lD9w*Y9Dd;%0K$1yU(`o6yL5`>^Zrh zL@FapKX}%C8h!dI78*bJP2eVh2VyN2&+a*G%Jj`2@{+x_cU$N-(OL?=Ep%xHe8ZN` zWqn)dF=P9HG_i3BQD9r>@b`h4w}YpO7~2KvENlz&_9R`_kERUX9yi=UC6lh34Ydkw zCmJABWW%D2iuyLNW>W$%tUU`)ik^JpFv78%(M1cBVKCjdn6lHrOP*jP43A~TswRw3 zZ4VbJN+`Um_Cn3MaDhJ+>dbQKtI|YUD7<>WSyZP3zv{a4A3&b6oG{fAW|;a&Mwg0O z-Praj^2Cl}WA(9%?iJ0I_+Ls>HJS6IWa6iRxSq{iC$HHl zaYYc0Q5?8^a*Q~G9zMmt|9kvU>w-Dw{L@@EZTsi(oF0EjiEsJsU(egUrzeAtNDgt| zX3@8Udv{WQ!}yH1B6RVve8!b>{ua>l8KUo1Fjd{Q*9>^8PS9Q^5>Hg*UYh#JiN0NQ zOBE2u>Mg_C(w z&t*7gC!Gfc$DS{53(alYJnELY4!>Q?+d{Y7GQW>m`@*)+uS9SYK+5-l`I?%`IjtZ@ zwuQ#pS?i)1H_Nnp4>1FnAz3q417foW)1>*$nb-}3KYRu}Cxd1W3B(#jz0_2R22&GW zPFri_FqY)E0vu3r*|Um*{RIN634KaYt0Tj0E8I#45>PO`xQZa8yOY{+TjIC6*k!|A zv#eBHZZsRpwuN@aY1vMk#V`XKebQM=o`2g`#9L;B5CP-I)9CU5c2jmOui3jrn=esK zSQCic3w=87vByQob*-Dx$q5Wtn`8z7cq9Q#yrS%lIN#*sFDHO$IPXtL^v}fOO!&Uf z#aPIP?TSZznLJxRU!44vF)`lMZZ3W!qr#wP86uxq#9}5q@!GnQP##bhgXG!FG~%r; z&{_%&UED&ROWJ+=c63c_6+&EBZ>No?jAC9juH(ZBw8_S1{QUXQMmo9UYmOo zZxc^8+iL5-4{U7!_Y(PI zSh14h&9=VR&nM6))xf=DO&f=$`ix+&MeelwvuW+xAXs25?UTuv*u>$qM1tg)IFZ0# zNB|QP;K<|8xp;>2=h}jHt|fezw|h=V@OrS3^W&6;bB?F$O>L#vo7xTF4;+?Bx&n-u zuw(#R9qwX)6fHJr#|e!XAs;-|_mV}}Y`%YwTehFynigVQP`&%8K-kJ5|`6GZ~b~N?`Z$FwUTa>)eqQzi{9@6rKS6Ficg;W zqlLPpEsb{swkq+tuSCg^lO@P$@Sw0`_>zEjCYCnL_ zM8rWjL%e)u&MXo@9G)wuMOyp;8-8+nUGjCF;rKt++*UxyHA#Yxp=)Bj`v_!Te98o4 z$O+tyGN(p=)2ts(HPC}=kvl$M&haL2Z`acik;SPo`N(JcW@}P{aP~T%cW(oaaxp2& zymdRO3%ads_WQh3Tw{!*ePdhb1}wgf+{QANsjuC^z0!6I+d?y{MrV_jEBl1Kt+7DE zpm^Cf^%e5(%)FN0pdKx-Wp$Hj*@+bCK zZ5MpG?2emowM z9+5t6=cqv##0YFvk^x__k>To&^i#_fBBL@+sjF^Y^hasbJe2V}^$z6ugiV9)T!Qth zflLM7F#4LG^pjg4>mf3)=NNk5O_du_#y6$}XRO^V)0*EzHj1%KTUCb=@!1 zIzdc9#9*x+BrR^$Vr&cD^WLHcGbbJAZK031Si7udDZ-Y`pRq0U?6dtl`6S>Vr|2Aa?u1g^H{h*6PCeq9Pzct4adcg2+j} z-t7LsfFlHlv;77tdfx6KyX~@Q!x*8uT~rv^sSwOoB{6;6u25yD-L5^zQoeEwb-VGKdtRRJ>yJK&F7CjgH-Y~V-uK3I z6(ARJd5BV zsvb!Cg%6U`@3z~?RpkfWCp_3IYzjxd;jRg%ELnk`MK=uT!zq0PEqJ(#HEoZeb17>~ zvU_ZkMj@LQKO5e3mpC#h`6?h=faQ(?85B~y5odx_{v2m^0+@@9pmUPqqQN_Heh8F} z{a4eaRBZgbu|J%%JmUKn#~Um$SPVaKieSi;&zMrIw}EGfjpv}G2_1IU z2q>#tu$wM(%+uU0b>NSpq%sA}+lN;EU)dP60|d32bKN*6c|O zTU8#nbW0g(45Lun7Wzndj>*dLt=mJ;t8=<-D7V}e`pFM%3*E&xHrU$Rz(b)I?u3iC zQEtLe&Y(wM{Pzz4ssR%d!3PWTcfp;{@04TjFAWd<3hAeZFH>al0RM?_Z|7 zmUX@Vd-FqNG9F9|MQD;6_tV7z(ULfX{9H;ZW6;c< zM@m=6_M1kLyXg9v&}UN>j(6FNt|MxCb`n_ZCCb03p0k19jE)U2T=kqIuh{U_T;cdO zFrR-McW?G?3*B#jeB!pyuOxMJTj(~HGPdfxEp#DAJ&g9WHrLvNaXIO;e4&S~-1)4* z_8Iiux2m4l_!m^|4j9>R!a!xd0G4c|Q1lkaSczntsxHjTS*03DZx65?mi#)EE2Li2&WZ!k?}1^F0{DC3kFbyag)7T%D@>=|tj*iuAMD zon*~IDq4qfPc(REDy(#pPwlFGbb=Fa1b zD`TscFtfXEm?->B&wem%xiI!#ZQ<>pqtsmh!pWQzl+%1I|D7Zqu=cjlqfVP>J;bAZ zeOqWI0wMW2hH=1vZK2!Pxx=<-T{+57Z#;Ig`!3@ULzfh8Dq?Dm#J7P9#`Y0vAn0f; zT9_X7t-cjZFCUMC_h}~^RH&$l6T}895V6T*WPK{Y0TrKYN1>L=f0iHJpg90b@`FOb zRA6ya)lP#2WwM>&DpL}Ct~zmde|&-30#;oN*nwXA5nq<`ffn~hS=DaWL z<;h=;H&_?=lL1vW$)Th#kLj=^Y8#502583#jZu<=O>u57{tZ0do1FFW3#$)Ufhl_` z<}qgf3DitOLRE#Tt+Yg3_wSzaN%XI^{=escdGsYSwq%{Z#1nzzx507&oldG7U`1xl zeeujM7%O-&EA5Ltwd?weJycMtcgg`=m(J|bK&P-3M)qIbzf zPwTr5kWB&23C6Cowyl)4VZtzW7fkQ7H69)nqz_|$>X2>J-w1G?S2*+NnISQF=9h3n zCci!y1x6)+E6^%}ipekbZAVl90*5Ax)EtaxGVu1Ii?(auqE|EK9}xm>)3qaqmAiqo z8O{$1a}E#QYy=a>fiZj*AwX}tW^BQ54h^{(@4i81eFk#_XV@JR-sQ(&-G2UrukMiM*VzhuW+hX_Cl{jluw6K z;x{w$zHhUeyzRCEH1ShFE^38>CapFk&mHYVQmRs33d!8B>FNfNbs%8GHAQ3ts57fj zMJ@nh3IVA?&`6^42n{`5n`emy(hnPZU|N6d(&%m|p1eDrtjMC8%ZB2H>J-B;93r4V{JPI9t_LyQAeIVRj+~*D+^A z2xJUG0YL?dw?wFrS~f1a5mp0GPc!HYX%kbr;hy;2Mb}(zZQ+UDCFbBXsdsZA+2ziu z?Y#Bjj5JOnsWwnM*eO8~j+xV?q4nXr1yQsHokSoKJUb>+CUfVztRM~ny^E&Azd%lU zn+dD%a4FPCbLnvO)-hr86tS3(cd}&zz;LEHjm^oBcleO&35(qQB{7^)j-N$V4P3On z;2q9X%3lE}Mlb(jUvRrjjp*${^`tg+PW(6HOsEkceRML zpgv_E<<)l0O(~>t0$WgGXs2KgWneTgjg+~{KAsCgF&4&YJZPHb4RY2<`v{UpI68WK zQQCJl+PT`vMr{btw}XCcWGUdKNY{fwIdI5WRSDhMwba{|0rLr)PUUHH<9@h2SR6T_ za86x=(F_W_rpynMM)Hu)aLmunJ$54?X18(d(l2-Yk;KCc-e`Nm`O`2>JX#AT2&W`e zetTrh<0jk#6$OIgMdy>O!tvwf;bJleIZ+{;XS9tQs-;ZOZUu_niQzSX(_Nn=#3xiu z=QG?&5@7Ap9m+7IRfMH8B*9;|crMAa*n2EGAI!UV)?c(F+QOF3y=#?jY+&`v+wb@0 z-&@QKt~|r54_>ycdGv}HrU zQUHyF?g$<&eSHA-65@I?i93=Zo{?498;)6^=>^w6P;g56n_T0*&Up+MjRH2!Bubsk z1rIIQ(`C_K?)*3!my4ko9vpB1Th8xV*QY7l^yw(0sQ0G&(+pp7X>~>J4^V zXkm%3vQG?C1Q5FWDJBjS459&Kt??g$wkk-1JQI&AAMcVP+k81nESayWP9s`djWEe% z7u%CS?~m`PsU9N0h`uev6Q1=;MN{#+(XRr@+;Rpo$GOt?cUtT`j-)uQAGec#ic|!t8NJ5Zp8g8gVl*_w2 zR}icH1LTp3&PW(54>B{RKr!;`99a3c6{mW%4zyvZgUP>woiWLiNQM-$=v*F?!E~0O z?C&VeLL0`$S(|~N5+D^{Fj||WUNAZaWD3yfbIUm8F&uQ54L|8b#4`@)f}AkPq={Js zyoyC3r+ieHSo~8sQCitW{e-k7if3!OEXXf+6Z>9$L>T~VP9P`=YtbY{m>!|d*v_*| z#2OcdXZI|NBQd9k#vwS0g#qs+PbPbeJ%3IOPF_X*<=TN9X3rIRSA9Av&vVh+SB=a! z=-oQ+hFQb@q?$sW*G|Y2NA!^ibWX0#HVOP;cuea}74A*&FF}Auf`|zjJq)W!4Dh-? z0|2t+Y=*_maea0-oWw4W>@E^6EF-+W0)QZ1p;S0GFDEIvp5{RF^#Q9QXED*3lxND~ z&dF+#7{566U&(;PTpS*!C;^nL0IKX6SD~s&iospn5}nv~ZlE%ez=(j3Z{c2CBqz9L zi>x?gbe6iAbC2absQ^ONu8o02Lc)2FOsGQ_UgsTq6@KIhrJ&Fy&`#A}RR`FR5p+tW z&HN1TNRI1hE$0Gc^mq?8O9NB_W)MC(8g*Ujc7cu;s~+bh#R8cg{*q*>@drAm=B9q& z1X1*Ga1NR`r6c}62m%FDRTmbLl(-fXGG5rL@ZGo70V8BL4ee2UhBi4i{gG2z&fZr)IUf`D8`wO!L z-WXx^!o-VE*cs*UA?6aQs;VWzajr(;^>P*e zHj5wZJ31~Hq3Wy?E*Q+8sK+E~D$m;M;B`KBAoxa%3 z97*Wv{r44CL6zWy8)^uGaF>i|#;4{!)u7yJd^*=&CRtbJ_h5&1zYFP^3zO^g0l&!H z(&ilRkt#xr@Ww?bA=h2OH+u^aQ+rjMU8iGdROok&6K>+Gml%Oc()^ViT4XP5J&=9T zmbF|0HFwYw2xixi0~xF^RKMy>V6}^SfRIEJ&Z2=z?nvL7O<~`wYgVlMg5(?-#o`&~ z02L%!aH5uxLe5VEfm&EV3ZoRYpZQEnII_APaj9a7fZ{0t)j$E)jZS!|6ul9MgjiPw zDLX0pgQP@Oa^k?LVIfylFHB^e2qPo|0ifPXkQM@hV1kku0wX!F4OZiLRz?rVt*DV~ zk`-!MS?G3*a*MlCh$4^-%NT@F11v1;i2+$|=GPJR1%NywF8g?k%s;F=(-U{d--3L8 zZ6T4EN?D4!mbAJy(tKvLe(uSHDn%uoMxtI;`xt#NiN+%E;?j#0hUNa zk-m#i1utT)T3v~#g!=U_PgMe-wnQcy>LUsD`LdD*-jeytoS9qp+9=39>T-&~Opw`C z{VA0_0enmSMYfgxlp63S*9MT1OwV!=(!Jysp1F)@3>-F8VR)#rp%=m_TO_P+>EP1l z3@l2AP{B1+1qsUFTy$|8fY#~E0`dlInJ@v)X3-FY>%RAPkK_MP!+7q~5DM@nRd+G> z&KH30L0`JBvwKjQ6)X&R=Oa|Mbv*_uZg21d<8kKIVjbN z)|bWFHJ?jOW*>4}02lk}=8LMRoTxP+=*y-jDQPgdA&EUKME(XOgE$@3J zw4C_-;ud2BcT%zlwHjDqso^&=nZ?syZ3@D@QI@U*fTZI95X-VGPfFLg29S$WZ?vIwqV}G-1$EycwNMwG z=2#`{kA({MuS)+MnunAUgUnS0IHl*9jP2nltJt}OV&LQ9mWPVQvI>0Dyu$28E5_Rv zaB&}uR(!OWVvd7gJf%0aB>Ya62>1=5hDkJgpJYM72wAx}xlYOqRE#kWb00l}DayeC z=K)Sdsupta!13#Gcm}yk&`L`}KKpdr;&>Yy0{OECc_%BFmO-gnaV~u$9^>z<^)t6k z{F$^;|HjD+X$9%-w0dc!-~T<5z;}I9$+hN!Ic}%{h%fZAq)|9ZBQ}zZi_C;V014c) zJIUesSYXiH^C<}Sk_NjW&2J9RE+;` zN#VQsN%E^?pVxIIu2Ly+bM5JnxhMFJ)F>Q3FpA&55UH`!5;5I)!rgM-{dKTl$=GbQ zhYcW+K|{*LCx9*^S=2`ClYBs}m{ZtTQKMv zl09AOsU+dWsFQ09tGoGty^GnM!@yRVkg*?S3r?g`pwBF-MEg`4nRjw5YF!T+XFBxV(G zlR^3#Bw|Pwm6*pMl$aPd?oYx`1&KW4$Z=;d2eV5!R=j1dvV0kbacS^@vBr>tsfmSq z!CWRuxPSFTW$5Y4=9yqMUcgsEJ4~rSn(yY9>YCX8o$8?)cuTj49pKX?5l-^%`))=; z)Cl^hz;<5Podh6%g?)j5JwT!fv2&Y;1(3(4EMSg7T%JaFv16`b!sMuEsfq0>FObO)fM*Ms=<__V$l>DrFO~!BXDGR870ud6SK|-^;$|K*{wW~KvE)K;Y+WXr?5wL z{gGNEz1(1Oa^Hm2s350Vq`=Qy5R7dJzxZ?sON3lPk#mhtuAR@ z@(7jyvNy#5zw6W2!dTX3%-|6pRjcvaFXf~HIh;WYN_OEpY9C;&lD87tYl2682)!Uw zhL{_eWWz=P;f3WGE|E;*sCbYGJLYf0DRPn^ffz>%&Ds3S1rO>dVZwbr;$MrspFtED(&S&z&@4MN^nufb6rH{pf=eKMeL+M7}<{BvI9F7pMrh zqyD|N#;-9jC>C-A&O0zeG5h_$B=aS9SdHz)ALWG;}azUI?00y;=ofRI~U z^lD%XdqD3HP(8ChNfx8;%&Tew&WJwnfh4Mr+8x6n&zQgqPAK3q->&;g0EV!H6je$R z7!R!kSX_2f?3x7-G6BiJD3BlY@+9Yv)B0%UXsXcukiH2)Tei1danbz5a@t_uTu^S4 zzUPF^*c`W^4DCQ!={KamTd3l z>aW3^rQ4#|ADBDmB%%!B`e7B?AJth|oV_tX!}4DAiIYj1akgCx6$XUI3N9FQpa5at zP(CymSQA5RL9TYV{#?rjzYqHNlcBFm)ZFboCBv>sj^QZbQs`@GrSo5ifTSILEjM_! zs+j-d*~pf`VdzTd4-0P&y$CjEsnUBQS0UlNo7phKUC$5P?;Yuc`fnR$&RxLATedhpW9P@W(Of=nGnfMW zP}wv8(#~);MfDwGVus{BY6lkSAh}}3VPq!|i8881Z_ALL22c}R-`#YdnMEAh+|R5t z))|@DssQ6X+y^je>aP*x{mvjz42^Lu@ka~OjU(VpI`>=nGIJ(K=FMj*aU@y=hU+l1 z zx~TyTE|-GXW1WU(bV3s_vQ=Bp!1Mq}kU%_WvXYxQax>0~gLp*(95z@sLV^0$6f$6e zA0Ewn)$;NhV86!-^09R2#1+KPy%PrxiV4u`+Ju2V_i zBS6#yegMbKkQ9moj_%GwfT(xHhnE<~0N}JI4inENQiXzB^s!#wXt#~9`Vgu*As|4+ zgqjE!Q9shEho4t5{vFjQs~iWq371uak5+Ig%g2o1^TI8GASq=1$Y}Ati1>e*J&MHw z-RlK6&EMkgm&UqkKH1gDDqhwDo)W!!UrWCI2ycW@K@%8p2!;F`W}?{KT@&m+G9y0G zN4XyMce#IP^5!J@_xna+guSAO!u@Hwx=|bXm^~`dl%!m-pPc3UGLsLFbP%@ez9tS& zoN?Nrf|Z(35HLmep2Ank@0_q;j_RzlwV@ms0_@z(zwV z=TfQqz)JUg!DaPizmlruuuK{%L10bLPy&iYU*|Kywj1gQxrxo_`F@i|x)#dIvBo z`){FvztGx5vCm&|7Ae7@PP2xdZUa{1gzxg)`rZvV=cpnf&TN4E+ZunU$K?5>JHEM6 zDtI9;2n^nV_H!10MHf9^Tlfpbra@Wy4;#T#UpKgsu6%)ear-7$A2D?P+CqT4of=o; zhoMZhUkJ=F3IPMvt^Uq~`uy~)|BE0Y?xcqc?;&XW5yRzVU)%{eF77D-b;C%u4yO;Y zTnzzo9FEIkM~UIn?s8BNxiDz?AdmEnZSri4EPaZwNg4>f3z^SWk+>3%I%`0F=>k2@ zSX;TWE18?2(|d!ZfJ_vyC)%?>=ed!=Z++3r1)({aBsqQsG8J0O&>Elp+8e1)9Feip z&zhtf4^cvrnQ?se^LY4NZcm{Ku%xZd40wrwUMXLJdmf3(Rt8-Fup8JAdrYCAdK%a; zZ3_V(evOvb0n3({Tlr*`%;_N30}S(=Os!>)EIA;UM_4+(JR}pI76}4%tvXHMt~kI^ zGJ%Vw*=^l?@qYNdbBxrPu2EAR{Yf>Ak&^c8ZhZzi`oB0Jc*Gxwx& zxT5~FeGQ`a*?b-Tu`cYABVd1KfH5Ns7?b>DT{OIZeBX*+GHv$Nry+G#9)cn$3ACAB z1pc*oB$(eIykz535cIA+GgxsQoY;L#`UaAS*SL(8Kp_3TK`YwJrr4x6v#}?~Rk>aW ziD1`*gvrzh?k!@=Nr!yj0uZMeoL19vao(N;W;dOgEQF>ebiJ|>n&p(go@4BPy5;gY ze}!eqlS3iWGK$mJ991A=HfEIz8G8jQMp+1@cSu-8aH9QkR2u5*Bp6P#k~RYXI}QBE zHrKMymK(l`=O|hb?eYaRNxx77T{?DNcaEycH_AI-U)0_4aE#UBaBhGP*w2}*y*0Ee zVO<@r*Ov*n2IgW!b>so?X7XhD;GTyRJ3Tz`o3Q9ms(*dJW0uQLP<3B=hpe)R@0Mks zFSr*G$RR6Lh4y2@fsKYA4jg|qfsVfc`B*pp?T`EwiSdFgpP#UDC;xcZ7!>O~p|W8$(xtH&{;DQ9V1Kowa!kU*@Ek_T}>nfsMZCE+$cY zE~=YY7f)IQvI#{DAfr^*U-uG9xpl%c%+F}!H|9MZaz_p1?o161AF)HYjy4yg$;-ewF9`&CraUhFHi;RegJ-o`Pu^Kb-JG* zR+Pjp4ixDs*XQSxvSv(P_wr$%#hXTY#T!t8|M@q0e*OAJ{)X${)?$7=9#sf{5&&zd z46x#Fq1dOv0u@3I=qi&V1O|vD;;R$I1$1|IN+>9nn>n`m42$}L{LhSJb(jyC`l}5O zMe8GjOhR`x7zvQYUo+6MK%=0m;f?^^Oj|OGpx&iu*Yh_)LO#!i4|X^e8g^g(rvZWF z?eReI1p|NSIBh^+w~=ilf!Wj+0IWYoR-ZLn4)pECmX$=N)WE-H9f6^cO3FV_33$*h5kgE zh~k=;v9s2?L8Y75-U&S(*+HvXetJv=5m*2lgmP#3rJ5ER6H2Z%g}XX(Yb3P0dTqo1 z>qmDr#Bf`c`|@RvzRrY_GeSh!lW%C=-C>_9i=XYxj!dXJ|FqFNCGI((I&}0N_Kooc z`K=&-69AO|Uw|2`#x_?1sJwppbF<;jK|Sl9^N0RlG{gZXFxx37m1^h$QanMk6TZ!j zl%n;Y>Vn_zdXztsn{wW*6%sRZvF4z9=A#T$7P%J7cZ$6LKY0R)J5ea`8*9aLc`9pt z1RBxB$H+!ACu=9wX9B~{=C4RZ(#+gGj@d?40D$5%_dX;y!<@Nsj^Y}#BhRC<@>+2N zA$6}IG z=Ey?#tQ6carK*4!Fo@M_0lW=zeot$%a(RvcrNreQ-z-Y8V6CR;6#1Zalf@isCjmY= zJn|9;7y^OK3V!6*Adz>%W0ikMLYdP*sV&C|oL;NX4WD}A%|_=7N;%dIw+PDPNHQrwB7DX5 zI(tVaHPGiqdI@6aWXOsl*`3y7zbEIE#DMX1VJpOk^+4G4`329W{oQSE0#nHe;wfwG zvo?Q42qKR3kFTp;U$+q~Xy}mjZpAP<54(`?o zaq`gpW8;JT0OV6O-reCULqwgKMC?#-=Wx{YE?v+!=uF#m4 z+3;08&v7S$34q~~4ZGqru;qE8jP*paV8tsyF7k^opX7`MjA!y*AJWhMh7#$&kZO16 z0}{p(!})&C$c6t2f)$Blk@&_e1bZr<*FnOQI=_7Gx+2D{&fla;1wtRoa=w4AutmUl6P*HIsK4PeJO|P_72ALV|)jRZJ4-JaJm0rMROctGfA|{Lr=l=sQ4n7eH$f$aho{Hz$N&v=)|5chl}PZI08IPA{0ptx;Q6Ww6qX1=i-@7hhY^VK*n>)v&t+5a z$fjpW1}lHo=f|wDmYO{oss|m|H`p&{N#J%Y4T2Y$KySuui^-8H6K$Fwh zQ6$yPBD7p;S1=#NJgUX)(b)P&+s9Y@E9yVK1HXa4B`nv&)^G5NDxVvN+a?sK16W94 z$#wLs%AFeKXVxEyvTu))V>8W?a?*OgbGt}?+;D|t&Yb=hws3D0~wq6O+fAf0J@izU#_wl^(19~t*bd7YeQCM6j`jN z!OP1?Fyu<>0~yF67Di&Z=cbeZP={M%l$+ORIu@ZMtacuE?GFY!e+5A$)@on`T>@Cs zzPGJJ6Qm{xkU+{j=8Ory0f6B|Sb&I7Ym1py81kvM+aS-{bUcc#SBPF8U)b-?m6gA% zMXw?YLB2xPvw~LO*BM6V3jCT+f5J#nQV79$H}VYD_s8SAVs`Ew$IJ?Do@3`4IN|3S&>k9Y?9BqLlD&V{>Jhh#TJvCU_`v!+vh$ zt1o^T>%Js&%M&q*et2bJRk_b3U==O00TlVDg~apGK*DwM%bP*>S(+mY{eYm$oW-WC zPz6+Qaxy>gNQsqSx2BJsdp!;TgSMyBKm+?JL(#zI-HZBl)vmz`y?m{INdWM)P#p!J zYR#az@A;&%Z4`5n(pfao2jtj|b%V(=2ib9T4?{I2jYvn08oe&SS8 zz(ci+;r)K0WrY&vS~+G&>a93c-_X?ZF&xV$Ies~>S42ShO-1WZ9x|pO-)cri4Y`B7 zW8(uDQ%#C>x?vXbPasKPr3sompUgu20&L`Y!qC8`Kj)xzd^fyp_7}H*Q`{>>d&KvY zRafkvO4M0$R6erMap?~EdyzYUxA0i#_Jf9#Vi3tTk*5aw3E3>!Cp+phL&{( zdIo2|cNq>b`a=DEN%2?$1G+uyoZU%r0Nc?EU&62Cro>F;*B)rNUJp@2V0m(ypvd;C zzeM^@CKLk`HSm3@_kppOR%9U%+!9W*7CoPwcLEN;)bOyhsP&Po_bk@m2N`A(ife%g z$Lq*)3Fm_Eli(kR)6-vXm^r}} z$zVQlQ9pd$L@AH-zH!OC@Z3^WixCq^g3>D>@Q{?=JKL$2FX(IfsP<+-4hCN=#JJ7O z>%-zcH;9lMe05QW@vo)pi&2~_Y2bS?nqw_m^Rm%#6o82cXt#J@27y2Ne@hVeKXB0^ zd2pT=NQm4!y+Nxy0B33wBHt&1KqCCTDxZ*k@;oCwu-_3Yna;rR=e(I{G@TXKr>K8E zeJ>CA&OaCi(lu|Yq zjbNbo)#3hBqM7%!e`A>zvGe{L2k@A|I)eo;*1!Z@|T`aZ-^TcyRq zuA&Y=)+uMc3S+K9h*9Y)(A!y7!r(KGXj!T9m49{&$hpoDMZX~?#9b2%MZR=7U1pFgR%7g-TP6VJxH>2lXawYls#B?`|WMdB4)lfP> z2#hqV@nMOXI_#0mE(lQ{NR8v+sGZtR2@PbgU*1bd*6+zPJZAg{QVXs}`ylC~yZ-yu z4s&#b4W^B9(%cXNb1NReadDa@S3+;kxP_0I%+lvSy(#ctgXawGDWHh`|CIrc*pJQ= zd@(mA2H_5#AWtzgi~fTQ$;;_O{G@YvJBx5F6i4Y0?*yiJf7z44lnT2uVZahKH&r!h zfigCzRewx;EuiLuM&LV!?`m`k?fxtyhAU0UMQJW0!C8Nu-e3UGDv9+9$hYD&VE&TN zw(JZ*sw8R$f&HW}rL2gvFmQD5`Ey7uV6(a}JIpYlk1WsZtEaU3_x(+hn#Au?({4)h zbRU^d_VNpjGPzJ_D^)@>>%v`9@9hhKP&xt+srZU~ast#EqMH_)%AX&*|dQP6FiHa4hxy2a3e5kMKAO&9NNti z;QRrIs2+hL>wvSU6d zQNS-Q6gZ0dN4H@Aebg85XXd55^1{BH&-7K-i!k-hd)q`a=GTtg8A{>sd|7BTgf!$u zEbBT>I~r-D&B6{~v$q0Bf@=qU>(A?}25^9nMS|i|kXh8iyp~q(nAW@xnu?=rDm!ti z&uqAl!~$f_Bz43BG-n+)QKDRm!wAftc}^UU5u%7eNyvAvgzTDzr~ut%a9iL<3!Nh}D0CAO5m2p@28r2jgUh0ne#1!(m|PDR=z!oLdUB@+V0e$9s3G^w^?BHYQT zH?Z#M*I>a*WF-WP9w0qY#)!r zyR2n<2+tk(FSsxJi>R+{vSj@}IO~n4MkuZbt=`309LjI* zE|w@44+{ezy8GN{hi(=vsntbK;*fkhutjOEquMjnIN(>W&#z=J=7|Z+b%f`BMV#Ag zb*JpI%|{h+g7Ca-tE0|Cb?Rm<^kycS^(+$P90Vx}{6kS6ujl$|w4XHN;jB_5PZ&>- z9Jg`-tnr*_^;&wgfU2;`%&W71N2BAoGu^ejd}f%PR4ty0)(f$5TI8Mr-lkuaL%*VYL8C*QMK6#dpPS?F- zf(IimMP_D>jMyh1iz%g)f@BmUQP(SPMeT>aQyvxa9}g4K{`%$E+#uT?1}ix2riO*p ztWg2>ef{TA@&5lFRsx&1Q2pECWJL)p@oFvteY2KEoBs@^{)J`(a_WW^@8v|z#IGrW zP>UQtQI(&})vV^QpMs6%Q^&>6b^1RaNXlcX8}G(BN4^6}{0wGLF8eeC^!9W9_z}pn zk3gUA~}HYRWa?G76+`3%<_piNlGY)b*iS9Tk{)1u!u z9^<2+Io?GmvR=PHX=d>y9nJ}QIV;uSDEi>jfr2scHMBVLyWZBOwriM*64-DtGXpSB z6@ToC4AeAO|79p&&R=@M3%4i<77>FNw-EOkR00m>~x_ z9b<(L(HzS`7BwLN44zGizx+!+)9pfLoG3-+#xg_VHMCigv%XZX0qe#f$6sJD5EGET zLgPLxF*~Z2r>4G7ie%-UF>1hweP=RC_y#YJ*F(~H<5piZ)S&udgCqZlW+SDIRqU2z z%;QO3t`QGSK@nN5V&kjbte;hfKw9NeJ!SEq07zZXGx?C@83yGVMGk4i?*#@=VTDJV+?(z?ll9d-49E)gEIhX z4F$~c0+7bM8?~{OI1k75p))91d9Sf&frXfrFTzX4s%s!00Gm`d+4-S%v*Mmr%vrorD@XAvctOoPVW3zcqrKw?0zQNT6AI z)eFpmP<9wDndO+G)QXQ7oj(M=9FwXAsYoIJ|`Ve3AN8c7k@CC#E3@Jju*4 zDbcNbWxStsgzKbh1@v94RHQt{I-Sqw54)G*sb`B)Y4VV=K!hHS5rlyMV(2M{xcMk$ zWxJES!aw#S{ zs&Rj6gVpo-_+~YkvCKj??z%qEl#KReLbe$hUdA>7+UDoKbI@J#4Qkes`z?o7|uxJrV$y z;l${YhPn8DiT1?7tsk}mKs^dp!kUSQ(@2H=KV!sRZiA?b$xG^O0(fqw>z}CWikf0? zo9%vL?sZbCy#0w%Wdd3ho-*fI=(6(qt6u5rpBN2kp>kVf0{sC#eBtOTz+TLzf#KiuVNqkXvg66O{y2VBl%6dc>Ocsb$HfjL%{ZTfPAGdi4{}(ON3O> zDSb)nR~AT}!}DBzMXCp79FIMq+DT?%8dTcTg}t6~$ncyMhp!^fef!XHLgINw;2r$l zRLci{P~=VWc}#v<$fcZr1mt6R;lb^hEKHLXa=xX&f#yQ7A(_NBNap8GngMNSa?_=m zzQd20m2-gRlw`M~*ATO2)=GHI2IMkg@h2oeXNe-oRqV&7ql)ss4{`uZd>!P{M}ojB z5kPVmVc#IA0Wl+=H};8P7+33OyqA&ZOQ^RB{GsI4v8FT5ChxE8meq`u-7bcB7F}mR z?@QXn@UYCesUV$83HhtLs?vSm{ohsY9#*bz%YPm+zx=0IqzEXb{9lSXs`5u3*4beX ztLbW;WTan;YM;am&t6h;R+Dk7>~!)hc>WwI6BR-#)8ouYXYX^le|=jrSsc+ZEfeMY z#|(ZRwZMA?Kqmg7ZES-aVO{WgS3jOlP5Q?gwmb6oHJB{rzN0pH-d~Ckli%DqK)IKz zS-|9S{scmM^!NHroYV_29_l-;1`%-0_ms=UGThur&huzQD>3~_$IGgPA)ZTUnbx!y zz0#uyRz-M)o0CaST~p-G)m7IF=in70@6^EE6L?48#?7cVHX!s`AV)C|6IBxB!%Pg% zJEFcSAoFX~;y$x_XT=yHuuD9SG~+5=QSnEk?ANdEX7leizKiaUo?qFcTV4WSa0?<%eoF}OxwPFm;sc=${ASJFV7xyA2G~40hp}jU638*G0wOpx|)H{bu zEje}JeHL052e+7Y8Rw^LF?p@4_-c=>Jd`AWaM!!u{NNxZPsDTGWXEPu<;1@URShy(_Lq}=c5FURqcPWJ< zHst5w1*ck(^(L7|X7H0Kd&}pxuK{Gaj|ZEASPo}e3RV)5e@lQDTeAAT{S`rYUo2R6 z0Q=Do=MV8=6MH=V2e1Y~hU7C$s*HoRD9*#J8r4wk6X3MA&Z=Wh0KvqV^__j!rdyT- z3`ER3bcs+69EJ2u1Ltars}S(OJv9fME6sDTm_ zZ|Nb5i`}HVPOZDhRM632KLKKW1@Ol4$<($wECFuT$A2|1J|0P)-t|{^VezsP zL@&VzF5|XHtAEW@)DOwO1*dKX6N@ftz~6C*91uxwt?2bcrS2RLj=)d3rXEIHEt&!W zy9uVaj#QFeXwTETop}OpVShsrJ_zU5L5jUqL{4AZ%K<>XmE*Q;2oqr{{~H$LwH5jw zHe$Y}KADJ8fIJb*gs?|Fi+z~(J;rRS(5Cy z*`VLKXRt@o`~hexlO8Wq+b7J4^G4%{;Mkwcov7j3B&vbT_MzlVEg8h2ayU075LQ7Q zCQHp*a)}Z~CZvd@rlo`CB8QGRbBIO-XNeT0K!HFxuP8x*vrZJ=vF!5fgT^*jh|pBn zf5P><=N0N?S`!POapHm?D{y{e1o;@14XU^AK0B%mT($b&BFs5(Nwg!ss({zngE&=& zeM}nwSO;a83H9Cju<(cJbp0e;m)?JZO!Ba30C~-O@+K*MxTV!`)`RBN;cp_q8iSQR-HD+z($w$t8 z)yY3gQGhRiCi@5rf3K?kdkr{XJc`{R>m9m~V!hwdCINK13m_??7=BQqLi5U1JK8XV z`5;H4)Y-HXz$nNjXQc+}HOxt#%fKcDsH@%^n|~lgg6M!$Ejo%`DT`gDamRR|SaAl!D+{bITsID{M(_c5Qa27W2tZ>n!)o+1jgO}hGG{jG<8)Mb8mv>1->rlc?^ zUxEK}R=s4q)6xdRbMtXr*oN1Ph}oJOhKWD1cW1@xpG$`~7%q&$?xZyS1d%438w zP~~9e`}GL_LZfYXzpt;3VXne44dNcWKH70?(#1tG@d)u<4JTs_+5jM|w1VIx12)bn z<0>r~9j=EoGCrKDxX^E&nd@o6(Je9VJxEwdLcWgGTeK=TNrRXF-WGt@XnhpgcE9x)Y`c^bXZ}&c=_(=?~ zuZfHLd3_@AdDAdi;-T~N+yWU7+5x-2 z?{2++7*qx>gd%lL9{GGdNtp=RA)DC76|el33czRP7MaBy_Gd!+BQ9q_I;4Lm+Tzvnf!9}1?X22r(XP{5=g8a9=c4Et=nGrYaHKZ`%ZM2CVgtFg_k@QC z1p&@MSfsd^JzwsfQ!q(D=c@?jbGm00x+;6{1-+I~`7MdjPeE7kan?(3tQM5MffjtM zU@%4MA5dK4I>El#AS)zWDU7|r_ISs?T5X1pImR5(KYM=`h1S;BRm zocsB#Qs}b5CM?NHkfDQhU$?3gsHeOmTD@V~@G&J#}r=~SV3==*vfkLU{s)%94T zzXxwDfHF%|$(O&EY|a9M+bi_%~mjVYcIg6IN5g4s^4Srl~u zNLs9s>^T5^y2F>MS)G!bkz2x|-5;+^D`a)+YXC=tri2R1VcYn1lC+ETSrqKM0jP-F6bU zU>T#40E98EM;fib@bQDhj2e!ih)4l6BhoM+0SB!`_|h{#srdscDn^nm?bpm8XiB(; z$~(^B`J=qP3ckC7)dJtz8Ah+$2hPe=MWXw`nEJSN9reWZIe2>fG(jecl4Jzskfq!x z7ju6c1D$0HfaLlXKsI;4EP~*@2l}jZE>l>fS$wu0;*;EDGW%qd*CU-X+nq}9FNa+0 zi%0aA>(@uyk#$IFb!d1O`KPTaOWxZ_=z&kA0_`$LAB@2L{cxD^$T|N)Nctfay&Z5&}=VVT<|k>M*3sM9p%r!5F?q z0XdUbmqjEe-*?398x1H%EDv|cJwts74;1qacR=$pGmeT6w%0=hdI*7jph+vjLN__> zPp+cY-~C0R@CY*eGmz&urty!Vr;hFL`Bd!^L8GK8QA8&wjSo?f(&TW0l97^F;r-)CW^a1!=GSkx@hjz?ynk8i z?zHf&Q-jfB6id+*!?#g^zx-lSQMk%D>=4rW*xN11+09T@@fN(*`r8|d!6D13(Q9a* zI0mJvw}Jd#!7Q2sdqCfH>_t{PL%w&oHmZ3~jh_Wit-n0$04+e$zgtLe_j^IgVLhrT z1e#fpIW6Z(P!GeY7T56IB~nnmzXWKUU$Jdl(d+{UvY7|5=ciFaW3#e*oMj z#SLNKq0HuhR1FKv?EY4vJwSw7_MY6)p>r2bURigFvk9s%ClAUAL&76=YSh8DS^>K5 z?<)UGkW@M&;}>~lquu2C_8%an3RVeSv7$s?fPxX|)S)76K#=NYJ8;rG?*bzz5x6Ab z2Ad_lwD+#LtuNQVN|UnA1Ov0~{-qG)w^pI0{Mkk%kyORLbB~3*dt+4CV)I@v_n?KB zG>WgkAx(*A9Vsbi3-YMZpt~zc?s?W1U*teq-2QhQhVSFqkd{Thz?p&`J8H*8l$1W;^~~kU{-%HL_Ov++uuUd`~-! zR#gd%6Pa1GP`!kOS0Dx(U}CW-k?~|?q?s_4SOH1dR2T$@!AyCQY(__rK<7qa^vwow zC&g_zkqHz`GKeZN!Cs;}eMnqz(1F+BC7~&EmoUi6kv~w3>Z$Irh~qEWLami0(nIa+ zR@7-VGkxE;=YURnQs@mS@F>fmu2aQn1rw5>lj6SxRdkL4jXRG;E=uQrq4!~d(CzwP zU8}5POv3GN5m1HgP35W>OulAO3}#Sn^4B%JEsoZxbtaQvg|~}aL3yu`>Q{P=4{sJ$ zY|`M7dh}@$5$r=BR^=!^ zK)v)of_PfGoroXC++O!%5WL*JO!)2COg!N!#3y9<5!S62Y%o8Eqw9d;2U01WbN~mD^$mkG z85p#`T$eU2-v*n4jX0=m2skLgqa$~~Lny%a-M}CpBn?J=C-2!>OHQ8h21R4)4v8%H zRmpQHsIYIk4UR$IPxKMcT!g9<%Hw^wYPT9XBT1(Wqe$)9ZgTW4aha;Q=oFy)f_7`>6q9wVhYbCiRC-?mS z=T4)W2BfXMfOgeXXHKHgxItjM{6a!6<{r97t=>J!-a@rk&0c-m;AtN-`wnf=lJ`3U z1^Wf2#D~Ac#3{nJ)lT%}v3f5@)G$hg^RWAU1Uk3t;y!|^Gs>Hw&*MA%ex=psdu!uye+Oy<%9wn*nwXVfUj*l&XE}3Fu_L?xM#*d zk)pswCAQ-Jzld1zWuYZU(<>_{JMZdoNE95D*mPMd(QbaI)4Xrr%b9C?$ZP0uA13k4 z(4eB24CZHs4xtVM!s4GR>T$Mhz@hnoFqeJzV@*=aVZd_+k{NZQ_dvbAXTUQus)d-rantW^)LF>!r@0b zP@RZ6XsYih=rMD7QxotWPd&S?S#WC`tm>4lXjz)UZgL|1Q-N0x1BKocf9fjqB=Pt_ zO^gFVL1)@IeZb@TERR|KQZdBL>*6+K^XmFwWRA&*xP|MAHf|wlZ)uV{$Lp zHzbY@VPr_jBaNWL`*5SALfoJUqYgZXba4$KG`Oma!tCbp{)Qr3lPA#Ytg(00J*Zyu z5C{9ufv7p4iMNtEv_A3$4<vi4YceT|Q3tlP4CDPLz&^7*=+`&dE&BX-Wr(EXNB_K7SS-{q1lw4+Ll+LyOj5 z{_5LzWg4(U#J!$Hv420;*R!|3QyW#jo-<@2zS#j+Y(vKn*3PQEj^5+}0AGP*+fYtLjzb@B zt45eFd=9WzBQJqmqEJPw>;J*}noFByZ)|7e*oVf_0XR;>;-S)ufb|QGj%VQfhOMa6 z!SbF5bapUJo~b_>VvqwmGkZ_~4Jzb&#_t7|K^dwTaSlv6haV92>>hs8N3mEY@3?Y) zO4^%$trt@{W}omd*6^#rjgKMDPuz}WYS@@3IR24KUUkE3ACY1A;}buAcKrC++l6_4 z>&QalI>mS4Ojg)AWQ`Az;WH>(RYCZ}>gI(*242iLr~~T+gdO+pLeTZld@n-kjnTJ# zr$GO`Ugz!`MhG21iH7!NIre{>rvH$(85C6$X7Cowq7(5nAxF}J8XcMI+fd+N6|AG$ zrPrm8UVYdgQl4((RlP@xe3tlhb7laxDTvA&NSUai6v#`&Jt@rCOqdO8#WDoh1e8^c z`haWZxeeH>*aVoLK!6}D5F!@cwJeRZ zQttitz?V@i4FId)YISQ_wrszqCyxP0x{rSN!?1pVNHb7DoqanZf~6SRdx7C2T3Wv8 z&#x)vQoo2O*F=5gw1Rg8jskvuyYO4+LF{twL#d7i!>H`%8FE>TLP%Jv#LooVf z#ebYD;s^xFhDyPXN(6F{egFSsi_%ge|WF^84M(EJKON@%L11fGS1Y@zn3>yf|a z2uPiL9IRtrAI-kd>y_b#O&?9hDg48Q(A0g4$k9yeEzrFnR~o1LhzmgZTP!4y&?@Jr zx1EG(l<|^Vb~cLhg9(6OQl9%d`uh&p8FVJwP2O9%{ztd!r_vu{Xn645Ik(fzfjdDQ z$;pABV+SM=2p}~YfbMk}rps*(Sm*!vd(7JAv~C1ftGM%< z5*YdQ9!!?K5gGeOClCJ5C}m{228q25sy~t?th&ir;PKHXs;fB}0tiKVtYCGn;t<>U zG?PkB)7RLljiZ_9D4S|f1W^Xw2hLocw+$?@;WU*OZ8In^-S>UR`}gTA98|JhAHfq^ zL@dtr>mZ*ne#ww201xY@Kxq*LmRlTx7hsil50Tz*h!~v5qPoKblB*_qgM@-T>H$27 zjXJ!9;9$r$yVbNJbl+I)g}xa%ENa$2&ORfih$aI9=-^K-EK4>PGVN0VJh(`Jc@SGT zsVbY*xgB;ch_}7M#sc>LM~q(>CF3;sN{h^oTXApG;lY}n0D`pKRXW@Iu&yvYrye`Q zl>S|6bXUt4)`bHe6os7CZ<= zJxj-E83(8%1r;>fVVK!?6b2}I4;?GGr$}$NK*AYWR-p#Q3FDA|Bee753=hl7R{TROP5JiwzWgeJ)kI%dx zw{-EpBWa+~&m#xDWyigIvQRQl5`l^P3D-I6PMjK;?wn0PY>L~`CVD%H8M0|2F38i* z%AI*8Pp|jt+!x$T3``4cQ~_}XoLF83Um`CtC!erzJ;g`CO%TANfLAL1lurY*`qmn5 zGGYbU#kwR#mT*`)Tr(yw&u2~7r`+s{8re-YmY1**ys>=PYJQm=9RC$_(w%?E0rnP~EG*zGk*cJ)#RSoEVdDAm=k zY7C(yM1oGZ)&+xyJ5kL*T<3H9{8UAqMz+L2xBd#4eU`OG2C~(_!b5N|Ouh!Dyu?Ws z@K24jr(=V2S?m?nnDk@t=Bt=NmpPm?SD2I&M4@ZW$o~6vCp{~%C(UaCl#w=iZ<)G+ zN*+ZlNCEs&YJma2CUfc7f=hWLi# zDU2%T+Jkr!eKtQx`@vUgsaWW;ZCn7Z?q##KKJ6Z;`(yf7Y|VwMfkCmcO$rMA8fzf4 zVSKQ!%{gSyRv>0szjFDLJ4Vlvg3>+?@? z93BM_VX0*n%i(h5kxiJ%*&P6)z2Sa*1HKm+yn?(AY}1d5VdgI2;;@I#E@bDoMU$E( zz}NGBqMmgxe00CLU5l$?YKU&!q>{&U#{3O9tF*_9btYG zzV_M=2PfC#D!vNpw;SMbJq#X5CsL;9J}&VVVp50Ka?k87ZB>Kn2Qc9U@j!4b=|F*MVDO0<#F{thC<- z;X|bjrn3RGF7f8R0rA_l`KgOWjS`X`B?|tjEC=vmk{Ca0xlG^V&S~FG-i6nrp4|(+ z>JLndR^*$$#Pg6EoYu!ySycLOD3bk5_FuG!=Jw=GP@6yrkr+8JA>!j}DIf^;fdAhy zFE0iR0$R;`(NAnlvdVjLyT5VU|E?XiT0BLqSJho<8uE)m2<3UwVhRLE4Ti)b6w4yf z-^#`9byUK|V}o09MF-HLJQA{?Ll|Y_mQLWPd+pnqs|WskquZ1ZZ1v=ak1vJ!s@Zgf z51T?rtmZC=d_(@(%}WvMUB_BDgO*XXMYtsfF7yqe73E&Rpa?F@LHQlmh@=x%8-^P^ z0Ud67y&%XvKoJTy&^(hB+Af~JOp`d`0+{P@-aB3$j+CZoU^_Yr>ummcHS~+~;`=CK z-~DC*1ylL%175Ae5FGqj0Nb^v^A%z|dcowm?S$w$6Fuy>g%~I?%cmC;w(gU@um-rK zNFd(_9J9wF<2^te7h&&0q%P1?7<9(MoK4r0xN!aIOVRrA97kO>@uq;4m!r7+hUlNx z(pO#P^o;;K1#Vlp54EADl%bNrpZFqBxB)E?6!5~W2F2iJ_6AEEr8MAGdWt;s=v83=#Ax|@c!bJ#8O zCm=sx_F2+X;2td&D$2m;WmJ_XP|1OSntX}u&%kTv?$}sgh|`0fG@+7zxt99NK?VkrSG_!a+}Z8EE2K zVltU+M-*pNQAWClzGXC@_FYUR&<(~>2(Iyx+dhg$QRsbwKq08_q!B#3Kp>{!d0qvP zeR1?1nUVLP)AC?0L}#VoK57YJ&V0VA&MNUA|QKqaY}t zn-|}Y?GeEYuj09<7C;*Rt4`~$P8fwq-v_HdEz4;+D+Pexqq+jk=V+=)dFSAuMj{b` z3`BkD#ggNq;5>rJ0X1+vaP*=0AL?T{ChX!1qAShHkR8m*3B`4rMoxge9yGq;e(S47 zp>BloE#OZk#ogq3g^xAfhb8JL^H8j-KbB0n zNzVhjnZ!xcoRlC|jG&`W0*|Z-(RvH2zb`u)>GWo9&)}?|i1uKb%#)n1Qu!X~VYYz; z1C4WCi_)3!XzF^UT689+JozKLj1;lY$D*4iuF_~9VcF3=a)GjETvC8Hpi>H?SQ&^n zB=Kz73jMubaR;>g*>1Erbs0I+aa;5XzD46B(yzy~Dr^K6!)b&Rsf97o%^V-GJT+y^GTpm5DYmKMzXN_L0dGt< zj~_MjS5vKv2G`FBvJaT9leu)ugmgD~CT_>xC0d;e^X^I@v#m8D+iY}bYlI()nSwAC z6>WDzM$^fEimT3C7tI3tT^d_I-}5+j z5pnQd9!&kwYh8^mljURjkCI&!1@P#VflF{7?nqSbvyQb~sJ?j)O0o3ItF) zgl%y9E%W7x#sN8T>0*A(Ng6uk>HuZ=V&$`2t;Cu`UNZZxWaJLFjk4%#ZY z4Xm-DzIZ1@HC!cpUKeh)pT2CY08KL-c&N?7l&3=!ChKr~1$g08iEd))I7Q1N1%2Mh zrrJiNw|;K13Y((80zpTBz&?SX-{3qTe&8jF&wY>pU{i>CHvgb>J7TU;Krul z(a3ve^VuNfE@k0|lE5zP9e^F{{G#L6c@+Fq_V_;v#fkekI0UZDTTvu)rxt~Z#dK^? z&heI*Z&d{Br8?+CE+E{B-xH z^!(#9h15I(iXtp2i1F3l`6ayS_08V{M=rtksTz8>F~tnOb|q823s@J9eOyx3Apy+e z=}1Z>?n=vm6iWu~^Ht-n2Eouj?y`B&UuM;3mj#NGG`i>foOwSZUkE0(^k4~4hh(N2iTc%5^Zl2;g-f8(A$a}lRhLKZ zF`k{nhnW)UHvecH>WXB6KD)o5hgTMDqs%B{y9W}@cD>lQAg1-=6MMZi-_QH`0#`VB z{F$5qU-f_~K+YisMD>Tt6=>>xE04cWWb$$klrAoa{!mLKH2@e}AwnD2Q3dN_lV@4@ z4NB7S9*@4?WuX7=$Z49!nd%;K4fom3c`$NrZrgoTn5eAcv%=KWF_*;|!P3qy^qO53 z)4tQP{^*?wh@kMmaO1?snYE63AxJG%K;iMWDiXrZLh_}h8@%O^>;dpPYCk``31zW? zS_NtYm^qR)=KrxT=7QTy{kr!x#|w6^10z5D<7||L;rm<{c+Tq#38#bOz9L8<12?tya!5Kq-v;yFj1)?4!gsr}6OC7v%=%14E4gst*jOPD3@g z>}mq923Xc^c@UY5U7&!8=G85~+7Z~3zcb0vi6@ji4shNAy?;KQPXZ>z3SQ)4?SFUN z3(;GKiE@$=n`)Oy+Cb})Gn1rO&PmTXXu)+@8-PVuITs6f;3&@?2cMO*0l6euvx)f- zHn`;<^g2~JRm~DtA#90RQPf}$AiZ`>X5ol*K^ZSo1#peF1hoQ}rd?mtn19tyin#Qn zH~axG%SR~Hpnyyq>Q%<1BFsL0{ZLeSx!Y=86EyK*57D-XuvB42j?5|MeE& z6inL3c({@WPtX)uZjw^FW00ld&bNP7Z>IIg?+S$n`-&%Jp@&-vkVa3Q`k&lD6Dc0V z3xIqF=Y7mnZt)6R{)(ZE*U;@tK4GwS3hoV}g6mI|PzjCG^+NLghu9S+1L(a?lsOE5 zu_(&$03i=I7#FJTq~L*+j3 z2;=5pUk(8&X@*#s?1Yp9VCCs_Xv>_^KvfCf1I0a)_%3U}5|dxl-aI51#SUyFPF@dq z0F@GS*0vnbUhpa_GU10v6An~B`y0I9F1yFQ&KW-TR($$seMggla0m#`e_0g(ox8&d zfP+l$eSOR&v`dJtrr=_-vsT@){2h@x=o>nJzoD>)PHgX?>>RxD==0{w2kwfC^gz_~ zlnLQ*9cP_fx|<0r_S~i?W@K*gbe{L#Dg#pv2&5mwVe#yTPs(}C4nrE?C_!aqcJwvv zUP2`F)V%DtcRq!S=AMSdX5#7}?jKnAxEEv`wzY1d`%wABK7N_mj~_q&{Ldf%{qxWN z{@gnR{&WpI@ zt;UU=ydR5cENS{Fi7W@F2aU8$U=;m~%=Hnw53Y}9HB_Qa?E+1M*f9p{6X2U2cr@@! z)LKUn;kU&;TcAg)ONG6CeYHxEuOGJi2Ms!;{F-A^%J2Og;X4am3aVb4VS+$p?` z+21j;)xESjlA$rE{y%Zg!XCG6BnBjRwJ%Alw=7w9Y%6W-%foS!ueq;(|L$^MuSxIf zCT-K^>PKuTwq(8bO=1oNFdWXur8kPd?+Vit2!J3L1TgQJpb%G(ZJ`%)G5x(OgmIIRC#^$9p!P3QwOygjN9fQ<8jwf$2|beTCSZ`#q3tWPJmca0LD zVt#{`T8bdlhsw|sL20kD0_17SV5nk$q^?eT9vDmak=}$dFh8grfGF|3Y8ose5KP`F zLgxBMr1t3Fs(gE>NANnZL2FnmUH0}V9fN|;WX9yJ14lx88ZI-AQrBSeoH2l)bsWdd zWT|1(QZs2TH9DQ8PS;+6C&uFl6c{vFhG~}CZl}|9I-8m_&D=9mliA|wWHK0zFR!jf zqv3dB)6BLv%J`3nD5ArbrkJ{YZ14z5(AOIRzzGqONbvkE%j zM4bNu)sZBVZ}3|RXm|FD0PRU^wMEGGcX3z7)-Q;DmYF=q`;uTcA}9@^FaTXd1409r zUsHw(19v|UnTOo2KYkOU@TCWF44YtO2SnhO)nBy$R@5gN|7(L0o0{!DiB!q->; z_*u)k8UPzhs9~6RyjO%>80z9}&?MCJ>UsYlOD0vU-w+yBm0g_(_n)G)N|6H{@)~M( zJ!uLkmkowRH5c}q8VVn|6e>u-A}oRDCQ7~Z`lmp#;Jp=0fQuLb5CRN^qf8AVP&OSx zy2`7FR*&%35E$&}D+s?9oe<#lKZMTG%!sE7H6+m_4~M`5YLv@-*V?_3@rZ|i9ARwW zS!s^)hDf*Cz16k;O23~@#{c;150lZPA{_*Gvuz*N0BViKD7V|D#-t`oU001b>9t!c zwzt)l<$iy8Wx3VuSbAOq*fzt_=;~@P9@&5SF&$5a)2UGyMKNgqah%R<5@QmPy0mUK znn@hH3ovOqnT`j8;l<_U>6^3ji^1jPmBj_78D_?y$Ef_YshYFa!+1&rg@j_qg8P|} zl#5K)siBCZG&`MPuRUysCXHUTr1j&d@aUn6Dgcls_~8FZML$M5WU3R`F&Je;oL8hE z?Aga!i8&-c2y$1%jH5QRI2R=H;)Gai6yfYSt;Yt^5{p1&e$Dj;6oT%o^fq?3clP$WE4^kCWz+Qh;{3%A&nX6iK^h7~ z=B5||2F4%)>R7is&DHh(-ob(GYGZ3_d3DvGy1Kf0_4@V6$;q43zvtk2II@&F8bibo zq*-bZoy0jdEO7ywVT*4!-y4Zdu}zIQSxS=L#>W26b`-}-fkhxJ2JrIu)ytn=ot&Ky z$76$_k_e5;vaEg&mqXQ_xQ=J_9Zji6;gnYJ^-8fdnlJ(mT_b{3Hp#K!c~jC1XMWTO_s{kf^@s&(|Pf*`9^SW5mQg-v*&C z;vS%%w@CjLK`UgQjQ}%>vU)LSFHv6Cg5La@EQV$VNjIR#L@(mGg`};Rsiafk4+t+FuEZyPL3+b;F%*qYPJTFugM!zMHnX3I%+;% z?Jyf9qF&Y)uMY2I22bC-dHVdve>{8f`t)p+ ziwta^8F8EmoMKfM9}*pP+pXo^GDOkIoBXvY3JB;zHNFrc~IFnP1dBK74`ULAWkPksOBcm@g48B&QGIYWBmUaw4Fpg*k zg6P!P{WpPHP#D&_$MRL=e?8qpn*yLwM(1U^ObMYzK=*ilXU(=J96_mmxcCEI3-&V) ziTMq5MHAzio`5A9T9e`haGxNzT-f2mm5E%2nas@R8$oLSLg|9@6%GbV07-pcQ^u9B z`VT6_96MI|txq)wG#Rbc4+q0uITfnvJW4PDec`*C5Ejb^(Nu&};?-FArA!3M1P&Sc zznnPsLSUQ(BMww~#vnuh{x0TQxJzky&wBVJy*yM>H-R`)G?=Irli<1p_5EvboK8mY znGwYkr80G}v;iRz5ZXuqjO%%Ad-Lvt2fGIcGh-|zetdlN=JX^<^xpQy`qujT?)K5M zAKm6;P(g421g?L;IBBeJtnJ@E*t>PGwYS?|?m-+~T@0Q+dGh`De|z=nT)M|HotNs4!>Pj3d3#6M(r@Gfj;y8!?_N+;FEjZcC0ex!I#HHbIJQxlJ78H+1 zqk)w|Oea%|Axwtj*?2UF;>dQ`X}4SLw#}@sbaprY<%@?Oo?cu$dw%@=4^N-|_+l^` zp^6GZmEq8x?c=y8x4|aBUvrPTZ<8S}_kKevHIvS>_hDgkO#_W0Z^S&r; zC@R1dsF2;e3dcuE-~iNCl^FQIF8Cvks0T{rB?Qq~`cFm-!gD_}0{9hZ@#m+(WE#)^ zeC>z|&;dTBprOpQ?&Vt~T)IkA@=x8I#Z)On)=!L4cK25~;i0=R?%()jW+^V60W zKZJ$%NH%+*z&#fV4^cwo55&m_i7f)1v7GmaeOUhnd^Mk#SKI1#Z{NMwSZbYKUcNec z^QZ6re0=n(*-X|~yUjRm?`~V5{_5p%HcJf|78^81L#x->yM62S;oTbt`<>Mloy4;= zJ9>Hamv8_4^y#y+^8se6xwN!q@q+dK*3NdX*X{N?7V5V;ttgI^nvseGD$IhVp2?J< zm`Q$48I2g{mDn~28niLlEXz}x;bfAI$5zHaI(c*U=KOLv8eCb-Wq3Kby0nk5I)t50 zyW8#TtakUdHXeNN{<9xn{`vdA{qW@Z*~L|=z(H|fLwaKX%uPIr!D>J^Swc z)wTO~Z+-m1y+3~U-S576{Nni3fOPOoch{*d7Z+mjIfg~m13V=a3Z&0s)Bwe@7YuxR zP(DZsn40&{om;r7D!E^^D=44@0R{lz$xKF8l)TZUAv=9Z{wonBPE(c!-$QEGE|BNQ z?wo74f_m6rPuR1H66OEj0?_gtklM+l8vsVpTN%>OX9!6skpYN^=tm-bv*vTV^KNG{L^(Vr8u`)p*C7Gs_T8`BZCrd;jS5bL=iXPp& zS7)uqo{7Y=5N#sPyDDy=MOU0>xBO&vTa6FF_Cbu(M;ctXjbMf2+jwVP)Q&ZID9?(& zdkA zA(cTulh=|U-@#h8SoT3*A|JBMT}CClO^yUez&Ikmu=Wt1*^$%*!k#TcFodtq#(Nln zs|AP@k~t`K3#uUcX}+q`+~2)* zuy^P7&Ar|2{_1iwj%L%z)nH(8f$?x;(rIfc+1=VWyk~Kh+x?YpHXBdJBV)WNAo)~P zYICjs!QtKa@7-Cms86K9r1qh$roNRkHTKi#%ql6E3_VdkPbrT52bb#INx)b-WXxQ4 zFF=cv8W)>_Vqd&K{`D!ijuwG<^Um0Vue&NaK%(2|388{n{+#AnRWWQgWR9{UC zNtn1P_FG0R5ayX2^Nj)gS*0nI0$g_`9E@~g^uK&%9zHZP)A zf^hrb1ZhM~dXTI+AZ$dT4uRmk;!y*AHs8ZD4WK%pgXQRfRt}yBv=(O|>zx@mBRQ zbUUq;<>g4nM=yR8efHabeeeDUR;3Q2$O;7>fBnr@|Nhm}C(o9amTupE z@8P3QKl{yR_dmIR9Yz?Dxg4MDAxYJ_QG@LUs(VDlt+R>buY7za07JVF9r_OWKj4JW!6E+-MQl zNMxl)OAz;0&x(JDFu{K}z>oM#qtBw?sF&&K`I=zpUmHs>8bzb3qj4Uv&;|t16_8%u zN`Hh?DP$DkU3|G#+-+c0>#dadl73K4l(-rcP*%w{5PflYuQndPJA_9iuA&Bt-7?YJ zF`eq@aRKa0j5kXq3o*nnHIL?iUM~oo5g=pr-y+0Mg8g^Z7!jFZ2dBLwJLh0UkMmgKI#S)x={M z+Jm%dbl*n$hRqCSsN$uT)nP-_8XD8o%#86a?;5f0Eq5%(8)AL`laC&L`Q`rMU5g8# zhSx{Ozx(Px|L|X5pPij=ZEin&^vR%7OiCjH8hm+hdHA z9MYTVESt?tYL6V2o6M7lRo`M2o673p8KrItg3CmJ_RU+(WVzSc+F0G%-rU$&YbNn{ zIJmkvpN_||R;%6a&Al6UtSVxEHJwhZWg%d;v9|i)!F`*Oq&JiN3eSi4NhoO zDqR4TFAVAKXYjRLy@^mV_28Tv7hDd?*W6WkP$fm5zkdjCPYmgy>b05+`7&fU6#$-5 z1dgl^2tqR)B4)brwh}meK(V+8JK(Syyd`+pFQRaj`(X-!-bRa&se*rhNVrc2>+Lao zWojU72xSlbp_x*G?w^}Lq^ctYD9~XRi5baeaDN?OadI^8uNN6kQw&BbQNd552n4S@ zY-T2ZfY1s;`#`}{&-M&K;t&=X62re7d>Np4sLE?2!XdzM_(f1C5x1%4AZO(oDz1fv zs|8;U{-tJF)@Zd=luW17C~3^nbe7Hx7_A|Rq1S6KudeLu@9iBPHkX$X@y)B(fBgCn z-~9gfh#O%A`Tg`7Rp@!7_CgAp^@lzdugrT-`w0-URlwgh8C({o@<3G-L@4Cy!YP0 z#`+3o6ASq7e{g?mdwV>d<^F-jvy;@Qda0i(TeK1FW-b=O2vFqI*NhtO{88o+oFJ+;R{XRIMw@GlplzY z9EC-b>W7fOPsUE7R7dMyOSw-BX2n>)8t+z0l8`UNSeC8mxC2r<1s#&SU*?m-yqg8ilBPw zrz-inK&iaL7O?@Qm_ov+jHd{V=H=t4LkGSE_+&~wi0T13WQO0fWMd>NKI+G{2PB#v z*@OpZyx8bJKb!myM-pW)ADEwlLto=yO416|s13%Q%LcvLXJ*-SFzjz^49DZtU^4;7 z@Xyd7=p@7F)#>@lN^e!e;QZ|GfBCDWfE&%FhmRgwp?YtvkLgTJFEIs(Bc(x^45w*c zn5HJn9P3_wdnF^OR1#i;g(@7t!F-u1MsC)`ag;>K(vmeQ=#EE|bDMI%KR+c)t$wHb z>4(3*d;8|))nIdN6)=n9q}A>oy*xfUyG&CBaWqR|nyP80E(iARsTQ;l@CRT)EY1f) zY`FxUnFhou)GR!#gfcJ61y>)!UzQ-a9F-7SL4}fQ0Oc~)3>I5$#r&2%z%sc{NnDzc zkrDXaBB8lRu~l`b4rd@S;$okpi>1nis(%5-0b|%3Jsud#T-p@n6}5*g_`EyS&ckwJ}C(XA59ZX)=EZ9g_#7iYh>j1HNBq z`sklo?F}oJ3}yLV*0g0@W}zuy{8r`!&b<^X#EntK?4c;)kH%{MYyrrr@Y6+}Lx3PV z1tAv^9m4u$j7R(S+U2R1FaegE3G^7|F;lsjudd@$p`tTsgQ9FX4+mzAJcBG0fC?XI zF2=`eSkSP-BWKBopx_=GFbE;kiwDEy1M>qRP~`sXItaoYEU*cp?lf@peV`07GLiRX zT*g}vkZmf*{-9Z@X&MyDh5+O|fKd?Cf>OVp+Yty*pQ#;FY_1MnNi@uc!^oJGPW$wH z&}wLtYU{mk|6tcD!M#2?-P~M1Sl{^TpT2wk^hu-H{P3fXwr=hG1q@ySu!awJU*fX9)MC&}W?s$#KN2dk^A zYr1=SG@}_wyHEP%(tG+7``$=9>gk)sJFf@_gfN5Q^Y6huBeS@(YXm*vaQG2$2kbU; zhV;=2qJZRijRB&ZOqhq{Ffi$llvLN`c$@@27S{}%YLmRu6dI{l0zX-7E;$k2>eH`3 zpn?04+Ssh$Q8$Zrrxb^;PHQ{xJ+*?MX_;?#&B!w?s)O`jM-1pKMLEz$Ht-faQ^wFF z(-s3cMIS>x6hjbBB&067+@>D-It+&Uy=plvb6uz=_joZ*2NOY@CdYpo{3P!884ygo zvdyIllm}qkGy&cL*OA+ zq&MNggm);$mM2oP{3Tron&?KI+_B%RKfHQ*>%oIQ^@m3%*)0F?=nOW zumA7=^Z)9C`|PvNpM3H8!JU(MdA@%0w{G5bdyDPZ_3O0G{r6X| zUY?%5U9ZoU^LdtVWW=As>~F7MX9fTH#oA|3Y-UyY$hexX!I)NfZMw*&U#LpYh~;9IFu^JdE5M+4L7(OD^}q@sG3Z%_0o z2oc~5vX@rZ0Sl6fnH0mUgf_7jds_mAL8lSY!-F-vIWSeU8_+FrJ7lCdl%mv>S^=h) z32jJG+55uiMjgN!+$H!Bo$w(ccwzp%Aa!B#QA)h5+RIBJz- zcZ{;W)kQKw^#JBJ1WHGm9)W+EU~1-hGz#ji*8 zP=cce=MD{Ru1l081-l7rC5TJ77n;~JW&5_Q?f#el<)41>%Ww9U{`|vF5AWap@yG8@ zjt_$CzkTubKmVuy?d$Kq+gr{qUcGdi^ZDUHST0iPaWs$ z2XVght*gFopv?w|c88yZkz@y4#90Sii$;)WYeGmYUO@LuaV7Z++H^$N(Xu<5z zEv%#gS41vDKZY~`IO@_(u|6tfr-BH3JP}x`qE<^0EElU;BC6I9ttKEx>x;olk#1JE zyTcmGz_a&syXOC_Yc6#qd3_J}2RYfI;iMbDO;NDM!~oINZyK za548^e);t8fA{OVcaGn@`s=Ne{qKMH?&@a+%iacy=B?e2Cfb zEnC5zUu@QEweR+J+nuvF@uGH(brWSL?oU&z55Bh~oWGyXy5*wF!vCC3XfX>f|MKUT zfBDm!zx^e904|ryz5Q(p!P#>E&71ds_`{$3O<&wSf~SbN9vk(tmpbEB-Dl(U79nB2 zlLQX#f|~DJ0Cz8?F`&{e1S1Z!%`!3hvKYj9Pm1gb=pUm1^sx3*EY#b|02o7%sSc5~ zLJErJam_!u{BZ%9g#l{}$ahNY3I9)O`6np`+a|Jl#L-eU=7mNpN|Et2%&s1;F{y`4 zABRwm<}LDO*8@_emx<*Xme^>~jS&Iq8p3=sO1=`<+$04f>8e^1#nn1t`e6NWqAb4X z$sv1pgaW3t#p7{_c!ttJ+L_$t1vv<1;sYY}CQ2Bm-Hag7;3K(DD8LJj)JU#KLPR{h zA66QjB;j&4_=%~3h~E5BGuew#U{$E%*$4>_pPTel5{*%aa4$xK8fYXWbM*4W5(=M9 zGMfhXH?eW_i!8JmA)k1zH1lVa{1-g&!7L|oB7@{%w|XPqv+i0moNK%^Yq!%9R7BFd4Brp zZ?lXY4^}?o{}|)>MY_C9xyO3FU1!X3BecsV-XH<5fHsm{hxTS^Z!Y5}>M(4GDS8}Y zQ)Os-RpsH7n?B0F@ln_&1m5QU?dN+3UwoOf7##ocFMr9oe_p+M?T{-um6|~aGTKc9 zUoqN8YvXujrXz;cfQew{Zpy#F9KF=w|6WYU!;r#s)p#)AzJ*ic}%s2}1}ozM^4JM#BTrzpwMVU zW_u^XgTWDhuN7eapmm5OXp8EEh>wRE_y*#MX@Hzc0rn`-&^<$}-H4oQ*nJW(Oi|xJ z^)Ne#D1&q|h?E~iwW#%0xJ>!&I50I>XMO3h%Jbn>uR<5__x6PCLWO82iAWIh=yOT$ zRVfl*RYe}D2d0@@ou5p3S{48pMCPJla@VO}og9Am+h4u-^$&-)j_1q8d@;{5|G)h4 z_p_OQ@Z{0nYW4oj>+=uq7t3t)w%T6);yj+6#Oj#KmOBK-+r6T0JF!V;`<7|0!+?eK?~7+m}Wqw642ivv6eQL%b}4X1+n1X!gkUT z)BX!gEEV`p8556^`mVmNiuWrEiZ@C@{AAybGOuu58+gxrf**%bIU=KM!~oR7@qc!s z{0MHO5drWpq2QN7Oo|h2*V+^KDMtf0kf2zN=2@R2Zr-#h?zhT}ZhEeZ60Bq$5tP>#YRv=UtewR={5f~ldq9>IUg!TnYl}pg%^t0)V z^gM70h9wSKIW91KeWU?xK9pcze0@l?op~5>2tSNNgHAAWnff=5z-BzUv5?aZZ=Dsb;kOev$J*1ou5FfVKmkdT=KtpnqP5-m7sAc0T` z!NiIGcj$xCh&v)=4P+4{6&uB&kE8cny?z%SK7abdKmP9aqkAd1O~1+I!-scg=ch;6 zr0rmJ`u6q3>4(+9{@%gLyL`eXU9S7f%S|rZI6NC&HKr!~^6y|Ew&jX=9bQHrFTu2>3LUW{#Y zkuc`4#suFq3Lz{_?7%cYT!Nnr4T^5KU=-;YuHxl6^XOQwM&`-da~m|81=OeoJB?|@ zfbH*xwiQN6po0@~!J;W^yTio^!`|!Xe41Yh$Otm z)xp5D0c|zH7Lp?b(2?!v>GI6bZ%$j+vdLZ{ltGOmXOCjp7=`H6rTnzE@?597z`@_G! z|LOO~d+EjV$N%|X{{HJ{Pv)~=h9H$Bw*t#hVExX+Kv+xQR9=>F6Tv!k7nzz(C=}Q* zjlq%VzFy6QuVM&P6Nrq+7J${t7W2)Bqe$WSKsBK!pVIQdsdfILM)vAPm`)$+{J?_~ z(D_!uDab^M`P9rjT!pbd`!3XjO}=p;Tzp|c@Yf{&a)xQXxq zu2Af#WQ~LVpa-lV(PVO5v_DKtF`zV-(#!U3DB9#z<^ag7Js1S4v^@}bqz>@#n*{04 zx>&H8>||ma_>Qz@oKUn9AwfaH6k)qK_!5~qwR`lXq8z{WkLn5!N*i=*6y?Q8n`*ky^^AdnM(dh8~2Km8C2Ml8pQBB;q&Ds9K-8z`v zJ6V4D>AnB`fBnZ_efQ;R(PRV)neY1DNu_Oh|qKdi#VQ)NpW7-hF@O$Fo6GkM~NZN463QCGj&UwpWl8&qCVrKVX_m?>2fJ})3g;hyo z1!Mlan>X@ath0frMR9sBSJf5{!briIuob`v+dkSqLYIFXf`E0e0Jd?m7HU-VISn4` zDGBy<%LTxV=q~{FjtZi@km;>vi7Qx?LwAWngxndDH0O?~M>zz-0EpGwE3(^PosLUgPz3ovHq8bqT^cg#aV%6i{8_rG)s`sI|k5`4H%7S@4w#Bd{;Nl%?=J;{N~q> zzI+-(+QdE|j+?D%{%Wa~_)I{>sIeHk+97eTKFC>o=J0jXvH+h@_xn zaFo`Kh{^h-ScetG0IbMEx@8o~}?XQ0L z?)hpt*H=ZvG@Fz$Edl_2Hl?}H!l0cgeJ@r7&TNY)0g0Gnst3fxVIY)a_NvUCv+!P# zJwr{}ubt^T*Z~wVoJJMY0Ks%dPv4T1+KZ?R-Cl+DQNs>ZQgafiGibv?E(bt6zX0e_ zl%D`*w$&~sABR#F2m(r-9WU)j?o1B;QnQON=fRwQQ@<*}KDW2fo_jNbfXWE^*G9Jd zNN%DK)|ZxvJ5dWd)0dV3CgKh5Z|0@Y_GoYcS*4h+H+o+)?SgjYD4jMKbWs5LDoznV zcick~n7YRXh5u;tcTr$c9uxqM3 zl%i+7W}zXf&1M=(`#a|(-JfOSRc9}+dHsIV z!-|hnPgKAgn!x%%ulh=P#+Vd=814{odYR7SHJ$@+r35bKC(@&lS2A`pi2f?siaCGQwEFBM>0(#_%XW?P9RVS zFc|z|VKBCsfG`eZ{}}p_yu1{@M*){eB*wcT1XFM|I(UXQNe|>O=+C-n27X@vPBVmN z?6h3I4FKxki+FIXihgLP7sCDEcTBN*gzJe$g-J7ti+!>6+jhuXX^p+94-jlYYvA;~ z(SBZAsx4btks*oPjp?szx<3xqnV}^D0!jM;ggU27t99Nj#{fZUw1Ou~Ku}QQivW~{ z2xG@-JiRk~?~oT%ThL2HSQNEW`T#I0WXX?ijvXJG%nW{WG_Wg zJ}dyN1qei+A#O`xJ3g!s=4DT*DbR5Ony4VO>r(ICW$KqFM=yT!cefuu=)0s@J7T|y z{dzH*U0$5Od;MxY?~ad;y?1G|$*y{RX7LT80K07fCgQq8E{*#Z`iONg4$%tg@mC|$ zcNo;9#UfzU(8-XijzRL(aT~uYl89%T`3~}Z_WSd2`)Kj_?$JN|`o(X4e6hEfsjg_~ zS!DGvg=7o{A^y~_(^|-C5D2?9?vYUlE`($%{8E4g74UGa4e)SXqPW~Nk|Z8!KLN6x zVdTd{3#|rLJsuwPi!tFR+$@ox}PnF%=}%!#)>RDiJu; zEKM;46BHw5JqIZ2uMT~_Pq{dYq?lqa>nY3uL<|NXEJ9MqY~Z5+7I-wbD!m}1 zhOfi%Ut8>tmR;;|C^J?_s`S1PaEg9bbszQ>$>x-(*N*bZ!agUITXo%X8zY%;7{IqjMsO> zHicHRG3jX{eiklHOPINtQ{*=a zkx8*Xb{Rpp{V`Nl72rP2lrTV=LAuimYGnfa ze;3$wYv3Zp!1LF)>Ssso7X0KcqcrY=GMuu3GJFH`fFoBUA1PQ(U z?UV0*`I7R zqPMm2)}>2t)W>K7Mz|CY`X?-LWgU~EeY{={JVGLkYr${!`5HmswAL0lk!Yr$km45i z@6R87a`->}`2COHeVzG!Ol|f!2Y`Pk%HBEySy5<6yBwi&HvE&*K^-(cGIa$AL?tyA zwRoFAK}EFIBC`yrljXVeneyV1ZR0h%F=Vc;8qBfN?-@G#4gx`*c>RJzEy1k?SX6|5w> ztjHF=d8F6}d$8-3Fh1lgeT>=-Fb~qyJA(-5MkyVD4q;y?h&d`RUep5WDDSJ-EaC}A zzFvkzuPJ%sR~@|Yt4dyxJw@gRArhU|lb$6f!mXh1^-d5>N8dh5JGQQENLBBl_}T8C z>%nh)S{xmG{i`2tJ-FAWKKAR>Z$0)?Od)iqrynlP&Q`1Jw7+sG<@Q`^SPvpD06U_W zH)lmE5Xt}`KW=Ztj(?K=r-!+=#-i#vRc)mta*Vz_eePryO1O;PI~ri5ExiS-%DX78$4*F)C0wkd0)*2qFItucH_~-XG)p zL1p~x@N!9^)*A*KMK^GWHsJW8iN^OP(4sX2rh=~CcBMf4CXGGiSukJ=L^}l}#&n!) zppyR}7XJ!Sf3ur--tF|`Z>{-uGy1zJgVu&zpQy<*NK$*_k1@*)LK+bRB5YBpIlJLf zgkq;os!fKer-q(AvBu@aEC&qHe;d2DT2=6joU|xpNeLvVwu?2&6Ejgc-YN!*H6RtR z58K@Gg9}4%M6eTuTyY#IhI>fXSFpZn9AYsibkeO9Gd3)JC{8CvnUKD0(I>ThnbZ(N zQHyr)YR%{OmS6q&tK<83F8Yg2zc#N27dM*^@7`tAewOg>EtWCHjbi=2FZF(-7C&;e zc!QK>I1C*@>$Jr#n@})<`>0@X!UxXWUV9ls3<0jTs!oSP0*v6fg9nGaxvEkDl zhcH?2vKwd{`R?!+b3UP+E7OQSjpFa$y*Jf9b%ShDU6^ zls36xG5_NGUu-4(Ni_PJ3)qDe`_m5})|VHD2Zx#6SKB&ergOhsNrd`v-`0+aBpS9> zz|Gqh9bQZlJo1>TG=F1WL~$18h_r7zE2wTFF=_%{x+)7emU(C%oX3oXlr$IAp-yqp zr+zv2CkKm9@1Oj`Z@b#|Gy2TVWc4KoOkK2l-6G?=kY2jg7l;g6pi?P3T7 zL=Xlou;6frn3Trvv zX^~AEX@Y-CI}Tu$h^c1{@2sd#fW z-aZ`CD9)EiCt{j)kDouk`{d!J1lz06A7eZ}J=?6;8S7`&zMgB0K_55kCbvRs#*DfE zJHwNJll2vbyyoi%FVEl#BaZqC@MA#A^OPp;P16&h$fx#<4E93H?qi@E%!}o83@g@j3 zYON!m%)%-C=Jy&zZAivHD-t1Z;avzN3UDFl3sz5LTY%Hl&8 zgV$B=)e1kQ;^7~aOM~ZO1FFEZiikQXp$sHSgx%9Z?EJrYa__f)_uVIVj=dWZchN5X zdfvw(*fQg{~H0H012Cdh_izb|MDwm1&+bT?xo_ zMnlX7tPEwqMiaAAl3yvBaLWh6&Lu}HNP~o6$ibPIpQjPRgJRrEAAW$rdUDm5O@Zs_ z*TBOQpF1`wmYrtRBW33F9HTMCBqiiL?syGP;@HE$mFAyaj;#-d^MXy402 zZ(R;AxBo9L)cJ5DKv~7i*$?!D>1BB(hVS!8$P=vcXtYj51$md2i|+Vv{_N?4zx(mU z(c#|EJc10K$5~A@Kpm#L(2)_iK#G0DpGAw>{OAr^hzq_E7G=?w3>VQH(2fwZA;rb_M~tGDpVoTEz|y@YGFG+N8JFhIEyicvU|F z3NzwF!EY1L4l#q1!6S%?ax^UGApHc9P*5fV_rv;Z8XN%=zJcS;a0+=kom^c!v;L@r zk7Dc74#LoLXIv7FJw}$e05kp}Al$Z*^DdNAVZQ^lU*0rk#i(Ma06c0SJ(J__lL;N^ z07)nr1b~O*Dd?f5-HGvmdHhbqIbkMl(g%{-(wS1>`;R0Mq%1U6jPxO+FEb5S>^R{C zo5pOYVNQUVZ@~tva84mO2p7vkxAy7a&dIZ1eB(N|k<8qx{dF-)BHhc?{{CvU=eBJ0 zh7!{Xqz24jTZ;W~2CxRW-ZcR?<*d!1cwMrIno)i2JWS+R4Mpou(YjWw4qR^*&eDd* zHiOQqfD9i7gO?2FMepNk5l#-5-+cMmH{U$Xbig=u%j`W?GKGZY37&os`Ozt@vu;Vp z%cB0UQ&_qi@baw|&8P(1!Yu;$hQPIoYc@ft=jSWOn+M+ zQn@%neK-not88lMX7sooKnt1@WB`omb<#i`08pCFx8~bp$t@eemtADoin^+PhszXT zLXfzNHQ-tUezC&doT$?UuTJspce(kc9k`zkBLW<$@1)X&U?`jlO^@J^Yzs3QouTN^ z>(De|QeujLr7%z`H>c-kK)yI201k;=NE9i+`je7==y{E_nAU575apKvbgx`n@0=K^ zTX-PE$%8_Y$z_|Ga9gURLo`c<9+`Jcjm83e)48F*q2KX9m$PAbXuS|wJ~7J$!*)5x zuVW1R%P)TMO}9V4>@WJbR_oTj@6XT9v&;T+vD{zn1@Bb&hbeBmEzVZvb|C&^0T}Bz zY^Zh@vNSGGE72to`$^j2gEvTqHpF<^sNX@AS#lOiuO9>S{VBDHND;zjq%`D?S|i4& zUxF(8gO96axO;o`!;9xnKYQ4PJ&PgcC)TWUAb>@@* zEPxCI$DOQ6M`3j~Ag5~?&1R}dZ4OwOAon9h20H?B9Bu%cYv9|Y95g})bhUUC0C2nk z6yF^2DWDK4#L3dyzU%OF-!Vp%T2RMmQxm!DS7Dr)_~a@;+Rgpe1?h_nwxR0q;MEJd zpV)o?24T`#8{{8+_aK(7s#zF`!Y_dW9{E7>nX9orNsdz6ho2g_IeK7PkgagVa$ty~ z_K?m@i$ezw010Ay5h1Nz?4?9CMjtxs!@4ky1H0>GXwwp5_Bj~Ii%tL$irp}4_Qtt> z=AVB1)xjsXF8WP0U1sdJQt^xPv-xbcC)Ru#g155By+FgbOzQaK0A!c?>c5=@JO^M% z3iK-I@B3{fZ?suwvOe>r$oItovCrFxw4(BhycV=)4$$BOn71+uny=oU5@YZZO~E z0n$a`!D9I>_*;l#*%cIP9hf}WmJN_L3Vtbug#qg^bNb!}tQG9*?J&1+nxYYtjne_s z&*RJBPoK=1aBb3X3zRC24J4jv_xb@OZ}hY_LfTJrO8UmS3&vaJ7V8inKKtUq)6dqa zmj>5$-8#$nb2&c`A*}XS!TZFzb}^poS%wc^KKk7g{0QSo8l^Fc2ZxD_0^szDww=?* zHsL?KJ4{x}`DE1pTN~A?sSp7Ml*_a!2C{<+Q9?!qT8nloqVGPWe%__SRrmRmPk#9R z>(y$(hG|+Ug^h2J&$0C{!`!WnP^ShofMFVs^m|>dnT*MSj9NwywZ^K+ZpMrZIDSYz zm=wTpk=OIjM*8fj_0f^zSdooew46F)eTr~KXSEVba%E=raUzD@j@L-mKm(>GA`Td) zdtJyP={8HX_1wI2Xfa|tax7^hqkke4nbF5MSRKq}J4{KdXb2x8*}=iIn^wSGec;&L z>dQBPfXJRfJ75tA&e7}!4EIT+4m>q+s8>LGcnfR7JY1~d)OJUF9MdMs29hCVYgJGCyKhm7xBIj4=_wGE5Ff4bhTD@ zgdze_=rn-6yp~q3#A3fxLqlAqv^qKZ?8R3X!tS~lB`9xnB{It-p?ecF}-B>kNv&$I6f} zO=!Kd<3O7L)qTRPpLiv$1La@afsLcIu`;aDTw~inFzm=;2Xi}ua1xXad)$#n)JI|c zls;B10R9zCJ^-b$7QBTtKDGeD`jD-(Jn}$#jwb9fn4<&&Yp5|2@`Ov(Ri^-WZo-*c zzic-0s?V2pQA!vx12+W|=%2hW+TYtqumVSBY-x@#CXQ@$%L(2RqtKp`tdNC&O7OP} z9(nXto^RQ+(;$FPqD-1{RtVBfe|3;wL{x!Iewg`U0Hsc7@$eoO%9 zhAK(R#0UzH4*@h#kSM~%P1_(BkKj}gWB=Zbp?m7lG>%q7L{_=zA}zzbM1 zA&M*j&WC@(&He4$CH&^@0BC@%1#vM7g8^sLKdK`f!c`;ICir1sHrMPVcf3;sF&~5- z0ly~UMvW-1?f}We1ITa6ZVQvP*Zi$vM=FHR88}U=0lMQ5rP)JW@t^+MF$0lZqW;E+ zon4M5#5BzMGwxT3Y^s9&yhXUJ(CKap9n$NWZXb!YV5tlBKZu=e&(2}&JOC1c5v=c6 z`U>}V`~fYXHSNgUJ}~kS2=9*yP~cFdK>pMq#L%cG+tZ;AZENI&*lavN+?8IwmF$BO zH>@VprT1#OU-3qyIBuXJd`M1t>Y*ij8%%K%xe2_|dNJai~=piNMFa{%(PTIB-KJ{hnN3=ganNom2B4t!q> z02I8Fr9pv?r{#?)hW5>TWGn$D3;1TV$FS?!K?P3dzqiogDdMV79_mzMZ%rS88_1=J zlW4PnFO7dIxoNddlbQJ2Q<@JLSLQ|(uvHC&9T|{D(SKD=j*!QYdo%c0L%c&7(UFzk zAbntYW~jp~4ZjYUvI1lhz=IfI014;8QT75(9kE^HYkOHall?R14fseWFG<1p9klO= z0_Vd(54ruZg}R=i@tCVy&unTeQY=tk-b<6^*JqoH#Ggj;V6lzrm4Lqx?CbN%n7C;G zH7X@hQM%3W6FzcHJfvVAeMO`J2V9NIyLNGPa{Tyg3iQOsZFZ# z-xO|MtQOaoc^j3jw{8QsK0mZnDYv5U$%;W@_BK&rGL&i2DiA0th8VqzolEnXKUyuG zKYjA_^T%EAKxNF_5mFo|;)B(l9pO~=JhA0)5RfjoEaqRUaU;XIYeJczjR!96g2Fca zI^UcO6o{Y!QNY}d`x&59Z3_FMz8hd{NQqW>3=`_@lU%!XcQ4#!8V54Kp2CEYWG3uX zeC2GqDdK||)gt;qX-A=a$H*GJ+tf1?`llpJT`svY^@Cap02jG#2&QcbiD9&#{=62h zI4zAGgmwbl&|uP^gqp;y?+t_jUc0Sr00ew;AnYZH`R7eIpHHUd4Y^wm;7Zmh{@hTgEb z;Q!nyNk@TosTg8*`loqJ$NM&W3D8Zi)Z{rvM!ZXe5a z&D9p_XU|NzK?szmPwUn&3PAw9*a*lOUojOyeAimpG!|O4CN*Z@Uj)%TElmhmti^zF zD@~JKyf2|3NN0aO+T~1Ylo^V2(6$H*T>Hw`mpUXqg-xh|`~3vg$C-&en91Z9&QZn* zu(TDHI|{53ox<_0n*W;P(qNC93PiXATuqx;{l>LOVRcJAa6{uiH~e;^X;~0jSttT> z*sV!+TwwZMAT$~-@s>W+#f85 zJU?s=9BcfuiZ}Yy_dY)O^zrRa?yNU!mGIAIv&}jq{fo>CFXr>E>oT&e+Ts|C^4nbt zA)61Q0S-V|k0u+E8^A#CFSX_?94@MP7g(|h)1gc@%uK{oRxKa(5Mq#OX|X;-dx%H^ zH!)R=QmMlgT%2`rf9@aMzxCx8Pxh9xsf7Y7M8Zl9p4k5CP8*Ml0^LBL&UrqWsctb3 z4q_Pzgy)*be3)khKt}fP@ZkpTQ~)*|w+5%MpVtOt(ZHm-9&nm|0XzpIX72BKJjNr1 z;Ue|#wF!ceM9zR3-Ytx2Ku&|IF(2Rs#ExK(K(lXkHW$F_Yf)(w!j`)t18QS5}l^B56hjH)wD$vY;cRxwZrN#tu=(bna;Z}(nTRTEFNl@MGHukJO znEncnJ3<^j98WI*M=Er9MB7ngfS$}Un0i$5J zUjq>s!4W$j=UqJ7n|<}=r;i`r4W9H144lvh@;?}Jg}TZ^oi_qz_5&dw{_+j4&Ge6d z30Foi^Bk-Mj}7333ytN`9nAi225~2a5@C4cJ~sir8ho*lef~SR~)L$VLBWAFgT8GtFR?h-MjFI zdMI%s3V9HyJ!Hx;_8Qaf4_$o;T;KV}&!2_G{Bo<^kKTvH za(TIx<8Qq8IaW^yU5ruOrNVqZ&y8Ebci*>IeFtWif=NK12ceH`FpV&nS3i_T?T z{rAL`Pf)*9ij+O|72xLA%O#AgARv>xi85vnNVKy3D^QNgQx__9?%iVMZlCNwfBxCw z;nKSZo(+FUGR}tyON+g4&2YdZMedrg?BOg*NZWaOM9l_5Y-=sep0tx%U46@xXh?= z^fg}WKOQ{FO|A!&(xw0v2-btOeGKDAm{Lfdx}uLwTtff>$iB3gx)+C_jRWP3_+D(P z;c*cT_C;9@nh8sGbFi>O=6k5{jg`@T{ac&HBq=rDzNauWdMfY+J}vetnGeaqJwS?Sdq8XixM15j8PUf`o3?mU zPYoSij5bxrK6!Ti+Ji9t`^)ar$M-&ca<}W)_no*(q68?N)r5RroAt9wtQn_dy*Q6* z#FJhdpoVdQ*TQKvKl>!LK+%Pb(XlLyH^BO|e;sw(v`XCiB;9TNELx*Pc2wZk#1$TU zQ~(}LQ(f3hp4(g#9EYNb(83byst@ZWfgZvvn^TaC)_Q@RJo?Aueqe5f>*kkEX@^xm z1^^&yJzSM?DP%~r*tS462oYyUNEUQs?pfNes}_advJPMt!6rI&0>r1+xq1w;egKop z#=CMHUJ!s4T%-hdv@{nHt#;7Ow0^~m!(bU2Ss26_?lX{}fr1KEuj@@Nk@>J1zZZqV zi;<;@-OU7`ljT~bmq62OVIYvb$HhUt(Jsm z1_>yo1pDb-TDuhI{>k$%qTdogx&Lf7&zCwoJzH-!A%yI}m&DOrwfAHQa*e#58z5}hiP=NLrLm7DF3!l0wj0A?`5X{X!-oh&yS8)LfuA4 zBbg8Y|2M5W^9F>)p;zaKr>(u!Zox72r7Bozp9WrQ+QgVz6DYF})&z(Am zNmITwW$M)q00lQ3)5;^p0J$bM<`LG=U@!bmj2QYsScpbIjR1v`ThPu$ar}_c5B6*r zsy4m=5ITd+ApNCo+>reaiOK+8F_ZX(r9O#nS;_OU$In_F2;^)F`6NCz@Z1Z+Io;{P(He zH%1?wrw)xd0-|kNqq$op*(%ltl~JfK@k`0u5Nh&eX`h8|<`wIQFcIbm3m`&#Ygqo2 z?mm8aeCK%6Z}h3MVE_E=wBO_-ak*S{UDyizUA{xs^zUsW+n-CfpH!S8B~p>>x_yC6C(q*+g-il>Y|z3BDVYtiis+TKu5UPbK$bb2Cff)r^5WlKmN z`R5=7pr|(5S-as0<-++`=lx+lw=?_i3dEjsH9vM&rhQ)ke6>RABjfY=WaR8d^4 z_Oe%aWoZ}7Mc;3Q{2AHL=Zn?;K{oG8E_Cz7!NFlSo1dPZ0Tw9e$|~ zZ7n@Wfyv^Uj6MXhDFElVAYA*vw-e#H6wzw2WO+%G_kgW$3hfY3UVi)P$aN3Vdsy*l zyB5(;FKy9=x~-<)pByZ|{_69SlSAn>Ca@*sEj+`OdfmD+8v6{`norr;!eEg_8mpn= zaLf<_Wcs=%yPXHPY1bcX2MQ(i%_Kk=VU^to1_1m`K56uLgo0^cnLfi7P&*Ng1LYK- zVU0M8kG4P~IBgq;^^PJlvOW=++Q^kpD`QE}S==JKADikhUETl2R9%}-D6skTz*1=< z_ktuyqegh15$2cXVmXchL;Mz?VpHm?EJkMv659lF|JOr7uznzco*y{;fjAXt-xSOJ z*<}$SSfS4!ditA?-`;Zb@_;~CmCRIxbO9jSnWc{d!m(s4Vy+c8b%kysh7`q0)JkK& ziz>YMjN8P-)&mGbjE})XRH+r3oUT&o!4#Awr=p7P?d5jR_?Yenk((XJcKjq4qF}g< zOP@S`u-IQ}bl9$&WlW!~^)-U+YITs?HhprwTdfXPC$~Pldv|_*-p5$YPR&7pUDJrA zCN^Go!OmVY371u`0UUqfl*WTY1L@yj z#otuRYWhX?Ota;RVhJebFVOiglV&H~f^Ovk9Je0;={3e=M`;9gF5OrSj9f0`v5nbi zpQJ7%sj-Zzqsd}m*{zFv>B-as83sAJ;S}%*qcj#q5-7~GlNY+d@Qe62o-Vpu|FEW| zE}lD`M5bD-!gV|Xx6@g+RUj!uVGlwx<*Y(pD>$tUQJW@tOG1PLt*0xyl_K{P%FVDo zGH_TMi~|621>OazFf~$zTbARrU|F5meG!N9TJBmP90`>&2}#p9aH0fj6QDI;Au;gM zV89K~`KW!LJW{}yfLIm2o(q~j45X@%+Z#jEX`?Yl@>-$q4=ES5{5tS*KqLGcfuKE|RU)=|qwRJJ1T^e>n=puu9Gss8w@uu)zjf`qb$sA0 zr8-BHg$8z1Y@bFF)DP2m5x$?YNQbVG8Yh9dUL+F_6*>(c)pgyh6Q~D@BOh@uivLMd z4yeE8KNgr~ia-bZ-4{yW?kl5mMRB>grwJ>EyxXa}cXbdRN_FB}M zcyB~_La0%EsL@J1X9F!>*%2?NW&No`;zy+*1zI>s50mU=HN>86O%F|)4|=#^_Lrg8 zSV+OUYO-pmf^k5oTb)`C>9#55J_rJM#Y~y>F%6q2_5hDQhha6gWtgQ#o`x4IPHo3N z9!*Ansc`flj56>M9;bIsZrxLo)GHKmuE9uTSCdeIGb%ayE2!QpIJjSSURiddz?5HC z8xEfXWkSlTLw(JH zm>D49z0-XanP_sHT(QTFHK@b7?{nE$z=^i>hUwRR)S~91n_Q1l?@b}l8+n0zRq{Dj z;?^Mq)zAX4SR{(1N(htaz3al(weQ~DThG3DJPY18B51P1-yu5%|Hy0QZorM$hr}uX zLS=;9Dl(f1cF*{JTWmLM>$zm`HI8uE=(}%FBwvqon*7*DN7=IYpZvc}W0IpyZ zowq7?>@u_`VyoS)9uomLeym?@psrX08c<>}EtG>R0tv(sgGH04}IzFQl+f--10S#WZGj>l zWoV<{G&tGS4RjWqhrIRUFI)5+iMEDaAcsl7^{sYI-^FGUJIaP87h*I1n_KQp^X|AO zP$!^eO64!o=-^TfB(nD)0q0m6l~hVh-XV5-DNdjrr#3+wkOw5q&YZlRp?PbRT?1eU zLu8za2eG)J(U+bMLVnncPNcqN{+q}YP=3Jk5%JAh=Rc|A`sF`|&xIZc=iyZIfdDhCHdNH5NxSJn+bzr}L2Hw9exzxs!=NqzP0gy3M13>LfoaEYwkQ1DB&~A-Nx!OqG z@6OJb($9!n01lP`@mykT&fs%LUIrn-20c>?kb|`}Sy6C7tF&038E^hY4e>&`Q|aCj zKMWGrTAWp)x($HdSF!}1F*N4kU0ex_Q-oX>*);=V+B9Su#|o?Ld5Us&?6 zYBBV1+Ri`@n*iXH2B{aIDw6D(YP2X_flCk}Av#2pLTG&yrv)I`Rtt+bEN+jW?sC+_ zi5d(>VIYltSFgMfX2QPbf;7s`0i8RoNL`BQ>^VN85ICU}B_j$|(WOw&%}1qOE*L}D z_|&ZycRziy&gjE!G2HRVNlYn+*Qyd-S19LF5bMkJo^yirZBGt zLpXMJouEO?r$n2A= z8ow_>fZ$YRlrLMZPd8QOejtJ9A@Cw59wI>bctKd?dhOy=;BVmri^^=P=?_`czj*rD zqr?4GVXPk`b;3DF9V z6z9KXZikYXao!tHLNM9T{yZua2u8v2V4Dg*ji^{+#mBm4S9CP(1d@<`x!`r(y5g^I zI(#D069+Ihdpa|@cY(d_VZoP62ngV#@3gS;P8)>|kaWyiLP&b9ogQ;%Vg38yd+&0I z)g93xD8<^RgS&U;`}0lQ(_tz zW52m{afs9@RALLQ(JUG{11F_UbSS9up#p9~8PLSlk*b~`b&AImb-zZ=HS$2U=m_0k z7c?)gV`IAiZP9+_{bwh+TI3j3AWO_;hqUxbcVY4xtEwL{E`a+bVW2YoKE-Z}``!J! zx1Kz?>qCkC1!V!yyF!cmsXE-9=6rdd5hM_n00dr~`Ue%+sO>(W1Cq~$K@=i^ceA3KMOpEOXOU9%$*C6E zQk;J~4}disydqd9`ZSQ`Eh+$`SQj@AF}P@{@ua9DofM1+Y%HxCCq+q)z+@;=jL}b4 zRB(`F>q4>Ek6MC{VvZ+|l`F0wc;_A1diZumDE*XN!2(tWyYrvizY|mMf{U@=-``)a zFSDOs?!32uuzzs)_T78eb+_+542OpoFaLV_?v0DRgZC}o?e&t86HRUD6{;T~qyzep zkl530pupoPTbn%!mOl>Ojvzu=6$a}pr`@vqWA%eWuG%(Mcif@P zL2rhHeYNo;NW-Z98{znM+i&sz_Pj2*S%`=G%TGUjxLVHqC>PK2QU>(KF)W0b;FA{7 z6Q;2gG>?Hpf=V0_!SQ*cMQ!?}YJb8kmk>Y+=u9FVzg0Mu;Puj4a@eUaTNJm z#R@(lJTo{j>nzRWTSq2}T3(RW}$P3U!MO(adx-u_;8%kN{awv9(8 zCqMo4D(`sT-MVx4{Csn{N!hyZ=<%bpS-<(?ACd(1OJwlXE``3q2h@+G&6r;vgyS$l z1v48d4nt_+Ss1WS_D)zxODE;@6(b>B94>8iH4s;6SC2cH6+?AnFe-CQh3@rTS(gW*> zHEoy2F6<1ccE;~&kflC&@`(Qd1we8@Frr@!^%3L@qvU<4CA%OhKqhF| zp(C#}akKacsO-J<45OZ^(vob{v*Ci5XxJmzgCPF()JvOXS2xtIO1@Y`SdeT}jQa&CK7`1KJf_o5!+fs|r7UHJ~69eowj+O=ih zBS@hFmM8jY7AP@Lu#&5|9C9ZLWaR{g(@3w7h6;Nx=-ZbPbJ6%t7$9FtlDgz;AQwXu z^^1g`&T`U0;=KF25AXZnHHO^Y-m2?nryovJ@_Va;+mD{SKRt8v`Mocmg_B#CFaPrP z)!$+U=B?A9K%d20x@dwcRmme~-P-d9`)`<1j4Ip|AlBG-bVs(2|pS zE=Y~&%?2=<`rjwHnmF|d6f3;6-t?DijplDk{gJ#p_Fz#;pDa{QNerPy8GV$**Xg62 z%N_*kCAisa!z-%Mf=gX+-InQJJbG|%76KDpZrVzD6fiY`ZP0Taf=ryW$AV~)FmF;+G6jVciGi~V(Jcdis$B(C- z38nymd5K_bLvF7QV%TaE*{vI47|d1!=(!E0WIlUCca2W}u>Y90@O5N2Xglb7fN#T8 z!n;9_QcMGKQUL6tq7x);!ZJYNWS6t}|19>0KRq5ZV#YFsYq5@kqC*enS$@5;q4Jud zO7aC|dgaUIuy0p4uKI)>s2f!wpi##x2A|+{nD>KVkjIO*SSB_*G}jBSW2AqP(JFP( z_1&Pm(hwWp&-WKcw@z{@{V3vq+js9}-~GOixzF*fJGt`V?Cjv=_Ui8abbk8szy9m` z^3=y3QxtR*z5=O6e7ItvfIT9!tu=Y;8S7CX3P4oVKr=&qo5;a{b}DP2GL!)g6U&o8 z#@ab~JvzPno^tD}61gu=lf20Q{IxvXJS`W#EFcKg(kOoASdv}kh-0U{)N5qZD=WVd z8MsXD`KrJA3AwZjwWNieJkJ{?i;?3 zdPc76xkao}1i)32QaZ?(Y%9#}JM0?3P9LoLH6h+GDkIQ-6me@z!AIA-UfxLTLcqML zIsE*#d#4|*f(Jesh%usb!A3y0405rldB}LIyq!t{o8K1rSJWNlm2Z8_yI+6tx$ID( zZwE9qRwV=(K_**-jP)Mwtws^gl(~o6Si1PGbET~-%zdZoa4jbrH8Q_c%8S>leL?2x+50nmI#g z;P|b?s?krKN8Dpo?Kd1p(4E2JR9um~4tw5q#X;Zl>v$Lv6gt3}X{PyZcvu`&?+n|@ zSwjzzypf)|jb6kfKHhn7_vrTVCT_g<^TqPP<4^zm$3I1p^FMj|?DXA-59b%3fAeB~ z`_9=P{_CIr-~W;}7dG?Y_z02@Tc3+n98-Lnc@~5{wuRx=JvFIvRE8N3Kv1@+JW)*l z5BgVBC{tR)oG0#zAO119GAz1CW=wRqaR{$E4 zp8(=t=MTb3$Ywj^Z5_x5x0Z%3y!`3yt2gglu#}sa->h9nbMTI|d{v;Xy+%XC?~akq z)I0|hgw_naD5`ZTOD7M?P12AMg&b-8?&{? znu&cL*YQW49+E-0jp!J^?FPPO(dWoM2Z}d)a=a>GiIAW$7Z~QW(GclQ1_MrW*K2Ct zd2~SYIK&G6AwF-D^tjOi4`?;tsRB>Zf{Phff_JhMsf+wJB7mz$0Ju^*SI1N8q4?%u zsHGb+_!$j;Jphx>zW1oY^B60?0ZjIHK}`0WS^N)@B2L&3z>OrbpSe+=E}F~?*cw2g zzrcFuy&Ceq^IoYZ>m$bqz*~T$3c*!VzTjmy6!&@)@|#pYZ+$gTi&Wg89(wO{iDm10 z6uFKV|L3}N2z`+LE_L%9kmq)EG07O5oZPxN&pCG2F{T`)f4lg3;gGh zozJGZA$6FVXd*rn{+o(XAILOkrlQw}Pf^}*FOv3NvN~?!AfBH?NeMB3dVAbtA5BD% z4}8@&L?+{xiVjyzM$yaF@^-U{ms#4s&SfpCgA`oqeA-)d4<6iEE`t2}7N9bj##(G9 zO;UZD`vklTy*^Bkk$?vk4%n+D2$^hqSiS_w1JfJW1@ug|?*o;8J-t5aNGo+yFFpp} z{uzyj%X^0H;9TXXrxwQP=)iEJ7zShoQuou1Q~(~WDS7tBj%0I(uOB>iqGJU00l9^- zKF*v5{HRio;6`X1IDG(AjcwlWkftF@X_h;j)_n^q;2l7TU}cXQ4SWNk;fc^$ z39GugxN;bwl1FA5=L~T*uddT&4e@kCLH|*PD~R2P&MFueSvHQ2iI_MaV_eH|8&5)Q`pp z%B7F4@1-Egkwlwx4k3x6SR3?P^q=wmW+V3(P5;ct9L(qVaNngUqV_A6Y1U-62kCEn zi9~{y2^_%MNW=xN7#y5CY8?WG*ft@3ynAxbmE!dw(-rp{O(nA~;x9BvnY6yz2F4h9Cg6s*Cm;q|R!)tgSY0M8`K6_e0>_71O8+ly4La`Yv zpt21QibqOD`Rnz1v(Be(hhEIz+~NM-gS)q9T>$?z2?^Yc5q7Om75?o~CvPSz(_!7l z63`T3navKFi~#H*ieI<=XTE%*skE?tRo&U9s=tv9KVgR*a6^R3OuDYhct8rML#ma) zk$h#36r!GyJ?RU^YE6e7ln#x`&QWMG{UchJlL{C~pcY7US*y#LE^XJqapyL^UMtE0 zB4R=SKMux8z}GM-CZ&Dy2`Br=0emp4K?kH*aJk|5g!BewK`$#^tO{NA<0`CXGG|W* zgYc@03ls%Pt7aVFtcg@-lam8t2YrY%4n+S@(0mMSquyhk*3P)1WL^=64&79Pjidwa z;dk$7`2pr`0S@7A;OPtKXEsd>biA!NaECQsikA*($Vq3axnw8!1!MiCbOR4*e>ui-GU|7XBE8H zCPU|n;CkHt<~^1Fg+HR_)e1j-~oq&DljtzpkYGD0s>iiJzMGguRw^VRD_R&WDd?_uRwpAZ5UgiO&m{f|zev(f@y!3I39D*-GX^38r+P4~| zf7pmHW4#gI4Y5QC{wTlfy}gUg1qs6Z4RJod2eY{?fdN3gUup7Awz~R33Y{u5OJsgg zddSRw08>cfEZS)Emlj3k)C|T&MiS zLX8@#H_my(a-AzX`-xu%^L{8?2s!%_j>RXh8^fqRcsdj!>_ z`lDd%6KI;z^!7nlWJ4_Sl~g(_9i1F2`)vwN&APYm-mX@MtJ`o`p zLm0|otl!=P{Wn8AjORn#%hl+g8Qj($zBxJ$Y1%uW^$qt}ABgtCvY=fN;0q%GJdFTr zXtpcJ7)|;y8bLuT8?RMx3@689)0tujU7?PX?^G-- z%3uu|3k3*epRS)uMN?oI4WtHI0XD2SRrZtIy?$yWS^@iQH4e+@&@!t0O9k7?KCPQN zypd7A@qM*YZ%g(T(oi^vL@$*{nO(#%dy~iHVPp+dg?mKU?nKwhaBgS?bZ-j~=2^gAnG!Y4b`=w9f`^T1LrJ&8y;t^UCu}E;ruy+-A;WV(pc7p&V z90li!s+CkrAMwedl49{`?k)M4xbbMS=4dIwUNn7#wN$Te4?kcadlj!DKb3dbHek#N zT$8rd`fGHrDl@f{)H#_rlJ4Wh2TGYCVLHh`)wfE0Y@-xvKxk(`(Tv)ImTr;e6@VkA zuuc+-G6oiESM#dI8P20V^o|alsERu?;8%~MZsx1qU)8@jZ7K>*(FGtQU#!LZzSxna zUW<1dDQl+{`GlM$-B)uz;R_dg^VvMa*z1GL=ZoyQC*rcOw_1I8e;T9PKRWK;zkU73 z|L^Sd-6r-id5&V`EN;9dK}4~yLdQNzjZFEVhEMR&BX!MK;Xr)3dYXBMep)eRQYklwu8Y!*gEpD@VU(>v92&nz8#Jf0Sg~Qy z7PS-XrEFr-5_?PiCnGDn3Q3BSqkb)}eQVM5n`S8<9_}rcGhl{^$n!c!s%e3h)O2n{ zFxL)jH}v8&?_!wYYYi%Y13%nHGY;E1vSomG(ln$iv3|NCU_^Eb1{lA$=}%dC$2iNj zp6WjzC`Al_j5v4yUVeDAQoG&=$6C3bN5?kahok-jh^Bd-&eVrGUbG=fouhV#v5VlQ zS4kCvB5W?^YebgDWQtT1b!bm*g%g({8ZFVQehfl9%fJhJG+=ox6-AY;aY2n@hf|qr zA!j8@ZYD(^oJ^`LBrAXMW$Hsht)Nd$Yz9aWHqs`Ta0Mh|Y5OxNnyWSs0oq7P9#CV_ z^w47vu&rP)R|SgzfE%bcWcM`|axz2t!J z0c`JvODO`bp?^8fGcG8*Bu=uUC~*S$Gwq15QqpF|^O|i>XakkWSi*Dl(qGF~W0B-- z-Yecj0<^dwK1b@|u&AUG=O2RGk7sK~0M-im(YKkK{k!TUf@IUN3o)eXEi{V@`tL9zN|Wf;fFOT2CNq=czplz*cPiQKxk zOR;p&q%_l3NJy80cz=aFf}ayUIPYeIsRlkq{|b(e7L?q=Ds(Aq_Z0oZn~?}LmrDOF zMAI(WnrzP_f7ICmsxSD^xL{d)qDx_0iK~NE-}fr%p3Uc3u)o>#xoJfGW)HnK{lWwBV^?oE?>=bY;1^k6ak1?W%S&OG^V)Vn-0V72)LA%5&2~`4B$jvWX z*tz&PKnp?I6Qc7n$Ypw)Z$ZDAS+`isW4cuSZ#J8&3wzgftJVI+#YIeUKA*?S^V7Gl zHWwHDCd!rVALbLGXgIoR!~f6Rn>Oo`9Oq$`S^b`K?lLo&6$=4ykxf#PWh*FKq&9|4 zhr;^i@UOLgfP)_#wkd>22?Qw-AVGkb!OURhF6W-}c2~LI%*uM=iRxD0GZzRuZ{0rC z)z!;e-Br0~CdT^k+-VP%Afk7K|COC+;i?Bnh1XE!S&9|{$Ts2kqF znl-DsnqM#&PVGS^X|>ue2d~%|NI%k1zl0$st#$O|P9H9{3Q|cHN$^84Y5#IR&zd!4 z$3?Y1d2+2POA~>a@~9MBAIJi+dBmu-TF7+LjSK#IB`gJZaQ_|_z?^Jd)T9$$sR;yl zMU^tt6WYYa4;6|WfT#KY0P7zL5AcvXFI_6*=-@%%_F&MhrM{YQB5s1{*fLTWhxUOY zgTWmGR$c!YoGFiAThuBzyD%OzX`%xf0Xup_FulStc4UZPiwpUQA!2S4h z>B){$wnKLTl_||5Jj&>rNtdS-K^!i&1d0(&wd?P}EBCc675EM@Alex)mQTDkD5X@l z^;?Vx3l_|;?VSBu@?~qdzXl>#mPaO$qDFAoL1bA>;Po4J7kmb`F2jo%SrY6l+VwJs$gTPY?JE1`9AdfZrAO~*dgFg(9Fl$z_>n5vvzGvb?|WwkwCmF+cnj{ zhjWMwJ)&7^Etp{yH?4k6SJjo$*U19s-oXuXCx~^o@T-(~8rH^Y#{5&x5nHlmNjKM5 zr<2^D4cj_Mu-0}}BLi|EPb?G|U=nM_FBDiiuj<4rUQngJ#ut5%| zpU=aJ4DZ8jtp2OA=HvUI`e4jV`6s;cMnHjZMwW{n{+CSv!6=g|$!Y@U=)Hsl{j;e- zIq;Xp8IRa-p$-xc*_O(TG?>6}A0|hBwgcSx%J$-efmJD1-wByl?8m6x2{bJtg*g$R zsVyHmjII^r-Jpy+5MBbefrQ++RHul7l?Q5%K&@o~J>V#O6m1Q;poxb-cth63Cf_7d z>2iEff0k}K+2ux6n-9JsQV{rKt0+0ByPIKeVG5LHVWjFoyxtnG!HoArf%~mU6$uK! zUW*2;kTh~DTSe5-d{gSTx+Jhcqlkp%Q6LbNQeYdY(*oX?7`Bo0%O?aA+b$1>Mfw+%TTm^K4)r7tWzFhpbEjp9`X74LU{C*>l zeejoH;{78rB(IKCCD`&aG4`EU&p z>B z<^b_2S}@hho@x!A&Oz!OdNab$87`?dH?<*kg{2uFWFLKzOp5q|a5|@t>eyVdprv`X zFtf|mZM6$<4V(~#6IDcAwnhE*SQ7*G(@FA-2N~EqzGpWN?%8L&v0KqpU|AUmK&&|< zP?RMg`$S<01JLit_k-Lek!B75Lbea}F`%6Q4{7hbEVJLWpJ_9 z9=T{zi7%)0n0`rIR-xL)Uvh*X&pBn6^J|q$OW16fxjx%lgp0G*c$gyM^{&C`uL<1b z4rANBSu_x?NPTp3s`cC+R;T$qms&(*nx^K9Vn5}JFTOnA-!GiO0uv(%Tx4l0s7i_x zq<_5y_dha7OK@wh$~Qe2i$5|HK|30|R()9BaGcS+gu1$zYFJY^?WRygH+ z^XN*yaPuJNrmfhiR%yO4hTZd}?guezu$935tN>skKiBuw(eP!rUEIR}4y&yX;4lzD z$fy`b|MXunmd9fpqx|D`M#c}#2Rz~$c%OmmFENMFXCHU*<@mVzp&;t;7Qtnz_+r*^ zJ$K{OjM4rEkrDSF7gxiK1)GCYC_nGk;P7sps9^vwIt-C}SVQmV=)^IQ+E(yr+tHIC z0)!ONuZ>JT0gb6++|Mv%FiOGVSFeU|S8s)Zh{%q?1&Ln}tBD}NJkU05kDY2mAY}MV zihp6^5IgMX*`r>8n}_;H%z#lsUS7J;%@+NnjtNzYXbB(1YDti*iVI#H>&s;ee+qtC zj{C8SS{qz6Q%9V__85ZcXoVBn+chJkLCGo?1^;rdVXZaKbLe#I-TmFNoJDU|iT`TW zH_uC0GB9m~SrSa%V5$is05n+&R@h$Nj^fSgQo=BR;(6??fS|$v32qvMUQjL=vk(CC5hZWNhPMG5|aHJm@#m^7_06-)`WCk8bQ!e$aufHAGl(S*}NZq0a(A13wAWwv9#~< zNTGcDp8~TvYl0mg=k*0Zq-{5}8@GgPG0mC{qk{^Rmu%I~EL!5Nb48`i4{yTL+G$Id zDyldW??GIP@%Kq{nDGnZ@!7jmdu8H9JJYXmUu{@Fusdtv6wW^!s#YX%19C%MU7w~Y zm$|ClMKqt!=bTjy@z3Y`WXWA-%kByb4lT!%raC#v0&N; zu+Q8f5PPvo0-K9fidMJ7F{iA6JUWgiCVm1&q)qJJs0iZ?=>k$g_~8KEC$`I7Buz#!ul`H4DoOqx?}@?tNUm~4?hqn3DYE` zC1gEIQ;$ZT)d4sQDn}B*k=lrXJ!v`HQ;AeREq_vS#O+}XAxaVhMLvb(U!D33Ev7tF ztP8~sx8WDEFevn!8Q6_V>Nfoyd=acxLnhz&fyH7H*vnO4&ZVZ6>buUT^Z5>G4MOmP z8#ov@VG}E`J3O|)!NZ5EwTW$MwPqXty>(eQq#t)-Q^_}%@$Pc}=@{Vi&G2yL{c#Av zUlz%-w%30s6Q$KDGIc_J;T)~`r?=mekQJ_ehddO zJGcEEvBygE)Y*Gx^rNy*u=02jhqxVqKOww1@DC4ocP2oLpVwa#@W~IN)dEp7?E8?O zrfKC%sY$}hvZ_`5O?eW$NVyv+a-7x;(|v_@-@e5V+t|_K7!*WCpGE-GhejcK*~!Cl zb9G9K2hEc#SH+Nn=(^uL)yb8*X%Mc4Rr~O`%J;6Vy(r%+Qs8AcnAbH?kh`?uJVWvqeA#% zN`COSjyqwRr?&p>Mfn&sLpV{8@5@AiA@HB)B2r2l;YBrFU1fRIIU5*Z`4bCn9K4Bi z#SW7QL~xSj4EX-w_l`aLtvmdDgePN|sQQ&3$Qv`%VefE%A0Kl-cWnF1WuPRL4+Z!& zzkwK_Vpiev_e=ZEzOQ3EHf77GgDE)?qxN0l6^b+$*sPKxIirU8o+|A8No{}t=oEo2 zK3CaFF1(5RQAbo`+d+rHB4mf|ZZ*tM{yQbrgD6I_-3(=6C>4{2Za5*P7JL~fb!;LR zp#TisarDZe3&a?uTiF*v{Pu?s^xjoU#gdQ6Oo(L)+5!puD*%4_&V+|Q0g(%EqmSzA zi0n!tAo(2C>nqKOuRWtDeTOe_AX)#O%&NT&%3>6NYW7&x;TO=Y zqs8$aQQ1xw3?wz&lwx-8$5n$_4i-0OdSE7Bq-rDIv{Y>rNX{}{O@W;uhj1$*P}9a; z1+K?4sz41O@ub1)(?G{xVi1pUKjV97LEs!fzU6Vo0R^ysV8nFCY(3AcQGd@B26+fw zVud*2c}oKuQ{6ORc+dn`(rC1m*&zC_u0(>c$iDZ;t6C8ic@%*?c(&EVVDGXJZhGB??pjBfnKUm**ZKa~uiu43n!@CwM?Q0=UUf3m7sL|XjE5aS!wLtkl==#+?#EiLC#5Jom|s__K&s}nv@ z@$->EW5e9NI}U&yJC+qgpBf8gD9P0Ha3XW5T59x3)0C%aN-*c`p~i}Zc)-gQ9(U&Q zm9oNc*qCr05s+d)^+2e{X?!`!X8R2Y!a&Z}BrhO9pAqVzhW=OxkMDgX%pPWiPV^wB zf5Q~o3`0L&ilkx*{1T>}&w%6bf3_XjdhFR2fni%c?5QB26t(}}E4Sn%YR*p=@H4Gu z8{>ApguqmU(-w%=qIJv8L$Cwm7;znz*J0T=3I_I}66UIq+qKp-F>$*YmU#dXR46eg zVSPUay@X#3y^VLFk5AX)nq#yBAj}^>C`Pg#8hp&j7b>D*%vjF{Mn3@;B;fy08-xvX z50MKpm8L<+_#HN1QVD-HB2=w&#KDE4*pK6eaeuaPQzJwdSE$;Dn~Q2{zNcZ{-*|lq z)SkqNgOq^vHEg(p2!N5Mz_M?c;K%tf$F*QOO)hL!Nt|WZ6B-q5&n(4XbK$7G+qo+Q z>;*XVn04^}&OwiF!?BU_@FwG!f zPC;ikkB<^+wStetYOr@th27LJ{7`bkO>YM&mQse-jC|i3BEd43CD%prJeQn{3et0( zu1=!rgbrN*2DKjXVIPnM4NuN&w|I|+tPLS=GxHE{?1O-^qsc2mqqr@nt%Jn+nB7O6 zoJ9{B=_DZ=F&e1jnadLm7N7v!kEjIRtqhSGlFj%Fi3j%L?8Sd!)=9$v-|LE$OF&%K z9hoG(n_80wp3iFMg40h~L|P**YuoNuO9>fOWPINOebX*;6tjE3z6)$1&H#=-$$pk% z-6H^mbw#j9m*csD6fei(eSRR&K5Wns()f4=-<=CLf{bwxnc0c1RBz(xITVw2IZ$WmUOk|LQemNjX1~jTl z*!S6PyRug3ap3#TSbxtu7`rmGOZ=eIbg~Y#WQc)NPi7QWa?8M22@N089jrf<#Qj+vfz`jr!u1uG&f~9~7wmu9iAHMvut?Ix+JjgPl8n(`_Mu)e@g zI|{)eA`EOx9++wi(P^4oKmzm=>u*_>d7f2u{c{#scLx=|L`~Rt)u&DLiPjK(;T>}z zYyqvX^BxXz^)S6K&UdsIZV!XuQksQcspiNRG5`^QNomKQK@Fgf2LLe35)YGAmZCXJ zlEv)>gt@ViB-@H{A^Y>RX=GUD!ECYmcy*M&sOnNInWU5=wWzP8OM5Xp8v4ZG>KUyN zAUje=T5=hDgX0Kgg@-eD^c0+R#GJ&Xn6IXgGWfF_^#P9ijO$si&l7EI8Cib@?gn!5 z2-rZj(ynhwNk-xTZy>1}=h5tI11T~0A{-FbZ(9+}fRs`e+iFAkS+kq(MW#dyZsfB@ zCNhn;odeG!iD~N8L+fuR!)6VsfPmoe>Q$uRjnm@Lr}lvRccdLhGJp8zF-&O$@D#+# zQT_?~fH_?)*;_!_PX)jkZsqkdNAV|@5{dQUKG!*I+LXi~G#1I6%SmeyaZS;hUEV#K-I3PlJGHUnkL8nIiH+Tt(PJ-YtbOm)DVr@Iby9U z1>Z9V+}0b z*kyGXB843ZJXw&77jq;pg+C!%kYeVPrMgO3ReN~po}>qEXK{`&qwR765p`w2vK0U9 zU0yQU4wWG=DBDko|5XuKOz$YIDrsowD_)i=Qv73d&!uIBthuT?Bl_G`(LCk*dGX&= z%A(4PMirsSi##3h*o(MBZ~+L2c0>A%@MB+8@Fj~pC?*>ucSiki5Q2OMxh6KzjI#PY-cii4U>}NTp0U)l%F$TZ5|*5m)EKd9quq1zyL+vBvmI6y&mD zi4O*K}eW3U+2cdAqwujW=vDr4N$Ni6i2N^(0 zFQTpaMk7@aL&2Oq7G$JMPhI5C*44EEy@;*RM%qxd5;AwABXgc|!~WU0ehu6&_%Ms~ ze6a~AS5m>N_5l4bWP^db&(3ZGXj@J!J5C2QLN8`D*!eT=XMzA&j8#jUFpBzVAQ@PJ z&zyj2+)Q8}fgg7aer7-YbzQ$aH5NYj5`-s9hsuEU!RsT)t?UcbydR(dsdGyyW=;h< zwtcxVH%bXi#5oz`D~o|{8%p2QbyeXa$6;0D%JEHv*s_~#C(TuoWU~d=X2vg8LXj2m z_@LK9&OLZ)K(h*KO_l+qW2i^nMAgOGYCLN`o&0y^thEO^PP({*WC6l%mT4q$kjf4q zp#7{sqzZo|HV&u|{{}W>02vBTBJ%9VH?{$xZl`@a1vU?s#c6Tvc=7AHk0TQFH8k&! zsL14?@%`zt6t_4kH3anr9R7iq4A`H;XOOdt?$6QKuu~s-)R)q89hkH~o0#60eNmS@ zb+wwwJ?buo18dD~)i%Nw2LpN!=Ti;K|De7hc*QO3cqa))e|{EpY*vIC9)#>iudD~! zl;GJIzbTGODEarMYp^mB zl>?Zx?z!mGx7vRyN%i5b?^_vHI;pHc#}t4qhTBr^;0VED;({aoU2IPuKsNcbX3)s+ zROLoU5un<)%~o7VCsO-PvAVYoH_RVVqUu)Ll^I-it|f& zIQr*im#S^8vm3PpvPqY+)cNcTP63IUa4`p~!aTPg1(*|hq396AI2M5T>$nvNxGe@YJ1;ms}C8N9{p+2jkkf06unT5{smY6gxaZpuzvhd^Q*uz$X?fw`? z{SY1=s@-8^>v^Ib+wmPo;fLw+$fEAAlhE8_7f(6#4j9Y=6KUnz9(PzGy(TR^7LjfiAvf9#z-T?fT{HUeG3gn#@IxsoeRS2$HO1GN@66G$7~HOK|+H)rV$s@iP*B2J&x z&A87aE1MUe_x5}#QMXEu$*8n6)E#-rJ~KEjOVEqttR?WL2bZ5MTxVV@OFftHRpqBV3;AO23QzRPw$ck_{hVqyM)ZI zxZQgk>p>|%JGT)rF@Yove`BeVRTmSOxINRQ#_S(x6=IUdIe~R*h}Z`aC9Ea^;VO>> zFcquLrK+x5_tNxq7HVIa=i9|)c@Xz!vUBj&^_o61NU86DA7bMdD%O&!BG4-nyFywJ zKJupUz;L~++CRYh=yVK~A zoOPP=<)5SfuIUe{XZte@ST&WSgo2o7J7?DxTH6IBq5zY(2l?3%*~c~qwT>0)|KM+^ z!sL8H>%M{C2tAc92?q?+t(N0@I_etPwWG?*u`@VgwC6n3%!&qxLHO7RtcCmEhHZ!%2}h8HA4Y zG6={4-r_%_W5aG)kgd7Qbyn5f%sEwaTIQvcMN~)WtF^GXmase=btoG5qRg=q7eIn{ z#@Alm=RhT;Y^|5w^JhdX#O>{&>dmZ@{XAgRG@u!hIwK4pe*Ol?QJH==*nWQI8|a^~Fvj zsF+%#im!Ff@yjXegpxzoua5G2C48*lRbYtU6}iG$Z$t6QjF8mCj;II}2+Ysl&N!!p zY<5WzKow0DgeSl|9*B%q4j zi*SMPdJhL!r?MUk0ru^$H(ZKuywO|Q8Gv0M?Hv$-s(&~HCM43xV@63ZR84Os>gw_g zqYGUjr{>V22A938BI*b2uXJ|*QbVS`O{k;Oc27(&_QB#kj)tA(#x=__OId8v+Wb-* zetHh>ma~J28)KknAU+dVlJ`Y z$)1e7y}eGhJT^=QHn^a71ZtY(WL_|`S{Ca!FCqVzqOz=ewq_kmfEoQ)p2fBAqeV!C z;kz;-Kt#4vTOpR7WM8Wk6VlkoYhO)lH>>y_n+^mn(H@XThS+vugyY_H8<*WuyZDx) zhQPoNM@Z&Bz> zuG2pm!|yVGZ*crLWaa5htXM0VoXOCA3^&>3cfBV_1O!I z1=gI=Cyo39=A?|#LwpS(b65FHv$hZ6#PwzjZ%;U5I5Non}ToI0j#-zT^m}_thI{3XfJ0~v5vljxuGrPRBk!k zx-V+*vkJXcTT`ul^Fgqfz#1bOH}#Z5u1}Z5`T{TFCjH?+0JF`tJ+%7~tCbcC>s`^v zd<7)6*tX6XP4NBkO@ViZ3Z}3yCPMl<2R5n;z||4@kArYsAuf}uM$W~Ypx}%|=xCK1 zbeg^LMwphx9$O_ci8A70p$6O829E*neF4q6h44TYVFm!aImBHhvPmzE>Z_AN>){`& zhmNRKE7E%eK;B)XV|}3T(6(BYXaY|9HC9k309Qb$zn>@}{UVz|9XL8X6zUuIU^pf_ z*sBa*-eE32Zr|&*=b*^Kr-y?e`K^lj_uYk^suK2n&gkcL+pxbi`U||Yg_nMk3M!;n z8i^(gD4`!Kp*X?{K`S;U5kj+Ivy=1CUAFoqoW9@E0vt<*U>P22av4oU{D=S7zxLhX zWGfx0T?}vpE!xLflX^>URNwUuB4|sBv;B|658en12=kgS^deCbezPQq0pYR1aS4R6 zK9++@j)CyXD~;U`F4FdT6cq^li{FBu9v?z%VHJYdkBwia$Okfb?}qfF_3zlb>#wZV zzJ8%Okk_In@)B()N>+P<4sHkFZAy8n11fJ>>4+{2u!4wD59DrfTO`d-u1{Gm^%$MJ zl$L^Lxj-?DHYL$E>d@Ku>tc}n4nz`zFpyq@HzZ;*Z+b?}mct>(O%k) zZIrWYgFMpi$8UAG?S&VgbNmgxx6mfy^%Xjg>q^{Zo`cO?jO-S(H|6Kk5xM9FLB@XADIpZQ%M(`VVnw0J zOhD(t1Ig^0v`t;idBIZII(vP8DK8v;H~2BAW}*K#us`o^*vcGCzNckx1O;9r&uc=5~uCKC%kezF{ zyidz@u8(f6r_*UEWuE5fMD~+_A&M~d|i;bFNt)R%ABTYMy zvqn@{3XW3Qov%v#755rEMl$e)++~!pzJ;Mi($bbtG3w$fr?($ZQXVZ!spel=N?MZK zor_IyBKF(&%(%Z->_@~dT-KzlTTHIH#VYjB1zK-g&_S}E5tv~)fr zf+?8BDY|FbcNBy&Fmy$f#mjQ8Z`hv5cNRs*cU{Zl0qzxvRexgs?hI2`< zYCp{Pz|l7!vIO(KWijKkOJ(T~T~g7`cC&}3h{jU~vOmm{1b+tSK^iJA0v)iZ0J~@3 z7dWYQx02|btV6gvc}W#QQu-K1nv)aNGYQ4}?OlTREGo-KAH4JH-}>m$b*}SWUFL37 zaC3A0z{xB&u?Gdd(7JY0cyj6aO3PgUp+V?KM5xefwqAJ`+j&lcZFhu z6ew{-bfdwdwXqGY6N_qYtxe28!NB@zP}MPi40uokZsH(ueY)Y0(8g&a_r?hF(ELEu zuUU+gz?vYSlCY#o6{6V&|I)-zAQ>9m$;wugdsFVHcr8>8In}J?`ZV10hdjR`kIsPmqdx{bf+fM^V9VAI3k#<~lb8%x^u4R>C7{KX zQ90JWLx#d#%CFX4kU{o@Hsh3s+LRn?AI{x#?9##2&DSkFMWFiTv!sD=b1?H$pnluB z#NSs)7Ys-b>8I+`Cq9b0_-#Eih(`?(DMd7iYyC@I!rw=0O172c46^xH&HoSZ@Pi(z|be^z!*L&mEYj z)1#+PuHSh#KYFW5zP-Eq!S{avIhy}qRpZqP2iz4z?J-+6ldSr}Qe|a0)0+Q#0z8}Pd`+kXCMsF2fe}`wLQ@x6G>0SdqBB-Pd-;EZE7AuG0 z{g2fI{M>JDB)G||v0#r$fd}3Sf|lVI5h6eo9Zs~5+*$00pfS~H_{@$StHifuL|`bOd9t&4%rJh?7*f8$3b{a zN}f|r9AjUqSe0CxSlR~m3|P@Atj*L$K@iXc^F#b$_QjAh{1mL~?NC{Pg#5~v3KAz-Y zF)md*YkqNzhH%SidH?+nzwz}Cr*uERdNEzS`{>c5`FwA4&luvR%(u5yr|avRlv1I}0y$K=;zFr}0S>mEID0h9RC^C;V!0kwS}oyTN&$ zbrF{$iFmUl#2}J3%nF{g*w`&;sTKX4hKS_#xUUEyh=}G}R5gB}|TJ-g2q@%$?S48|KH9j3Q&gp;VG{Z9yEC{V}*N!X4u zVd`fEvknozRn@BzoZrB!f!t#&f=+%kXRSq-EXmLZ=GG$O7P}G-pIS;0_Ni-E&|t5^ zO%T$f5(eN>W7ZdtH*DgE);f+k_DC{^$^lF&y32JtLZRViNaSl4WqDojiy?i-`UVMc&+t?u zK=zK~Y`rVvlxXBYN+oM9A`OEf!6=kVg!O~&0di+eKc!i))BKO`60grd4sQU)!c|Rq z54pUmeDJ{sAAa~zxqtCxEkF6&pP6m#silg&eV#shQIeih`sCx!=A{5zmudrEt6c=d zIPT}~j3xZ-a7fpY@C&arE<>!nq!y@H_rsA@14RBAda}Y(TzBXlow)5+5Wzs3cA<)` z4iT@*p_|d*IYHTyU91#iQj8xlb3$cZI8Nra}Q`0xp*w=>k6kf0zdr zGpHP~b;7NX(1|$TD>i4~L-q;F}(-jB8XGeA!{MQRo-$}yShV>7&{aNAGr+KnU z7PsmH;)gkZNm+GC$(Hy!d9KWLTQ3`@Ma^isl<+5Nbbe^|bGv9y1(XC~$EEG2Yw((%*Zv*=FZUSdF+CSIxPtQIpMCOYfA*)j%;)p@#q$^U_j9T# z=iCW;UF(*jeKC|(*2)U=T$aV>C+GO!93}`f&slj=r~{bLtD~L*Mz>xOem!GkM>cN8 zR}}M^nqgfFcYLe*TVOs(!bK;J6q=~s7lQPdiWTyEGvbqx$ArN;!E0yEuwLB6jpLL-^$_-xNC_$j_EKzoeFVVztjKs6una(CjC0vK5J5w( zZUQ{OkV5p&(Bt*5+kViM|LTy^oJ32}DXVdLhJ>nQgrqhFn~KlLntetkNkMBMJfNum zNf9|G%=q-OPZRSYFP&2wjrK{@7u(}l{dHHjuL=>U%_zk?u%fAouwdD%u{gnW{4mcJ z*f2p8I*G=Ywt(>Gpb+^IzROy3r}G&GUR-mNGAMt?87Ks*M0V%i9fsfGcfX zd~Qd>v5li zXJ{DXl=x(JaskUxrg`u6AYn1E>_I#2qOnxHpX+&T&inR7Q3mgI|3@G>vYpqW|UCJ{4CGcFH{fm5l^uO65JKF0dk z{i<37feNuRdDX|?<1%&TvL=XW`M#pS^H?lxPwk)vL=BLo;pfi%TLyEC$%>Ew$xaZ)n-r(+=%(s-{t6XRC8F0yH zunGdc;=xoYlVp|sK}8}f-3!l4E1-_rF6+66Z)-2wETMXlil~uL3R!zC;7&jTZLon9J7-&N zRZ*e8(k`l^O?cSlQ;M_d@0glP@Ku#Xjlt{e3CRr0JC95?^IW zRphSd{r$P=L@TidHWw%v&tX?9cR3y>i_QmDF4l2 zlo!>$6)}pg3Vq;paBmKRSdk)kU)&8BD@03q0j;6pS!vjy2|a?$5aH*9xz#y_jLON4p0u9}_ikN^tZUeXWZj zevy59J{W!R^T2Ek?+Ve9V^6IPjjmD|ego4lXhc|CP5!i+)m{g{dtpE1pJaZaAPER7&}eS*Z4aR9 z#g8-hzm(<4lgF~$-$|)y%FoLrMe;Q1lqWq+lc=oQB;r~Xt(qVncZRyvM~6({1MdTD z?=^PY1J+OFh}W%TDA*-l!QDZPF13zhK%<6R;7}cSP$tkZjywr5`w_=&`|XSY_Nj5q zT`doC!m3{ljkIWnGDWO-O)4s7n$oOEQ;}5lROD(&dY%jN6$v~Ad_$uC{WXLJwGJ2^ zt)&V4sSgn@$mlQQw5Arm-7&XKJ*jzwUu7hpW0Zf8@$^Wm@8lKvUJ6L-aF3TUJOkoc zu+cWLRitsYv*GTq)~2W$z(YV43H^%{(Nc@rY&J$;lS`x=D zE!p~5&5js!(TDw)tcrn$U8YylfW*4QI$0gh0Eb$r(!s?snGf^5QV?Aqvi`Aubc=sx z&u3-bbG`uXqmArTQE=A`{r4GQ(4(1L?;A5O9dLr+uF|Mi+u#ibc}6y@(cSk1K_vwv zjR}O&7~^k6&qJtQnm~FvzyIF5D_q}9r>y7u+p^3x6_t8*HCTU^NXIV01{LoL>ALkrs>Q zB@ER!GHKM%SmyusH~*e6grGi&!dH@uIN{=bIx2BlhuDw;q7HoT19j;s$Pb&c7%kv> znIuZNjiEJw3dc4)4~h1s?EKY%a_TeUAC+zGj3vza76e9*#_l|d>;yJiBOM9$qIF>0 z5a`!{7I1XtwU(&>CdE`C|A(=wA3>F#6Jv=%Cj1H{L?8jLX~i%vE3<$9!G|Bb_ukjm zh&@+Vr+40Y`qo>I-+Jqm1|f6hg2@8=h7_URY@6~t_ zP@=We`x+=m#EK&(aU^`f^@MzQNxiU2o^Bm?{vBE zH0fHqRI^A+q7@8fsvIR|99te%lA+=`$d;KL)kgxfBmOCi>~IARTYd5Z==oRL(Iq1F)p{wZhvAb7 z?NGp`ozyQ2Mih%X0+TxxfqzOvFUrm^WIi}tAKxY3j z=w>SWNmbJbgwwaJ&4rP_s6Z_M;|Ue1(g2S93OplpH`LLHp4}B6bT%-2{Bi)|x0NC( zVx`6bx6|XfQn7r5D&KKG7&KhvUeQsEG4k0}xUk6`D@y>-~UWj06 zdt=@UXgefyU(Ks(-vS>8k70~k%x>-@!0;oG=OR@mk?SeloaE{{mwK8s-`>9Z{EKJv zTwLO+nv>PkmHMTM#_DI%fW45tOaw4+YQj2j5ms5u4j~6ZI>_<=(2)BAMI&ZmEODDX z*(7I^<7f2$OM|-Y1VctQG>{a11*asW;E>BzhdZ}Hxh@~q;*f#yvOokM+` zY7-x5xzzHd zBvQo3^RffADam1pf2e4V&S92B-g&zvN6p4SRfLM7C1N7D)*eZBp?(hVIy5*z>674$ z75mna(HnEsgsRt)N+>x%D3wwJN(xRs7GOl`z@-d)rxfp9;6BN*ibid>UmVhgH<18v zXXzCOypFaFY#$ZG?6VFSRSm39s!CbQ78~b8r9AzbqKDedR6If~B zwWY}qQ6Q#05j{(WtR?QUUG@MM28F&EU=EM~c!2!?E)T}~tRFd+T}6IVIA)oawPvZO zi=1{^%JS;fs@z}flYqvFe;N#)aqAM|C5;Nj>)R{xYSx_f< zg{}I>xyy5yHIcN;sKU@sk@*N?OtR&q8oN;#?^Dt__GKT#gzy4w5Gvl7Ng(=4gbcRb zT2hGm6WsMN>L%n5yl)U3KoxV^B9_6*fJDSLm>sX_1E7rB#%LsM>;&PAW3k%#IOqk_JUp2Vc){9LIo;N!9uCujNc3c;rI> zwXp?}iyGpkhoo1*&p0|4Db;+n4MD3v!IY=#)6Lb@ji{D6V$OIrme!@HXnjEVK>*Ie zd4ky)$4G90tV8f+I`q)nZK>Se|BciamY2QD^5@4bd%GfXEUqIWYd>-WVXyj}IQ}}8tSQ@% zSF5z5m!jgu>e_x(|CP2c8qfs*;P#EAPsFV%Vfw&PjQnNHWQVXkG#1`Mo?MZWrBH0w z0!M6)(hpjjJopB>62{*VcwKrZ=R_EbGW+^bh6qtnk_;X2`WZwNgqA~i>0t*Bs_3Tn z9|UgzX%MRWQl%&$G1|#iHuEJy=Yy;B%+(dX{q1jk?fnmKKmYvI?MwUKb*a@VCX(UB z4uM%W{8Ir1Vw&2vumTzv8%^6Fy;Lyqkno`wpd>NQ3?4){tK~xMfX|BD{{(=OL7{CM z_Io1;@)Juk~;Nu2b_a2=W|FjF-&vinb2OGNy%qdSoJ`cOy{G7T8GYU-GfIc)pMxxI1=F6f2E zB3}Ra%*XMX*owm8SUxn;Kys{+31moZ7(_g@`(ctTcDz+yAr<_PZxC%y8#>Dk$t7Xq zsAkOWrCS&JdCjEXWdW_OS;_cs>Usb_(Pw3II0~R}ptX2CHZ;;vk2Q-1O2Uf?Zi1?L zC@wXQ4MhJ!MZ9B{3@b^^)YE%(5TJGr;V)K+CIbioyvpx6HbZ!s!h(7$j`iCK#@sV+ z->21Z%@Se4mJpNlb=HT3LgLb?Ax`L|0E(k{FIa{h*Cs1#y-4d_T1l;pPD?&^y`lmV zz$;|(&C9=^+B5Zz^VxBocqJ8kFnfl>gki$C3|hA$BByEk?ce?Ft4CM2Uw&50Y#x1S zA-)P2+bX&zvK|fJAj}8hdjyF1D?p#LyB&m|b*q(dDVamMtA!@0@1z>=#{*)=&Uf2I zOb`q^Ys7&2F+Q?eMxV1c2aMLf3m=23#)KA$j2|WC`z5`)pP#?Dd-iNK>6>-R(Z0Wof?{cLg8g4`~Y@7S||Nk@5( z(UW6uKu2rdD^w0}iw2@YOR!~G+M&r2WRM1y9yp)YKkf<_3~+EgBnUbP$d4h1VhCHj zMrf=yb^M{SP|f&ZF&0*_Vh`&@vvyaj&ZDbfm=t%-*$IvPrz6a{$C7EQQ8N^~Q_MF4vY7RD%i3tR76KK$T=-}rmKnd*G| z{EH;XB3Vi)vsmBAS1$#e3^Lb;2L?;JDjZ_BKw7VZ4CHu`tZ$9eqM6$EaiI4pt*6t9 z*@}|qn2!o^T^Pwhep}gjRp`MFEC-3(JM8f0aI7XkfD)}K&sFZ1`s~GO(D(e)&!2ty z?Dl+Ks;H2*4Ad{%9TPc2SdGTB47@BLQAA(@YwGsCh2L0-J!b1`*FLSSb|%UZ&Q4wc zakRK^QD+A5tjxsIQw%`1EGV>-4uEaSAmN0>m7;v^t2{PFoB9y{x{2^Oq>VRQ%5+r87U685btgG~8^8&7wKWRSsgykUURU+jnlJSMRy6;Kyng%~Dghm7R=e6XMO1pa{ zu=i`*7>_0m4LdeBf-3kJtWT_jY}wC&O{c87r{edG*1^m^zy%~7x5H~Jr*KPee}MZ9 zNVVVF6I9gfZAP+OVf9mqaRI^JHrV2rF;JLz1)Y#(tq1o3y|keV+|C6itoe(aAFWqQ zZD3o$Y9*A%0Dp%GOUs*mZM{G9RT$A2=4j=&&?>lkp?j(|U=nt(n>H*FPuUwdo;k*< zWs0#zF(~D?e)BgTzxV$9VqMz5^mE)8$GWTkDws!#z{$yWEg@zmnLm?M>}WJbKa(x4 zj+)SWzw0P1sEH#Pi~8d))j5zZ*3uG$eMDDOga;(z&qtVlTTl&AnCircJ=mtSY{~H7 z9NF^`wo^%6fSS$QNnf4I8mebL&#&%E&DXac7~52!8sou9DOH5Xwqtd25M^+!#6ft< za9A*Uasbc$BV9h4Sn~>m{gvYPa)cLLwjP{w5E-7^jtnM1MgW=D1DcX%<`}P_$Y6OR zZ0}(dLzq zBLfiFANbDq;tZH?VnUP6xrF~c(as+HGa@cIt3Jf)u}yc=Lv|T6)@xm(la*A9)XpD+ z!%*koTJ&2Kl`27bZ8^Cj`?}kS2*pVtI9NW(l8?ZoP>{Dk_aou%0-bcBB37!^gYSfD znIp|m z4uXE&R)KJw%7GV8U~Yl$m}}oGSc_uz*|NnPHko3x_cj2H-XFy->yn3^;!rk46uU<9 zxC4p-lO6p>ZG2ePm&Kt_i$SVNM!Ey-fR_cOUI7-s7>4A?x(c8q2!WwM5;H z?gw1p@JMleg>#JEs7dPkT~Jc52f3XhR8in40`Ddq@`c}5+}R

W@DB;G=JSqn?+S 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 z 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

(cH1cJ08s!+Zf28!ykE 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 Date: Thu, 13 Aug 2026 16:23:19 +0800 Subject: [PATCH 040/138] Attempt to fix Windows CI build --- .github/workflows/build_orca.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index a7652c3bd6..62d3071a82 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -377,6 +377,13 @@ jobs: dir "C:/Program Files (x86)/Windows Kits/10/Include" choco install nsis + - name: Install pkg-config + # FFmpeg is discovered via pkg-config (pkg_check_modules LIBAV in + # src/slic3r/CMakeLists.txt); the Windows runners don't ship it. + if: runner.os == 'Windows' && !vars.SELF_HOSTED + run: | + choco install pkgconfiglite -y + - name: Build slicer Win if: runner.os == 'Windows' working-directory: ${{ github.workspace }} From 2d9a3be88f5cc48b3293606fb159f11cb94c405f Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 16:34:42 +0800 Subject: [PATCH 041/138] FIX: GTK video window resize ran in a free function without member access wxMediaCtrl_OnSize referenced wxMediaCtrl2's private m_gtk_video_window, which does not compile on Linux/GTK. Move the resizing into wxMediaCtrl2::DoSetSize where the member is in scope. --- src/slic3r/GUI/MediaPlayCtrl.cpp | 6 ------ src/slic3r/GUI/wxMediaCtrl2.cpp | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index dc1f95b88b..71fbcb054b 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -828,12 +828,6 @@ bool MediaPlayCtrl::get_stream_url(std::string *url) void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height) { -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_gtk_video_window) { - const wxSize client_size = GetClientSize(); - m_gtk_video_window->SetSize(0, 0, client_size.GetWidth(), client_size.GetHeight()); - } -#endif wxSize size = videoSize; if (!size.IsFullySpecified()) size = {16, 9}; int maxHeight = (width * size.GetHeight() + size.GetHeight() - 1) / size.GetWidth(); diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp index 6a2e07d8d6..6ab9ec5913 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ b/src/slic3r/GUI/wxMediaCtrl2.cpp @@ -604,6 +604,13 @@ void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) { wxWindow::DoSetSize(x, y, width, height, sizeFlags); if (sizeFlags & wxSIZE_USE_EXISTING) return; +#if defined(__LINUX__) && defined(__WXGTK__) + // Keep the native GStreamer video window filling the client area. + if (m_gtk_video_window) { + const wxSize client_size = GetClientSize(); + m_gtk_video_window->SetSize(0, 0, client_size.GetWidth(), client_size.GetHeight()); + } +#endif wxMediaCtrl_OnSize(this, m_video_size, width, height); } From 5b7a58c8bbec95acfadc5d5410b535d8f040a9d7 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 16:59:46 +0800 Subject: [PATCH 042/138] Install required tools for Linux --- scripts/linux.d/arch | 3 +++ scripts/linux.d/cachyos | 3 +++ scripts/linux.d/clear-linux-os | 1 + scripts/linux.d/debian | 3 +++ scripts/linux.d/fedora | 3 +++ scripts/linux.d/gentoo | 3 +++ scripts/linux.d/suse | 3 +++ 7 files changed, 19 insertions(+) diff --git a/scripts/linux.d/arch b/scripts/linux.d/arch index ead963e9a6..dc554b10b5 100644 --- a/scripts/linux.d/arch +++ b/scripts/linux.d/arch @@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=( wayland-protocols webkit2gtk-4.1 wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/cachyos b/scripts/linux.d/cachyos index d491747ace..6137a3ef5c 100644 --- a/scripts/linux.d/cachyos +++ b/scripts/linux.d/cachyos @@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=( wayland-protocols webkit2gtk wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/clear-linux-os b/scripts/linux.d/clear-linux-os index 149e805549..41ca68e835 100644 --- a/scripts/linux.d/clear-linux-os +++ b/scripts/linux.d/clear-linux-os @@ -20,6 +20,7 @@ export REQUIRED_BUNDLES=( perl-basic texinfo wget + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/debian b/scripts/linux.d/debian index f4d500760e..c33b24d2b7 100644 --- a/scripts/linux.d/debian +++ b/scripts/linux.d/debian @@ -27,6 +27,9 @@ REQUIRED_DEV_PACKAGES=( ninja-build texinfo wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/fedora b/scripts/linux.d/fedora index ca4eceaee3..993ea0b10a 100644 --- a/scripts/linux.d/fedora +++ b/scripts/linux.d/fedora @@ -31,6 +31,9 @@ REQUIRED_DEV_PACKAGES=( webkit2gtk4.1-devel wget libcurl-devel + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/gentoo b/scripts/linux.d/gentoo index f172ac92c3..f8554542ff 100644 --- a/scripts/linux.d/gentoo +++ b/scripts/linux.d/gentoo @@ -32,6 +32,9 @@ REQUIRED_DEV_PACKAGES=( sys-devel/m4 virtual/libudev x11-libs/gtk+:3 + dev-util/pkgconf + dev-lang/yasm + dev-lang/nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/suse b/scripts/linux.d/suse index 1f4db45f46..682960650b 100644 --- a/scripts/linux.d/suse +++ b/scripts/linux.d/suse @@ -30,6 +30,9 @@ REQUIRED_DEV_PACKAGES=( webkit2gtk4-devel wget libcurl-devel + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] From 547bcc7b0322068273df73d8cf1d63c186b954f0 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 17:05:44 +0800 Subject: [PATCH 043/138] Install required tools for macOS --- .github/workflows/build_deps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_deps.yml b/.github/workflows/build_deps.yml index 9927f5bef6..4e01051073 100644 --- a/.github/workflows/build_deps.yml +++ b/.github/workflows/build_deps.yml @@ -149,7 +149,7 @@ jobs: working-directory: ${{ github.workspace }} run: | if [ -z "${{ vars.SELF_HOSTED }}" ]; then - brew install automake texinfo libtool + brew install automake texinfo libtool pkgconf yasm nasm fi ./build_release_macos.sh -dx ${{ !vars.SELF_HOSTED && '-1' || '' }} -a ${{ inputs.arch }} -t 10.15 (cd "${{ github.workspace }}/deps/build/${{ inputs.arch }}" && \ From 482da7288f957b54bf5d6891c5d44307ed33372c Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 17:29:01 +0800 Subject: [PATCH 044/138] Add ffmpeg to flatpak --- scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index c33425f23f..3cbd311fcd 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -312,6 +312,12 @@ modules: sha256: 0ba163956f2d468b19a91b96c5aba66ee9610843ea41dda628ea44cdafde7db7 dest: external-packages/wxInspector + # FFmpeg n7.0.3 + - type: file + url: https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz + sha256: deedcabe339165214a3637df4c86a507aef0d793cf8774ff68735f4737e8ddbc + dest: external-packages/FFMPEG + # --------------------------------------------------------------- # Fallback archives for deps normally provided by the GNOME SDK. # These are only used if find_package() fails to locate them. From 43ec8805b1658c48145a5ec4ee5811a9e3241f49 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 18:57:39 +0800 Subject: [PATCH 045/138] Fix Linux build --- CMakeLists.txt | 2 +- src/CMakeLists.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ac80726085..c66c099023 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1095,7 +1095,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) endfunction() -function(bambustudio_copy_sos target config postfix output_sos) +function(orcaslicer_copy_sos target config postfix output_sos) set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") message ("set out_dir to CMAKE_CURRENT_BINARY_DIR: ${_out_dir}") diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index df559aa397..4a381663e9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -284,11 +284,11 @@ else () if (NOT APPLE) set(output_sos_Release "") set(output_sos_Debug "") - add_custom_target(BambuStudioSosCopy ALL DEPENDS BambuStudio) + add_custom_target(OrcaSlicerSosCopy ALL DEPENDS OrcaSlicer) if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") - bambustudio_copy_sos(BambuStudioSosCopy "Debug" "d" output_sos_Debug) + orcaslicer_copy_sos(OrcaSlicerSosCopy "Debug" "d" output_sos_Debug) else() - bambustudio_copy_sos(BambuStudioSosCopy "Release" "" output_sos_Release) + orcaslicer_copy_sos(OrcaSlicerSosCopy "Release" "" output_sos_Release) endif() endif() if (APPLE AND NOT CMAKE_MACOSX_BUNDLE) From 86d442919784ca397c7c239dd1f91066c4afad7b Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 21:33:57 +0800 Subject: [PATCH 046/138] Try fix appimage build --- .../platform/unix/build_linux_image.sh.in | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/dev-utils/platform/unix/build_linux_image.sh.in b/src/dev-utils/platform/unix/build_linux_image.sh.in index 85134b764d..eef2178743 100755 --- a/src/dev-utils/platform/unix/build_linux_image.sh.in +++ b/src/dev-utils/platform/unix/build_linux_image.sh.in @@ -96,12 +96,23 @@ copy_shared_object_to_dir() { } bundle_dependency_closure() { - local dst_dir="$1" + local dst_dir + dst_dir="$(cd -- "$1" && pwd)" shift local -a queue=("$@") local target dep dep_real copied_path declare -A seen=() + # Dependencies are resolved with ldd, which only searches the default + # loader path. Deps-built shared libraries (e.g. the FFmpeg stack) are not + # installed there and carry no RUNPATH of their own, so once copied into + # the bundle ldd can no longer resolve one sibling from another + # (libavcodec -> libavutil) and reports it as missing. Extend the loader + # path with the bundle directory plus the source directories of files + # already bundled, so every library that was resolved once keeps resolving + # for its own dependencies. The audit script does the same + # (scripts/check_appimage_libs.sh). + local -a search_dirs=("$dst_dir") while [ ${#queue[@]} -gt 0 ]; do target="${queue[0]}" @@ -128,11 +139,12 @@ bundle_dependency_closure() { seen[$dep_real]=1 copy_shared_object_to_dir "$dep" "$dst_dir" + search_dirs+=("$(dirname "$dep_real")") copied_path="$dst_dir/$(basename "$dep_real")" if [ -e "$copied_path" ]; then queue+=("$copied_path") fi - done < <(appimage_list_direct_dependencies "$target") + done < <(LD_LIBRARY_PATH="$(IFS=:; printf '%s' "${search_dirs[*]}")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" appimage_list_direct_dependencies "$target") done } From 41c107d4f7c2ced5dde2cc3a21df6060892e4d76 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 22:22:02 +0800 Subject: [PATCH 047/138] Fix Linux AppImage bundling of deps-built shared libraries The AppImage dependency closure resolves each bundled ELF's DT_NEEDED entries with plain ldd, which cannot resolve the deps-built FFmpeg stack (libavcodec/libavutil/libswscale) once it is copied into the bundle: those libs are not installed in any standard loader path and carry no RUNPATH of their own, so ldd reports the siblings as missing and the build aborts. Extend the loader path with the bundle directory plus the source directories of already-bundled files (mirroring scripts/check_appimage_libs.sh), and key the dedup set on the bundled file path instead of the source path so dependencies resolved from the bundle directory are not copied onto themselves. Co-Authored-By: Claude --- .../platform/unix/build_linux_image.sh.in | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/dev-utils/platform/unix/build_linux_image.sh.in b/src/dev-utils/platform/unix/build_linux_image.sh.in index eef2178743..873cf2e1b1 100755 --- a/src/dev-utils/platform/unix/build_linux_image.sh.in +++ b/src/dev-utils/platform/unix/build_linux_image.sh.in @@ -83,6 +83,10 @@ copy_shared_object_to_dir() { src_real="$(readlink -f "$src")" dst_name="$(basename "$src_real")" mkdir -p "$dst_dir" + if [ "$src_real" = "$dst_dir/$dst_name" ]; then + # Already bundled; the dependency resolved from the bundle directory. + return 0 + fi cp -fL "$src_real" "$dst_dir/$dst_name" if [ -L "$src" ]; then @@ -101,7 +105,7 @@ bundle_dependency_closure() { shift local -a queue=("$@") - local target dep dep_real copied_path + local target dep dep_real dep_key copied_path declare -A seen=() # Dependencies are resolved with ldd, which only searches the default # loader path. Deps-built shared libraries (e.g. the FFmpeg stack) are not @@ -133,11 +137,17 @@ bundle_dependency_closure() { continue fi - if [ -n "${seen[$dep_real]}" ]; then + # Key dedup on the bundled file rather than the source path: once + # ldd resolves a library from the bundle directory (via the + # LD_LIBRARY_PATH above) its path is a dst_dir path, which differs + # from the source path the first resolution returned. Keying on + # the source path would re-copy the file onto itself. + dep_key="$dst_dir/$(basename "$dep_real")" + if [ -n "${seen[$dep_key]}" ]; then continue fi - seen[$dep_real]=1 + seen[$dep_key]=1 copy_shared_object_to_dir "$dep" "$dst_dir" search_dirs+=("$(dirname "$dep_real")") copied_path="$dst_dir/$(basename "$dep_real")" From e9ca9d41d183b7ef8afb25de118e6ecad30b92af Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Thu, 13 Aug 2026 23:40:37 +0800 Subject: [PATCH 048/138] Attempt to fix Linux unit test --- tests/CMakeLists.txt | 66 ++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3cbdc25f5f..28818a5ee1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,25 +30,57 @@ if (APPLE) target_link_libraries(test_common INTERFACE "-liconv -framework IOKit" "-framework CoreFoundation" -lc++) endif() -# Copies runtime DLLs next to each test executable. Handles both single-config -# generators (CMAKE_BUILD_TYPE set) and multi-config generators (Ninja -# Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs must -# land in every per-config output directory. +# Copies runtime shared libraries next to each test executable. Handles both +# single-config generators (CMAKE_BUILD_TYPE set) and multi-config generators +# (Ninja Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs +# must land in every per-config output directory. On Windows the loader finds +# DLLs in the executable's directory; the Linux branch below does the same for +# the deps-built FFmpeg libraries and adds an $ORIGIN rpath, since the ELF +# loader does not search the executable's directory and the CI unit-test runner +# only receives the tests artifact (no deps install). function(orcaslicer_copy_test_dlls) - if (NOT WIN32) - return() - endif() - set(_configs ${CMAKE_CONFIGURATION_TYPES}) - if (NOT _configs) - set(_configs "${CMAKE_BUILD_TYPE}") - endif() - foreach(_cfg IN LISTS _configs) - if (_cfg STREQUAL "Debug") - orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls) - else() - orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls) + if (WIN32) + set(_configs ${CMAKE_CONFIGURATION_TYPES}) + if (NOT _configs) + set(_configs "${CMAKE_BUILD_TYPE}") endif() - endforeach() + foreach(_cfg IN LISTS _configs) + if (_cfg STREQUAL "Debug") + orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls) + else() + orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls) + endif() + endforeach() + elseif (UNIX AND NOT APPLE) + # Only test executables that link libslic3r_gui pull in the FFmpeg + # shared libraries (src/slic3r/CMakeLists.txt links PkgConfig::LIBAV + # into it). Copy them next to the executable and give it an $ORIGIN + # rpath so the loader finds them when the tests run on the CI unit-test + # runner, which only receives this build/tests tree. + get_target_property(_linked_libs ${_TEST_NAME}_tests LINK_LIBRARIES) + if (NOT "libslic3r_gui" IN_LIST _linked_libs) + return() + endif() + + set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN") + add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.8.100 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.1.100 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.1.100 + "$" + COMMENT "Copying FFmpeg libraries next to test executable" + VERBATIM) + endif() endfunction() # Register Catch2 tags as CTest labels so `ctest -L`/`-LE` can filter by tag. From 73131735a276e49ed7dac933f958103c16fc1637 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 13:25:29 +0800 Subject: [PATCH 049/138] Fix Linux unit tests loading deps-built FFmpeg libraries The test executables that link libslic3r_gui (which links PkgConfig::LIBAV) have a load-time dependency on the deps-built FFmpeg shared libraries. The CI unit-test runner only receives the tests artifact, so those libraries were unresolvable there (Ubuntu 24.04 ships libavcodec.so.60, not .61). Copy the libraries next to each affected test executable and give it an $ORIGIN rpath, mirroring the Windows branch that copies DLLs next to every test executable. orcaslicer_copy_sos now places the copies in the per-config output directory for multi-config generators, like orcaslicer_copy_dlls does. Co-Authored-By: Claude --- CMakeLists.txt | 14 +++++++++++--- tests/CMakeLists.txt | 24 +++++++----------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c66c099023..1e65dc5132 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1097,8 +1097,16 @@ endfunction() function(orcaslicer_copy_sos target config postfix output_sos) - set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") - message ("set out_dir to CMAKE_CURRENT_BINARY_DIR: ${_out_dir}") + get_property(_is_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + get_target_property(_alt_out_dir ${target} RUNTIME_OUTPUT_DIRECTORY) + + if (_alt_out_dir) + set(_out_dir "${_alt_out_dir}") + elseif (_is_multi) + set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}/${config}") + else () + set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") + endif () file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 @@ -1114,7 +1122,7 @@ function(orcaslicer_copy_sos target config postfix output_sos) ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.1.100 DESTINATION ${_out_dir}) - set(${output_dlls} + set(${output_sos} ${_out_dir}/libavcodec.so ${_out_dir}/libavcodec.so.61 ${_out_dir}/libavcodec.so.61.3.100 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 28818a5ee1..c403152c4e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -63,23 +63,13 @@ function(orcaslicer_copy_test_dlls) endif() set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN") - add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${CMAKE_PREFIX_PATH}/lib/libavcodec.so - ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 - ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 - ${CMAKE_PREFIX_PATH}/lib/libavutil.so - ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 - ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.8.100 - ${CMAKE_PREFIX_PATH}/lib/libswscale.so - ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 - ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.1.100 - ${CMAKE_PREFIX_PATH}/lib/libswresample.so - ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 - ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.1.100 - "$" - COMMENT "Copying FFmpeg libraries next to test executable" - VERBATIM) + set(_configs ${CMAKE_CONFIGURATION_TYPES}) + if (NOT _configs) + set(_configs "${CMAKE_BUILD_TYPE}") + endif() + foreach(_cfg IN LISTS _configs) + orcaslicer_copy_sos(${_TEST_NAME}_tests "${_cfg}" "" _unused_sos) + endforeach() endif() endfunction() From 9d37ee4709fdb60bcbfe2b98778b73e604a4d47b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 14 Aug 2026 15:12:18 +0800 Subject: [PATCH 050/138] removed changes that are out of scope --- src/slic3r/GUI/DeviceManager.cpp | 24 ++++------- src/slic3r/Utils/BBLPrinterAgent.cpp | 61 ---------------------------- src/slic3r/Utils/BBLPrinterAgent.hpp | 10 ----- src/slic3r/Utils/IPrinterAgent.hpp | 10 ----- src/slic3r/Utils/NetworkAgent.cpp | 21 ---------- src/slic3r/Utils/NetworkAgent.hpp | 3 -- 6 files changed, 9 insertions(+), 120 deletions(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 48f3cb60fc..d8487f4660 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -1731,11 +1731,9 @@ int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read int MachineObject::command_ams_calibrate(int ams_id) { - if (!m_agent) return -1; - int rtn = m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); - if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) - show_unsupported_dlg(rtn); - return rtn; + std::string gcode_cmd = (boost::format("M620 C%1% \n") % ams_id).str(); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; + return this->publish_gcode(gcode_cmd); } int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max) @@ -1773,11 +1771,9 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s int MachineObject::command_ams_refresh_rfid(std::string tray_id) { - if (!m_agent) return -1; - int rtn = m_agent->command_ams_refresh_rfid(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); - if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) - show_unsupported_dlg(rtn); - return rtn; + std::string gcode_cmd = (boost::format("M620 R%1% \n") % tray_id).str(); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; + return this->publish_gcode(gcode_cmd); } int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id) @@ -1793,11 +1789,9 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id) int MachineObject::command_ams_select_tray(std::string tray_id) { - if (!m_agent) return -1; - int rtn = m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()); - if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) - show_unsupported_dlg(rtn); - return rtn; + std::string gcode_cmd = (boost::format("M620 P%1% \n") % tray_id).str(); + BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd; + return this->publish_gcode(gcode_cmd); } int MachineObject::command_ams_control(std::string action) diff --git a/src/slic3r/Utils/BBLPrinterAgent.cpp b/src/slic3r/Utils/BBLPrinterAgent.cpp index 9d422552fe..ef85e0a1ff 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.cpp +++ b/src/slic3r/Utils/BBLPrinterAgent.cpp @@ -2,9 +2,7 @@ #include "BBLNetworkPlugin.hpp" #include "NetworkAgentFactory.hpp" -#include #include -#include namespace Slic3r { @@ -22,65 +20,6 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr cloud) // Communication // ============================================================================ -std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& tray_id) -{ - return (boost::format("M620 R%1% \n") % tray_id).str(); -} - -std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id) -{ - return (boost::format("M620 C%1% \n") % ams_id).str(); -} - -std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id) -{ - return (boost::format("M620 P%1% \n") % tray_id).str(); -} - -int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) -{ - const std::string gcode = ams_refresh_rfid_gcode(tray_id); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; - nlohmann::json j; - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = gcode; - j["print"]["sequence_id"] = std::to_string(sequence_id); - return publish(dev_id, j, lan_mode); -} - -int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) -{ - const std::string gcode = ams_calibrate_gcode(ams_id); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; - nlohmann::json j; - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = gcode; - j["print"]["sequence_id"] = std::to_string(sequence_id); - return publish(dev_id, j, lan_mode); -} - -int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) -{ - const std::string gcode = ams_select_tray_gcode(tray_id); - BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode; - nlohmann::json j; - j["print"]["command"] = "gcode_line"; - j["print"]["param"] = gcode; - j["print"]["sequence_id"] = std::to_string(sequence_id); - return publish(dev_id, j, lan_mode); -} - -int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode) -{ - const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0); - if (rtn == 0) { - BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn; - } else { - BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn; - } - return rtn; -} - int BBLPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { auto& plugin = BBLNetworkPlugin::instance(); diff --git a/src/slic3r/Utils/BBLPrinterAgent.hpp b/src/slic3r/Utils/BBLPrinterAgent.hpp index a04cd00175..a8880bf6bf 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.hpp +++ b/src/slic3r/Utils/BBLPrinterAgent.hpp @@ -5,7 +5,6 @@ #include "ICloudServiceAgent.hpp" #include #include -#include namespace Slic3r { @@ -29,12 +28,6 @@ public: // Communication int send_message(std::string dev_id, std::string json_str, int qos, int flag) override; - static std::string ams_refresh_rfid_gcode(const std::string& tray_id); - static std::string ams_calibrate_gcode(int ams_id); - static std::string ams_select_tray_gcode(const std::string& tray_id); - int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; - int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override; - int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override; int disconnect_printer() override; int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override; @@ -92,9 +85,6 @@ public: FilamentSyncMode get_filament_sync_mode() const override; private: - // why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json. - int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode); - std::shared_ptr m_cloud_agent; }; diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index 85a1ffb8fc..0fa3616344 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -84,16 +84,6 @@ public: */ virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0; - // why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect - // gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's - // publish funnel turns into a dialog. - virtual int command_ams_refresh_rfid(std::string, std::string, int, bool) - { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - virtual int command_ams_calibrate(std::string, int, int, bool) - { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - virtual int command_ams_select_tray(std::string, std::string, int, bool) - { return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } - /** * Establish a direct LAN connection to a printer. */ diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index b169fca052..0d77e5e660 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -767,27 +767,6 @@ int NetworkAgent::send_message(std::string dev_id, std::string json_str, int qos return -1; } -int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) -{ - if (m_printer_agent) - return m_printer_agent->command_ams_refresh_rfid(dev_id, tray_id, sequence_id, lan_mode); - return -1; -} - -int NetworkAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) -{ - if (m_printer_agent) - return m_printer_agent->command_ams_calibrate(dev_id, ams_id, sequence_id, lan_mode); - return -1; -} - -int NetworkAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) -{ - if (m_printer_agent) - return m_printer_agent->command_ams_select_tray(dev_id, tray_id, sequence_id, lan_mode); - return -1; -} - int NetworkAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) { if (m_printer_agent) diff --git a/src/slic3r/Utils/NetworkAgent.hpp b/src/slic3r/Utils/NetworkAgent.hpp index 317a357135..d7032b7a20 100644 --- a/src/slic3r/Utils/NetworkAgent.hpp +++ b/src/slic3r/Utils/NetworkAgent.hpp @@ -142,9 +142,6 @@ public: int set_on_local_message_fn(OnMessageFn fn); int set_server_callback(OnServerErrFn fn); int send_message(std::string dev_id, std::string json_str, int qos, int flag); - int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode); - int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode); - int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode); int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl); int disconnect_printer(); int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag); From 111364d8808750fff1b02b3bb331b5f87781aeaa Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 20:08:35 +0800 Subject: [PATCH 051/138] Add design doc for macOS FFmpeg player Co-Authored-By: Claude --- .../2026-08-14-ffmpeg-player-macos-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md diff --git a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md new file mode 100644 index 0000000000..812a7bf735 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md @@ -0,0 +1,110 @@ +# FFmpeg Media Player for macOS — Design + +Date: 2026-08-14 +Branch: `dev/ffmpeg-player-macos` + +## Problem + +The branch's new FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) is used on +Windows and Linux, but macOS still runs the old player: `wxMediaCtrl2.mm`, an ObjC +`BambuPlayer` class dlsym'd from the Bambu network plugin that renders via CALayer. +On macOS, `wxMediaCtrl3` is currently aliased to `wxMediaCtrl2` and FFmpeg is not linked +into the app at all. + +Goal: make macOS use the same FFmpeg player as Windows/Linux, linking the **static** +FFmpeg libraries from the deps build instead of dynamic ones. + +## Current state (verified) + +- New player (Win/Linux): `GUI/wxMediaCtrl3.cpp` + `GUI/AVVideoDecoder.cpp`. Decodes with + FFmpeg (libavcodec/libswscale/libavutil), renders frames into `wxImage` (non-Windows) / + `wxBitmap` (Windows) drawn in a `paintEvent`, feeds via the `Bambu_*` C API + (`BambuTunnel.h`, `BAMBU_DYNAMIC`) dlsym'd from the network plugin through + `StaticBambuLib::get()` (`GUI/Printer/PrinterFileSystem.cpp`, compiled on all platforms). +- Old player (macOS): `GUI/wxMediaCtrl2.mm` uses the ObjC `BambuPlayer` class found via + `dlsym(module, "OBJC_CLASS_$_BambuPlayer")` in `libBambuSource.dylib`. +- The macOS network plugin `libBambuSource.dylib` already exports the full Bambu C API + (verified with `nm`), so the new player needs zero plugin changes. +- FFmpeg linking in `src/slic3r/CMakeLists.txt` is guarded by `if (NOT APPLE)` — + macOS currently does not link FFmpeg. +- `deps/FFMPEG/FFMPEG.cmake`: non-MSVC branch builds FFmpeg from source with + `--enable-shared`. The existing arm64 deps build on the dev machine happened to be + configured with both static and shared enabled, so `libavcodec.a` / `libswscale.a` / + `libavutil.a` are already present at + `deps/build/arm64/OrcaSlicer_dep/usr/local/lib/`. +- `EVT_MEDIA_CTRL_STAT` is `wxDEFINE_EVENT`'d in `wxMediaCtrl2.cpp` (Win/Linux) and + `wxMediaCtrl2.mm` (macOS); the define in `wxMediaCtrl3.cpp` is commented out. +- `wxMediaCtrl2` is never instantiated anywhere on any platform — dead code. +- `StatusPanel` already creates `wxMediaCtrl3`; `MediaPlayCtrl` only uses the + `wxMediaCtrl3` interface (`Load/Play/Stop/GetState/GetVideoSize/GetLastError/SetIdleImage`), + so no UI-side changes are needed. + +## Approach (approved) + +**Reuse the shared player on macOS.** Compile the existing `wxMediaCtrl3.cpp` + +`AVVideoDecoder.cpp` on macOS so all three platforms run one implementation. +Rendering uses the existing `wxImage` → `DrawBitmap` paint path, identical to Linux. +Known trade-off: frames are scaled to the widget's logical (1x) size, so Retina is +slightly soft compared to the old CALayer player. Accepted for now; a Retina-aware +scaling follow-up is possible later. + +Rejected alternative: a native CGImage/CALayer renderer for macOS — faster and +Retina-crisp, but adds a second render implementation to maintain. + +## Changes + +### 1. Enable the FFmpeg player on macOS (source) + +- `GUI/wxMediaCtrl3.h`: remove the `#ifdef __WXMAC__` branch (lines 18–22) that aliases + `wxMediaCtrl3` → `wxMediaCtrl2`. macOS then compiles the real `wxMediaCtrl3` class, + including the `BAMBU_DYNAMIC` BambuTunnel path used on Linux. +- Event symbol fix: move `wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent)` into + `wxMediaCtrl3.cpp` (uncomment the existing line) and remove it from + `wxMediaCtrl2.cpp`. One definition total in the lib; all three platforms resolve it. + +### 2. Static FFmpeg linking (deps + app) + +- `deps/FFMPEG/FFMPEG.cmake`: in the non-MSVC branch, pass + `--disable-shared --enable-static` when `APPLE`. Linux keeps `--enable-shared`; + Windows keeps its prebuilt shared DLL zips. Fresh macOS deps builds install only + `libavcodec.a` / `libswscale.a` / `libavutil.a` — no dylibs to bundle, no + rpath/install_name handling. (The existing local arm64 deps build already contains + the `.a` files, so no deps rebuild is strictly needed to try the change locally, + but a fresh CI deps build must produce them.) +- `src/slic3r/CMakeLists.txt`: + - APPLE branch of `SLIC3R_GUI_SOURCES`: add `GUI/wxMediaCtrl3.cpp`, + `GUI/wxMediaCtrl3.h`, `GUI/AVVideoDecoder.cpp`, `GUI/AVVideoDecoder.hpp`; + remove `GUI/wxMediaCtrl2.mm` and `GUI/wxMediaCtrl2.h` (the `.h` stays on + disk for the Win/Linux build of `wxMediaCtrl2.cpp`, but nothing on macOS + includes it after this change). + - Add an APPLE mirror of the `NOT APPLE` FFmpeg block: `find_library` for + `libavcodec.a`, `libswscale.a`, `libavutil.a` under `${CMAKE_PREFIX_PATH}/lib` + with `NO_DEFAULT_PATH`, link them (order avcodec → swscale → avutil), and add + `${CMAKE_PREFIX_PATH}/include` as a SYSTEM include directory. Deps are built with + `--disable-zlib` and no external codecs, so the three static libs link cleanly. + +### 3. Remove the old player + +- Delete `GUI/wxMediaCtrl2.mm` and `GUI/BambuPlayer/BambuPlayer.h` (header used only + by the `.mm`; the real `BambuPlayer` lives inside the network plugin). +- Remove the now-dead `__WXMAC__` section of `GUI/wxMediaCtrl2.h`. +- `wxMediaCtrl2.cpp` (Win/Linux) stays in the build as-is (dead but harmless; out of + scope to remove on this branch). + +### 4. Verification + +- Build on macOS: `cmake --build build_arm64` (or `build/arm64`). +- Confirm no dynamic FFmpeg dependency: `otool -L` on the app binary shows no `libav*` + dylib references. +- Runtime: with the network plugin loaded, the Device tab camera preview streams via + the FFmpeg player (check the device page / `MediaPlayCtrl`). +- macOS `ctest` still passes — static linking means no test-executable `.so` copying + hacks (unlike the Linux shared-lib setup). + +## Out of scope + +- Linux (shared libs, AppImage/flatpak bundling) and Windows (prebuilt DLL zips) + keep their current FFmpeg setup. +- Retina-aware frame scaling / native CGImage rendering (follow-up if visual quality + is judged insufficient). +- Audio streaming (neither player plays audio in this UI path). From ec31750330e272e1a6ccf3f23d5f0e2617a74d58 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 20:11:09 +0800 Subject: [PATCH 052/138] Add implementation plan for macOS FFmpeg player Co-Authored-By: Claude --- .../plans/2026-08-14-ffmpeg-player-macos.md | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md diff --git a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md new file mode 100644 index 0000000000..811626fc8b --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md @@ -0,0 +1,376 @@ +# macOS FFmpeg Media Player Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make macOS use the same FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) as Windows/Linux, linking the static FFmpeg libraries from the deps build, and remove the old `wxMediaCtrl2.mm` BambuPlayer-based player. + +**Architecture:** The new player is platform-neutral C++ already used on Linux/Windows. Enabling it on macOS is pure build wiring: compile `wxMediaCtrl3.cpp` + `AVVideoDecoder.cpp` on macOS, drop the `__WXMAC__` alias that redirects `wxMediaCtrl3` to the old `wxMediaCtrl2`, and link static FFmpeg (`libavcodec.a`/`libswscale.a`/`libavutil.a`) from the deps install. The Bambu stream API is dlsym'd at runtime from the network plugin (`libBambuSource.dylib`), which already exports it — no plugin changes needed. Rendering reuses the existing `wxImage` → `DrawBitmap` paint path (same as Linux). + +**Tech Stack:** C++17, wxWidgets, CMake, FFmpeg 7.0.3 (libavcodec/libswscale/libavutil), macOS (Xcode generator), `deps/` ExternalProject build system. + +## Global Constraints + +- Branch: `dev/ffmpeg-player-macos`. Commit after every task. +- **Linux and Windows builds must not change** — the FFmpeg deps flag change is guarded by `APPLE`; Linux keeps `--enable-shared`, Windows keeps its prebuilt DLL zips. +- Static FFmpeg only on macOS: deps produce `libavcodec.a`/`libswscale.a`/`libavutil.a`; the app links those explicitly — the app binary must have **no** `libav*` dylib references (`otool -L` check). +- Follow existing code style: PascalCase classes, snake_case functions, C++17. +- No changes to `StatusPanel.cpp`, `MediaPlayCtrl.*`, or the BambuTunnel interface — the app already creates `wxMediaCtrl3` and uses only its public interface. +- The player cannot be unit-tested (hardware/plugin-dependent GUI code); verification is build-level, link-level, and manual runtime on a Mac. +- `localization/i18n/list.txt` references only `wxMediaCtrl2.cpp` (Win/Linux, stays) — no translation-list changes needed. +- Build dirs on the dev machine: main app = `build_arm64/` (Xcode generator, multi-config), deps = `deps/build/arm64/` (Unix Makefiles). App target name: `OrcaSlicer`. Substitute your own configured build dirs where noted. + +--- + +### Task 1: Enable wxMediaCtrl3 on macOS and link static FFmpeg + +**Files:** +- Modify: `src/slic3r/GUI/wxMediaCtrl3.h` (lines 18–22: the `#ifdef __WXMAC__` alias branch) +- Modify: `src/slic3r/GUI/wxMediaCtrl3.cpp:13` (uncomment the event define) +- Modify: `src/slic3r/GUI/wxMediaCtrl2.cpp:101` (remove the event define) +- Modify: `src/slic3r/CMakeLists.txt` (APPLE source list ~lines 779–792; FFmpeg link block ~lines 905–910) + +**Interfaces:** +- Consumes: nothing new (all classes already exist). +- Produces: `wxMediaCtrl3` class compiled on macOS with the same interface as Linux/Windows — `Load(wxURI)`, `Play()`, `Stop()`, `SetIdleImage(wxString)`, `GetState()`, `GetLastError()`, `GetVideoSize()`, event `EVT_MEDIA_CTRL_STAT` defined once in the lib (from `wxMediaCtrl3.cpp`). + +- [ ] **Step 1: Remove the macOS alias in wxMediaCtrl3.h** + +Current (lines 16–23 of `src/slic3r/GUI/wxMediaCtrl3.h`): + +```cpp +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#ifdef __WXMAC__ + +#include "wxMediaCtrl2.h" +#define wxMediaCtrl3 wxMediaCtrl2 + +#else + +#define BAMBU_DYNAMIC +``` + +New: + +```cpp +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#define BAMBU_DYNAMIC +``` + +Also remove the matching `#endif` that closed the `#else` branch (the one before the final `#endif /* wxMediaCtrl3_h */`), so the file's `#ifndef`/`#endif` guard pair stays balanced. + +- [ ] **Step 2: Move the EVT_MEDIA_CTRL_STAT definition into wxMediaCtrl3.cpp** + +In `src/slic3r/GUI/wxMediaCtrl3.cpp:13`, uncomment: + +```cpp +//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +becomes: + +```cpp +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +In `src/slic3r/GUI/wxMediaCtrl2.cpp:101`, delete: + +```cpp +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +(One definition total in the lib — `MediaPlayCtrl.cpp:59` binds this event on the media ctrl.) + +- [ ] **Step 3: Update the APPLE source list in CMakeLists.txt** + +In `src/slic3r/CMakeLists.txt`, the APPLE branch (currently compiles `wxMediaCtrl2.mm`, which becomes dead on macOS): + +```cmake + GUI/wxMediaCtrl2.mm + GUI/wxMediaCtrl2.h + GUI/wxMediaCtrl3.h + ) +``` + +becomes: + +```cmake + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp + GUI/wxMediaCtrl3.cpp + GUI/wxMediaCtrl3.h + ) +``` + +(The `else ()` branch — Win/Linux — stays exactly as it is.) + +- [ ] **Step 4: Link static FFmpeg on macOS** + +In `src/slic3r/CMakeLists.txt`, the FFmpeg block (currently `if (NOT APPLE)`): + +```cmake +if (NOT APPLE) + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() +``` + +becomes: + +```cmake +if (APPLE) + # Static FFmpeg from the deps install: nothing to bundle into the .app, + # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. + find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) +else () + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() +``` + +The deps install (`${CMAKE_PREFIX_PATH}/lib`) already contains the three `.a` files from the existing arm64 deps build — no deps rebuild needed for this task. + +- [ ] **Step 5: Reconfigure and build the app** + +Run (Xcode generator; `cmake` re-runs automatically on build): + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +``` + +Expected: configure succeeds (no `pkg_check_modules` errors on macOS, `find_library` finds all three `.a` files), compile succeeds (`wxMediaCtrl3.cpp` and `AVVideoDecoder.cpp` compile on macOS without changes), link succeeds. + +If CMake complains that `wxMediaCtrl3.h` is included but not in the source list or similar IDE-only warnings — ignore; headers in the list are cosmetic. + +- [ ] **Step 6: Verify no dynamic FFmpeg dependency** + +```bash +otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" +``` + +Expected: prints `OK: no dynamic FFmpeg` (empty grep output). This is the whole point of static linking — nothing to bundle into the `.app`. + +- [ ] **Step 7: Quick sanity — macOS unit tests still pass** + +```bash +ctest --test-dir build_arm64/tests/libslic3r --output-on-failure +``` + +Expected: passes (add `-C RelWithDebInfo` if the multi-config generator requires it). If no tests were built in this build dir, build target `tests` first (`cmake --build build_arm64 --config RelWithDebInfo --target tests`). + +- [ ] **Step 8: Commit** + +```bash +git add src/slic3r/CMakeLists.txt src/slic3r/GUI/wxMediaCtrl3.h src/slic3r/GUI/wxMediaCtrl3.cpp src/slic3r/GUI/wxMediaCtrl2.cpp +git commit -m "feat: use FFmpeg media player on macOS with static FFmpeg" +``` + +--- + +### Task 2: Static-only FFmpeg in the macOS deps build + +**Files:** +- Modify: `deps/FFMPEG/FFMPEG.cmake` (non-MSVC branch, APPLE section and CONFIGURE_COMMAND) + +**Interfaces:** +- Consumes: nothing. +- Produces: a deps install on macOS containing only `libavcodec.a`, `libswscale.a`, `libavutil.a` (+ headers) — no `libav*` dylibs, so no bundling/rpath machinery is ever needed on macOS. Linux and Windows output are unchanged. + +- [ ] **Step 1: Add the static flag variable** + +In `deps/FFMPEG/FFMPEG.cmake`, inside the non-MSVC `else ()` branch, in the existing `if (APPLE)` block: + +```cmake + if (APPLE) + set(_minos_cmd + "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + ) +``` + +add after the `_minos_cmd` set: + +```cmake + # Static FFmpeg: nothing to bundle into the .app, no rpath handling. + # Shared flags must come AFTER --enable-shared below so they win. + set(_link_cmd --enable-static --disable-shared) +``` + +and add a matching `else ()` after the `if (IS_CROSS_COMPILE) ... endif()` block inside that `if (APPLE)`, so non-Apple Unix keeps shared: + +```cmake + else () + set(_link_cmd --enable-shared) + endif () +``` + +(If the existing `if (IS_CROSS_COMPILE)` block is the last thing inside `if (APPLE)`, the new `else ()` closes the `if (APPLE)` itself.) + +- [ ] **Step 2: Use the variable in CONFIGURE_COMMAND** + +In the `ExternalProject_Add(dep_FFMPEG ...)` configure command: + +```cmake + "--prefix=${DESTDIR}" + --enable-shared +``` + +becomes: + +```cmake + "--prefix=${DESTDIR}" + --enable-shared + ${_link_cmd} +``` + +Order matters: `--enable-shared` comes first, then `--enable-static --disable-shared` (APPLE) or `--enable-shared` (Linux) — the last flag wins in FFmpeg configure. + +- [ ] **Step 3: Rebuild the FFmpeg dep (slow — several minutes, run in background)** + +The changed CONFIGURE_COMMAND invalidates the ExternalProject stamp, so this re-configures and rebuilds FFmpeg: + +```bash +cmake --build deps/build/arm64 --target dep_FFMPEG +``` + +For a fully clean static-only check (removes the previous shared build tree, which can leave stale `.dylib` files behind in the in-source build): + +```bash +rm -rf deps/build/arm64/dep_FFMPEG-prefix +cmake --build deps/build/arm64 --target dep_FFMPEG +``` + +- [ ] **Step 4: Verify the artifacts** + +```bash +ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.a +ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.dylib 2>/dev/null || echo "OK: no dylibs" +``` + +Expected: `libavcodec.a` present, second command prints `OK: no dylibs`. Check `libavutil` and `libswscale` the same way. + +- [ ] **Step 5: Verify the app still links against the static libs** + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" +``` + +Expected: build succeeds, `OK: no dynamic FFmpeg`. + +- [ ] **Step 6: Commit** + +```bash +git add deps/FFMPEG/FFMPEG.cmake +git commit -m "build: build static-only FFmpeg for macOS deps" +``` + +--- + +### Task 3: Remove the old macOS player + +**Files:** +- Delete: `src/slic3r/GUI/wxMediaCtrl2.mm` +- Delete: `src/slic3r/GUI/BambuPlayer/BambuPlayer.h` (and the empty `BambuPlayer/` dir) +- Modify: `src/slic3r/GUI/wxMediaCtrl2.h` (remove the `#ifdef __WXMAC__` section, lines 22–60) + +**Interfaces:** +- Consumes: Task 1 (macOS no longer references `wxMediaCtrl2` — nothing includes `wxMediaCtrl2.h` on macOS anymore; `wxMediaCtrl2` is never instantiated on any platform). +- Produces: a clean tree where the old BambuPlayer-based player is gone from macOS. The `BambuPlayer` ObjC class itself remains inside the network plugin (external prebuilt binary) — only the GUI-side consumer is removed. + +- [ ] **Step 1: Delete the old player files** + +```bash +git rm src/slic3r/GUI/wxMediaCtrl2.mm +git rm src/slic3r/GUI/BambuPlayer/BambuPlayer.h +rmdir src/slic3r/GUI/BambuPlayer 2>/dev/null || true +``` + +- [ ] **Step 2: Strip the __WXMAC__ section from wxMediaCtrl2.h** + +In `src/slic3r/GUI/wxMediaCtrl2.h`, remove the entire macOS branch of the `#ifdef __WXMAC__` guard — from `#ifdef __WXMAC__` (line 22) through the closing `};` of the mac class (line 60), and the `#else` marker — leaving only the non-mac `class wxMediaCtrl2 : public wxMediaCtrl { ... };` definition followed by the final `#endif /* wxMediaCtrl2_h */`. The resulting file keeps its `#ifndef`/`#endif` include guard pair balanced. + +The file stays on disk because Win/Linux compile `wxMediaCtrl2.cpp`, which includes it. + +- [ ] **Step 3: Grep for leftover references** + +```bash +grep -rn "wxMediaCtrl2.mm\|BambuPlayer/BambuPlayer.h\|BambuPlayer" src/slic3r --include="*.cpp" --include="*.h" --include="*.mm" --include="*.txt" +``` + +Expected: no hits in `src/slic3r/GUI` (ignore `localization/i18n/list.txt:196`, which lists the Win/Linux `wxMediaCtrl2.cpp` and stays). + +- [ ] **Step 4: Rebuild the app** + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +``` + +Expected: configure + compile + link succeed with the deleted files gone. + +- [ ] **Step 5: Commit** + +```bash +git add -A src/slic3r/GUI +git commit -m "refactor: remove old BambuPlayer-based media player from macOS" +``` + +--- + +### Task 4: Runtime verification on hardware + +**Files:** none — manual verification. + +**Interfaces:** consumes all prior tasks. Final gate: the new player must actually stream on a Mac. + +- [ ] **Step 1: Launch the freshly built app** + +```bash +open build_arm64/src/RelWithDebInfo/OrcaSlicer.app +``` + +Expected: app launches normally; no crash in the network/device subsystem. + +- [ ] **Step 2: Load the network plugin and open the Device tab** + +Log in / ensure the network plugin (`libBambuSource.dylib`) loads, select a printer, open the Device tab (camera monitoring panel). + +Expected: the camera preview area shows the idle image initially (no crash — this exercises `wxMediaCtrl3::SetIdleImage` and the `wxImage` load path on macOS for the first time). + +- [ ] **Step 3: Start the stream and watch it render** + +Click play / wait for `MediaPlayCtrl` to start the stream. + +Expected: live video renders in the panel. Check the console/log output (`BOOST_LOG` goes to the terminal if run from it, or check the log file): +- `stat_log ...` lines appear (the `EVT_MEDIA_CTRL_STAT` path is live — proves the Bambu C API dlsym worked from `libBambuSource.dylib`); +- no repeated decode/error messages like `AVVideoDecoder: ...` or `can not find function ...` (proves `StaticBambuLib::get` resolved all Bambu functions); +- Stop/Play toggle works; idle image reappears on stop; +- window resize keeps aspect ratio (exercises `DoSetSize`/`adjust_frame_size`/`paintEvent`). + +- [ ] **Step 4: Confirm the old player is really gone** + +Expected: nothing in the logs references `BambuPlayer` (the ObjC class is no longer dlsym'd); the video path is entirely `wxMediaCtrl3` + `AVVideoDecoder`. + +If a printer is unavailable, at minimum verify Steps 1–2 (launch + idle image) and note in the PR that live-stream verification needs hardware. + +- [ ] **Step 5: Final review pass** + +```bash +git log --oneline -6 +git show --stat HEAD # and each of the three task commits +``` + +Expected: the last 4 commits are the design doc + the 3 implementation tasks (each task commit touches only its listed files). Review the diff for scope: no Linux/Windows changes beyond the two `EVT_MEDIA_CTRL_STAT` lines in Task 1, no `StatusPanel`/`MediaPlayCtrl` changes. From 2c8f56dd849fd1aadccf8f0a50c8427bf46497d4 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 21:01:43 +0800 Subject: [PATCH 053/138] feat: use FFmpeg media player on macOS with static FFmpeg --- src/slic3r/CMakeLists.txt | 15 ++++++++++++--- src/slic3r/GUI/wxMediaCtrl2.cpp | 2 -- src/slic3r/GUI/wxMediaCtrl3.cpp | 2 +- src/slic3r/GUI/wxMediaCtrl3.h | 9 --------- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 0436c9a817..afdee92145 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -784,8 +784,9 @@ if (APPLE) GUI/DeepLinkHandlerMac.mm GUI/DeepLinkHandlerMac.h GUI/GUI_UtilsMac.mm - GUI/wxMediaCtrl2.mm - GUI/wxMediaCtrl2.h + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp + GUI/wxMediaCtrl3.cpp GUI/wxMediaCtrl3.h ) FIND_LIBRARY(DISKARBITRATION_LIBRARY DiskArbitration) @@ -903,7 +904,15 @@ if (SLIC3R_PCH AND NOT SLIC3R_SYNTAXONLY) add_precompiled_header(libslic3r_gui pchheader.hpp FORCEINCLUDE) endif () -if (NOT APPLE) +if (APPLE) + # Static FFmpeg from the deps install: nothing to bundle into the .app, + # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. + find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) +else () pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET libavcodec libswscale diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp index 6ab9ec5913..7500de6626 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ b/src/slic3r/GUI/wxMediaCtrl2.cpp @@ -98,8 +98,6 @@ public: }; #endif -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - wxMediaCtrl2::wxMediaCtrl2(wxWindow *parent) { #if defined(__LINUX__) && defined(__WXGTK__) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 0e4bffb3a7..098db68808 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -10,7 +10,7 @@ #include #endif -//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); BEGIN_EVENT_TABLE(wxMediaCtrl3, wxWindow) diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index 859722d421..1d64955ffd 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -15,13 +15,6 @@ wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); -#ifdef __WXMAC__ - -#include "wxMediaCtrl2.h" -#define wxMediaCtrl3 wxMediaCtrl2 - -#else - #define BAMBU_DYNAMIC #include #include @@ -89,6 +82,4 @@ private: std::thread m_thread; }; -#endif - #endif /* wxMediaCtrl3_h */ From 4531dc2af178a6aeaedbfd8dcfdc068a02624a70 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 21:10:26 +0800 Subject: [PATCH 054/138] build: build static-only FFmpeg for macOS deps Co-Authored-By: Claude --- deps/FFMPEG/FFMPEG.cmake | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake index 26ce001833..42e915ca82 100644 --- a/deps/FFMPEG/FFMPEG.cmake +++ b/deps/FFMPEG/FFMPEG.cmake @@ -22,10 +22,13 @@ if (MSVC) else () if (APPLE) - set(_minos_cmd - "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" - "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + set(_minos_cmd + "--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}" + "--extra-ldflags=-mmacosx-version-min=${DEP_OSX_TARGET}" ) + # Static FFmpeg: nothing to bundle into the .app, no rpath handling. + # Shared flags must come AFTER --enable-shared below so they win. + set(_link_cmd --enable-static --disable-shared) if (IS_CROSS_COMPILE) set(_cross_cmd --enable-cross-compile) set(_pic_cmd --enable-pic) @@ -37,7 +40,9 @@ else () set(_cc_cmd "--cc=clang -arch x86_64") endif() endif() - endif() + else () + set(_link_cmd --enable-shared) + endif () set(_build_j -j) if(DEFINED ENV{CMAKE_BUILD_PARALLEL_LEVEL}) @@ -55,6 +60,8 @@ else () ${_cc_cmd} "--prefix=${DESTDIR}" --enable-shared + ${_link_cmd} + ${_minos_cmd} --disable-doc --enable-small --disable-outdevs From 97955dbab8ffcc014fa276fbab019b953d45f9a0 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 21:15:20 +0800 Subject: [PATCH 055/138] refactor: remove old BambuPlayer-based media player from macOS Co-Authored-By: Claude --- src/slic3r/GUI/BambuPlayer/BambuPlayer.h | 30 ---- src/slic3r/GUI/wxMediaCtrl2.h | 44 ------ src/slic3r/GUI/wxMediaCtrl2.mm | 181 ----------------------- 3 files changed, 255 deletions(-) delete mode 100644 src/slic3r/GUI/BambuPlayer/BambuPlayer.h delete mode 100644 src/slic3r/GUI/wxMediaCtrl2.mm diff --git a/src/slic3r/GUI/BambuPlayer/BambuPlayer.h b/src/slic3r/GUI/BambuPlayer/BambuPlayer.h deleted file mode 100644 index fe5f0d049a..0000000000 --- a/src/slic3r/GUI/BambuPlayer/BambuPlayer.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// BambuPlayer.h -// BambuPlayer -// -// Created by cmguo on 2021/12/6. -// - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface BambuPlayer : NSObject - -+ (void) initialize; - -- (instancetype) initWithDisplayLayer: (AVSampleBufferDisplayLayer*) layer; -- (instancetype) initWithImageView: (NSView*) view; -- (int) open: (char const *) url; -- (NSSize) videoSize; -- (int) play; -- (void) stop; -- (void) close; - -- (void) setLogger: (void (*)(void const * context, int level, char const * msg)) logger withContext: (void const *) context; - -@end - -NS_ASSUME_NONNULL_END diff --git a/src/slic3r/GUI/wxMediaCtrl2.h b/src/slic3r/GUI/wxMediaCtrl2.h index c22cc10f2f..a29bf6dba6 100644 --- a/src/slic3r/GUI/wxMediaCtrl2.h +++ b/src/slic3r/GUI/wxMediaCtrl2.h @@ -19,48 +19,6 @@ void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, in typedef struct _GstElement GstElement; #endif -#ifdef __WXMAC__ - -class wxMediaCtrl2 : public wxWindow -{ -public: - wxMediaCtrl2(wxWindow * parent); - - ~wxMediaCtrl2(); - - void Load(wxURI url); - - void Play(); - - void Stop(); - - void SetIdleImage(wxString const & image); - - wxMediaState GetState() const; - - wxSize GetVideoSize() const; - - int GetLastError() const { return m_error; } - - static inline const wxMediaState MEDIASTATE_BUFFERING = static_cast(6); - -protected: - void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; - - static void bambu_log(void const *ctx, int level, char const *msg); - - void NotifyStopped(); - -private: - void create_player(); - void * m_player = nullptr; - wxMediaState m_state = wxMEDIASTATE_STOPPED; - int m_error = 0; - wxSize m_video_size{16, 9}; -}; - -#else - class wxMediaCtrl2 : public wxMediaCtrl { public: @@ -114,6 +72,4 @@ private: wxSize m_video_size{16, 9}; }; -#endif - #endif /* wxMediaCtrl2_h */ diff --git a/src/slic3r/GUI/wxMediaCtrl2.mm b/src/slic3r/GUI/wxMediaCtrl2.mm deleted file mode 100644 index cc081a89fe..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.mm +++ /dev/null @@ -1,181 +0,0 @@ -// -// wxMediaCtrl2.m -// OrcaSlicer -// -// Created by cmguo on 2021/12/7. -// - -#import "wxMediaCtrl2.h" -#import "wx/mediactrl.h" -#include - -#import -#import "BambuPlayer/BambuPlayer.h" -#import "../Utils/NetworkAgent.hpp" - -#include -#include - -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - -#define BAMBU_DYNAMIC - -void wxMediaCtrl2::bambu_log(void const * ctx, int level, char const * msg) -{ - if (level == 1) { - wxString msg2(msg); - if (msg2.EndsWith("]")) { - int n = msg2.find_last_of('['); - if (n != wxString::npos) { - long val = 0; - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - if (msg2.SubString(n + 1, msg2.Length() - 2).ToLong(&val)) - ctrl->m_error = (int) val; - } - } else if (strstr(msg, "stat_log")) { - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); - evt.SetEventObject(ctrl); - evt.SetString(strchr(msg, ' ') + 1); - wxPostEvent(ctrl, evt); - } - } else if (level < 0) { - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - ctrl->NotifyStopped(); - } - BOOST_LOG_TRIVIAL(info) << msg; -} - -wxMediaCtrl2::wxMediaCtrl2(wxWindow * parent) - : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) -{ - NSView * imageView = (NSView *) GetHandle(); - imageView.layer = [[CALayer alloc] init]; - CGColorRef color = CGColorCreateGenericRGB(0, 0, 0, 1.0f); - imageView.layer.backgroundColor = color; - CGColorRelease(color); - imageView.wantsLayer = YES; - create_player(); -} - -wxMediaCtrl2::~wxMediaCtrl2() -{ - BambuPlayer * player = (BambuPlayer *) m_player; - [player dealloc]; -} - -void wxMediaCtrl2::create_player() -{ - auto module = Slic3r::NetworkAgent::get_bambu_source_entry(); - if (!module) { - //not ready yet - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "Network plugin not ready currently!"; - return; - } - Class cls = (__bridge Class) dlsym(module, "OBJC_CLASS_$_BambuPlayer"); - if (cls == nullptr) { - m_error = -2; - return; - } - NSView * imageView = (NSView *) GetHandle(); - BambuPlayer * player = [cls alloc]; - [player initWithImageView: imageView]; - [player setLogger: bambu_log withContext: this]; - m_player = player; -} - -void wxMediaCtrl2::Load(wxURI url) -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - - BambuPlayer * player = (BambuPlayer *) m_player; - if (player) { - [player close]; - m_error = 0; - m_error = [player open: url.BuildURI().ToUTF8()]; - } - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); -} - -void wxMediaCtrl2::Play() -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - BambuPlayer * player2 = (BambuPlayer *) m_player; - [player2 play]; - if (m_state != wxMEDIASTATE_PLAYING) { - m_state = wxMEDIASTATE_PLAYING; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - } -} - -void wxMediaCtrl2::Stop() -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - BambuPlayer * player2 = (BambuPlayer *) m_player; - [player2 close]; - NotifyStopped(); -} - -void wxMediaCtrl2::SetIdleImage(wxString const &image) -{ -} - -void wxMediaCtrl2::NotifyStopped() -{ - if (m_state != wxMEDIASTATE_STOPPED) { - m_state = wxMEDIASTATE_STOPPED; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - } -} - -wxMediaState wxMediaCtrl2::GetState() const -{ - return m_state; -} - -wxSize wxMediaCtrl2::GetVideoSize() const -{ - BambuPlayer * player2 = (BambuPlayer *) m_player; - if (player2) { - NSSize size = [player2 videoSize]; - if (size.width > 0) - const_cast(m_video_size) = {(int) size.width, (int) size.height}; - return {(int) size.width, (int) size.height}; - } else { - return {0, 0}; - } -} - -void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) -{ - wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; - wxMediaCtrl_OnSize(this, m_video_size, width, height); -} From bf40951a4f569cdd1caff497de1d06b606587c3e Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 21:52:31 +0800 Subject: [PATCH 056/138] fix: add static FFmpeg NOTFOUND guard; drop dead wxMediaCtrl2.h include --- src/slic3r/CMakeLists.txt | 3 +++ src/slic3r/GUI/MonitorBasePanel.h | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index afdee92145..fa09f5d712 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -910,6 +910,9 @@ if (APPLE) find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + if (NOT LIBAVCODEC_LIBRARY OR NOT LIBSWSCALE_LIBRARY OR NOT LIBAVUTIL_LIBRARY) + message(FATAL_ERROR "Static FFmpeg (libavcodec.a/libswscale.a/libavutil.a) not found under ${CMAKE_PREFIX_PATH}/lib. Rebuild the deps — FFMPEG builds static-only on macOS.") + endif () target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) else () diff --git a/src/slic3r/GUI/MonitorBasePanel.h b/src/slic3r/GUI/MonitorBasePanel.h index c5c76acec3..42424a7187 100644 --- a/src/slic3r/GUI/MonitorBasePanel.h +++ b/src/slic3r/GUI/MonitorBasePanel.h @@ -34,7 +34,6 @@ #include "Widgets/AxisCtrlButton.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/StaticLine.hpp" -#include "wxMediaCtrl2.h" #include "MediaPlayCtrl.h" /////////////////////////////////////////////////////////////////////////// From dd910dcb85d879d0007c69593fc0e108ba559b3c Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 22:10:29 +0800 Subject: [PATCH 057/138] build: drop redundant --enable-shared in FFmpeg deps configure The literal --enable-shared was always overridden by ${_link_cmd} (--enable-static --disable-shared on Apple, --enable-shared elsewhere) and FFmpeg configure processes these flags in order, last one wins. Remove it and the stale comment documenting the workaround. Co-Authored-By: Claude --- deps/FFMPEG/FFMPEG.cmake | 2 -- 1 file changed, 2 deletions(-) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake index 42e915ca82..60e856f41f 100644 --- a/deps/FFMPEG/FFMPEG.cmake +++ b/deps/FFMPEG/FFMPEG.cmake @@ -27,7 +27,6 @@ else () "--extra-ldflags=-mmacosx-version-min=${DEP_OSX_TARGET}" ) # Static FFmpeg: nothing to bundle into the .app, no rpath handling. - # Shared flags must come AFTER --enable-shared below so they win. set(_link_cmd --enable-static --disable-shared) if (IS_CROSS_COMPILE) set(_cross_cmd --enable-cross-compile) @@ -59,7 +58,6 @@ else () ${_arch_cmd} ${_cc_cmd} "--prefix=${DESTDIR}" - --enable-shared ${_link_cmd} ${_minos_cmd} --disable-doc From 7e3724b5f32c098288be779ad7ce05f9e7d67036 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 22:30:14 +0800 Subject: [PATCH 058/138] refactor: remove dead wxMediaCtrl2 player wxMediaCtrl2 was never instantiated on any platform (USE_WX_MEDIA_CTRL_2 is 0 everywhere); wxMediaCtrl3 replaced it. Delete wxMediaCtrl2.cpp/h, drop them from the Win/Linux source list and the gettext list.txt, and collapse the preprocessor-dead #if USE_WX_MEDIA_CTRL_2 gate in MediaPlayCtrl.h. Co-Authored-By: Claude --- localization/i18n/list.txt | 1 - src/slic3r/CMakeLists.txt | 2 - src/slic3r/GUI/MediaPlayCtrl.h | 7 - src/slic3r/GUI/wxMediaCtrl2.cpp | 649 -------------------------------- src/slic3r/GUI/wxMediaCtrl2.h | 75 ---- 5 files changed, 734 deletions(-) delete mode 100644 src/slic3r/GUI/wxMediaCtrl2.cpp delete mode 100644 src/slic3r/GUI/wxMediaCtrl2.h diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 1614bc453b..5174679abf 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -193,7 +193,6 @@ src/slic3r/GUI/ObjColorDialog.cpp src/slic3r/GUI/SyncAmsInfoDialog.cpp src/slic3r/GUI/WipeTowerDialog.cpp src/slic3r/GUI/wxExtensions.cpp -src/slic3r/GUI/wxMediaCtrl2.cpp src/slic3r/GUI/WebUserLoginDialog.cpp src/slic3r/GUI/WebGuideDialog.cpp src/slic3r/GUI/KBShortcutsDialog.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index fa09f5d712..3a59504b6e 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -794,8 +794,6 @@ else () list(APPEND SLIC3R_GUI_SOURCES GUI/AVVideoDecoder.cpp GUI/AVVideoDecoder.hpp - GUI/wxMediaCtrl2.cpp - GUI/wxMediaCtrl2.h GUI/wxMediaCtrl3.cpp GUI/wxMediaCtrl3.h ) diff --git a/src/slic3r/GUI/MediaPlayCtrl.h b/src/slic3r/GUI/MediaPlayCtrl.h index 4908a782ca..0a01daefc9 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.h +++ b/src/slic3r/GUI/MediaPlayCtrl.h @@ -8,14 +8,7 @@ #ifndef MediaPlayCtrl_h #define MediaPlayCtrl_h -#define USE_WX_MEDIA_CTRL_2 0 - -#if USE_WX_MEDIA_CTRL_2 -#include "wxMediaCtrl2.h" -#define wxMediaCtrl3 wxMediaCtrl2 -#else #include "wxMediaCtrl3.h" -#endif #include diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp deleted file mode 100644 index 7500de6626..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ /dev/null @@ -1,649 +0,0 @@ -#include "wxMediaCtrl2.h" -#include "libslic3r/Time.hpp" -#include "I18N.hpp" -#include "GUI_App.hpp" -#include "libslic3r/Utils.hpp" -#include -#include -#include "LinuxDisplayBackend.hpp" -#include -#include -#ifdef __WIN32__ -#include -#include -#include -#include -#endif - -#ifdef __LINUX__ -#include "Printer/gstbambusrc.h" -#include // main gstreamer header -#endif - -#if defined(__LINUX__) && defined(__WXGTK__) -#include -#include - -namespace { -bool ensure_gstreamer_initialized_for_liveview() -{ - GError* error = nullptr; - if (!gst_init_check(nullptr, nullptr, &error)) { - BOOST_LOG_TRIVIAL(error) << "wxMediaCtrl2: gst_init_check failed before native Wayland liveview setup" - << (error ? std::string(": ") + error->message : std::string()); - if (error) - g_error_free(error); - return false; - } - - return true; -} - -bool is_gstreamer_feature_available(const char* feature) -{ - if (!ensure_gstreamer_initialized_for_liveview()) - return false; - - GstElementFactory* factory = gst_element_factory_find(feature); - if (!factory) - return false; - - gst_object_unref(factory); - return true; -} - -void set_gstreamer_feature_rank(const char* feature, guint rank) -{ - GstElementFactory* factory = gst_element_factory_find(feature); - if (!factory) - return; - - gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(factory), rank); - gst_object_unref(factory); -} - -void configure_wayland_gstreamer_liveview_path() -{ - static bool configured = false; - if (configured) - return; - configured = true; - - if (!ensure_gstreamer_initialized_for_liveview()) - return; - - // Prefer software decode for Bambu liveview on Wayland/NVIDIA, where - // zero-copy GL/DMABUF display paths can be fragile. Keep hardware - // decoders available as lower-ranked fallbacks for VAAPI/NVDEC/V4L2-only - // installations instead of passing preflight and then blocking autoplug. - set_gstreamer_feature_rank("avdec_h264", GST_RANK_PRIMARY + 300); - set_gstreamer_feature_rank("openh264dec", GST_RANK_PRIMARY + 100); - set_gstreamer_feature_rank("nvh264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("vaapih264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("vah264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("v4l2h264dec", GST_RANK_MARGINAL); -} -} - -#endif // defined(__LINUX__) && defined(__WXGTK__) - -#ifdef __LINUX__ -extern "C" int gst_bambu_last_error; - -class WXDLLIMPEXP_MEDIA - wxGStreamerMediaBackend : public wxMediaBackendCommonBase -{ -public: - GstElement *m_playbin; // GStreamer media element -}; -#endif - -wxMediaCtrl2::wxMediaCtrl2(wxWindow *parent) -{ -#if defined(__LINUX__) && defined(__WXGTK__) - m_native_wayland = Slic3r::GUI::is_running_on_wayland(); - if (m_native_wayland && is_gstreamer_feature_available("gtksink")) - configure_wayland_gstreamer_liveview_path(); - else if (m_native_wayland) { - m_gtk_sink_error = _L("Native Wayland liveview requires the GStreamer GTK video sink. Please install the gtksink plugin for GStreamer, then restart OrcaSlicer."); - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: native Wayland liveview disabled because GStreamer gtksink is unavailable"; - } -#endif -#ifdef __WIN32__ - auto hModExe = GetModuleHandle(NULL); - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: GetModuleHandle " << hModExe; - auto NvOptimusEnablement = (DWORD *) GetProcAddress(hModExe, "NvOptimusEnablement"); - auto AmdPowerXpressRequestHighPerformance = (int *) GetProcAddress(hModExe, "AmdPowerXpressRequestHighPerformance"); - if (NvOptimusEnablement) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: NvOptimusEnablement " << *NvOptimusEnablement; - *NvOptimusEnablement = 0; - } - if (AmdPowerXpressRequestHighPerformance) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: AmdPowerXpressRequestHighPerformance " << *AmdPowerXpressRequestHighPerformance; - *AmdPowerXpressRequestHighPerformance = 0; - } -#endif -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_native_wayland) - wxControl::Create(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize); - else -#endif - wxMediaCtrl::Create(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxMEDIACTRLPLAYERCONTROLS_NONE); -#ifdef __LINUX__ - gstbambusrc_register(); -#ifdef __WXGTK__ - if (m_native_wayland && m_gtk_sink_error.empty()) - m_use_gtk_sink = CreateGtkSinkPlayer(); - if (m_native_wayland && !m_use_gtk_sink && m_gtk_sink_error.empty()) - m_gtk_sink_error = _L("Failed to initialize the native Wayland GStreamer video sink. Please check your GStreamer GTK plugin installation."); -#endif - if (!m_use_gtk_sink && m_imp) { - auto playbin = reinterpret_cast(m_imp)->m_playbin; - g_object_set(G_OBJECT(playbin), - "audio-sink", nullptr, - nullptr); - } else if (!m_use_gtk_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: wxMediaCtrl backend is unavailable"; - } - Bind(wxEVT_MEDIA_LOADED, [this](auto & e) { - m_loaded = true; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(0); - event.SetEventObject(this); - wxPostEvent(this, event); - }); -#endif -} - -wxMediaCtrl2::~wxMediaCtrl2() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - DestroyGtkSinkPlayer(); -#endif -} - -#if defined(__LINUX__) && defined(__WXGTK__) -bool wxMediaCtrl2::CreateGtkSinkPlayer() -{ - GstElement *playbin = gst_element_factory_make("playbin", "orca-wayland-gtk-playbin"); - if (!playbin) - return false; - - GError *error = nullptr; - GstElement *video_sink = gst_parse_bin_from_description( - "videoconvert ! videoscale ! video/x-raw,format=BGRx ! gtksink name=orca_wayland_gtksink sync=false", - TRUE, - &error); - if (!video_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: failed to create gtksink video bin" - << (error ? std::string(": ") + error->message : std::string()); - if (error) - g_error_free(error); - gst_object_unref(playbin); - return false; - } - - GstElement *gtk_sink = gst_bin_get_by_name(GST_BIN(video_sink), "orca_wayland_gtksink"); - if (!gtk_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: failed to find gtksink in video bin"; - gst_object_unref(video_sink); - gst_object_unref(playbin); - return false; - } - - GtkWidget *gtk_widget = nullptr; - g_object_get(G_OBJECT(gtk_sink), "widget", >k_widget, nullptr); - if (!gtk_widget) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: gtksink did not expose a GtkWidget"; - gst_object_unref(gtk_sink); - gst_object_unref(video_sink); - gst_object_unref(playbin); - return false; - } - - gtk_widget_show(gtk_widget); - m_gtk_video_window = new wxNativeWindow(this, wxID_ANY, gtk_widget); - m_gtk_video_window->Show(); - g_object_unref(gtk_widget); - - g_object_set(G_OBJECT(playbin), - "video-sink", video_sink, - "audio-sink", nullptr, - nullptr); - gst_object_unref(video_sink); - - m_gtk_playbin = playbin; - m_gtk_sink = gtk_sink; - - GstBus *bus = gst_element_get_bus(playbin); - m_gtk_bus_watch_id = gst_bus_add_watch(bus, [](GstBus *, GstMessage *message, gpointer data) -> gboolean { - auto *self = static_cast(data); - if (!self || !self->m_gtk_playbin) - return G_SOURCE_REMOVE; - - switch (GST_MESSAGE_TYPE(message)) { - case GST_MESSAGE_ERROR: - { - GError *error = nullptr; - gchar *debug = nullptr; - gst_message_parse_error(message, &error, &debug); - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: gtksink pipeline error" - << (error ? std::string(": ") + error->message : std::string()) - << (debug ? std::string(" debug: ") + debug : std::string()); - if (error) - g_error_free(error); - if (debug) - g_free(debug); - - self->m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(self->GetId()); - break; - } - case GST_MESSAGE_EOS: - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(self->GetId()); - break; - case GST_MESSAGE_STATE_CHANGED: - if (GST_MESSAGE_SRC(message) == GST_OBJECT(self->m_gtk_playbin)) { - GstState old_state; - GstState new_state; - GstState pending_state; - gst_message_parse_state_changed(message, &old_state, &new_state, &pending_state); - - if (new_state == GST_STATE_PLAYING) { - self->m_loaded = true; - self->m_gtk_state = wxMEDIASTATE_PLAYING; - self->PostGtkSinkStateEvent(); - } else if (new_state == GST_STATE_PAUSED && old_state < GST_STATE_PAUSED) { - // Treat only upward READY/NULL -> PAUSED as load completion. - // PLAYING -> PAUSED is a normal teardown step before NULL. - self->m_loaded = true; - self->m_gtk_state = wxMEDIASTATE_PAUSED; - self->PostGtkSinkStateEvent(); - } else if (new_state <= GST_STATE_READY && old_state >= GST_STATE_PAUSED) { - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(); - } - } - break; - default: - break; - } - - return G_SOURCE_CONTINUE; - }, this); - gst_object_unref(bus); - - BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: using GTK native Wayland video sink"; - return true; -} - -void wxMediaCtrl2::DestroyGtkSinkPlayer() -{ - if (m_gtk_bus_watch_id) { - g_source_remove(m_gtk_bus_watch_id); - m_gtk_bus_watch_id = 0; - } - - if (m_gtk_playbin) { - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - } - - if (m_gtk_video_window) { - m_gtk_video_window->Destroy(); - m_gtk_video_window = nullptr; - } - - if (m_gtk_playbin) { - gst_object_unref(m_gtk_playbin); - m_gtk_playbin = nullptr; - } - - if (m_gtk_sink) { - gst_object_unref(m_gtk_sink); - m_gtk_sink = nullptr; - } - -} - -void wxMediaCtrl2::PostGtkSinkStateEvent(int id) -{ - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(id); - event.SetEventObject(this); - wxPostEvent(this, event); -} -#endif // defined(__LINUX__) && defined(__WXGTK__) - -#define CLSID_BAMBU_SOURCE L"{233E64FB-2041-4A6C-AFAB-FF9BCF83E7AA}" - -void wxMediaCtrl2::Load(wxURI url) -{ -#ifdef __WIN32__ - InvalidateBestSize(); - if (m_imp == nullptr) { - static bool notified = false; - if (!notified) CallAfter([] { - auto res = wxMessageBox(_L("Windows Media Player is required for this task! Do you want to enable 'Windows Media Player' for your operation system?"), _L("Error"), wxOK | wxCANCEL); - if (res == wxOK) { - wxString url = IsWindows10OrGreater() - ? "ms-settings:optionalfeatures?activationSource=SMC-Article-14209" - : "https://support.microsoft.com/en-au/windows/get-windows-media-player-81718e0d-cfce-25b1-aee3-94596b658287"; - wxExecute("cmd /c start " + url, wxEXEC_HIDE_CONSOLE); - } - }); - m_error = 100; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - { - wxRegKey key11(wxRegKey::HKCU, L"SOFTWARE\\Classes\\CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); - wxRegKey key12(wxRegKey::HKCR, L"CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); - wxString path = key11.Exists() ? key11.QueryDefaultValue() - : key12.Exists() ? key12.QueryDefaultValue() : wxString{}; - wxRegKey key2(wxRegKey::HKCR, "bambu"); - wxString clsid; - if (key2.Exists()) - key2.QueryRawValue("Source Filter", clsid); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": clsid %1% path %2%") % clsid % path; - - std::string data_dir_str = Slic3r::data_dir(); - boost::filesystem::path data_dir_path(data_dir_str); - auto dll_path = data_dir_path / "plugins" / "BambuSource.dll"; - if (path.empty() || !wxFile::Exists(path) || clsid != CLSID_BAMBU_SOURCE) { - if (boost::filesystem::exists(dll_path)) { - CallAfter( - [dll_path] { - int res = wxMessageBox(_L("BambuSource has not correctly been registered for media playing! Press Yes to re-register it. You will be promoted twice"), _L("Error"), wxYES_NO); - if (res == wxYES) { - std::string regContent = R"(Windows Registry Editor Version 5.00 - [HKEY_CLASSES_ROOT\bambu] - "Source Filter"="{233E64FB-2041-4A6C-AFAB-FF9BCF83E7AA}" - )"; - - auto reg_path = (fs::temp_directory_path() / fs::unique_path()).replace_extension(".reg"); - std::ofstream temp_reg_file(reg_path.c_str()); - if (!temp_reg_file) { - return false; - } - temp_reg_file << regContent; - temp_reg_file.close(); - auto sei_params = L"/q /s " + reg_path.wstring(); - SHELLEXECUTEINFO sei{sizeof(sei), SEE_MASK_NOCLOSEPROCESS, NULL, L"open", - L"regedit", sei_params.c_str(),SW_HIDE,SW_HIDE}; - ::ShellExecuteEx(&sei); - - wstring quoted_dll_path = L"\"" + dll_path.wstring() + L"\""; - SHELLEXECUTEINFO info{sizeof(info), 0, NULL, L"runas", L"regsvr32", quoted_dll_path.c_str(), SW_HIDE }; - ::ShellExecuteEx(&info); - fs::remove(reg_path); - } - return true; - }); - } else { - CallAfter([] { - wxMessageBox(_L("Missing BambuSource component registered for media playing! Please re-install OrcaSlicer or seek community help."), _L("Error"), wxOK); - }); - } - m_error = clsid != CLSID_BAMBU_SOURCE ? 101 : path.empty() ? 102 : 103; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - if (path != dll_path) { - static bool notified = false; - if (!notified) CallAfter([dll_path] { - int res = wxMessageBox(_L("Using a BambuSource from a different install, video play may not work correctly! Press Yes to fix it."), _L("Warning"), wxYES_NO | wxICON_WARNING); - if (res == wxYES) { - auto path = dll_path.wstring(); - if (path.find(L' ') != std::wstring::npos) - path = L"\"" + path + L"\""; - SHELLEXECUTEINFO info{sizeof(info), 0, NULL, L"open", L"regsvr32", path.c_str(), SW_HIDE}; - ::ShellExecuteEx(&info); - } - }); - notified = true; - } - wxRegKey keyWmp(wxRegKey::HKCU, "SOFTWARE\\Microsoft\\MediaPlayer\\Player\\Extensions\\."); - keyWmp.Create(); - long permissions = 0; - if (keyWmp.HasValue("Permissions")) - keyWmp.QueryValue("Permissions", &permissions); - if ((permissions & 32) == 0) { - permissions |= 32; - keyWmp.SetValue("Permissions", permissions); - } - } - url = wxURI(url.BuildURI().append("&hwnd=").append(boost::lexical_cast(GetHandle())).append("&tid=").append( - boost::lexical_cast(GetCurrentThreadId()))); -#endif -#ifdef __WXGTK3__ - GstElementFactory *factory; - int hasplugins = 1; - - factory = gst_element_factory_find("h264parse"); - if (!factory) { - hasplugins = 0; - } else { - gst_object_unref(factory); - } - - factory = gst_element_factory_find("openh264dec"); - if (!factory) { - factory = gst_element_factory_find("avdec_h264"); - } - if (!factory) { - factory = gst_element_factory_find("vaapih264dec"); - } - if (!factory) { - factory = gst_element_factory_find("vah264dec"); - } - if (!factory) { - factory = gst_element_factory_find("nvh264dec"); - } - if (!factory) { - factory = gst_element_factory_find("v4l2h264dec"); - } - if (!factory) { - hasplugins = 0; - } else { - gst_object_unref(factory); - } - - if (!hasplugins) { - CallAfter([] { - wxMessageBox(_L("Your system is missing H.264 codecs for GStreamer, which are required to play video. (Try installing the gstreamer1.0-plugins-bad or gstreamer1.0-libav packages, then restart Orca Slicer?)"), _L("Error"), wxOK); - }); - m_error = 101; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - wxLog::EnableLogging(false); -#endif - m_error = 0; - m_loaded = false; -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - const std::string uri = std::string(url.BuildURI().ToUTF8().data()); - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - g_object_set(G_OBJECT(m_gtk_playbin), "uri", uri.c_str(), nullptr); - m_gtk_state = wxMEDIASTATE_STOPPED; - GstStateChangeReturn state = gst_element_set_state(m_gtk_playbin, GST_STATE_PAUSED); - if (state == GST_STATE_CHANGE_FAILURE) { - m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - PostGtkSinkStateEvent(GetId()); - } - return; - } - if (!m_imp) { - m_error = m_native_wayland && !m_gtk_sink_error.empty() ? 104 : 100; - m_loaded = false; - if (m_native_wayland && !m_gtk_sink_error.empty() && !m_gtk_sink_error_notified) { - m_gtk_sink_error_notified = true; - const wxString message = m_gtk_sink_error; - CallAfter([message] { - wxMessageBox(message, _L("Error"), wxOK); - }); - } - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } -#endif - wxMediaCtrl::Load(url); -} - -void wxMediaCtrl2::Play() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - GstStateChangeReturn state = gst_element_set_state(m_gtk_playbin, GST_STATE_PLAYING); - if (state == GST_STATE_CHANGE_FAILURE) { - m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - m_gtk_state = wxMEDIASTATE_STOPPED; - PostGtkSinkStateEvent(GetId()); - } - return; - } - if (!m_imp) { - m_error = m_native_wayland && !m_gtk_sink_error.empty() ? 104 : 100; - if (m_native_wayland && !m_gtk_sink_error.empty() && !m_gtk_sink_error_notified) { - m_gtk_sink_error_notified = true; - const wxString message = m_gtk_sink_error; - CallAfter([message] { - wxMessageBox(message, _L("Error"), wxOK); - }); - } - PostGtkSinkStateEvent(GetId()); - return; - } -#endif - wxMediaCtrl::Play(); -} - -void wxMediaCtrl2::Stop() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - m_gtk_state = wxMEDIASTATE_STOPPED; - m_loaded = false; - PostGtkSinkStateEvent(0); - return; - } - if (!m_imp) - return; -#endif - wxMediaCtrl::Stop(); } - -void wxMediaCtrl2::SetIdleImage(wxString const &image) {} - -wxMediaState wxMediaCtrl2::GetState() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) - return m_gtk_state; - if (!m_imp) - return wxMEDIASTATE_STOPPED; -#endif - return wxMediaCtrl::GetState(); -} - -int wxMediaCtrl2::GetLastError() const -{ -#ifdef __LINUX__ -#ifdef __WXGTK__ - if (m_use_gtk_sink && m_error) - return m_error; -#endif - if (m_error) - return m_error; - return gst_bambu_last_error; -#else - return m_error; -#endif -} - -wxSize wxMediaCtrl2::GetVideoSize() const -{ -#ifdef __LINUX__ - // Gstreamer doesn't give us a VideoSize until we're playing, which - // confuses the MediaPlayCtrl into claiming that it is stuck - // "Loading...". Fake it out for now. - return m_loaded ? wxSize(1280, 720) : wxSize{}; -#else - wxSize size = m_imp ? m_imp->GetVideoSize() : wxSize(0, 0); - if (size.GetWidth() > 0) - const_cast(m_video_size) = size; - return size; -#endif -} - -wxSize wxMediaCtrl2::DoGetBestSize() const -{ - return {-1, -1}; -} - -void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) -{ - wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; -#if defined(__LINUX__) && defined(__WXGTK__) - // Keep the native GStreamer video window filling the client area. - if (m_gtk_video_window) { - const wxSize client_size = GetClientSize(); - m_gtk_video_window->SetSize(0, 0, client_size.GetWidth(), client_size.GetHeight()); - } -#endif - wxMediaCtrl_OnSize(this, m_video_size, width, height); -} - -#ifdef __WIN32__ - -WXLRESULT wxMediaCtrl2::MSWWindowProc(WXUINT nMsg, - WXWPARAM wParam, - WXLPARAM lParam) -{ - // The stream source sends WM_USER+1000 with a synchronous SendMessage from its own threads, - // so this runs re-entrantly on the UI thread at whatever message-retrieval point the player - // happens to be in - often nested inside an Orca log statement. Never BOOST_LOG_TRIVIAL here: - // boost::log is not re-entrant on one thread, and doing so corrupted its per-thread record - // state, crashing later in unrelated places (the player, the log filter, a plug-in heap free). - // Post the string out (as the stat branch does) and log it on a clean stack instead. - if (nMsg == WM_USER + 1000) { - wxString msg((wchar_t const *) lParam); - if (wParam == 1) { - if (msg.EndsWith("]")) { - int n = msg.find_last_of('['); - if (n != wxString::npos) { - long val = 0; - if (msg.SubString(n + 1, msg.Length() - 2).ToLong(&val)) - m_error = (int) val; - } - } else if (msg.Contains("stat_log")) { - wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); - evt.SetEventObject(this); - evt.SetString(msg.Mid(msg.Find(' ') + 1)); - wxPostEvent(this, evt); - } - } - return 0; - } - return wxMediaCtrl::MSWWindowProc(nMsg, wParam, lParam); -} - -#endif diff --git a/src/slic3r/GUI/wxMediaCtrl2.h b/src/slic3r/GUI/wxMediaCtrl2.h deleted file mode 100644 index a29bf6dba6..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.h +++ /dev/null @@ -1,75 +0,0 @@ -// -// wxMediaCtrl2.h -// libslic3r_gui -// -// Created by cmguo on 2021/12/7. -// - -#ifndef wxMediaCtrl2_h -#define wxMediaCtrl2_h - -#include "wx/uri.h" -#include "wx/mediactrl.h" - -wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - -void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); - -#if defined(__LINUX__) && defined(__WXGTK__) -typedef struct _GstElement GstElement; -#endif - -class wxMediaCtrl2 : public wxMediaCtrl -{ -public: - wxMediaCtrl2(wxWindow *parent); - ~wxMediaCtrl2(); - - void Load(wxURI url); - - void Play(); - - void Stop(); - - void SetIdleImage(wxString const & image); - - wxMediaState GetState(); - - int GetLastError() const; - - wxSize GetVideoSize() const; - -protected: - wxSize DoGetBestSize() const override; - - void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; - -#ifdef __WIN32__ - WXLRESULT MSWWindowProc(WXUINT nMsg, - WXWPARAM wParam, - WXLPARAM lParam) override; -#endif - -private: -#if defined(__LINUX__) && defined(__WXGTK__) - bool CreateGtkSinkPlayer(); - void DestroyGtkSinkPlayer(); - void PostGtkSinkStateEvent(int id = 0); - - bool m_native_wayland = false; - bool m_use_gtk_sink = false; - wxString m_gtk_sink_error; - bool m_gtk_sink_error_notified = false; - GstElement *m_gtk_playbin = nullptr; - GstElement *m_gtk_sink = nullptr; - unsigned int m_gtk_bus_watch_id = 0; - wxWindow *m_gtk_video_window = nullptr; - wxMediaState m_gtk_state = wxMEDIASTATE_STOPPED; -#endif - wxString m_idle_image; - int m_error = 0; - bool m_loaded = false; - wxSize m_video_size{16, 9}; -}; - -#endif /* wxMediaCtrl2_h */ From adcbdddc1215cf03cbac1823741abd7ebf9caa1f Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 22:48:33 +0800 Subject: [PATCH 059/138] refactor: remove dead GStreamer bambusrc plugin and its build dep gstbambusrc was the GStreamer source element for the old wxMediaCtrl2 Wayland player, its only consumer (deleted in the previous commit). The new FFmpeg player handles bambu:/// URIs through the Bambu C API instead. Drop the plugin and the gstreamer-1.0 / gstreamer-base-1.0 REQUIRED pkg-config dependencies that existed solely for it. Co-Authored-By: Claude --- src/slic3r/CMakeLists.txt | 11 - src/slic3r/GUI/Printer/gstbambusrc.c | 657 --------------------------- src/slic3r/GUI/Printer/gstbambusrc.h | 78 ---- 3 files changed, 746 deletions(-) delete mode 100644 src/slic3r/GUI/Printer/gstbambusrc.c delete mode 100644 src/slic3r/GUI/Printer/gstbambusrc.h diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 3a59504b6e..7d799da4a8 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -799,12 +799,6 @@ else () ) endif () -if (UNIX AND NOT APPLE) - list(APPEND SLIC3R_GUI_SOURCES - GUI/Printer/gstbambusrc.c - ) -endif () - set(ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY}") string(STRIP "${ORCA_UPDATER_SIG_KEY_B64}" ORCA_UPDATER_SIG_KEY_B64) string(REPLACE "\n" "" ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY_B64}") @@ -948,11 +942,6 @@ if (UNIX AND NOT APPLE) target_compile_definitions(libslic3r_gui PRIVATE wxHAVE_GDK_WAYLAND) endif () - # We add GStreamer for bambu:/// support. - pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) - pkg_check_modules(GST_BASE REQUIRED gstreamer-base-1.0) - target_link_libraries(libslic3r_gui ${GSTREAMER_LIBRARIES} ${GST_BASE_LIBRARIES}) - target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GSTREAMER_INCLUDE_DIRS} ${GST_BASE_INCLUDE_DIRS}) endif () # Add a definition so that we can tell we are compiling slic3r. diff --git a/src/slic3r/GUI/Printer/gstbambusrc.c b/src/slic3r/GUI/Printer/gstbambusrc.c deleted file mode 100644 index d3f4ab112a..0000000000 --- a/src/slic3r/GUI/Printer/gstbambusrc.c +++ /dev/null @@ -1,657 +0,0 @@ -/* bambusrc for gstreamer - * integration with proprietary Bambu Lab blob for getting raw h.264 video - * - * Copyright (C) 2023 Joshua Wise - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - * - * Alternatively, the contents of this file may be used under the - * GNU Lesser General Public License Version 2.1 (the "LGPL"), in - * which case the following provisions apply instead of the ones - * mentioned above: - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Library General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Library General Public License for more details. - * - * You should have received a copy of the GNU Library General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 59 Temple Place - Suite 330, - * Boston, MA 02111-1307, USA. - */ - -#ifdef HAVE_CONFIG_H -# include -#endif - -#include - -#include "gstbambusrc.h" - -#include -#include -#ifndef EXTERNAL_GST_PLUGIN -#define BAMBU_DYNAMIC -#endif -#include "BambuTunnel.h" - -#ifdef BAMBU_DYNAMIC -// From PrinterFileSystem. -#ifdef __cplusplus -extern "C" -#else -extern -#endif -BambuLib *bambulib_get(); -BambuLib *_lib = NULL; -#define BAMBULIB(x) (_lib->x) - -#else -#define BAMBULIB(x) (x) -#endif - -GST_DEBUG_CATEGORY_STATIC (gst_bambusrc_debug); -#define GST_CAT_DEFAULT gst_bambusrc_debug - -static GstStaticPadTemplate srctemplate = GST_STATIC_PAD_TEMPLATE ("src", - GST_PAD_SRC, - GST_PAD_ALWAYS, - GST_STATIC_CAPS_ANY); - //GST_STATIC_CAPS("video/x-h264,framerate=0/1,parsed=(boolean)false,stream-format=(string)byte-stream")); - -enum -{ - PROP_0, - PROP_LOCATION, -}; - -static void gst_bambusrc_uri_handler_init (gpointer g_iface, - gpointer iface_data); -static void gst_bambusrc_finalize (GObject * gobject); -static void gst_bambusrc_dispose (GObject * gobject); - -static void gst_bambusrc_set_property (GObject * object, guint prop_id, - const GValue * value, GParamSpec * pspec); -static void gst_bambusrc_get_property (GObject * object, guint prop_id, - GValue * value, GParamSpec * pspec); - -static GstStateChangeReturn gst_bambusrc_change_state (GstElement * - element, GstStateChange transition); -static GstFlowReturn gst_bambusrc_create (GstPushSrc * psrc, - GstBuffer ** outbuf); -static gboolean gst_bambusrc_start (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_stop (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_is_seekable (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_query (GstBaseSrc * bsrc, GstQuery * query); -static gboolean gst_bambusrc_unlock (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_unlock_stop (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_set_location (GstBambuSrc * src, - const gchar * uri, GError ** error); - -#define gst_bambusrc_parent_class parent_class -G_DEFINE_TYPE_WITH_CODE (GstBambuSrc, gst_bambusrc, GST_TYPE_PUSH_SRC, - G_IMPLEMENT_INTERFACE (GST_TYPE_URI_HANDLER, - gst_bambusrc_uri_handler_init)); - -static void -gst_bambusrc_class_init (GstBambuSrcClass * klass) -{ - GObjectClass *gobject_class; - GstElementClass *gstelement_class; - GstBaseSrcClass *gstbasesrc_class; - GstPushSrcClass *gstpushsrc_class; - - gobject_class = (GObjectClass *) klass; - gstelement_class = (GstElementClass *) klass; - gstbasesrc_class = (GstBaseSrcClass *) klass; - gstpushsrc_class = (GstPushSrcClass *) klass; - - gobject_class->set_property = gst_bambusrc_set_property; - gobject_class->get_property = gst_bambusrc_get_property; - gobject_class->finalize = gst_bambusrc_finalize; - gobject_class->dispose = gst_bambusrc_dispose; - - g_object_class_install_property (gobject_class, - PROP_LOCATION, - g_param_spec_string ("location", "Location", - "URI to pass to Bambu Lab blobs", "", - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS))); - - gst_element_class_add_static_pad_template (gstelement_class, &srctemplate); - - gst_element_class_set_static_metadata (gstelement_class, "Bambu Lab source", - "Source/Network", - "Receive data as a client over the network using the proprietary Bambu Lab blobs", - "Joshua Wise "); - gstelement_class->change_state = - GST_DEBUG_FUNCPTR (gst_bambusrc_change_state); - - gstbasesrc_class->start = GST_DEBUG_FUNCPTR (gst_bambusrc_start); - gstbasesrc_class->stop = GST_DEBUG_FUNCPTR (gst_bambusrc_stop); - gstbasesrc_class->unlock = GST_DEBUG_FUNCPTR (gst_bambusrc_unlock); - gstbasesrc_class->unlock_stop = - GST_DEBUG_FUNCPTR (gst_bambusrc_unlock_stop); - gstbasesrc_class->is_seekable = - GST_DEBUG_FUNCPTR (gst_bambusrc_is_seekable); - gstbasesrc_class->query = GST_DEBUG_FUNCPTR (gst_bambusrc_query); - - gstpushsrc_class->create = GST_DEBUG_FUNCPTR (gst_bambusrc_create); - - GST_DEBUG_CATEGORY_INIT (gst_bambusrc_debug, "bambusrc", 0, - "Bambu Lab src"); -} - -static void -gst_bambusrc_reset (GstBambuSrc * src) -{ - gst_caps_replace (&src->src_caps, NULL); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } -} - -static void -gst_bambusrc_init (GstBambuSrc * src) -{ - src->location = NULL; - src->tnl = NULL; - - gst_base_src_set_automatic_eos (GST_BASE_SRC (src), FALSE); - gst_base_src_set_live(GST_BASE_SRC(src), TRUE); - - gst_bambusrc_reset (src); -} - -static void -gst_bambusrc_dispose (GObject * gobject) -{ - GstBambuSrc *src = GST_BAMBUSRC (gobject); - - GST_DEBUG_OBJECT (src, "dispose"); - - G_OBJECT_CLASS (parent_class)->dispose (gobject); -} - -static void -gst_bambusrc_finalize (GObject * gobject) -{ - GstBambuSrc *src = GST_BAMBUSRC (gobject); - - GST_DEBUG_OBJECT (src, "finalize"); - - g_free (src->location); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - } - - G_OBJECT_CLASS (parent_class)->finalize (gobject); -} - -static void -gst_bambusrc_set_property (GObject * object, guint prop_id, - const GValue * value, GParamSpec * pspec) -{ - GstBambuSrc *src = GST_BAMBUSRC (object); - - switch (prop_id) { - case PROP_LOCATION: - { - const gchar *location; - - location = g_value_get_string (value); - - if (location == NULL) { - GST_WARNING ("location property cannot be NULL"); - goto done; - } - if (!gst_bambusrc_set_location (src, location, NULL)) { - GST_WARNING ("badly formatted location"); - goto done; - } - break; - } - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); - break; - } -done: - return; -} - -static void -gst_bambusrc_get_property (GObject * object, guint prop_id, - GValue * value, GParamSpec * pspec) -{ - GstBambuSrc *src = GST_BAMBUSRC (object); - - switch (prop_id) { - case PROP_LOCATION: - g_value_set_string (value, src->location); - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); - break; - } -} - -int gst_bambu_last_error = 0; - -static GstFlowReturn -gst_bambusrc_create (GstPushSrc * psrc, GstBuffer ** outbuf) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (psrc); - - (void) src; - GST_DEBUG_OBJECT (src, "create()"); - - int rv; - Bambu_Sample sample; - - if (!src->tnl) { - return GST_FLOW_ERROR; - } - - while ((rv = BAMBULIB(Bambu_ReadSample)(src->tnl, &sample)) == Bambu_would_block) { - GST_DEBUG_OBJECT(src, "create would block"); - usleep(33333); /* 30Hz */ - } - - if (rv == Bambu_stream_end) { - return GST_FLOW_EOS; - } - - if (rv != Bambu_success) { - gst_bambu_last_error = rv; - return GST_FLOW_ERROR; - } - -#if GLIB_CHECK_VERSION(2,68,0) - gpointer sbuf = g_memdup2(sample.buffer, sample.size); -#else - gpointer sbuf = g_memdup(sample.buffer, sample.size); -#endif - *outbuf = gst_buffer_new_wrapped_full(0, sbuf, sample.size, 0, sample.size, sbuf, g_free); - - /* Synthesize monotonic timestamps at the announced frame rate, anchored - * to the first frame's arrival time. The X1C's RTSPS server emits - * unreliable decode timestamps (wildly non-monotonic jumps, or sometimes - * none at all); forwarding them directly froze the pipeline after a few - * seconds. Pacing on a synthesized clock — the same trick mpv uses when - * it reports "No video PTS! Making something up." — gives smooth - * playback regardless of network jitter, and only drops late frames if - * the printer can't keep up. A snap-back resets the anchor if real - * arrival drifts more than two frame periods from the synthesized - * timeline (e.g. announced framerate was wrong). - */ - GstClock *clock = GST_ELEMENT_CLOCK(psrc); - GstClockTime base_time = gst_element_get_base_time((GstElement *)psrc); - GstClockTime running_now = GST_CLOCK_TIME_NONE; - if (clock) { - GstClockTime now = gst_clock_get_time(clock); - if (now != GST_CLOCK_TIME_NONE && now >= base_time) - running_now = now - base_time; - } - - /* Adapt the period to actual inter-arrival time via EWMA. The announced - * frame_rate is unreliable on Bambu printers (X1C announces 30 but - * delivers ~28), so trusting it causes the synthesized timeline to drift - * relative to real time, which makes the sink consider frames late and - * skip pacing entirely. Measuring the real rate keeps PTS in step with - * arrival on average, so the sink can pace inside bursts while still - * tracking the printer's actual frame cadence. - */ - if (src->avg_period == 0) { - int fps = src->frame_rate > 0 ? src->frame_rate : 30; - src->avg_period = GST_SECOND / fps; - } - if (running_now != GST_CLOCK_TIME_NONE && src->last_arrival != 0) { - GstClockTimeDiff delta = GST_CLOCK_DIFF(src->last_arrival, running_now); - /* clamp to plausible video frame periods (5..200 ms) so a one-off - * burst-of-zero or long stall doesn't poison the average */ - if (delta > 5 * GST_MSECOND && delta < 200 * GST_MSECOND) { - src->avg_period = (src->avg_period * 15 + (GstClockTime)delta) / 16; - } - } - src->last_arrival = (running_now != GST_CLOCK_TIME_NONE) ? running_now : src->last_arrival; - GstClockTime period = src->avg_period; - - /* Lead time: schedule frames a few periods in the future of their - * arrival, so the sink has a small jitter buffer. Without this, frames - * arriving slightly later than expected land behind the running clock - * and the sink renders them immediately, producing visible stutter. - * 100ms is invisible for a live print-monitor view. - */ - const GstClockTime LEAD = 100 * GST_MSECOND; - - if (!src->sttime) { - src->sttime = (running_now != GST_CLOCK_TIME_NONE) ? running_now + LEAD : LEAD; - src->frame_count = 0; - } - - GstClockTime pts = src->sttime + src->frame_count * period; - - /* Safety net: with the lead applied, expected drift is roughly -LEAD - * (pts sits LEAD ns ahead of running_now). Re-anchor only if the - * synthesized timeline diverges from that expectation by several frame - * periods, which indicates a real disturbance (printer paused, stream - * resumed, large fps change) rather than ordinary jitter. - */ - if (running_now != GST_CLOCK_TIME_NONE) { - GstClockTimeDiff drift = GST_CLOCK_DIFF(pts, running_now); - GstClockTimeDiff expected = -(GstClockTimeDiff)LEAD; - GstClockTimeDiff slack = (GstClockTimeDiff)(4 * period); - if (drift > expected + slack || drift < expected - slack) { - GST_DEBUG_OBJECT(src, "ts drift %" G_GINT64_FORMAT " ns; re-anchoring", drift); - src->sttime = running_now + LEAD; - src->frame_count = 0; - pts = src->sttime; - } - } - - GST_BUFFER_PTS(*outbuf) = pts; - GST_BUFFER_DTS(*outbuf) = pts; - GST_BUFFER_DURATION(*outbuf) = period; - src->frame_count++; - GST_DEBUG_OBJECT(src, - "sttime:%lu, DTS:%lu, PTS: %lu~", - src->sttime, GST_BUFFER_DTS(*outbuf), GST_BUFFER_PTS(*outbuf)); - - return GST_FLOW_OK; -} - -static void _log(void *ctx, int lvl, const char *msg) { - GstBambuSrc *src = (GstBambuSrc *) ctx; - GST_DEBUG_OBJECT(src, "bambu: %s", msg); - BAMBULIB(Bambu_FreeLogMsg)(msg); -} - -static gboolean -gst_bambusrc_start (GstBaseSrc * bsrc) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - - GST_DEBUG_OBJECT (src, "start(\"%s\")", src->location); - - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } - -#ifdef BAMBU_DYNAMIC - if (!_lib) { - _lib = bambulib_get(); - if (!_lib->Bambu_Open) { - return FALSE; - } - } -#endif - if (BAMBULIB(Bambu_Create)(&src->tnl, src->location) != Bambu_success) { - return FALSE; - } - - int rv = 0; - BAMBULIB(Bambu_SetLogger)(src->tnl, _log, (void *)src); - if ((rv = BAMBULIB(Bambu_Open)(src->tnl)) != Bambu_success) { - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - gst_bambu_last_error = rv; - return FALSE; - } - - int n = 0; - while ((rv = BAMBULIB(Bambu_StartStream)(src->tnl, 1 /* video */)) == Bambu_would_block) { - usleep(100000); - } - if (rv != Bambu_success) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - gst_bambu_last_error = rv; - return FALSE; - } - - src->video_type = AVC1; - n = BAMBULIB(Bambu_GetStreamCount)(src->tnl); - GST_INFO_OBJECT (src, "Bambu_GetStreamCount returned stream count=%d",n); - for (int i = 0; i < n; ++i) { - Bambu_StreamInfo info; - BAMBULIB(Bambu_GetStreamInfo)(src->tnl, i, &info); - - GST_INFO_OBJECT (src, "stream %d type=%d, sub_type=%d", i, info.type, info.sub_type); - if (info.type == VIDE) { - src->video_type = info.sub_type; - src->frame_rate = info.format.video.frame_rate; - GST_INFO_OBJECT (src, " width %d height=%d, frame_rate=%d", - info.format.video.width, info.format.video.height, info.format.video.frame_rate); - } - } - - src->sttime = 0; - src->frame_count = 0; - src->last_arrival = 0; - src->avg_period = 0; - return TRUE; -} - -static gboolean -gst_bambusrc_stop (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "stop()"); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } - - return TRUE; -} - -static GstStateChangeReturn -gst_bambusrc_change_state (GstElement * element, GstStateChange transition) -{ - GstStateChangeReturn ret; - GstBambuSrc *src; - - src = GST_BAMBUSRC (element); - - (void) src; - - switch (transition) { - case GST_STATE_CHANGE_READY_TO_NULL: - //gst_bambusrc_session_close (src); - break; - default: - break; - } - - ret = GST_ELEMENT_CLASS (parent_class)->change_state (element, transition); - - return ret; -} - -/* Interrupt a blocking request. */ -static gboolean -gst_bambusrc_unlock (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "unlock()"); - - return TRUE; -} - -/* Interrupt interrupt. */ -static gboolean -gst_bambusrc_unlock_stop (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "unlock_stop()"); - - return TRUE; -} - -static gboolean -gst_bambusrc_is_seekable (GstBaseSrc * bsrc) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - - (void) src; - - return FALSE; -} - -static gboolean -gst_bambusrc_query (GstBaseSrc * bsrc, GstQuery * query) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - gboolean ret; - GstSchedulingFlags flags; - gint minsize, maxsize, align; - - switch (GST_QUERY_TYPE (query)) { - case GST_QUERY_URI: - gst_query_set_uri (query, src->location); - ret = TRUE; - break; - default: - ret = FALSE; - break; - } - - if (!ret) - ret = GST_BASE_SRC_CLASS (parent_class)->query (bsrc, query); - - switch (GST_QUERY_TYPE (query)) { - case GST_QUERY_SCHEDULING: - gst_query_parse_scheduling (query, &flags, &minsize, &maxsize, &align); - flags = (GstSchedulingFlags)((int)flags | (int)GST_SCHEDULING_FLAG_SEQUENTIAL); - gst_query_set_scheduling (query, flags, minsize, maxsize, align); - break; - default: - break; - } - - return ret; -} - -static gboolean -gst_bambusrc_set_location (GstBambuSrc * src, const gchar * uri, - GError ** error) -{ - if (src->location) { - g_free (src->location); - src->location = NULL; - } - - if (uri == NULL) - return FALSE; - - src->location = g_strdup (uri); - - return TRUE; -} - -static GstURIType -gst_bambusrc_uri_get_type (GType type) -{ - return GST_URI_SRC; -} - -static const gchar *const * -gst_bambusrc_uri_get_protocols (GType type) -{ - static const gchar *protocols[] = { "bambu", NULL }; - - return protocols; -} - -static gchar * -gst_bambusrc_uri_get_uri (GstURIHandler * handler) -{ - GstBambuSrc *src = GST_BAMBUSRC (handler); - - /* FIXME: make thread-safe */ - return g_strdup (src->location); -} - -static gboolean -gst_bambusrc_uri_set_uri (GstURIHandler * handler, const gchar * uri, - GError ** error) -{ - GstBambuSrc *src = GST_BAMBUSRC (handler); - - return gst_bambusrc_set_location (src, uri, error); -} - -static void -gst_bambusrc_uri_handler_init (gpointer g_iface, gpointer iface_data) -{ - GstURIHandlerInterface *iface = (GstURIHandlerInterface *) g_iface; - - iface->get_type = gst_bambusrc_uri_get_type; - iface->get_protocols = gst_bambusrc_uri_get_protocols; - iface->get_uri = gst_bambusrc_uri_get_uri; - iface->set_uri = gst_bambusrc_uri_set_uri; -} - -static gboolean gstbambusrc_init(GstPlugin *plugin) -{ - return gst_element_register(plugin, "bambusrc", GST_RANK_PRIMARY, GST_TYPE_BAMBUSRC); -} - -#ifndef EXTERNAL_GST_PLUGIN - -// for use inside of Bambu Slicer -void gstbambusrc_register() -{ - static int did_register = 0; - if (did_register) - return; - did_register = 1; - - gst_plugin_register_static(GST_VERSION_MAJOR, GST_VERSION_MINOR, "bambusrc", "Bambu Lab source", gstbambusrc_init, "0.0.1", "GPL", "BambuStudio", "BambuStudio", "https://github.com/bambulab/BambuStudio"); -} - -#else - -#ifndef PACKAGE -#define PACKAGE "bambusrc" -#endif - -GST_PLUGIN_DEFINE (GST_VERSION_MAJOR, GST_VERSION_MINOR, bambusrc, "Bambu Lab source", gstbambusrc_init, "0.0.1", "GPL", "BambuStudio", "https://github.com/bambulab/BambuStudio") - -#endif diff --git a/src/slic3r/GUI/Printer/gstbambusrc.h b/src/slic3r/GUI/Printer/gstbambusrc.h deleted file mode 100644 index d5c022a40e..0000000000 --- a/src/slic3r/GUI/Printer/gstbambusrc.h +++ /dev/null @@ -1,78 +0,0 @@ -/* bambusrc for gstreamer - * integration with proprietary Bambu Lab blob for getting raw h.264 video - * - * Copyright (C) 2023 Joshua Wise - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - * - * Alternatively, the contents of this file may be used under the - * GNU Lesser General Public License Version 2.1 (the "LGPL"), in - * which case the following provisions apply instead of the ones - * mentioned above: - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Library General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Library General Public License for more details. - * - * You should have received a copy of the GNU Library General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 59 Temple Place - Suite 330, - * Boston, MA 02111-1307, USA. - */ - -#ifndef __GST_BAMBUSRC_H__ -#define __GST_BAMBUSRC_H__ - -#include -#include -#include - -G_BEGIN_DECLS - -#define GST_TYPE_BAMBUSRC (gst_bambusrc_get_type()) -G_DECLARE_FINAL_TYPE (GstBambuSrc, gst_bambusrc, - GST, BAMBUSRC, GstPushSrc) - -typedef void *Bambu_Tunnel; - -struct _GstBambuSrc -{ - GstPushSrc element; - GstCaps *src_caps; - gchar *location; - Bambu_Tunnel tnl; - GstClockTime sttime; - int video_type; - int frame_rate; - guint64 frame_count; - GstClockTime last_arrival; - GstClockTime avg_period; -}; - -extern void gstbambusrc_register(); - -G_END_DECLS - -#endif /* __GST_BAMBUSRC_H__ */ From 9c7e6711c6130ccb8298c6552224ae0ff659bfeb Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Fri, 14 Aug 2026 23:06:16 +0800 Subject: [PATCH 060/138] build: move FFmpeg media player sources to the common GUI list wxMediaCtrl3 and AVVideoDecoder are platform-neutral C++ compiled on all three platforms, so list them once in the common SLIC3R_GUI_SOURCES instead of duplicating them in the APPLE and non-APPLE branches. The else() branch is now empty and drops out entirely. Co-Authored-By: Claude --- src/slic3r/CMakeLists.txt | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 7d799da4a8..718da3705b 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -38,6 +38,8 @@ set(SLIC3R_GUI_SOURCES GUI/AuxiliaryDataViewModel.hpp GUI/AuxiliaryDialog.cpp GUI/AuxiliaryDialog.hpp + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp GUI/Auxiliary.hpp GUI/BackgroundSlicingProcess.cpp GUI/BackgroundSlicingProcess.hpp @@ -601,6 +603,8 @@ set(SLIC3R_GUI_SOURCES GUI/WipeTowerDialog.cpp GUI/wxExtensions.cpp GUI/wxExtensions.hpp + GUI/wxMediaCtrl3.cpp + GUI/wxMediaCtrl3.h plugin/PythonInterpreter.cpp plugin/PythonInterpreter.hpp plugin/PythonPluginBridge.cpp @@ -784,19 +788,8 @@ if (APPLE) GUI/DeepLinkHandlerMac.mm GUI/DeepLinkHandlerMac.h GUI/GUI_UtilsMac.mm - GUI/AVVideoDecoder.cpp - GUI/AVVideoDecoder.hpp - GUI/wxMediaCtrl3.cpp - GUI/wxMediaCtrl3.h ) FIND_LIBRARY(DISKARBITRATION_LIBRARY DiskArbitration) -else () - list(APPEND SLIC3R_GUI_SOURCES - GUI/AVVideoDecoder.cpp - GUI/AVVideoDecoder.hpp - GUI/wxMediaCtrl3.cpp - GUI/wxMediaCtrl3.h - ) endif () set(ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY}") From 86a63e7e2f0077018f2c68fd0c4801035251d732 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Sat, 15 Aug 2026 08:52:52 +0800 Subject: [PATCH 061/138] deps: disable FFmpeg VideoToolbox/AudioToolbox HW-accel on macOS The static libavcodec.a/avutil.a compiled the auto-detected videotoolbox/audiotoolbox objects, which reference VideoToolbox framework symbols (_VTDecompressionSession*). The app link line happened to satisfy them transitively, but the orca_stubgen module link (CI-only) failed with undefined symbols. The player decodes in software (swscale), so disable both HW-accel paths to keep the static libs self-contained. Co-Authored-By: Claude --- deps/FFMPEG/FFMPEG.cmake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake index 60e856f41f..38e952bbdd 100644 --- a/deps/FFMPEG/FFMPEG.cmake +++ b/deps/FFMPEG/FFMPEG.cmake @@ -27,7 +27,10 @@ else () "--extra-ldflags=-mmacosx-version-min=${DEP_OSX_TARGET}" ) # Static FFmpeg: nothing to bundle into the .app, no rpath handling. - set(_link_cmd --enable-static --disable-shared) + # Disable the VideoToolbox/AudioToolbox HW-accel paths: the player decodes + # in software (swscale), and the auto-detected HW objects would drag in + # system frameworks that the static libs would then depend on. + set(_link_cmd --enable-static --disable-shared --disable-videotoolbox --disable-audiotoolbox) if (IS_CROSS_COMPILE) set(_cross_cmd --enable-cross-compile) set(_pic_cmd --enable-pic) From 80471419813a0f4d7289da1365d27855d7945a91 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Wed, 19 Aug 2026 09:28:58 -0500 Subject: [PATCH 062/138] test: fix the flaky multiline lightning smoothing assertion (#15294) --- tests/fff_print/test_fill.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 07460d3990..21a5000401 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -980,7 +980,11 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", " const SparseInfillShape smooth = shape_for("100%"); REQUIRE(sharp.path_count > 0); - REQUIRE(smooth.path_count <= sharp.path_count); + // The loop count varies by a loop or two between platforms and between runs, so this is not an + // exact comparison. Smoothing should leave it about where it was; uncapping the smoothing + // reach, the regression this guards against, adds about 10%. + const size_t allowed_extra = sharp.path_count / 50; // 2% + REQUIRE(smooth.path_count <= sharp.path_count + allowed_extra); // The outlines are still rounded. REQUIRE(smooth.point_count > sharp.point_count); REQUIRE(smooth.sharp_turns < sharp.sharp_turns); From f5f3d2221dd929360407aa2ae6759302a8d2c575 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 19 Aug 2026 14:47:34 -0300 Subject: [PATCH 063/138] AI Translation update (#15300) --- localization/i18n/OrcaSlicer.pot | 144 ++++++++--- localization/i18n/ca/OrcaSlicer_ca.po | 192 +++++++++++--- localization/i18n/cs/OrcaSlicer_cs.po | 194 ++++++++++++--- localization/i18n/de/OrcaSlicer_de.po | 192 +++++++++++--- localization/i18n/en/OrcaSlicer_en.po | 144 ++++++++--- localization/i18n/es/OrcaSlicer_es.po | 192 +++++++++++--- localization/i18n/eu/OrcaSlicer_eu.po | 192 +++++++++++--- localization/i18n/fr/OrcaSlicer_fr.po | 262 ++++++++++++++------ localization/i18n/hu/OrcaSlicer_hu.po | 192 +++++++++++--- localization/i18n/it/OrcaSlicer_it.po | 192 +++++++++++--- localization/i18n/ja/OrcaSlicer_ja.po | 192 +++++++++++--- localization/i18n/ko/OrcaSlicer_ko.po | 196 ++++++++++++--- localization/i18n/lt/OrcaSlicer_lt.po | 196 ++++++++++++--- localization/i18n/nl/OrcaSlicer_nl.po | 196 ++++++++++++--- localization/i18n/pl/OrcaSlicer_pl.po | 196 ++++++++++++--- localization/i18n/pt_BR/OrcaSlicer_pt_BR.po | 202 +++++++++++---- localization/i18n/ru/OrcaSlicer_ru.po | 238 ++++++++++++++---- localization/i18n/sv/OrcaSlicer_sv.po | 200 ++++++++++++--- localization/i18n/th/OrcaSlicer_th.po | 192 +++++++++++--- localization/i18n/tr/OrcaSlicer_tr.po | 198 ++++++++++++--- localization/i18n/uk/OrcaSlicer_uk.po | 194 ++++++++++++--- localization/i18n/vi/OrcaSlicer_vi.po | 196 ++++++++++++--- localization/i18n/zh_CN/OrcaSlicer_zh_CN.po | 196 ++++++++++++--- localization/i18n/zh_TW/OrcaSlicer_zh_TW.po | 192 +++++++++++--- 24 files changed, 3722 insertions(+), 958 deletions(-) diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index 88e49a85fc..6ec0ecd9cf 100644 --- a/localization/i18n/OrcaSlicer.pot +++ b/localization/i18n/OrcaSlicer.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -4452,6 +4452,20 @@ msgstr "" msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "" +#, possible-c-format, possible-boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "" + +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "" + +#, possible-c-format, possible-boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "" + +msgid "Adjust" +msgstr "" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4533,6 +4547,12 @@ msgid "" "No - Disable Arachne Wall Generator and set [Displacement] mode of the Fuzzy Skin" msgstr "" +msgid "Brim ear radius" +msgstr "" + +msgid "Brim width" +msgstr "" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "" @@ -4784,6 +4804,12 @@ msgstr "" msgid "Calibration error" msgstr "" +msgid "This printer is not configured with the hardware this control needs." +msgstr "" + +msgid "This control is not supported on this printer." +msgstr "" + msgid "Network unavailable" msgstr "" @@ -5615,7 +5641,7 @@ msgstr "" msgid "Size:" msgstr "" -#, possible-c-format, possible-boost-format +#, possible-boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "" @@ -5790,6 +5816,9 @@ msgstr "" msgid "Project" msgstr "" +msgid "Device (Web)" +msgstr "" + msgid "Yes" msgstr "" @@ -7780,19 +7809,19 @@ msgstr "" msgid "Replaced with 3D files from directory:\n" msgstr "" -#, possible-boost-format +#, possible-c-format, possible-boost-format msgid "✖ Skipped %s: same file.\n" msgstr "" -#, possible-boost-format +#, possible-c-format, possible-boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "" -#, possible-boost-format +#, possible-c-format, possible-boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "" -#, possible-boost-format +#, possible-c-format, possible-boost-format msgid "✔ Replaced %s.\n" msgstr "" @@ -8472,6 +8501,15 @@ msgstr "" msgid "Pop up to select filament grouping mode" msgstr "" +msgid "Visible plugin pages" +msgstr "" + +msgid "pages" +msgstr "" + +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "" + msgid "Behaviour" msgstr "" @@ -8797,6 +8835,14 @@ msgstr "" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "" +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "" + +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" + msgid "Experimental Features" msgstr "" @@ -9052,9 +9098,21 @@ msgstr "" msgid "Preset Inside Project" msgstr "" +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "" + msgid "Detach from parent" msgstr "" +msgid "Unique preset" +msgstr "" + +msgid "Parent preset" +msgstr "" + +msgid "This preset does not inherit from another preset." +msgstr "" + msgid "Name is unavailable." msgstr "" @@ -9732,20 +9790,6 @@ msgstr "" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "" - -msgid "Adjust to the set range automatically?\n" -msgstr "" - -msgid "Adjust" -msgstr "" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "" @@ -9931,6 +9975,9 @@ msgstr "" msgid "Setting Overrides" msgstr "" +msgid "Retraction when switching material" +msgstr "" + msgid "Basic information" msgstr "" @@ -10057,6 +10104,12 @@ msgstr "" msgid "Printable space" msgstr "" +msgid "Printer Agent" +msgstr "" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, possible-boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10179,9 +10232,6 @@ msgstr "" msgid "Z-Hop" msgstr "" -msgid "Retraction when switching material" -msgstr "" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11445,6 +11495,9 @@ msgstr "" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr "" +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr "" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "" @@ -11740,9 +11793,6 @@ msgstr "" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "" -msgid "Printer Agent" -msgstr "" - msgid "Select the network agent implementation for printer communication." msgstr "" @@ -12279,9 +12329,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "" -msgid "Brim width" -msgstr "" - msgid "This is the distance from the model to the outermost brim line." msgstr "" @@ -12347,6 +12394,12 @@ msgid "" "0 to deactivate." msgstr "" +msgid "Brim ears outer only" +msgstr "" + +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "" + msgid "upward compatible machine" msgstr "" @@ -13359,6 +13412,12 @@ msgstr "" msgid "Gyroid" msgstr "" +msgid "Sparse infill smooth factor" +msgstr "" + +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "" @@ -13839,6 +13898,12 @@ msgstr "" msgid "Klipper" msgstr "" +msgid "Skip G-code config block" +msgstr "" + +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "" + msgid "Pellet Modded Printer" msgstr "" @@ -14800,6 +14865,12 @@ msgstr "" msgid "Retraction distance when extruder change" msgstr "" +msgid "Retraction Length (Toolchange)" +msgstr "" + +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "" + msgid "Z-hop height" msgstr "" @@ -14893,6 +14964,9 @@ msgstr "" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "" +msgid "Extra length on restart (Toolchange)" +msgstr "" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "" @@ -15278,6 +15352,12 @@ msgstr "" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "" +msgid "Wait for temperature on wipe tower" +msgstr "" + +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "" + msgid "No sparse layers (beta)" msgstr "" @@ -18253,9 +18333,6 @@ msgstr "" msgid "Print Host upload" msgstr "" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "" - msgid "Select a Flashforge printer" msgstr "" @@ -19087,9 +19164,6 @@ msgstr "" msgid "User canceled." msgstr "" -msgid "Head diameter" -msgstr "" - msgid "Max angle" msgstr "" diff --git a/localization/i18n/ca/OrcaSlicer_ca.po b/localization/i18n/ca/OrcaSlicer_ca.po index 79a9d82df4..b7eb022c0d 100644 --- a/localization/i18n/ca/OrcaSlicer_ca.po +++ b/localization/i18n/ca/OrcaSlicer_ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2025-03-15 10:55+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -4828,6 +4828,23 @@ msgstr "La temperatura actual de la cambra és superior a la temperatura segura msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "La temperatura mínima de la cambra (%d℃) és superior a la temperatura objectiu de la cambra (%d℃). El valor mínim és el llindar a partir del qual comença la impressió mentre la cambra continua escalfant-se cap a l'objectiu, de manera que no l'hauria de superar. Es limitarà al valor objectiu." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "L'alçada de capa és massa petita. S'establirà al mínim (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "L'alçada de capa està fora dels límits establerts a Configuració de la Impressora -> Extrusora -> Límits d'alçada de la capa, això pot causar problemes de qualitat d'impressió." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Voleu ajustar-la automàticament al límit (%g mm)?" + +msgid "Adjust" +msgstr "Ajustar" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4948,6 +4965,13 @@ msgstr "" "Sí - Activa el generador de parets Arachne\n" "No - Desactiva el generador de parets Arachne i estableix el mode [Desplaçament] de la pell difusa" +# AI Translated +msgid "Brim ear radius" +msgstr "Radi de l'orella de la Vora d'Adherència" + +msgid "Brim width" +msgstr "Ample de la Vora d'Adherència" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "El mode espiral només funciona quan els bucles de paret són 1, el suport està desactivat, la detecció d'acumulació per sondeig està desactivada, les capes de la coberta superior són 0, la densitat de farciment dispers és 0 i el tipus de timelapse és tradicional." @@ -5202,6 +5226,14 @@ msgstr "No s'ha pogut generar el gcode cali" msgid "Calibration error" msgstr "Error de calibratge" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Aquesta impressora no està configurada amb el maquinari que necessita aquest control." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Aquest control no és compatible amb aquesta impressora." + # AI Translated msgid "Network unavailable" msgstr "Xarxa no disponible" @@ -6067,7 +6099,7 @@ msgstr "Volum:" msgid "Size:" msgstr "Mida:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "S'han trobat conflictes de rutes gcode a la capa %d, Z = %.2lfmm. Si us plau, separeu els objectes conflictius més lluny ( %s <-> %s )." @@ -6248,6 +6280,10 @@ msgstr "Multidispositiu" msgid "Project" msgstr "Projecte" +# AI Translated +msgid "Device (Web)" +msgstr "Dispositiu (Web)" + msgid "Yes" msgstr "Sí" @@ -8361,19 +8397,19 @@ msgstr "No s'ha seleccionat el directori per a la substitució" msgid "Replaced with 3D files from directory:\n" msgstr "Substituït amb fitxers 3D del directori:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Omès %s: mateix fitxer.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Omès %s: el fitxer no existeix.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Omès %s: la substitució ha fallat.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Substituït %s.\n" @@ -9116,6 +9152,18 @@ msgstr "Amb aquesta opció habilitada, podeu enviar una tasca a diversos disposi msgid "Pop up to select filament grouping mode" msgstr "Finestra emergent per seleccionar el mode d'agrupació de filaments" +# AI Translated +msgid "Visible plugin pages" +msgstr "Pàgines de connectors visibles" + +# AI Translated +msgid "pages" +msgstr "pàgines" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Nombre de pàgines de connectors que es mostren com a pestanyes fixes abans que la resta de pàgines es replegui en un desplegable a l'última pestanya." + msgid "Behaviour" msgstr "Comportament" @@ -9506,6 +9554,18 @@ msgstr "Mostrar els perfils no compatibles" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Mostra els perfils incompatibles o no compatibles a les llistes desplegables d'impressora i de filament. Aquests perfils no es poden seleccionar." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimental) Utilitza agents d'impressora en lloc d'amfitrions d'impressió" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Envia els treballs d'impressió de les impressores que no són Bambu a través dels agents de connector d'impressora en lloc del flux clàssic de pujada a l'amfitrió d'impressió.\n" +"Quan està desactivat, OrcaSlicer utilitza el comportament antic de l'amfitrió d'impressió." + # AI Translated msgid "Experimental Features" msgstr "Funcions experimentals" @@ -9776,9 +9836,25 @@ msgstr "Perfil d'usuari" msgid "Preset Inside Project" msgstr "Perfil intern del Projecte" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Copia en aquest perfil tots els valors heretats del perfil pare i elimina la relació d'herència. Els perfils compatibles només amb el perfil pare poden deixar de ser compatibles." + msgid "Detach from parent" msgstr "Desvincula del pare" +# AI Translated +msgid "Unique preset" +msgstr "Perfil únic" + +# AI Translated +msgid "Parent preset" +msgstr "Perfil pare" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Aquest perfil no hereta de cap altre perfil." + msgid "Name is unavailable." msgstr "El nom no està disponible." @@ -10521,22 +10597,6 @@ msgstr "Estàs segur que vols activar aquesta opció?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Els patrons de farciment estan dissenyats normalment per gestionar la rotació automàticament per garantir una impressió correcta i aconseguir els efectes desitjats (p. ex., Gyroid, Cúbic). Rotar el patró de farciment dispers actual pot portar a un suport insuficient. Procediu amb precaució i comproveu minuciosament qualsevol problema d'impressió potencial. Esteu segur que voleu activar aquesta opció?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"L'alçada de la capa és massa petita.\n" -"Es posarà a min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "L'alçada de la capa supera el límit a Configuració de la Impressora -> Extrusora -> Límits d'alçada de la capa, això pot causar problemes de qualitat d'impressió." - -msgid "Adjust to the set range automatically?\n" -msgstr "Voleu ajustar el rang automàticament?\n" - -msgid "Adjust" -msgstr "Ajustar" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Característica experimental: Retreure i tallar el filament a major distància durant els canvis de filaments per minimitzar el flux. Tot i que pot reduir notablement el flux, també pot elevar el risc d'esclops de broquets o altres complicacions d'impressió." @@ -10735,6 +10795,9 @@ msgstr "Trobades paraules clau reservades" msgid "Setting Overrides" msgstr "Anul·lacions de configuració" +msgid "Retraction when switching material" +msgstr "Retracció en canviar de material" + msgid "Basic information" msgstr "Informació bàsica" @@ -10867,6 +10930,12 @@ msgstr "Perfils de processos compatibles" msgid "Printable space" msgstr "Espai imprimible" +msgid "Printer Agent" +msgstr "Agent de la impressora" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Seleccioneu la implementació de l'agent de xarxa per a la comunicació amb la impressora. Els agents disponibles es registren a l'inici." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10997,9 +11066,6 @@ msgstr "Límits d'alçada de capa" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Retracció en canviar de material" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12380,6 +12446,10 @@ msgstr " està massa a prop de la zona d'exclusió, i es provocaran col·lisions msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " és massa a prop de l'àrea de detecció d'acumulació i es causaran col·lisions.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " està parcialment fora de l'àrea imprimible, i no es pot imprimir.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Les temperatures de broquet seleccionades són incompatibles. La temperatura de broquet de cada filament ha d'estar dins del rang de temperatura de broquet recomanat dels altres filaments. Altrament, es pot produir una obturació del broquet o danys a la impressora." @@ -12714,9 +12784,6 @@ msgstr "Utilitzar 3MF en lloc de G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Activeu-ho si la impressora accepta un fitxer 3MF com a treball d'impressió. Quan està activat, Orca Slicer envia el fitxer laminat com a .gcode.3mf, en lloc d'un fitxer .gcode simple." -msgid "Printer Agent" -msgstr "Agent de la impressora" - msgid "Select the network agent implementation for printer communication." msgstr "Seleccioneu la implementació de l'agent de xarxa per a la comunicació amb la impressora." @@ -13402,9 +13469,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Velocitat dels ponts interns. Si el valor s'expressa com un percentatge, es calcularà en funció de la velocitat del pont (bridge_speed). El valor per defecte és del 150%." -msgid "Brim width" -msgstr "Ample de la Vora d'Adherència" - msgid "This is the distance from the model to the outermost brim line." msgstr "Distància del model a la línia de la Vora d'Adherència més exterior" @@ -13488,6 +13552,14 @@ msgstr "" "La geometria es simplificarà abans de detectar angles pronunciats. Aquest paràmetre indica la longitud mínima de la desviació per a la simplificació.\n" "0 per desactivar" +# AI Translated +msgid "Brim ears outer only" +msgstr "Orelles de la Vora d'Adherència només a l'exterior" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Genera orelles de ratolí només al contorn exterior del model, excloent-ne els forats i les seccions tancades." + msgid "upward compatible machine" msgstr "màquina compatible ascendent" @@ -14679,6 +14751,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroide" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Factor de suavitzat del farciment poc dens" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Controla com s'arrodoneixen les cantonades del farciment poc dens. 0% manté el traçat original amb cantonades vives, mentre que 100% produeix les corbes més amples possibles entre línies de farciment adjacents." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Acceleració del farciment superficial superior. L'ús d'un valor inferior pot millorar la qualitat de la superfície superior" @@ -15232,6 +15312,14 @@ msgstr "Amb quin tipus de Codi-G és compatible la impressora." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Omet el bloc de configuració del G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "No escriu el CONFIG_BLOCK (els parells clau/valor de la configuració del laminador) al fitxer G-code. Això pot ajudar amb impressores el microprogramari de les quals falla en analitzar aquestes línies de comentari (p. ex. Anycubic go-klipper). Nota: el fitxer G-code ja no contindrà la configuració del laminador, de manera que en tornar-lo a importar a OrcaSlicer no es restaurarà la configuració." + msgid "Pellet Modded Printer" msgstr "Impressora modificada de pellets" @@ -16321,6 +16409,14 @@ msgstr "Retracció llarga al canviar d'extrusor" msgid "Retraction distance when extruder change" msgstr "Distància de retracció al canviar d'extrusor" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Longitud de retracció (Canvi d'eina)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Quan s'activa la retracció abans d'un canvi d'eina, el filament es retira la quantitat especificada (la longitud es mesura sobre el filament en brut, abans d'entrar a l'extrusor)." + msgid "Z-hop height" msgstr "Alçada Z-hop" @@ -16419,6 +16515,10 @@ msgstr "Longitud addicional en reiniciar" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Quan la retracció es compensa després d'un desplaçament, l'extrusor introduirà una quantitat addicional de filament. Aquest ajustament rarament es necessita." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Longitud addicional en reiniciar (Canvi d'eina)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Quan la retracció es compensa després d'un canvi d'eina, l'extrusor introduirà una quantitat addicional de filament." @@ -16835,6 +16935,14 @@ msgstr "Canvi d'eina a la Torre de Purga" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Força el capçal a desplaçar-se a la Torre de Purga abans d'emetre l'ordre de canvi d'eina (Tx). Només és rellevant per a impressores multiextrusor (multicapçal) que utilitzen una Torre de Purga de tipus 2. Per defecte, Orca omet aquest desplaçament en màquines multicapçal perquè el firmware gestiona el canvi de capçal, cosa que pot fer que l'ordre Tx s'emeti sobre la peça impresa. Activeu aquesta opció si voleu que el canvi d'eina s'emeti sempre sobre la Torre de Purga." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Espera la temperatura a la Torre de Purga" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Recull la nova eina sense esperar que arribi a la temperatura d'impressió, es desplaça a la Torre de Purga i hi espera la temperatura, just abans de purgar. El degoteig de l'escalfament cau sobre la torre en lloc del model, i el desplaçament se solapa amb l'escalfament. Només és rellevant per a impressores multiextrusor (multicapçal) que utilitzen una Torre de Purga de tipus 2. El microprogramari o la macro de canvi d'eina no han d'esperar la temperatura pel seu compte. Quan està desactivat, l'espera de temperatura s'emet just després de l'ordre de canvi d'eina." + msgid "No sparse layers (beta)" msgstr "Sense capes poc denses( beta )" @@ -20121,9 +20229,6 @@ msgstr "Impressora Física" msgid "Print Host upload" msgstr "Pujada al amfitrió( host ) d'impressió" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Seleccioneu la implementació de l'agent de xarxa per a la comunicació amb la impressora. Els agents disponibles es registren a l'inici." - # AI Translated msgid "Select a Flashforge printer" msgstr "Seleccioneu una impressora Flashforge" @@ -21066,9 +21171,6 @@ msgstr "Alguna cosa inesperada ha passat en intentar iniciar sessió, torneu-ho msgid "User canceled." msgstr "Usuari cancel·lat." -msgid "Head diameter" -msgstr "Diàmetre del cap" - msgid "Max angle" msgstr "Angle màxim" @@ -21887,6 +21989,22 @@ msgstr "" "Evitar la deformació( warping )\n" "Sabíeu que quan imprimiu materials propensos a deformar-se, com ara l'ABS, augmentar adequadament la temperatura del llit pot reduir la probabilitat de deformació?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "L'alçada de la capa és massa petita.\n" +#~ "Es posarà a min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "L'alçada de la capa supera el límit a Configuració de la Impressora -> Extrusora -> Límits d'alçada de la capa, això pot causar problemes de qualitat d'impressió." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Voleu ajustar el rang automàticament?\n" + +#~ msgid "Head diameter" +#~ msgstr "Diàmetre del cap" + #~ msgid "Print order within a single layer." #~ msgstr "Ordre d'impressió dins d'una sola capa" diff --git a/localization/i18n/cs/OrcaSlicer_cs.po b/localization/i18n/cs/OrcaSlicer_cs.po index e21fd3086c..b521a8073b 100644 --- a/localization/i18n/cs/OrcaSlicer_cs.po +++ b/localization/i18n/cs/OrcaSlicer_cs.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: Jakub Hencl\n" "Language-Team: \n" @@ -4786,6 +4786,23 @@ msgstr "Aktuální teplota komory je vyšší než bezpečná teplota materiálu msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Minimální teplota komory (%d℃) je vyšší než cílová teplota komory (%d℃). Minimální hodnota je práh, při kterém tisk začíná, zatímco se komora dále ohřívá k cílové teplotě, takže by ji neměla překročit. Bude omezena na cílovou hodnotu." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Výška vrstvy je příliš malá. Bude nastavena na minimum (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Výška vrstvy je mimo limity nastavené v Nastavení tiskárny -> Extruder -> Omezení výšky vrstvy, což může způsobit problémy s kvalitou tisku." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Upravit ji automaticky na limit (%g mm)?" + +msgid "Adjust" +msgstr "Upravit" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4906,6 +4923,13 @@ msgstr "" "Ano – povolit Arachne Wall Generator\n" "Ne – zakázat Arachne Wall Generator a nastavit režim [Displacement] pro Fuzzy Skin" +# AI Translated +msgid "Brim ear radius" +msgstr "Poloměr ouška límce" + +msgid "Brim width" +msgstr "Šířka límce" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Spirálový režim funguje pouze tehdy, když je počet smyček stěny 1, podpěry jsou vypnuté, detekce usazenin sondováním je vypnutá, počet horních plných vrstev je 0, hustota řídké výplně je 0 a typ časosběru je tradiční." @@ -5160,6 +5184,14 @@ msgstr "Nepodařilo se vygenerovat kalibrační G-code." msgid "Calibration error" msgstr "Chyba kalibrace" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Tato tiskárna nemá nakonfigurovaný hardware, který tento ovládací prvek vyžaduje." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Tento ovládací prvek není na této tiskárně podporován." + # AI Translated msgid "Network unavailable" msgstr "Síť není dostupná" @@ -6029,7 +6061,7 @@ msgstr "Objem:" msgid "Size:" msgstr "Velikost:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Byly nalezeny konflikty drah G-kódu ve vrstvě %d, Z = %.2lf mm. Oddělte prosím konfliktní objekty více od sebe (%s <-> %s)." @@ -6210,6 +6242,10 @@ msgstr "Více zařízení" msgid "Project" msgstr "Projekt" +# AI Translated +msgid "Device (Web)" +msgstr "Zařízení (Web)" + msgid "Yes" msgstr "Ano" @@ -8320,19 +8356,19 @@ msgstr "Nebyla vybrána složka pro nahrazení" msgid "Replaced with 3D files from directory:\n" msgstr "Nahrazeno 3D soubory ze složky:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Přeskočeno %s: stejný soubor.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Přeskočeno %s: soubor neexistuje.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Přeskočeno %s: nahrazení se nezdařilo.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Nahrazeno %s.\n" @@ -9070,6 +9106,18 @@ msgstr "Pokud je tato volba povolena, můžete odeslat úlohu na více zařízen msgid "Pop up to select filament grouping mode" msgstr "Zobrazit dialog pro výběr režimu seskupení filamentů" +# AI Translated +msgid "Visible plugin pages" +msgstr "Viditelné stránky pluginů" + +# AI Translated +msgid "pages" +msgstr "stránek" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Počet stránek pluginů zobrazených jako pevné karty, než se zbývající stránky sbalí do rozbalovací nabídky na poslední kartě." + msgid "Behaviour" msgstr "Chování" @@ -9457,6 +9505,18 @@ msgstr "Zobrazit nepodporované předvolby" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Zobrazovat nekompatibilní/nepodporované předvolby v rozevíracích seznamech tiskáren a filamentů. Tyto předvolby nelze vybrat." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimentální) Používat agenty tiskárny místo tiskových hostů" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Směruje tiskové úlohy pro tiskárny jiné než Bambu přes agenty pluginů tiskárny místo klasického nahrávání na tiskový host.\n" +"Pokud je vypnuto, OrcaSlicer používá původní chování tiskového hosta." + # AI Translated msgid "Experimental Features" msgstr "Experimentální funkce" @@ -9724,10 +9784,26 @@ msgstr "Uživatelská předvolba" msgid "Preset Inside Project" msgstr "Předvolba v projektu" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Zkopíruje do této předvolby všechny hodnoty zděděné z nadřazené předvolby a odstraní vztah dědičnosti. Předvolby kompatibilní pouze s nadřazenou předvolbou mohou přestat být podporovány." + # AI Translated msgid "Detach from parent" msgstr "Oddělit od nadřazeného" +# AI Translated +msgid "Unique preset" +msgstr "Samostatná předvolba" + +# AI Translated +msgid "Parent preset" +msgstr "Nadřazená předvolba" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Tato předvolba nedědí z jiné předvolby." + msgid "Name is unavailable." msgstr "Název není k dispozici." @@ -10469,22 +10545,6 @@ msgstr "Opravdu chcete tuto možnost povolit?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Vzory výplně jsou obvykle navrženy tak, aby automaticky pracovaly s rotací a zajistily správný tisk i zamýšlený efekt (např. Gyroid, Cubic). Otočení aktuální řídké výplně může vést k nedostatečné opoře. Postupujte opatrně a pečlivě zkontrolujte možné problémy při tisku. Opravdu chcete tuto možnost povolit?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Výška vrstvy je příliš malá.\n" -"Bude nastavena na min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Výška vrstvy přesahuje limit v Nastavení tiskárny -> Extruder -> Omezení výšky vrstvy, což může způsobit problémy s kvalitou tisku." - -msgid "Adjust to the set range automatically?\n" -msgstr "Automaticky upravit do nastaveného rozsahu?\n" - -msgid "Adjust" -msgstr "Upravit" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Experimentální funkce: Stažení a odstřižení filamentu na větší vzdálenost během výměny filamentu pro minimalizaci purge. Ačkoliv to může výrazně snížit purge, může to také zvýšit riziko ucpání trysky nebo jiných komplikací při tisku." @@ -10684,6 +10744,9 @@ msgstr "Byla nalezena rezervovaná klíčová slova" msgid "Setting Overrides" msgstr "Přepisování nastavení" +msgid "Retraction when switching material" +msgstr "Retrakce při změně materiálu" + msgid "Basic information" msgstr "Základní informace" @@ -10816,6 +10879,13 @@ msgstr "Kompatibilní procesní profily" msgid "Printable space" msgstr "Tisknutelný prostor" +# AI Translated +msgid "Printer Agent" +msgstr "Agent tiskárny" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Vyberte implementaci síťového agenta pro komunikaci s tiskárnou. Dostupní agenti jsou registrováni při spuštění." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10943,9 +11013,6 @@ msgstr "Omezení výšky vrstvy" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Retrakce při změně materiálu" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12363,6 +12430,10 @@ msgstr " je příliš blízko oblasti vyloučení a může způsobit kolize.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " je příliš blízko oblasti detekce shlukování a dojde ke kolizi.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " je částečně mimo tisknutelnou oblast a nelze jej vytisknout.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Vybrané teploty trysky nejsou kompatibilní. Teplota trysky každého filamentu musí spadat do doporučeného rozsahu teplot ostatních filamentů. Jinak může dojít k ucpání trysky nebo poškození tiskárny." @@ -12696,10 +12767,6 @@ msgstr "Použít 3MF místo G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Zapněte, pokud tiskárna přijímá jako tiskovou úlohu soubor 3MF. Je-li zapnuto, odešle Orca Slicer slicovaný soubor jako .gcode.3mf místo prostého souboru .gcode." -# AI Translated -msgid "Printer Agent" -msgstr "Agent tiskárny" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Vyberte implementaci síťového agenta pro komunikaci s tiskárnou." @@ -13387,9 +13454,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Rychlost vnitřních mostů. Pokud je hodnota zadána v procentech, vypočítá se podle bridge_speed. Výchozí hodnota je 150 %." -msgid "Brim width" -msgstr "Šířka límce" - msgid "This is the distance from the model to the outermost brim line." msgstr "Vzdálenost od modelu k nejvzdálenější brim linii." @@ -13470,6 +13534,14 @@ msgstr "" "Geometrie bude decimována před detekcí ostrých úhlů. Tento parametr určuje minimální délku odchylky pro decimaci.\n" "0 pro deaktivaci." +# AI Translated +msgid "Brim ears outer only" +msgstr "Ouška límce pouze na vnějším obrysu" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Vytvoří myší ouška pouze na vnějším obrysu modelu, bez otvorů a uzavřených částí." + msgid "upward compatible machine" msgstr "stroj zpětně kompatibilní" @@ -14646,6 +14718,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Faktor vyhlazení řídké výplně" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Určuje, jak silně se zaoblují rohy řídké výplně. 0% zachová původní ostrou dráhu, zatímco 100% vytvoří největší možné křivky mezi sousedními liniemi výplně." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Akcelerace výplně horní plochy. Použití nižší hodnoty může zlepšit kvalitu horní plochy." @@ -15198,6 +15278,14 @@ msgstr "Jaký typ G-code je s tiskárnou kompatibilní." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Vynechat konfigurační blok G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Nezapisuje CONFIG_BLOCK (dvojice klíč/hodnota s konfigurací sliceru) do souboru G-code. Může to pomoci u tiskáren, jejichž firmware při zpracování těchto řádků s komentáři havaruje (např. Anycubic go-klipper). Poznámka: soubor G-code již nebude obsahovat nastavení sliceru, takže jeho opětovný import do OrcaSlicer konfiguraci neobnoví." + msgid "Pellet Modded Printer" msgstr "Tiskárna na pelety" @@ -16265,6 +16353,14 @@ msgstr "Dlouhá retrakce při změně extruderu" msgid "Retraction distance when extruder change" msgstr "Délka retrakce při změně extruderu" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Délka retrakce (Změna nástroje)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Když je retrakce spuštěna před změnou nástroje, filament se zatáhne o zadanou hodnotu (délka se měří na nezpracovaném filamentu, než vstoupí do extruderu)." + msgid "Z-hop height" msgstr "Výška Z-hopu" @@ -16362,6 +16458,10 @@ msgstr "Dodatečná délka při restartu" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Při kompenzaci retrakce po pohybu přesunu extruder posune toto přídavné množství filamentu. Toto nastavení je potřeba jen zřídka." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Dodatečná délka při restartu (Změna nástroje)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Při kompenzaci retrakce po výměně nástroje extruder posune toto přídavné množství filamentu." @@ -16780,6 +16880,14 @@ msgstr "Výměna nástroje na věži na očištění trysky" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Vynutí přejezd tiskové hlavy k věži na očištění trysky před vydáním příkazu k výměně nástroje (Tx). Týká se pouze tiskáren s více extrudery (více tiskovými hlavami), které používají věž na očištění trysky typu 2. Ve výchozím nastavení Orca na strojích s více tiskovými hlavami tento přejezd vynechává, protože výměnu hlavy řeší firmware, což může vést k vydání příkazu Tx nad tištěným dílem. Zapněte tuto volbu, chcete-li, aby byla výměna nástroje vždy vydána nad věží na očištění trysky." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Čekat na teplotu na věži na očištění trysky" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Vyzvedne nový nástroj, aniž by čekal na dosažení tiskové teploty, přejede na věž na očištění trysky a počká na teplotu tam, těsně před čištěním. Materiál vytékající při ohřevu skončí na věži místo na modelu a přejezd se překrývá s ohřevem. Relevantní pouze pro tiskárny s více extrudery (více tiskovými hlavami) používající věž na očištění trysky typu 2. Firmware ani makro pro změnu nástroje nesmí na teplotu čekat samo. Pokud je vypnuto, čekání na teplotu se vloží hned po příkazu ke změně nástroje." + msgid "No sparse layers (beta)" msgstr "Žádné řídké vrstvy (beta)" @@ -20043,9 +20151,6 @@ msgstr "Fyzická tiskárna" msgid "Print Host upload" msgstr "Nahrání na tiskový server" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Vyberte implementaci síťového agenta pro komunikaci s tiskárnou. Dostupní agenti jsou registrováni při spuštění." - # AI Translated msgid "Select a Flashforge printer" msgstr "Vyberte tiskárnu Flashforge" @@ -21002,9 +21107,6 @@ msgstr "Při pokusu o přihlášení došlo k neočekávané chybě, zkuste to p msgid "User canceled." msgstr "Zrušeno uživatelem." -msgid "Head diameter" -msgstr "Průměr hlavy" - msgid "Max angle" msgstr "Maximální úhel" @@ -21873,6 +21975,22 @@ msgstr "" "Zamezte kroucení\n" "Víte, že při tisku materiálů náchylných ke kroucení, jako je ABS, může vhodné zvýšení teploty vyhřívané desky snížit pravděpodobnost kroucení?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Výška vrstvy je příliš malá.\n" +#~ "Bude nastavena na min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Výška vrstvy přesahuje limit v Nastavení tiskárny -> Extruder -> Omezení výšky vrstvy, což může způsobit problémy s kvalitou tisku." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Automaticky upravit do nastaveného rozsahu?\n" + +#~ msgid "Head diameter" +#~ msgstr "Průměr hlavy" + #~ msgid "Print order within a single layer." #~ msgstr "Pořadí tisku v rámci jedné vrstvy." diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index 50384598e3..436966457a 100644 --- a/localization/i18n/de/OrcaSlicer_de.po +++ b/localization/i18n/de/OrcaSlicer_de.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: Heiko Liebscher \n" "Language-Team: \n" @@ -4692,6 +4692,23 @@ msgstr "Die aktuelle Kammer-Temperatur ist höher als die sichere Temperatur des msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Die minimale Druckraumtemperatur (%d℃) ist höher als die Ziel-Druckraumtemperatur (%d℃). Der Minimalwert ist der Schwellenwert, bei dem der Druck beginnt, während der Druckraum weiter auf die Zieltemperatur heizt; er sollte diese daher nicht überschreiten. Er wird auf die Zieltemperatur begrenzt." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Die Schichthöhe ist zu klein. Sie wird auf den Mindestwert (%g mm) gesetzt." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Die Schichthöhe liegt außerhalb der in Druckereinstellungen -> Extruder -> Schichthöhenlimits festgelegten Grenzen. Dies kann zu Problemen mit der Druckqualität führen." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Automatisch an den Grenzwert (%g mm) anpassen?" + +msgid "Adjust" +msgstr "Anpassen" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4812,6 +4829,13 @@ msgstr "" "Ja - Arachne Wall Generator aktivieren\n" "Nein - Arachne Wall Generator deaktivieren und den Modus [Verschiebung] des Fuzzy Skin setzen" +# AI Translated +msgid "Brim ear radius" +msgstr "Radius der Brim-Ohren" + +msgid "Brim width" +msgstr "Randbreite" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Der Spiralmodus funktioniert nur, wenn die Wandschleifen 1 sind, die Stütze deaktiviert ist, die Klumpenerkennung durch Abtasten deaktiviert ist, die oberen Schichtlagen 0 sind, die Dichte der spärlichen Füllung 0 ist und der Zeitraffertyp traditionell ist." @@ -5066,6 +5090,14 @@ msgstr "Fehler beim Generieren des Kalibrierungs-G-Codes" msgid "Calibration error" msgstr "Kalibrierungsfehler" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Dieser Drucker ist nicht mit der Hardware ausgestattet, die dieses Bedienelement benötigt." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Dieses Bedienelement wird von diesem Drucker nicht unterstützt." + # AI Translated msgid "Network unavailable" msgstr "Netzwerk nicht verfügbar" @@ -5923,7 +5955,7 @@ msgstr "Volumen:" msgid "Size:" msgstr "Größe:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Konflikte von G-Code-Pfaden wurden bei Layer %d, Z = %.2lf mm gefunden.Bitte trennen Sie die konfliktbehafteten Objekte weiter voneinander (%s <-> %s)." @@ -6103,6 +6135,10 @@ msgstr "Multi-Gerät" msgid "Project" msgstr "Projekt" +# AI Translated +msgid "Device (Web)" +msgstr "Gerät (Web)" + msgid "Yes" msgstr "Ja" @@ -8191,19 +8227,19 @@ msgstr "Verzeichnis um daraus zu ersetzen wurde nicht ausgewählt" msgid "Replaced with 3D files from directory:\n" msgstr "Ersetzt durch 3D-Dateien aus Verzeichnis:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Übersprungen %s: gleiche Datei.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Übersprungen %s: Datei existiert nicht.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Übersprungen %s: Ersetzen fehlgeschlagen.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Ersetzt %s.\n" @@ -8941,6 +8977,18 @@ msgstr "Wenn diese Option aktiviert ist, können Sie eine Aufgabe gleichzeitig a msgid "Pop up to select filament grouping mode" msgstr "Popup zum Auswählen des Filament-Gruppierungsmodus" +# AI Translated +msgid "Visible plugin pages" +msgstr "Sichtbare Plugin-Seiten" + +# AI Translated +msgid "pages" +msgstr "Seiten" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Anzahl der Plugin-Seiten, die als feste Tabs angezeigt werden, bevor die übrigen Seiten im letzten Tab zu einem Dropdown zusammengefasst werden." + msgid "Behaviour" msgstr "Verhalten" @@ -9296,6 +9344,18 @@ msgstr "Nicht unterstützte Profile anzeigen" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Zeigt inkompatible/nicht unterstützte Profile in den Dropdown-Listen für Drucker und Filament an. Diese Profile können nicht ausgewählt werden." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimentell) Drucker-Agenten anstelle von Druck-Hosts verwenden" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Leitet Druckaufträge für Nicht-Bambu-Drucker über Drucker-Plugin-Agenten statt über den klassischen Druck-Host-Upload.\n" +"Wenn deaktiviert, verwendet OrcaSlicer das bisherige Druck-Host-Verhalten." + msgid "Experimental Features" msgstr "Experimentelle Funktionen" @@ -9558,9 +9618,25 @@ msgstr "Benutzerprofil" msgid "Preset Inside Project" msgstr "Projektbasiertes Profil" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Kopiert alle vom übergeordneten Profil geerbten Werte in dieses Profil und entfernt die Vererbungsbeziehung. Profile, die nur mit dem übergeordneten Profil kompatibel sind, können dadurch nicht mehr unterstützt werden." + msgid "Detach from parent" msgstr "Vom übergeordneten Element trennen" +# AI Translated +msgid "Unique preset" +msgstr "Eigenständiges Profil" + +# AI Translated +msgid "Parent preset" +msgstr "Übergeordnetes Profil" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Dieses Profil erbt nicht von einem anderen Profil." + msgid "Name is unavailable." msgstr "Der Name ist nicht verfügbar." @@ -10296,22 +10372,6 @@ msgstr "Sind Sie sicher, dass Sie diese Option aktivieren möchten?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Infill-Muster sind in der Regel so konzipiert, dass sie eine automatische Drehung ermöglichen, um einen ordnungsgemäßen Druck zu gewährleisten und die beabsichtigten Effekte zu erzielen (z. B. Gyroid, Cubic). Das Drehen des aktuellen spärlichen Infill-Musters kann zu unzureichender Unterstützung führen. Bitte gehen Sie vorsichtig vor und überprüfen Sie gründlich auf mögliche Druckprobleme. Sind Sie sicher, dass Sie diese Option aktivieren möchten?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Die Schichthöhe ist zu klein.\n" -"Sie wird auf min_layer_height gesetzt\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Die Schichthöhe überschreitet das Limit in Druckereinstellungen -> Extruder -> Schichthöhenlimits. Dies kann zu Problemen mit der Druckqualität führen." - -msgid "Adjust to the set range automatically?\n" -msgstr "Automatisch an den eingestellten Bereich anpassen?\n" - -msgid "Adjust" -msgstr "Anpassen" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Experimentelle Funktion: Filament beim Filamentwechsel weiter zurückziehen und abschneiden, um den Flush zu minimieren. Obwohl dies den Flush deutlich reduzieren kann, kann es auch das Risiko von Düsenverstopfungen oder anderen Druckkomplikationen erhöhen." @@ -10505,6 +10565,9 @@ msgstr "Reservierte Schlüsselwörter gefunden" msgid "Setting Overrides" msgstr "Überschreiben der Einstellungen" +msgid "Retraction when switching material" +msgstr "Rückzug bei Materialwechsel" + msgid "Basic information" msgstr "Grundlegende Informationen" @@ -10634,6 +10697,12 @@ msgstr "Kompatible Prozessprofile" msgid "Printable space" msgstr "Druckbarer Raum" +msgid "Printer Agent" +msgstr "Drucker-Agent" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Wählen Sie die Implementierung des Netzwerkagenten für die Druckerkommunikation. Verfügbare Agenten werden beim Start registriert." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10759,9 +10828,6 @@ msgstr "Höhenbegrenzungen für Schichten" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Rückzug bei Materialwechsel" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12103,6 +12169,10 @@ msgstr " ist zu nahe am Sperrbereich und es werden Kollisionen verursacht.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " ist zu nahe am Klumpenerkennungsbereich und es werden Kollisionen verursacht.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " liegt teilweise außerhalb des druckbaren Bereichs und kann nicht gedruckt werden.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Die ausgewählten Düsentemperaturen sind nicht kompatibel. Die Düsentemperatur jedes Filaments muss innerhalb des empfohlenen Düsentemperaturbereichs der anderen Filamente liegen. Andernfalls kann es zu Düsenverstopfungen oder Druckerschäden kommen." @@ -12418,9 +12488,6 @@ msgstr "Benutze 3MF statt G-Code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Aktivieren Sie diese Option, wenn der Drucker eine 3MF-Datei als Druckauftrag akzeptiert. Wenn aktiviert, sendet Orca Slicer die geslicete Datei als .gcode.3mf, anstatt als einfache .gcode-Datei." -msgid "Printer Agent" -msgstr "Drucker-Agent" - msgid "Select the network agent implementation for printer communication." msgstr "Wählen Sie die Netzwerk-Agent-Implementierung für die Druckerkommunikation aus." @@ -13091,9 +13158,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Geschwindigkeit der internen Brücken. Wenn der Wert als Prozentsatz angegeben wird, wird er auf der Grundlage der Brückengeschwindigkeit berechnet. Der Standardwert beträgt 150 %." -msgid "Brim width" -msgstr "Randbreite" - msgid "This is the distance from the model to the outermost brim line." msgstr "Abstand vom Modell zur äußersten Randlinie" @@ -13174,6 +13238,14 @@ msgstr "" "Die Geometrie wird vor der Erkennung scharfer Winkel reduziert. Dieser Parameter ist ein Indikator für die minimale Länge der Abweichung für die Reduzierung.\n" "0 zum Deaktivieren." +# AI Translated +msgid "Brim ears outer only" +msgstr "Brim-Ohren nur außen" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Erzeugt Mausohren nur an der Außenkontur des Modells, ohne Löcher und geschlossene Bereiche." + msgid "upward compatible machine" msgstr "Aufwärtskompatible Maschine" @@ -14341,6 +14413,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Glättungsfaktor der Füllung" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Legt fest, wie stark die Ecken der Füllung abgerundet werden. 0% behält den ursprünglichen scharfkantigen Pfad bei, während 100% die größtmöglichen Kurven zwischen benachbarten Fülllinien erzeugt." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Dies ist die Beschleunigung der Füllung von der obersten Schicht. Die Verwendung eines niedrigeren Werts kann die Qualität der Oberfläche verbessern." @@ -14874,6 +14954,14 @@ msgstr "Mit welcher Art von G-Code ist der Drucker kompatibel." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code-Konfigurationsblock auslassen" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Schreibt den CONFIG_BLOCK (die Schlüssel-Wert-Paare der Slicer-Konfiguration) nicht in die G-code-Datei. Das kann bei Druckern helfen, deren Firmware beim Verarbeiten dieser Kommentarzeilen abstürzt (z. B. Anycubic go-klipper). Hinweis: Die G-code-Datei enthält dann keine Slicer-Einstellungen mehr, sodass beim erneuten Importieren in OrcaSlicer die Konfiguration nicht wiederhergestellt wird." + msgid "Pellet Modded Printer" msgstr "Pellet-Modifizierter Drucker" @@ -15920,6 +16008,14 @@ msgstr "Langer Rückzug beim Extruderwechsel" msgid "Retraction distance when extruder change" msgstr "Rückzugslänge beim Extruderwechsel" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Rückzugslänge (Werkzeugwechsel)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Wenn vor einem Werkzeugwechsel ein Rückzug ausgelöst wird, wird das Filament um den angegebenen Betrag zurückgezogen (die Länge wird am rohen Filament gemessen, bevor es in den Extruder gelangt)." + msgid "Z-hop height" msgstr "Z-Hub-Höhe" @@ -16014,6 +16110,10 @@ msgstr "Zusätzliche Länge beim Neustart" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Wenn die Rückzugskompensation nach dem Reisemove durchgeführt wird, wird der Extruder diese zusätzliche Menge an Filament schieben. Diese Einstellung wird nur selten benötigt." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Zusätzliche Länge beim Neustart (Werkzeugwechsel)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Wenn die Rückzugskompensation nach dem Wechsel des Werkzeugs durchgeführt wird, wird der Extruder diese zusätzliche Menge an Filament schieben." @@ -16431,6 +16531,14 @@ msgstr "Werkzeugwechsel auf dem Reinigungsturm" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Erzwinge, dass der Werkzeugkopf zum Reinigungsturm fährt, bevor der Werkzeugwechselbefehl (Tx) ausgegeben wird. Nur relevant für Mehrfach-Extruder (Mehrfach-Werkzeugkopf) Drucker, die einen Typ-2-Reinigungsturm verwenden. Standardmäßig überspringt Orca die Fahrt auf Mehrfach-Werkzeugkopf-Maschinen, da die Firmware den Kopfwechsel übernimmt, was dazu führen kann, dass der Tx-Befehl über dem gedruckten Teil ausgegeben wird. Aktivieren Sie diese Option, wenn Sie möchten, dass der Werkzeugwechsel immer über dem Reinigungsturm ausgegeben wird." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Auf Temperatur am Reinigungsturm warten" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Nimmt das neue Werkzeug auf, ohne auf das Erreichen der Drucktemperatur zu warten, fährt zum Reinigungsturm und wartet dort unmittelbar vor dem Spülen auf die Temperatur. Das beim Aufheizen austretende Material landet auf dem Turm statt auf dem Modell, und die Fahrt überlappt sich mit dem Aufheizen. Nur relevant für Multi-Extruder-Drucker (mehrere Werkzeugköpfe) mit einem Reinigungsturm vom Typ 2. Die Firmware bzw. das Werkzeugwechsel-Makro darf nicht selbst auf die Temperatur warten. Wenn deaktiviert, wird das Warten auf die Temperatur direkt nach dem Werkzeugwechselbefehl ausgegeben." + msgid "No sparse layers (beta)" msgstr "Keine dünnen Schichten (Beta)" @@ -19650,9 +19758,6 @@ msgstr "Drucker" msgid "Print Host upload" msgstr "Hochladen zum Druck-Host" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Wählen Sie die Implementierung des Netzwerkagenten für die Druckerkommunikation. Verfügbare Agenten werden beim Start registriert." - msgid "Select a Flashforge printer" msgstr "Wählen Sie einen Flashforge-Drucker aus" @@ -20500,9 +20605,6 @@ msgstr "Es ist etwas Unerwartetes passiert, als Sie versucht haben, sich anzumel msgid "User canceled." msgstr "Benutzer abgebrochen." -msgid "Head diameter" -msgstr "Kopfdurchmesser" - msgid "Max angle" msgstr "Maximaler Winkel" @@ -21286,6 +21388,22 @@ msgstr "" "Verwerfungen vermeiden\n" "Wussten Sie, dass beim Drucken von Materialien, die zu Verwerfungen neigen, wie z.B. ABS, durch eine entsprechende Erhöhung der Heizbetttemperatur die Wahrscheinlichkeit von Verwerfungen verringert werden kann?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Die Schichthöhe ist zu klein.\n" +#~ "Sie wird auf min_layer_height gesetzt\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Die Schichthöhe überschreitet das Limit in Druckereinstellungen -> Extruder -> Schichthöhenlimits. Dies kann zu Problemen mit der Druckqualität führen." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Automatisch an den eingestellten Bereich anpassen?\n" + +#~ msgid "Head diameter" +#~ msgstr "Kopfdurchmesser" + #~ msgid "Print order within a single layer." #~ msgstr "Druckreihenfolge innerhalb einer einzelnen Schicht" diff --git a/localization/i18n/en/OrcaSlicer_en.po b/localization/i18n/en/OrcaSlicer_en.po index 232820f681..88fb455959 100644 --- a/localization/i18n/en/OrcaSlicer_en.po +++ b/localization/i18n/en/OrcaSlicer_en.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-06-17 15:44-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: \n" @@ -4448,6 +4448,20 @@ msgstr "" msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "" +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "" + +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "" + +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "" + +msgid "Adjust" +msgstr "" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4529,6 +4543,12 @@ msgid "" "No - Disable Arachne Wall Generator and set [Displacement] mode of the Fuzzy Skin" msgstr "" +msgid "Brim ear radius" +msgstr "" + +msgid "Brim width" +msgstr "" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "" @@ -4780,6 +4800,12 @@ msgstr "" msgid "Calibration error" msgstr "" +msgid "This printer is not configured with the hardware this control needs." +msgstr "" + +msgid "This control is not supported on this printer." +msgstr "" + msgid "Network unavailable" msgstr "" @@ -5611,7 +5637,7 @@ msgstr "" msgid "Size:" msgstr "" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "" @@ -5786,6 +5812,9 @@ msgstr "" msgid "Project" msgstr "" +msgid "Device (Web)" +msgstr "" + msgid "Yes" msgstr "" @@ -7776,19 +7805,19 @@ msgstr "" msgid "Replaced with 3D files from directory:\n" msgstr "" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "" @@ -8468,6 +8497,15 @@ msgstr "" msgid "Pop up to select filament grouping mode" msgstr "" +msgid "Visible plugin pages" +msgstr "" + +msgid "pages" +msgstr "" + +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "" + msgid "Behaviour" msgstr "" @@ -8793,6 +8831,14 @@ msgstr "" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "" +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "" + +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" + msgid "Experimental Features" msgstr "" @@ -9048,9 +9094,21 @@ msgstr "" msgid "Preset Inside Project" msgstr "" +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "" + msgid "Detach from parent" msgstr "" +msgid "Unique preset" +msgstr "" + +msgid "Parent preset" +msgstr "" + +msgid "This preset does not inherit from another preset." +msgstr "" + msgid "Name is unavailable." msgstr "" @@ -9728,20 +9786,6 @@ msgstr "" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "" - -msgid "Adjust to the set range automatically?\n" -msgstr "" - -msgid "Adjust" -msgstr "" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "" @@ -9927,6 +9971,9 @@ msgstr "" msgid "Setting Overrides" msgstr "" +msgid "Retraction when switching material" +msgstr "" + msgid "Basic information" msgstr "" @@ -10053,6 +10100,12 @@ msgstr "" msgid "Printable space" msgstr "" +msgid "Printer Agent" +msgstr "" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10175,9 +10228,6 @@ msgstr "" msgid "Z-Hop" msgstr "" -msgid "Retraction when switching material" -msgstr "" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11441,6 +11491,9 @@ msgstr "" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr "" +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr "" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "" @@ -11736,9 +11789,6 @@ msgstr "" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "" -msgid "Printer Agent" -msgstr "" - msgid "Select the network agent implementation for printer communication." msgstr "" @@ -12275,9 +12325,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "" -msgid "Brim width" -msgstr "" - msgid "This is the distance from the model to the outermost brim line." msgstr "" @@ -12343,6 +12390,12 @@ msgid "" "0 to deactivate." msgstr "" +msgid "Brim ears outer only" +msgstr "" + +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "" + msgid "upward compatible machine" msgstr "" @@ -13355,6 +13408,12 @@ msgstr "" msgid "Gyroid" msgstr "" +msgid "Sparse infill smooth factor" +msgstr "" + +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "" @@ -13835,6 +13894,12 @@ msgstr "" msgid "Klipper" msgstr "" +msgid "Skip G-code config block" +msgstr "" + +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "" + msgid "Pellet Modded Printer" msgstr "" @@ -14796,6 +14861,12 @@ msgstr "" msgid "Retraction distance when extruder change" msgstr "" +msgid "Retraction Length (Toolchange)" +msgstr "" + +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "" + msgid "Z-hop height" msgstr "" @@ -14889,6 +14960,9 @@ msgstr "" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "" +msgid "Extra length on restart (Toolchange)" +msgstr "" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "" @@ -15274,6 +15348,12 @@ msgstr "" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "" +msgid "Wait for temperature on wipe tower" +msgstr "" + +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "" + msgid "No sparse layers (beta)" msgstr "" @@ -18249,9 +18329,6 @@ msgstr "" msgid "Print Host upload" msgstr "" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "" - msgid "Select a Flashforge printer" msgstr "" @@ -19083,9 +19160,6 @@ msgstr "" msgid "User canceled." msgstr "" -msgid "Head diameter" -msgstr "" - msgid "Max angle" msgstr "" diff --git a/localization/i18n/es/OrcaSlicer_es.po b/localization/i18n/es/OrcaSlicer_es.po index 1913c4512a..9c5127e50a 100644 --- a/localization/i18n/es/OrcaSlicer_es.po +++ b/localization/i18n/es/OrcaSlicer_es.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: Ian A. Bassi <>\n" "Language-Team: \n" @@ -4564,6 +4564,23 @@ msgstr "La temperatura actual de la recámara es superior a la temperatura de se msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "La temperatura mínima de la recámara (%d℃) es superior a la temperatura objetivo de la recámara (%d℃). El valor mínimo es el umbral en el que comienza la impresión mientras la recámara continúa calentándose hacia el objetivo, por lo que no debería superarlo. Se ajustará al valor objetivo." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "La altura de capa es demasiado pequeña. Se establecerá en el mínimo (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "La altura de capa está fuera de los límites establecidos en Ajustes de la Impresora -> Extrusor -> Limite de Altura de Capa, esto puede causar problemas de calidad de impresión." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "¿Ajustarla automáticamente al límite (%g mm)?" + +msgid "Adjust" +msgstr "Ajustar" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4684,6 +4701,13 @@ msgstr "" "Sí: habilitar el generador de muros Arachne\n" "No: deshabilitar el generador de paredes Arachne y establecer el modo [Desplazamiento] de la piel rugosa" +# AI Translated +msgid "Brim ear radius" +msgstr "Radio de las orejas de borde" + +msgid "Brim width" +msgstr "Ancho del borde de adherencia" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "El modo espiral solo funciona cuando los bucles de perímetro son 1, el soporte está desactivado, la detección de agrupamientos mediante sondeo está desactivada, las capas superiores de la carcasa son 0, la densidad de relleno es 0 y el tipo de lapso de tiempo es tradicional." @@ -4938,6 +4962,14 @@ msgstr "Fallo al generar el G-Code de calibración" msgid "Calibration error" msgstr "Error de calibración" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Esta impresora no está configurada con el hardware que necesita este control." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Este control no es compatible con esta impresora." + msgid "Network unavailable" msgstr "Red no disponible" @@ -5779,7 +5811,7 @@ msgstr "Volumen:" msgid "Size:" msgstr "Tamaño:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Se han encontrado conflictos de rutas G-Code en la capa %d, Z = %.2lfmm. Por favor, separe más los objetos en conflicto (%s <-> %s)." @@ -5960,6 +5992,10 @@ msgstr "Multi-dispositivo" msgid "Project" msgstr "Proyecto" +# AI Translated +msgid "Device (Web)" +msgstr "Dispositivo (Web)" + msgid "Yes" msgstr "Sí" @@ -7997,19 +8033,19 @@ msgstr "No se seleccionó el directorio para el reemplazo" msgid "Replaced with 3D files from directory:\n" msgstr "Reemplazado con archivos 3D desde el directorio:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Omitido %s: mismo archivo.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Omitido %s: el archivo no existe.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Omitido %s: fallo al reemplazar.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Reemplazado %s.\n" @@ -8725,6 +8761,18 @@ msgstr "Con esta opción activada, puede enviar una tarea a varios dispositivos msgid "Pop up to select filament grouping mode" msgstr "Ventana emergente para seleccionar el modo de agrupación de filamentos" +# AI Translated +msgid "Visible plugin pages" +msgstr "Páginas de plugins visibles" + +# AI Translated +msgid "pages" +msgstr "páginas" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Número de páginas de plugins que se muestran como pestañas fijas antes de que el resto de páginas se agrupe en un desplegable en la última pestaña." + msgid "Behaviour" msgstr "Comportamiento" @@ -9074,6 +9122,18 @@ msgstr "Mostrar ajustes preestablecidos no compatibles" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Mostrar los ajustes preestablecidos incompatibles o no compatibles en los menús desplegables de impresoras y filamentos. Estos ajustes preestablecidos no se pueden seleccionar." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimental) Usar agentes de impresora en lugar de hosts de impresión" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Envía los trabajos de impresión de impresoras que no son Bambu a través de los agentes de plugin de impresora en lugar del flujo clásico de subida al host de impresión.\n" +"Cuando está desactivado, OrcaSlicer utiliza el comportamiento heredado del host de impresión." + msgid "Experimental Features" msgstr "Funciones experimentales" @@ -9333,9 +9393,25 @@ msgstr "Perfil de usuario" msgid "Preset Inside Project" msgstr "Perfil interno del proyecto" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Copia en este perfil todos los valores heredados del perfil padre y elimina la relación de herencia. Los perfiles compatibles solo con el perfil padre pueden dejar de ser compatibles." + msgid "Detach from parent" msgstr "Separar del elemento padre" +# AI Translated +msgid "Unique preset" +msgstr "Perfil único" + +# AI Translated +msgid "Parent preset" +msgstr "Perfil padre" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Este perfil no hereda de otro perfil." + msgid "Name is unavailable." msgstr "El nombre no está disponible." @@ -10031,22 +10107,6 @@ msgstr "¿Está seguro de que desea activar esta opción?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Los patrones de relleno suelen diseñarse para gestionar la rotación automáticamente y asegurar una impresión adecuada y lograr sus efectos previstos (p. ej., Giroide, Cúbico). Rotar el patrón de relleno actual puede provocar soporte insuficiente. Proceda con precaución y compruebe detenidamente posibles problemas de impresión. ¿Está seguro de que desea activar esta opción?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"La altura de la capa es demasiado pequeña.\n" -"Se establecerá en min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "La altura de la capa excede el límite en Ajustes de la Impresora -> Extrusor -> Limite de Altura de Capa, esto puede causar problemas de calidad de impresión." - -msgid "Adjust to the set range automatically?\n" -msgstr "¿Desea ajustar el rango automáticamente?\n" - -msgid "Adjust" -msgstr "Ajustar" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Función experimental: retraer y cortar el filamento a una mayor distancia durante los cambios de filamento para minimizar el purgado. Aunque puede reducir notablemente el purgado, también puede aumentar el riesgo de atascos de boquilla u otras complicaciones de impresión.Característica experimental: Retraer y cortar el filamento a mayor distancia durante los cambios de filamento para minimizar el descarte. Aunque puede reducir notablemente el descarte, también puede elevar el riesgo de atascos de boquillas u otros problemas en la impresión." @@ -10238,6 +10298,9 @@ msgstr "Palabras clave utilizadas y encontradas" msgid "Setting Overrides" msgstr "Sobreescribir Ajustes de impresora" +msgid "Retraction when switching material" +msgstr "Retracción al cambiar de material" + msgid "Basic information" msgstr "Información básica" @@ -10364,6 +10427,12 @@ msgstr "Perfiles de proceso compatibles" msgid "Printable space" msgstr "Espacio imprimible" +msgid "Printer Agent" +msgstr "Agente de impresora" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Seleccione la implementación del agente de red para la comunicación con la impresora. Los agentes disponibles se registran al iniciar el sistema." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10489,9 +10558,6 @@ msgstr "Límites de altura de la capa" msgid "Z-Hop" msgstr "Salto en Z" -msgid "Retraction when switching material" -msgstr "Retracción al cambiar de material" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11809,6 +11875,10 @@ msgstr " está demasiado cerca de una zona de exclusión, lo que provocará coli msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " está demasiado cerca del área de detección de aglomeraciones, y se producirán colisiones.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " está parcialmente fuera del área imprimible, y no se puede imprimir.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Las temperaturas de boquilla seleccionadas son incompatibles. La temperatura de boquilla de cada filamento debe estar dentro del rango de temperaturas recomendado para los demás filamentos. De lo contrario, podrían producirse atascos en la boquilla o daños en la impresora." @@ -12116,9 +12186,6 @@ msgstr "Utiliza 3MF en lugar de G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Activa esta opción si la impresora admite un archivo 3MF como trabajo de impresión. Cuando está activada, Orca Slicer envía el archivo cortado como un archivo .gcode.3mf, en lugar de como un archivo .gcode convencional." -msgid "Printer Agent" -msgstr "Agente de impresora" - msgid "Select the network agent implementation for printer communication." msgstr "Seleccione la implementación del agente de red para la comunicación con la impresora." @@ -12794,9 +12861,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Velocidad de los puntes internos. Si se expresa como un porcentaje, será Calculado en base a la velocidad de puente. El valor por defecto es 150%." -msgid "Brim width" -msgstr "Ancho del borde de adherencia" - msgid "This is the distance from the model to the outermost brim line." msgstr "Distancia del modelo a la línea más externa del borde de adherencia." @@ -12876,6 +12940,14 @@ msgstr "" "La geometría se verá diezmada antes de detectar angulos agudos. Este parámetro indica la longitud mínima de desviación para el diezmado\n" "0 para desactivar." +# AI Translated +msgid "Brim ears outer only" +msgstr "Orejas de borde solo en el exterior" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Genera orejas de ratón únicamente en el contorno exterior del modelo, excluyendo agujeros y secciones cerradas." + msgid "upward compatible machine" msgstr "máquina compatible ascendente" @@ -14011,6 +14083,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroide" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Factor de suavizado del relleno poco denso" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Controla cuánto se redondean las esquinas del relleno poco denso. 0% mantiene el trazado original con esquinas vivas, mientras que 100% produce las curvas más amplias posibles entre líneas de relleno adyacentes." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Aceleración del relleno de la superficie superior. El uso de un valor más bajo puede mejorar la calidad de la superficie superior." @@ -14544,6 +14624,14 @@ msgstr "Con qué tipo de G-Code es compatible la impresora." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Omitir el bloque de configuración del G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "No escribe el CONFIG_BLOCK (los pares clave/valor de la configuración del laminador) en el archivo G-code. Esto puede ayudar con impresoras cuyo firmware falla al analizar esas líneas de comentario (p. ej. Anycubic go-klipper). Nota: el archivo G-code ya no contendrá los ajustes del laminador, por lo que al importarlo de nuevo en OrcaSlicer no se restaurará la configuración." + msgid "Pellet Modded Printer" msgstr "Impresora Modificada para Pellets" @@ -15583,6 +15671,14 @@ msgstr "Retracción larga al cambiar de extrusor" msgid "Retraction distance when extruder change" msgstr "Distancia de retracción al cambiar de extrusor" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Longitud de retracción (Cambio de herramienta)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Cuando se activa la retracción antes de un cambio de herramienta, el filamento se retrae la cantidad especificada (la longitud se mide sobre el filamento en bruto, antes de entrar en el extrusor)." + msgid "Z-hop height" msgstr "Altura de Salto en Z" @@ -15676,6 +15772,10 @@ msgstr "Longitud extra de reinicio" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Cuando la retracción se compensa después de un desplazamiento, el extrusor expulsará esta cantidad adicional de filamento. Esta función no suele ser necesaria." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Longitud extra de reinicio (Cambio de herramienta)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Cuando se compensa la retracción después de cambiar de cabezal, el extrusor expulsará esta cantidad adicional de filamento." @@ -16082,6 +16182,14 @@ msgstr "Cambio de herramienta en la torre de purga" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Obliga al cabezal a desplazarse hasta la torre de purga antes de emitir el comando de cambio de herramienta (Tx). Solo es relevante para impresoras con múltiples extrusores (múltiples cabezales) que utilicen una torre de limpieza de tipo 2. Por defecto, Orca omite el desplazamiento en máquinas con múltiples cabezales porque el firmware se encarga del cambio de cabezal, lo que puede provocar que el comando Tx se emita por encima de la pieza impresa. Habilita esta opción si deseas que el cambio de herramienta se emita siempre por encima de la torre de purga." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Esperar la temperatura en la torre de purga" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Recoge la nueva herramienta sin esperar a que alcance la temperatura de impresión, se desplaza a la torre de purga y espera allí la temperatura, justo antes de purgar. El rezumado del calentamiento cae sobre la torre en lugar de sobre el modelo, y el desplazamiento se solapa con el calentamiento. Solo es relevante para impresoras multiextrusor (multicabezal) que usan una torre de purga de tipo 2. El firmware o la macro de cambio de herramienta no deben esperar la temperatura por su cuenta. Cuando está desactivado, la espera de temperatura se emite justo después del comando de cambio de herramienta." + msgid "No sparse layers (beta)" msgstr "Sin capas de baja densidad (beta)" @@ -19281,9 +19389,6 @@ msgstr "Impresora física" msgid "Print Host upload" msgstr "Mandar al servidor de impresión" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Seleccione la implementación del agente de red para la comunicación con la impresora. Los agentes disponibles se registran al iniciar el sistema." - msgid "Select a Flashforge printer" msgstr "Selecciona una impresora Flashforge" @@ -20125,9 +20230,6 @@ msgstr "Ha ocurrido algo inesperado al intentar iniciar sesión, inténtelo de n msgid "User canceled." msgstr "Cancelado por el usuario." -msgid "Head diameter" -msgstr "Diámetro de la cabeza" - msgid "Max angle" msgstr "Ángulo máximo" @@ -20861,6 +20963,22 @@ msgstr "" "Evita la deformación\n" "¿Sabías que al imprimir materiales propensos a la deformación como el ABS, aumentar adecuadamente la temperatura de la cama térmica puede reducir la probabilidad de deformaciones?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "La altura de la capa es demasiado pequeña.\n" +#~ "Se establecerá en min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "La altura de la capa excede el límite en Ajustes de la Impresora -> Extrusor -> Limite de Altura de Capa, esto puede causar problemas de calidad de impresión." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "¿Desea ajustar el rango automáticamente?\n" + +#~ msgid "Head diameter" +#~ msgstr "Diámetro de la cabeza" + #~ msgid "Print order within a single layer." #~ msgstr "Orden de impresión dentro de cada capa." diff --git a/localization/i18n/eu/OrcaSlicer_eu.po b/localization/i18n/eu/OrcaSlicer_eu.po index fa10cc387f..03e6d6685d 100644 --- a/localization/i18n/eu/OrcaSlicer_eu.po +++ b/localization/i18n/eu/OrcaSlicer_eu.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-07-20 13:33+0200\n" "Last-Translator: Manu Goiogana \n" "Language-Team: \n" @@ -4606,6 +4606,23 @@ msgstr "Uneko ganberako tenperatura materialaren tenperatura segurua baino handi msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Ganberako gutxieneko tenperatura (%d ℃) helburuko ganbera-tenperatura (%d ℃) baino altuagoa da. Gutxieneko balioa inprimaketa hasten den atalasea da, ganberak helbururantz berotzen jarraitzen duen bitartean; beraz, ez luke helburua gainditu behar. Helburuko baliora mugatuko da." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Geruza-altuera txikiegia da. Gutxienekora ezarriko da (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Geruza-altuera Inprimagailuaren ezarpenak -> Estrusorea -> Geruza-altueraren mugak atalean ezarritako mugetatik kanpo dago; horrek inprimatze-kalitateko arazoak sor ditzake." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Automatikoki mugara (%g mm) doitu nahi duzu?" + +msgid "Adjust" +msgstr "Doitu" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4725,6 +4742,13 @@ msgstr "" "Bai - Gaitu Arachne horma-sorgailua\n" "Ez - Desgaitu Arachne horma-sorgailua eta ezarri gainazal zimurraren [Desplazamendua] modua" +# AI Translated +msgid "Brim ear radius" +msgstr "Ertz-belarriaren erradioa" + +msgid "Brim width" +msgstr "Itsaspen ertzaren zabalera" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Espiral moduak baldintza hauetan bakarrik funtzionatzen du: horma-begiztak 1 izatea, euskarriak desgaituta egotea, haztatze bidezko material-metaketa detektatzea desgaituta egotea, goiko estalki-geruzak 0 izatea, dentsitate baxuko betegarriaren dentsitatea 0 izatea eta timelapse mota tradizionala izatea." @@ -4979,6 +5003,14 @@ msgstr "Hutsegitea gertatu da kalibrazioko G-Code-a sortzean" msgid "Calibration error" msgstr "Kalibrazio akatsa" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Inprimagailu honek ez dauka kontrol honek behar duen hardwarea konfiguratuta." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Kontrol hau ez da bateragarria inprimagailu honekin." + # AI Translated msgid "Network unavailable" msgstr "Sarea ez dago erabilgarri" @@ -5828,7 +5860,7 @@ msgstr "Bolumena:" msgid "Size:" msgstr "Tamaina:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "G-code ibilbideen gatazkak aurkitu dira %d geruzan, Z = %.2lf mm. Urrundu gehiago gatazkan dauden objektuak (%s <-> %s)." @@ -6005,6 +6037,10 @@ msgstr "Gailu anitz" msgid "Project" msgstr "Proiektua" +# AI Translated +msgid "Device (Web)" +msgstr "Gailua (Web)" + msgid "Yes" msgstr "Bai" @@ -8064,19 +8100,19 @@ msgstr "Ez da ordezkatzeko direktoriorik hautatu" msgid "Replaced with 3D files from directory:\n" msgstr "Direktorio honetako 3D fitxategiekin ordeztuta:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ %s saltatu da: fitxategi bera.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ %s saltatu da: fitxategia ez da existitzen.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ %s saltatu da: ezin izan da ordeztu.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ %s ordezkatu da.\n" @@ -8790,6 +8826,18 @@ msgstr "Aukera hau gaituta, zeregin bat hainbat gailutara bidali eta hainbat gai msgid "Pop up to select filament grouping mode" msgstr "Erakutsi filamentuak taldekatzeko modua hautatzeko leihoa" +# AI Translated +msgid "Visible plugin pages" +msgstr "Ikusgai dauden plugin-orriak" + +# AI Translated +msgid "pages" +msgstr "orri" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Fitxa finko gisa erakusten diren plugin-orrien kopurua; gainerako orriak azken fitxako goitibeherako zerrendan bilduko dira." + msgid "Behaviour" msgstr "Jokabidea" @@ -9142,6 +9190,18 @@ msgstr "Erakutsi onartzen ez diren aurrezarpenak" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Erakutsi bateraezinak edo onartu gabeak diren aurrezarpenak inprimagailuaren eta filamentuaren goitibeherako zerrendetan. Aurrezarpen hauek ezin dira hautatu." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Esperimentala) Erabili inprimagailu-agenteak inprimatze-hostenen ordez" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Bideratu Bambu ez diren inprimagailuen inprimatze-lanak inprimagailuaren plugin-agenteen bidez, inprimatze-hostera igotzeko fluxu klasikoaren ordez.\n" +"Desgaituta dagoenean, OrcaSlicer-ek inprimatze-hostaren aurreko portaera erabiltzen du." + msgid "Experimental Features" msgstr "Ezaugarri esperimentalak" @@ -9402,9 +9462,25 @@ msgstr "Erabiltzailearen aurrezarpena" msgid "Preset Inside Project" msgstr "Proiektu barruko aurrezarpena" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Aurrezarpen honetara gurasoaren balio heredatu guztiak kopiatzen ditu eta gurasoarekiko lotura kentzen du. Gurasoarekin soilik bateragarriak diren aurrezarpenak bateraezin gera daitezke." + msgid "Detach from parent" msgstr "Bereizi gurasotik" +# AI Translated +msgid "Unique preset" +msgstr "Aurrezarpen bakarra" + +# AI Translated +msgid "Parent preset" +msgstr "Guraso-aurrezarpena" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Aurrezarpen honek ez du beste aurrezarpen batetik heredatzen." + msgid "Name is unavailable." msgstr "Izena ez dago erabilgarri." @@ -10124,22 +10200,6 @@ msgstr "Ziur aukera hau gaitu nahi duzula?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Betegarri-patroiak normalean biraketa automatikoki kudeatzeko diseinatuta daude, behar bezala inprimatzeko eta nahi den efektua lortzeko (adibidez, Giroidea edo Kubikoa). Uneko dentsitate baxuko betegarri-patroia biratzeak euskarri eskasa eragin dezake. Kontuz jarraitu eta egiaztatu arretaz inprimatze-arazorik sor daitekeen. Ziur zaude aukera hau gaitu nahi duzula?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Geruza-altuera txikiegia da.\n" -"min_layer_height baliora ezarriko da\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Geruza-altuerak Inprimagailuaren ezarpenak -> Estrusorea -> Geruza-altueraren mugak ataleko muga gainditzen du; horrek inprimatze-kalitateko arazoak sor ditzake." - -msgid "Adjust to the set range automatically?\n" -msgstr "Doitu automatikoki ezarritako barrutira?\n" - -msgid "Adjust" -msgstr "Doitu" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Funtzio esperimentala: filamentu aldaketetan distantzia handiagoan atzera egitea eta moztea, purgatzea minimizatzeko. Purgatzea nabarmen murriztu dezakeen arren, pitaren buxadurak edo bestelako inprimatze-arazoak izateko arriskua ere handitu dezake." @@ -10333,6 +10393,9 @@ msgstr "Erreserbatutako gako-hitzak aurkitu dira" msgid "Setting Overrides" msgstr "Ezarpenen gainidazketak" +msgid "Retraction when switching material" +msgstr "Atzera-egitea materiala aldatzean" + msgid "Basic information" msgstr "Oinarrizko informazioa" @@ -10459,6 +10522,12 @@ msgstr "Prozesu-profil bateragarriak" msgid "Printable space" msgstr "Inprimatzeko espazioa" +msgid "Printer Agent" +msgstr "Inprimagailu-agentea" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Hautatu sare-agentearen inplementazioa inprimagailuarekin komunikatzeko. Erabilgarri dauden agenteak abioan erregistratzen dira." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10584,9 +10653,6 @@ msgstr "Geruza-altueraren mugak" msgid "Z-Hop" msgstr "Z jauzia" -msgid "Retraction when switching material" -msgstr "Atzera-egitea materiala aldatzean" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11912,6 +11978,10 @@ msgstr " bazterketa-eremu batetik gertuegi dago, eta talkak eragingo ditu.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " material-metaketa detektatzeko eremutik gertuegi dago, eta talkak eragingo ditu.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " inprimagarri den eremutik kanpo dago partzialki, eta ezin da inprimatu.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Hautatutako pita-tenperaturak ez dira bateragarriak. Filamentu bakoitzaren pita-tenperaturak gainerako filamentuen gomendatutako pita-tenperatura tartean egon behar du. Bestela, pita buxatu edo inprimagailua kaltetu daiteke." @@ -12228,9 +12298,6 @@ msgstr "Erabili 3MF G-codearen ordez" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Gaitu aukera hau inprimagailuak 3MF fitxategi bat inprimatze-lan gisa onartzen badu. Gaituta dagoenean, OrcaSlicerrek xerratutako fitxategia .gcode.3mf gisa bidaltzen du, .gcode fitxategi arrunt baten ordez." -msgid "Printer Agent" -msgstr "Inprimagailu-agentea" - msgid "Select the network agent implementation for printer communication." msgstr "Hautatu inprimagailuarekin komunikatzeko sare-agentearen inplementazioa." @@ -12905,9 +12972,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Barru-zubien abiadura. Balioa ehuneko gisa adierazten bada, Zubien abiadura-ren arabera kalkulatuko da. Lehenetsitako balioa % 150ekoa da." -msgid "Brim width" -msgstr "Itsaspen ertzaren zabalera" - msgid "This is the distance from the model to the outermost brim line." msgstr "Hau da modelotik itsaspen ertzaren kanporen lerrora dagoen distantzia." @@ -12987,6 +13051,14 @@ msgstr "" "Geometria sinplifikatu egingo da angelu zorrotzak detektatu aurretik. Parametro honek sinplifikaziorako desbideratzearen gutxieneko luzera adierazten du.\n" "0, desaktibatzeko." +# AI Translated +msgid "Brim ears outer only" +msgstr "Ertz-belarriak kanpoaldean soilik" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Sortu saguaren belarriak modeloaren kanpoko ingeradan soilik, zuloak eta itxitako atalak baztertuta." + msgid "upward compatible machine" msgstr "gorantz bateragarria den makina" @@ -14137,6 +14209,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroidea" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Dentsitate baxuko betegarriaren leuntze-faktorea" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Dentsitate baxuko betegarriaren izkinak zenbateraino biribiltzen diren kontrolatzen du. 0% balioak jatorrizko ibilbide zorrotza mantentzen du, eta 100% balioak ondoz ondoko betegarri-lerroen arteko kurbarik zabalenak sortzen ditu." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Hau da goiko gainazaleko betegarriaren azelerazioa. Balio txikiago batek goiko gainazalaren kalitatea hobetu dezake." @@ -14676,6 +14756,14 @@ msgstr "Inprimagailua zer G-code motarekin den bateragarria." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Saltatu G-code-aren konfigurazio-blokea" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Ez idatzi CONFIG_BLOCK (xerragailuaren konfigurazioko gako/balio bikoteak) G-code fitxategian. Lagungarria izan daiteke firmwareak iruzkin-lerro horiek prozesatzean huts egiten duen inprimagailuetan (adib. Anycubic go-klipper). Oharra: G-code fitxategiak ez ditu jada xerragailuaren ezarpenak edukiko; beraz, OrcaSlicer-era berriro inportatzeak ez du konfigurazioa berreskuratuko." + msgid "Pellet Modded Printer" msgstr "Pelletekin moldatutako inprimagailua" @@ -15719,6 +15807,14 @@ msgstr "Atzera-egite luzea estrusorea aldatzean" msgid "Retraction distance when extruder change" msgstr "Atzera-egite distantzia estrusorea aldatzean" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Atzera-egitearen luzera (Erreminta aldaketa)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Erreminta aldatu aurretik atzera-egitea abiarazten denean, filamentua zehaztutako kopurua atzeratzen da (luzera filamentu gordinean neurtzen da, estrusorean sartu aurretik)." + msgid "Z-hop height" msgstr "Z jauziaren altuera" @@ -15812,6 +15908,10 @@ msgstr "Berrabiaraztean luzera gehigarria" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Mugimenduaren ondoren atzera-egitea konpentsatzen denean, estrusoreak filamentu kantitate gehigarri hau bultzatuko du. Ezarpen hau gutxitan behar da." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Berrabiaraztean luzera gehigarria (Erreminta aldaketa)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Tresna aldatu ondoren atzera-egitea konpentsatzen denean, estrusoreak filamentu kantitate gehigarri hau bultzatuko du." @@ -16220,6 +16320,14 @@ msgstr "Tresna-aldaketa purgatze-dorrean" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Behartu inprimatze-burua purgatze-dorrera joatera tresna aldatzeko agindua (Tx) eman aurretik. 2. motako purgatze-dorrea erabiltzen duten estrusore anitzeko (inprimatze-buru anitzeko) inprimagailuetarako bakarrik da garrantzitsua. Lehenespenez, Orcak ez du joan-etorria egiten inprimatze-buru anitzeko makinetan, firmwareak buruaren aldaketa kudeatzen duelako; horren ondorioz, Tx agindua inprimatutako piezaren gainean eman daiteke. Gaitu aukera hau tresna-aldaketa beti purgatze-dorrearen gainean egin dadin." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Itxaron tenperatura purgatze-dorrean" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Hartu erreminta berria inprimatze-tenperaturara iritsi arte itxaron gabe, joan purgatze-dorrera eta itxaron han tenperatura, purgatu aurretik. Berotzeak eragindako jarioa dorrean erortzen da modeloan beharrean, eta desplazamendua berotzearekin gainjartzen da. Estrusore anitzeko (inprimatze-buru anitzeko) inprimagailuetan soilik da baliagarria, 2. motako purgatze-dorrea erabiltzen dutenean. Firmwareak edo erreminta aldaketaren makroak ez du tenperaturaren zain egon behar. Desgaituta dagoenean, tenperaturaren zain egoteko agindua erreminta aldaketaren komandoaren ondoren bidaltzen da." + msgid "No sparse layers (beta)" msgstr "Geruza bakandurik ez (beta)" @@ -19429,9 +19537,6 @@ msgstr "Inprimagailu fisikoa" msgid "Print Host upload" msgstr "Inprimatze-ostalariaren karga" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Hautatu sare-agentearen inplementazioa inprimagailuarekin komunikatzeko. Erabilgarri dauden agenteak abioan erregistratzen dira." - msgid "Select a Flashforge printer" msgstr "Hautatu Flashforge inprimagailu bat" @@ -20278,9 +20383,6 @@ msgstr "Ustekabeko zerbait gertatu da saioa hasten saiatzean; saiatu berriro." msgid "User canceled." msgstr "Erabiltzaileak bertan behera utzi du." -msgid "Head diameter" -msgstr "Buruaren diametroa" - msgid "Max angle" msgstr "Gehieneko angelua" @@ -21016,6 +21118,22 @@ msgstr "" "Saihestu okertzea\n" "Ba al zenekien ABS bezalako okertzeko joera duten materialak inprimatzean ohe beroaren tenperatura egoki igotzeak okertzeko probabilitatea murriztu dezakeela?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Geruza-altuera txikiegia da.\n" +#~ "min_layer_height baliora ezarriko da\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Geruza-altuerak Inprimagailuaren ezarpenak -> Estrusorea -> Geruza-altueraren mugak ataleko muga gainditzen du; horrek inprimatze-kalitateko arazoak sor ditzake." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Doitu automatikoki ezarritako barrutira?\n" + +#~ msgid "Head diameter" +#~ msgstr "Buruaren diametroa" + #~ msgid "Print order within a single layer." #~ msgstr "Geruza bakarreko inprimatze-ordena." diff --git a/localization/i18n/fr/OrcaSlicer_fr.po b/localization/i18n/fr/OrcaSlicer_fr.po index 1257994f16..fc58381106 100644 --- a/localization/i18n/fr/OrcaSlicer_fr.po +++ b/localization/i18n/fr/OrcaSlicer_fr.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: Guislain Cyril, Thomas Lété\n" @@ -4643,6 +4643,23 @@ msgstr "La température actuelle du caisson est supérieure à la température d msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "La température minimale du caisson (%d℃) est supérieure à la température cible du caisson (%d℃). La valeur minimale est le seuil à partir duquel l’impression démarre tandis que le caisson continue de chauffer vers la cible ; elle ne doit donc pas la dépasser. Elle sera limitée à la cible." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "La hauteur de couche est trop faible. Elle sera définie au minimum (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "La hauteur de couche est en dehors des limites définies dans Paramètres de l’imprimante -> Extrudeur -> Limites de la hauteur de la couche, ce qui peut entraîner des problèmes de qualité d’impression." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "L’ajuster automatiquement à la limite (%g mm) ?" + +msgid "Adjust" +msgstr "Ajuster" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4762,6 +4779,13 @@ msgstr "" "Oui - Activer le générateur de parois Arachne\n" "Non - Désactiver le générateur de parois Arachne et définir le mode [Déplacement] de la surface irrégulière" +# AI Translated +msgid "Brim ear radius" +msgstr "Rayon de la bordure à oreilles" + +msgid "Brim width" +msgstr "Largeur de la bordure" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Le mode spirale ne fonctionne que lorsque le nombre de parois est 1, le support est désactivé, la détection d'agglomération par sondage est désactivée, les couches supérieures sont à 0, la densité de remplissage clairsemé est à 0 et le type de timelapse est traditionnel." @@ -4835,7 +4859,7 @@ msgid "Calibrating the micro lidar" msgstr "Calibrage du micro-Lidar" msgid "Calibrating flow ratio" -msgstr "Calibration du ratio de débit" +msgstr "Calibration du rapport de débit" msgid "Pause (nozzle temperature malfunction)" msgstr "Pause (dysfonctionnement de la température de la buse)" @@ -5016,6 +5040,14 @@ msgstr "Échec de la génération du G-code de calibration" msgid "Calibration error" msgstr "Erreur de la calibration" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Cette imprimante ne dispose pas du matériel requis par ce contrôle." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Ce contrôle n’est pas pris en charge sur cette imprimante." + # AI Translated msgid "Network unavailable" msgstr "Réseau indisponible" @@ -5871,7 +5903,7 @@ msgstr "Volume :" msgid "Size:" msgstr "Taille :" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Des conflits de chemins G-code ont été trouvés au niveau de la couche %d, z = %.2lfmm. Veuillez séparer davantage les objets en conflit (%s <-> %s)." @@ -6052,6 +6084,10 @@ msgstr "Multi-appareils" msgid "Project" msgstr "Projet" +# AI Translated +msgid "Device (Web)" +msgstr "Appareil (Web)" + msgid "Yes" msgstr "Oui" @@ -7434,11 +7470,11 @@ msgstr "Erreur lors du chargement des shaders" msgctxt "Layers" msgid "Top" -msgstr "Du haut" +msgstr "Supérieur" msgctxt "Layers" msgid "Bottom" -msgstr "Du bas" +msgstr "Inférieur" # AI Translated msgid "Plugin Selection" @@ -8120,19 +8156,19 @@ msgstr "Le répertoire pour le remplacement n'a pas été sélectionné" msgid "Replaced with 3D files from directory:\n" msgstr "Remplacé par des fichiers 3D depuis le répertoire :\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Ignoré %s : même fichier.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Ignoré %s : le fichier n'existe pas.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Ignoré %s : échec du remplacement.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Remplacé %s.\n" @@ -8857,6 +8893,18 @@ msgstr "Si cette option est activée, vous pouvez envoyer une tâche à plusieur msgid "Pop up to select filament grouping mode" msgstr "Fenêtre contextuelle pour sélectionner le mode de regroupement des filaments" +# AI Translated +msgid "Visible plugin pages" +msgstr "Pages de plugins visibles" + +# AI Translated +msgid "pages" +msgstr "pages" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Nombre de pages de plugins affichées sous forme d’onglets fixes avant que les pages restantes ne soient regroupées dans un menu déroulant sur le dernier onglet." + msgid "Behaviour" msgstr "Comportement" @@ -9211,6 +9259,18 @@ msgstr "Afficher les préréglages non pris en charge" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Affiche les préréglages incompatibles ou non pris en charge dans les listes déroulantes d’imprimantes et de filaments. Ces préréglages ne peuvent pas être sélectionnés." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Expérimental) Utiliser les agents d’imprimante au lieu des hôtes d’impression" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Achemine les tâches d’impression des imprimantes non Bambu via les agents de plugin d’imprimante au lieu du flux classique d’envoi vers l’hôte d’impression.\n" +"Lorsque cette option est désactivée, OrcaSlicer utilise l’ancien comportement de l’hôte d’impression." + msgid "Experimental Features" msgstr "Fonctionnalités expérimentales" @@ -9472,9 +9532,25 @@ msgstr "Préréglage utilisateur" msgid "Preset Inside Project" msgstr "Préréglage intégré au projet" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Copie dans ce préréglage toutes les valeurs héritées du préréglage parent et supprime le lien d’héritage. Les préréglages compatibles uniquement avec le parent peuvent devenir incompatibles." + msgid "Detach from parent" msgstr "Détacher du parent" +# AI Translated +msgid "Unique preset" +msgstr "Préréglage unique" + +# AI Translated +msgid "Parent preset" +msgstr "Préréglage parent" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Ce préréglage n’hérite d’aucun autre préréglage." + msgid "Name is unavailable." msgstr "Le nom n'est pas disponible." @@ -10211,27 +10287,11 @@ msgstr "Voulez-vous vraiment activer cette option ?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Les motifs de remplissage sont généralement conçus pour gérer la rotation automatiquement afin d'assurer une impression correcte et d'atteindre les effets souhaités (ex. : Gyroïde, Cubique). La rotation du motif de remplissage clairsemé actuel peut entraîner un support insuffisant. Veuillez procéder avec précaution et vérifier soigneusement tout problème d'impression potentiel. Voulez-vous vraiment activer cette option ?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"La hauteur de couche est trop faible.\n" -"Elle sera définie à min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "La hauteur de la couche dépasse la limite fixée dans Paramètres de l’imprimante -> Extrudeur -> Limites de la hauteur de la couche, ce qui peut entraîner des problèmes de qualité d’impression." - -msgid "Adjust to the set range automatically?\n" -msgstr "S’ajuster automatiquement à la plage définie ?\n" - -msgid "Adjust" -msgstr "Ajuster" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." -msgstr "Fonction expérimentale : Rétracter et couper le filament à une plus grande distance lors des changements de filament afin de minimiser le rinçage. Bien que cela puisse réduire considérablement le rinçage, cela peut également augmenter le risque de bouchage des buses ou d’autres complications d’impression." +msgstr "Fonction expérimentale : Rétracter et couper le filament à une plus grande distance lors des changements de filament afin de minimiser la purge. Bien que cela puisse réduire considérablement la purge, cela peut également augmenter le risque de bouchage des buses ou d’autres complications d’impression." msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications. Please use with the latest printer firmware." -msgstr "Fonction expérimentale : Rétracter et couper le filament à une plus grande distance lors des changements de filament afin de minimiser l’affleurement. Bien que cela puisse réduire sensiblement l’affleurement, cela peut également augmenter le risque d’obstruction des buses ou d’autres complications d’impression. Veuillez utiliser le dernier micrologiciel de l’imprimante." +msgstr "Fonction expérimentale : Rétracter et couper le filament à une plus grande distance lors des changements de filament afin de minimiser la purge. Bien que cela puisse réduire sensiblement la purge, cela peut également augmenter le risque d’obstruction des buses ou d’autres complications d’impression. Veuillez utiliser le dernier micrologiciel de l’imprimante." msgid "" "When recording timelapse without toolhead, it is recommended to add a \"Timelapse Wipe Tower\" \n" @@ -10422,6 +10482,9 @@ msgstr "Mots clés réservés trouvés" msgid "Setting Overrides" msgstr "Forçage des réglages" +msgid "Retraction when switching material" +msgstr "Rétraction lors du changement de matériau" + msgid "Basic information" msgstr "Informations de base" @@ -10548,6 +10611,12 @@ msgstr "Profils de traitement compatibles" msgid "Printable space" msgstr "Espace imprimable" +msgid "Printer Agent" +msgstr "Agent d'imprimante" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Sélectionner l'implémentation de l'agent réseau pour la communication avec l'imprimante. Les agents disponibles sont enregistrés au démarrage." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10673,9 +10742,6 @@ msgstr "Limites de hauteur de couche" msgid "Z-Hop" msgstr "Saut en Z" -msgid "Retraction when switching material" -msgstr "Rétraction lors du changement de matériau" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12010,6 +12076,10 @@ msgstr " est trop proche d'une zone d'exclusion. Cela va entraîner des collisio msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " est trop proche de la zone de détection d'agglomération, et des collisions seront causées.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " est partiellement en dehors de la zone imprimable et ne peut pas être imprimé.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Les températures de buse sélectionnées sont incompatibles. La température de buse de chaque filament doit se situer dans la plage de température de buse recommandée des autres filaments. Sinon, un bouchage de la buse ou des dommages à l’imprimante peuvent survenir." @@ -12323,9 +12393,6 @@ msgstr "Utiliser le 3MF au lieu du G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Activez ceci si l’imprimante accepte un fichier 3MF comme tâche d’impression. Lorsque cette option est activée, Orca Slicer envoie le fichier découpé au format .gcode.3mf au lieu d’un simple fichier .gcode." -msgid "Printer Agent" -msgstr "Agent d'imprimante" - msgid "Select the network agent implementation for printer communication." msgstr "Sélectionner l'implémentation de l'agent réseau pour la communication avec l'imprimante." @@ -12689,7 +12756,7 @@ msgstr "" "Si réglée à 0, la largeur de ligne correspond à celle du remplissage plein interne." msgid "Internal bridge flow ratio" -msgstr "Ratio de débit du pont interne" +msgstr "Rapport de débit du pont interne" msgid "" "This value governs the thickness of the internal bridge layer. This is the first layer over sparse infill so increasing it may increase strength and upper layer quality.\n" @@ -12729,13 +12796,13 @@ msgstr "" "Le débit réel du remplissage solide inférieur utilisé est calculé en multipliant cette valeur par le rapport de débit du filament et, s’il est défini, par le rapport de débit de l’objet." msgid "Set other flow ratios" -msgstr "Définir d'autres ratios de débit" +msgstr "Définir d'autres rapports de débit" msgid "Change flow ratios for other extrusion path types." -msgstr "Modifier les ratios de débit pour d'autres types de chemin d'extrusion." +msgstr "Modifier les rapports de débit pour d'autres types de chemin d'extrusion." msgid "First layer flow ratio" -msgstr "Ratio de débit de la première couche" +msgstr "Rapport de débit de la première couche" msgid "" "This factor affects the amount of material on the first layer for the extrusion path roles listed in this section.\n" @@ -12744,10 +12811,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau sur la première couche pour les rôles de chemin d'extrusion listés dans cette section.\n" "\n" -"Pour la première couche, le ratio de débit réel pour chaque rôle de chemin (n'affecte pas les bordures et les jupes) sera multiplié par cette valeur." +"Pour la première couche, le rapport de débit réel pour chaque rôle de chemin (n'affecte pas les bordures et les jupes) sera multiplié par cette valeur." msgid "Outer wall flow ratio" -msgstr "Ratio de débit de la paroi extérieure" +msgstr "Rapport de débit de la paroi extérieure" msgid "" "This factor affects the amount of material for outer walls.\n" @@ -12756,10 +12823,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour les parois extérieures.\n" "\n" -"Le débit réel de la paroi extérieure est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel de la paroi extérieure est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Inner wall flow ratio" -msgstr "Ratio de débit de la paroi intérieure" +msgstr "Rapport de débit de la paroi intérieure" msgid "" "This factor affects the amount of material for inner walls.\n" @@ -12768,10 +12835,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour les parois intérieures.\n" "\n" -"Le débit réel de la paroi intérieure est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel de la paroi intérieure est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Overhang flow ratio" -msgstr "Ratio de débit de surplomb" +msgstr "Rapport de débit de surplomb" msgid "" "This factor affects the amount of material for overhangs.\n" @@ -12780,10 +12847,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour les surplombs.\n" "\n" -"Le débit réel de surplomb est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel de surplomb est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Sparse infill flow ratio" -msgstr "Ratio de débit du remplissage clairsemé" +msgstr "Rapport de débit du remplissage clairsemé" msgid "" "This factor affects the amount of material for sparse infill.\n" @@ -12792,10 +12859,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour le remplissage clairsemé.\n" "\n" -"Le débit réel du remplissage clairsemé est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel du remplissage clairsemé est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Internal solid infill flow ratio" -msgstr "Ratio de débit du remplissage solide interne" +msgstr "Rapport de débit du remplissage solide interne" msgid "" "This factor affects the amount of material for internal solid infill.\n" @@ -12804,10 +12871,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour le remplissage solide interne.\n" "\n" -"Le débit réel du remplissage solide interne est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel du remplissage solide interne est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Gap fill flow ratio" -msgstr "Ratio de débit du remplissage des espaces" +msgstr "Rapport de débit du remplissage des espaces" msgid "" "This factor affects the amount of material for filling the gaps.\n" @@ -12816,10 +12883,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour le remplissage des espaces.\n" "\n" -"Le débit réel du remplissage des espaces est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel du remplissage des espaces est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Support flow ratio" -msgstr "Ratio de débit des supports" +msgstr "Rapport de débit des supports" msgid "" "This factor affects the amount of material for support.\n" @@ -12828,10 +12895,10 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour les supports.\n" "\n" -"Le débit réel des supports est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel des supports est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Support interface flow ratio" -msgstr "Ratio de débit de l'interface de support" +msgstr "Rapport de débit de l'interface de support" msgid "" "This factor affects the amount of material for the support interface.\n" @@ -12840,7 +12907,7 @@ msgid "" msgstr "" "Ce facteur affecte la quantité de matériau pour l'interface de support.\n" "\n" -"Le débit réel de l'interface de support est calculé en multipliant cette valeur par le ratio de débit du filament, et si défini, le ratio de débit de l'objet." +"Le débit réel de l'interface de support est calculé en multipliant cette valeur par le rapport de débit du filament, et si défini, le rapport de débit de l'objet." msgid "Precise wall" msgstr "Parois précises" @@ -13000,9 +13067,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Vitesse des ponts internes. Si la valeur est exprimée en pourcentage, elle sera calculée sur la base de la vitesse du pont. La valeur par défaut est 150%." -msgid "Brim width" -msgstr "Largeur de la bordure" - msgid "This is the distance from the model to the outermost brim line." msgstr "Distance du modèle à la ligne de bord la plus externe" @@ -13043,10 +13107,10 @@ msgid "" "\n" "If your current setup already works well, enabling it may be unnecessary and can cause the brim to fuse with upper layers." msgstr "" -"Lorsqu'il est activé, le bordure est aligné avec la géométrie du périmètre de la première couche après l'application de la compensation du pied d'éléphant.\n" -"Cette option est destinée aux cas où la compensation du pied d'éléphant modifie considérablement l’empreinte de la première couche.\n" +"Lorsqu'il est activé, la bordure est alignée avec la géométrie du périmètre de la première couche après l'application de la compensation de la patte d'éléphant.\n" +"Cette option est destinée aux cas où la compensation de la patte d'éléphant modifie considérablement l’empreinte de la première couche.\n" "\n" -"Si votre configuration actuelle fonctionne déjà bien, son activation peut être inutile et peut provoquer la fusion du bordure avec les couches supérieures." +"Si votre configuration actuelle fonctionne déjà bien, son activation peut être inutile et peut provoquer la fusion de la bordure avec les couches supérieures." msgid "Combine brims" msgstr "Combiner les bordures" @@ -13082,6 +13146,14 @@ msgstr "" "La géométrie sera décimée avant de détecter les angles vifs. Ce paramètre indique la longueur minimale de l’écart pour la décimation.\n" "0 pour désactiver" +# AI Translated +msgid "Brim ears outer only" +msgstr "Bordure à oreilles sur le contour extérieur uniquement" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Génère des oreilles de souris uniquement sur le contour extérieur du modèle, en excluant les trous et les sections fermées." + msgid "upward compatible machine" msgstr "machine à compatibilité ascendante" @@ -13643,7 +13715,7 @@ msgid "" msgstr "" "Le matériau peut présenter un changement volumétrique après le passage de l’état fondu à l’état cristallin. Ce paramètre modifie proportionnellement tous les débits d’extrusion de ce filament dans le G-code. La valeur recommandée est comprise entre 0,95 et 1,05. Vous pouvez peut-être ajuster cette valeur pour obtenir une belle surface plate lorsqu’il y a un léger débordement ou un sous-débordement.\n" "\n" -"Le ratio de débit de l’objet final est cette valeur multipliée par le ratio de débit du filament." +"Le rapport de débit de l’objet final est cette valeur multipliée par le rapport de débit du filament." msgid "Enable pressure advance" msgstr "Activer la Pressure Advance" @@ -14236,6 +14308,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroïde" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Facteur de lissage du remplissage" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Contrôle le degré d’arrondi des angles du remplissage. 0% conserve le tracé anguleux d’origine, tandis que 100% produit les courbes les plus amples possibles entre les lignes de remplissage adjacentes." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Il s'agit de l'accélération de la surface supérieure du remplissage. Utiliser une valeur plus petite pourrait améliorer la qualité de la surface supérieure" @@ -14774,6 +14854,14 @@ msgstr "Avec quel type de G-code l'imprimante est-elle compatible." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Omettre le bloc de configuration du G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "N’écrit pas le CONFIG_BLOCK (les paires clé/valeur de la configuration du logiciel de découpe) dans le fichier G-code. Cela peut aider avec les imprimantes dont le firmware plante lors de l’analyse de ces lignes de commentaire (par ex. Anycubic go-klipper). Remarque : le fichier G-code ne contiendra plus les réglages du logiciel de découpe, sa réimportation dans OrcaSlicer ne restaurera donc pas la configuration." + msgid "Pellet Modded Printer" msgstr "Imprimante à pellets" @@ -15821,6 +15909,14 @@ msgstr "Rétraction longue lors du changement d'extrudeur" msgid "Retraction distance when extruder change" msgstr "Distance de rétraction lors du changement d'extrudeur" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Longueur de rétraction (Changement d’outil)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Lorsque la rétraction est déclenchée avant un changement d’outil, le filament est rétracté de la quantité spécifiée (la longueur est mesurée sur le filament brut, avant son entrée dans l’extrudeur)." + msgid "Z-hop height" msgstr "Hauteur du saut en Z" @@ -15914,6 +16010,10 @@ msgstr "Longueur supplémentaire" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Lorsque la rétraction est compensée après le mouvement de déplacement, l’extrudeuse poussera cette quantité supplémentaire de filament. Ce paramètre est rarement nécessaire." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Longueur supplémentaire à la reprise (Changement d’outil)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Lorsque la rétraction est compensée après le changement d’outil, l’extrudeur poussera cette quantité supplémentaire de filament." @@ -16012,11 +16112,11 @@ msgstr "" "Si l’angle maximal à l’intérieur de la boucle périmétrique dépasse cette valeur (indiquant l’absence d’angles vifs), une couture en biseau sera utilisée. La valeur par défaut est de 155°." msgid "Conditional overhang threshold" -msgstr "Seuil de dépassement conditionnel" +msgstr "Seuil de surplomb conditionnel" #, no-c-format, no-boost-format msgid "This option determines the overhang threshold for the application of scarf joint seams. If the unsupported portion of the perimeter is less than this threshold, scarf joint seams will be applied. The default threshold is set at 40% of the external wall's width. Due to performance considerations, the degree of overhang is estimated." -msgstr "Cette option détermine le seuil de surplomb pour l’application des coutures en écharpe. Si la partie non soutenue du périmètre est inférieure à ce seuil, des coutures en biseau seront appliquées. Le seuil par défaut est fixé à 40 % de la largeur de la paroi extérieure. Pour des raisons de performance, le degré de surplomb est estimé." +msgstr "Cette option détermine le seuil de surplomb pour l’application des coutures en biseau. Si la partie non soutenue du périmètre est inférieure à ce seuil, des coutures en biseau seront appliquées. Le seuil par défaut est fixé à 40 % de la largeur de la paroi extérieure. Pour des raisons de performance, le degré de surplomb est estimé." msgid "Scarf joint speed" msgstr "Vitesse de la couture en biseau" @@ -16025,7 +16125,7 @@ msgid "This option sets the printing speed for scarf joints. It is recommended t msgstr "Cette option définit la vitesse d’impression des coutures en biseau. Il est recommandé d’imprimer les coutures en biseau à une vitesse lente (moins de 100 mm/s). Il est également conseillé d’activer l’option « Lissage de la vitesse d’extrusion » si la vitesse définie varie de manière significative par rapport à la vitesse des parois extérieures ou intérieures. Si la vitesse spécifiée ici est supérieure à la vitesse des parois extérieures ou intérieures, l’imprimante prendra par défaut la plus lente des deux vitesses. Lorsqu’elle est spécifiée sous forme de pourcentage (par exemple, 80 %), la vitesse est calculée sur la base de la vitesse de la paroi extérieure ou intérieure. La valeur par défaut est fixée à 100 %." msgid "Scarf joint flow ratio" -msgstr "Ratio de débit de la couture en biseau" +msgstr "Rapport de débit de la couture en biseau" msgid "This factor affects the amount of material for scarf joints." msgstr "Ce facteur influe sur la quantité de matériau pour les coutures en biseau." @@ -16234,7 +16334,7 @@ msgstr "Taux de débit de la finition en spirale" #, no-c-format, no-boost-format msgid "Sets the finishing flow ratio while ending the spiral. Normally the spiral transition scales the flow ratio from 100% to 0% during the last loop which can in some cases lead to under extrusion at the end of the spiral." -msgstr "Définit le ratio de débit de finition lors de la fin de la spirale. Normalement, la transition de la spirale fait passer le taux de débit de 100% à 0% au cours de la dernière boucle, ce qui peut dans certains cas entraîner une sous-extrusion à la fin de la spirale." +msgstr "Définit le rapport de débit de finition lors de la fin de la spirale. Normalement, la transition de la spirale fait passer le taux de débit de 100% à 0% au cours de la dernière boucle, ce qui peut dans certains cas entraîner une sous-extrusion à la fin de la spirale." msgid "If smooth or traditional mode is selected, a timelapse video will be generated for each print. After each layer is printed, a snapshot is taken with the chamber camera. All of these snapshots are composed into a timelapse video when printing completes. If smooth mode is selected, the toolhead will move to the excess chute after each layer is printed and then take a snapshot. Since the melt filament may leak from the nozzle during the process of taking a snapshot, a prime tower is required for smooth mode to wipe the nozzle." msgstr "Si le mode fluide ou traditionnel est sélectionné, une vidéo en timelapse sera générée pour chaque impression. À chaque couche imprimée, un instantané est pris avec la caméra intégrée. Tous ces instantanés seront assemblés dans une vidéo timelapse une fois l'impression terminée. Si le mode lisse est sélectionné, l'extrudeur se déplace vers la goulotte d'évacuation à chaque couche imprimée, puis prend un cliché. Étant donné que le filament fondu peut s'échapper de la buse pendant la prise de vue, une tour d’amorçage est requise en mode lisse pour essuyer la buse." @@ -16326,6 +16426,14 @@ msgstr "Changement d’outil sur la tour d’essuyage" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Force la tête d’outil à se déplacer vers la tour d’essuyage avant d’émettre la commande de changement d’outil (Tx). Pertinent uniquement pour les imprimantes multi-extrudeurs (à têtes d’outil multiples) utilisant une tour d’essuyage de type 2. Par défaut, Orca omet ce déplacement sur les machines à têtes d’outil multiples car le firmware gère le changement de tête, ce qui peut entraîner l’émission de la commande Tx au-dessus de la pièce imprimée. Activez cette option si vous préférez que le changement d’outil soit toujours émis au-dessus de la tour d’essuyage." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Attendre la température sur la tour d’essuyage" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Prend le nouvel outil sans attendre qu’il atteigne la température d’impression, se déplace vers la tour d’essuyage et y attend la température, juste avant la purge. Le suintement dû à la chauffe se dépose sur la tour plutôt que sur le modèle, et le déplacement se superpose à la chauffe. Uniquement pertinent pour les imprimantes multi-extrudeurs (multi-têtes) utilisant une tour d’essuyage de type 2. Le firmware ou la macro de changement d’outil ne doivent pas attendre la température eux-mêmes. Lorsque cette option est désactivée, l’attente de température est émise juste après la commande de changement d’outil." + msgid "No sparse layers (beta)" msgstr "Pas de couches éparses (beta)" @@ -18217,7 +18325,7 @@ msgid "Record Factor" msgstr "Enregistrer le facteur" msgid "We found the best flow ratio for you" -msgstr "Nous avons trouvé le meilleur ratio de débit pour vous" +msgstr "Nous avons trouvé le meilleur rapport de débit pour vous" msgid "Flow Ratio" msgstr "Rapport de débit" @@ -19542,9 +19650,6 @@ msgstr "Imprimante Physique" msgid "Print Host upload" msgstr "Envoi vers l’imprimante hôte" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Sélectionner l'implémentation de l'agent réseau pour la communication avec l'imprimante. Les agents disponibles sont enregistrés au démarrage." - msgid "Select a Flashforge printer" msgstr "Sélectionner une imprimante Flashforge" @@ -20392,9 +20497,6 @@ msgstr "Un événement inattendu s’est produit lors de la connexion, veuillez msgid "User canceled." msgstr "L’utilisateur a annulé." -msgid "Head diameter" -msgstr "Diamètre de la tête" - msgid "Max angle" msgstr "Angle maximal" @@ -21176,6 +21278,22 @@ msgstr "" "Éviter la déformation\n" "Saviez-vous que lors de l’impression de matériaux susceptibles de se déformer, tels que l’ABS, une augmentation appropriée de la température du plateau chauffant peut réduire la probabilité de déformation?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "La hauteur de couche est trop faible.\n" +#~ "Elle sera définie à min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "La hauteur de la couche dépasse la limite fixée dans Paramètres de l’imprimante -> Extrudeur -> Limites de la hauteur de la couche, ce qui peut entraîner des problèmes de qualité d’impression." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "S’ajuster automatiquement à la plage définie ?\n" + +#~ msgid "Head diameter" +#~ msgstr "Diamètre de la tête" + #~ msgid "Print order within a single layer." #~ msgstr "Ordre d’impression au sein d’une même couche" diff --git a/localization/i18n/hu/OrcaSlicer_hu.po b/localization/i18n/hu/OrcaSlicer_hu.po index 98cd987512..9f8a849884 100644 --- a/localization/i18n/hu/OrcaSlicer_hu.po +++ b/localization/i18n/hu/OrcaSlicer_hu.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -4739,6 +4739,23 @@ msgstr "A kamra aktuális hőmérséklete magasabb az anyag biztonságos hőmér msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "A minimális kamrahőmérséklet (%d℃) magasabb a cél kamrahőmérsékletnél (%d℃). A minimális érték az a küszöb, amelynél a nyomtatás elindul, miközben a kamra tovább melegszik a célérték felé, ezért nem haladhatja meg azt. Az érték a célértékre lesz korlátozva." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "A rétegmagasság túl kicsi. A minimumra lesz állítva (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "A rétegmagasság a Nyomtatóbeállítások -> Extruder -> Rétegmagasság limitek menüpontban megadott határértékeken kívül esik, ez minőségbeli problémákat okozhat a nyomtatás során." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Szeretnéd automatikusan a határértékre (%g mm) igazítani?" + +msgid "Adjust" +msgstr "Módosítás" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4858,6 +4875,13 @@ msgstr "" "Igen - Engedélyezd az Arachne falgenerátort\n" "Nem - Tiltsd le az Arachne falgenerátort, majd állítsd a barázdált felületet [Eltolás] módra" +# AI Translated +msgid "Brim ear radius" +msgstr "Peremfül sugara" + +msgid "Brim width" +msgstr "Perem szélessége" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "A spirál mód csak akkor működik, ha a falhurkok száma 1, a támasz és a szondázásos csomósodásészlelés ki van kapcsolva, a felső héjrétegek száma 0, a kitöltés sűrűsége 0, a Timelapse típusa pedig hagyományos." @@ -5112,6 +5136,14 @@ msgstr "Nem sikerült létrehozni a kalibrációs G-kódot" msgid "Calibration error" msgstr "Kalibrációs hiba" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Ez a nyomtató nincs felszerelve a vezérlőelemhez szükséges hardverrel." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Ez a vezérlőelem nem támogatott ezen a nyomtatón." + # AI Translated msgid "Network unavailable" msgstr "A hálózat nem érhető el" @@ -5971,7 +6003,7 @@ msgstr "Térfogat:" msgid "Size:" msgstr "Méret:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "G-kód útvonalütközés található a(z) %d. rétegen, Z = %.2lfmm. Helyezd távolabb egymástól az ütköző objektumokat (%s <-> %s)." @@ -6153,6 +6185,10 @@ msgstr "Több eszköz" msgid "Project" msgstr "Projekt" +# AI Translated +msgid "Device (Web)" +msgstr "Nyomtató (Web)" + msgid "Yes" msgstr "Igen" @@ -8244,19 +8280,19 @@ msgstr "A cseréhez nem lett mappa kiválasztva" msgid "Replaced with 3D files from directory:\n" msgstr "Cserélve a mappából származó 3D fájlokra:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ %s kihagyva: azonos fájl.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ %s kihagyva: a fájl nem létezik.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ %s kihagyva: a csere sikertelen.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔%s lecserélve.\n" @@ -8993,6 +9029,18 @@ msgstr "Ezzel az opcióval egyszerre több eszközre küldhetsz feladatot és t msgid "Pop up to select filament grouping mode" msgstr "Felugró ablak a filamentcsoportosítási mód kiválasztásához" +# AI Translated +msgid "Visible plugin pages" +msgstr "Látható bővítményoldalak" + +# AI Translated +msgid "pages" +msgstr "oldal" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "A rögzített fülként megjelenő bővítményoldalak száma; a fennmaradó oldalak az utolsó fülön lenyíló listába kerülnek." + msgid "Behaviour" msgstr "Viselkedés" @@ -9362,6 +9410,18 @@ msgstr "Nem támogatott beállítások megjelenítése" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Megjeleníti a nem kompatibilis vagy nem támogatott beállításokat a nyomtató- és filamentlegördülő listákban. Ezek a beállítások nem választhatók ki." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Kísérleti) Nyomtatóügynökök használata nyomtatókiszolgálók helyett" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"A nem Bambu nyomtatók nyomtatási feladatait a nyomtató bővítményügynökein keresztül továbbítja a klasszikus nyomtatókiszolgálóra való feltöltés helyett.\n" +"Ha ki van kapcsolva, az OrcaSlicer a régi nyomtatókiszolgáló-viselkedést használja." + # AI Translated msgid "Experimental Features" msgstr "Kísérleti funkciók" @@ -9632,9 +9692,25 @@ msgstr "Felhasználói beállítás" msgid "Preset Inside Project" msgstr "Projekt a beállításon belül" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Az összes örökölt értéket átmásolja a szülő előbeállításból ebbe az előbeállításba, és megszünteti az öröklési kapcsolatot. A csak a szülővel kompatibilis előbeállítások támogatása megszűnhet." + msgid "Detach from parent" msgstr "Leválasztás a szülőről" +# AI Translated +msgid "Unique preset" +msgstr "Önálló előbeállítás" + +# AI Translated +msgid "Parent preset" +msgstr "Szülő előbeállítás" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Ez az előbeállítás nem örököl másik előbeállításból." + msgid "Name is unavailable." msgstr "A név nem elérhető." @@ -10376,22 +10452,6 @@ msgstr "Biztos, hogy engedélyezed ezt az opciót?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "A kitöltési minták általában maguk kezelik a forgatást a megfelelő nyomtatás és a kívánt hatás elérése érdekében (pl. Gyroid, Cubic). A jelenlegi kitöltési minta elforgatása elégtelen alátámasztáshoz vezethet. Kérlek, járj el körültekintően, és alaposan ellenőrizd a lehetséges nyomtatási problémákat. Biztos, hogy engedélyezed ezt a beállítást?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"A rétegmagasság túl kicsi.\n" -"A rendszer a min_layer_height értékre állítja.\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "A rétegmagasság meghaladja a Nyomtatóbeállítások -> Extruder -> Rétegmagasság limitek menüpontban megadott értéket, ez minőségbeli problémákat okozhat a nyomtatás során." - -msgid "Adjust to the set range automatically?\n" -msgstr "Szeretnéd az értéket automatikusan a beállított tartományhoz igazítani?\n" - -msgid "Adjust" -msgstr "Módosítás" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Kísérleti funkció: Filamentcsere közben nagyobb távolságon történő visszahúzás és elvágás az öblítés minimalizálása érdekében. Bár ez jelentősen csökkentheti az öblítés mértékét, növelheti a fúvóka eltömődésének vagy más nyomtatási problémák kockázatát." @@ -10587,6 +10647,9 @@ msgstr "Foglalt kulcsszavakat találtunk" msgid "Setting Overrides" msgstr "Beállítások felülbírálása" +msgid "Retraction when switching material" +msgstr "Visszahúzás anyagváltáskor" + msgid "Basic information" msgstr "Alapinformációk" @@ -10720,6 +10783,12 @@ msgstr "Kompatibilis folyamatprofilok" msgid "Printable space" msgstr "Nyomtatási terület" +msgid "Printer Agent" +msgstr "Nyomtatóügynök" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Válaszd ki a nyomtatóval való kommunikációhoz használt hálózati ügynököt. Az elérhető ügynököket indításkor regisztrálja a rendszer." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10845,9 +10914,6 @@ msgstr "Rétegmagasság limitek" msgid "Z-Hop" msgstr "Z-emelés" -msgid "Retraction when switching material" -msgstr "Visszahúzás anyagváltáskor" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12200,6 +12266,10 @@ msgstr " túl közel van a tiltott területhez, a nyomtatás során előfordulha msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " túl közel van a csomósodásészlelési területhez, és ez ütközést fog okozni.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " részben a nyomtatható területen kívül esik, ezért nem nyomtatható ki.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "A kiválasztott fúvóka hőmérsékletek nem kompatibilisek. Mindegyik filament fúvóka hőmérsékletének a többi filament ajánlott fúvóka hőmérsékleti tartományába kell esnie. Ellenkező esetben a fúvóka eltömődhet vagy a nyomtató megsérülhet." @@ -12530,9 +12600,6 @@ msgstr "3MF használata G-kód helyett" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Kapcsold be, ha a nyomtató 3MF fájlt fogad el nyomtatási feladatként. Bekapcsolva az Orca Slicer a szeletelt fájlt .gcode.3mf formátumban küldi el egyszerű .gcode fájl helyett." -msgid "Printer Agent" -msgstr "Nyomtatóügynök" - msgid "Select the network agent implementation for printer communication." msgstr "Válaszd ki a nyomtató kommunikációjához használt hálózati ügynök implementációját." @@ -13220,9 +13287,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "A belső hidak sebessége. Ha az érték százalékban van megadva, a bridge_speed alapján lesz kiszámítva. Az alapértelmezett érték 150%." -msgid "Brim width" -msgstr "Perem szélessége" - msgid "This is the distance from the model to the outermost brim line." msgstr "A modell és a legkülső peremvonal közötti távolság" @@ -13302,6 +13366,14 @@ msgstr "" "Az éles szögek észlelése előtt a geometria egyszerűsítve lesz. Ez a paraméter a leegyszerűsítésnél figyelembe vett eltérés minimális hosszát adja meg.\n" "0 értékkel kikapcsolható." +# AI Translated +msgid "Brim ears outer only" +msgstr "Peremfülek csak kívül" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Egérfüleket csak a modell külső kontúrján hoz létre, a furatokat és a zárt szakaszokat kihagyva." + msgid "upward compatible machine" msgstr "felfelé kompatibilis gép" @@ -14475,6 +14547,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Kitöltés simítási tényezője" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Azt szabályozza, hogy a kitöltés sarkai mennyire legyenek lekerekítve. A 0% megtartja az eredeti éles útvonalat, a 100% pedig a lehető legnagyobb íveket hozza létre a szomszédos kitöltővonalak között." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "A felső felületi kitöltés gyorsulása. Alacsonyabb érték használata javíthatja a felső felület minőségét" @@ -15017,6 +15097,14 @@ msgstr "Milyen G-kóddal kompatibilis a nyomtató." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code konfigurációs blokk kihagyása" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Nem írja a CONFIG_BLOCK blokkot (a szeletelő beállításainak kulcs/érték párjait) a G-code fájlba. Ez segíthet azoknál a nyomtatóknál, amelyek firmware-e összeomlik ezeknek a megjegyzéssoroknak a feldolgozásakor (pl. Anycubic go-klipper). Megjegyzés: a G-code fájl így már nem tartalmazza a szeletelő beállításait, ezért az OrcaSlicerbe való visszaimportálás nem állítja vissza a konfigurációt." + msgid "Pellet Modded Printer" msgstr "Granulátumos módosított nyomtató" @@ -16079,6 +16167,14 @@ msgstr "Hosszú visszahúzás extruderváltáskor" msgid "Retraction distance when extruder change" msgstr "Visszahúzási távolság extruderváltáskor" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Visszahúzás hossza (Eszközváltás)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Amikor a visszahúzás eszközváltás előtt aktiválódik, a filament a megadott értékkel húzódik vissza (a hossz a nyers filamenten mérve, mielőtt az az extruderbe kerülne)." + msgid "Z-hop height" msgstr "Z-emelés magassága" @@ -16172,6 +16268,10 @@ msgstr "Extra hossz újraindításkor" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Amikor a visszahúzás kompenzálásra kerül utazási mozgás után, az extruder ezt a további szálmennyiséget nyomja előre. Erre a beállításra ritkán van szükség." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Extra hossz újraindításkor (Eszközváltás)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Amikor a visszahúzás kompenzálásra kerül szerszámváltás után, az extruder ezt a további szálmennyiséget nyomja előre." @@ -16588,6 +16688,14 @@ msgstr "Szerszámcsere a törlőtoronyban" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "A szerszámcsere parancs (Tx) kiadása előtt a törlőtoronyhoz mozgatja a szerszámfejet. Csak a 2-es típusú törlőtornyot használó többextruderes (több szerszámfejes) nyomtatóknál van jelentősége. Az Orca alapértelmezés szerint kihagyja ezt a mozgást a több szerszámfejes gépeknél, mert a fejcserét a firmware kezeli. Emiatt azonban előfordulhat, hogy a Tx parancsot a nyomtatott tárgy felett adja ki. Kapcsold be ezt a beállítást, ha azt szeretnéd, hogy a szerszámcsere mindig a törlőtorony felett történjen." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Várakozás a hőmérsékletre a törlőtornyon" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Felveszi az új szerszámot anélkül, hogy megvárná a nyomtatási hőmérséklet elérését, a törlőtoronyhoz áll, és ott várja meg a hőmérsékletet, közvetlenül az öblítés előtt. A felfűtés közben kiszivárgó anyag a toronyra kerül a modell helyett, a mozgás pedig átfedésben van a fűtéssel. Csak több extruderes (több szerszámfejes) nyomtatóknál releváns, amelyek 2-es típusú törlőtornyot használnak. A firmware vagy a szerszámváltó makró nem várhat magától a hőmérsékletre. Ha ki van kapcsolva, a hőmérsékletre várakozás közvetlenül a szerszámváltó parancs után kerül kiadásra." + msgid "No sparse layers (beta)" msgstr "Nincsenek ritka rétegek (béta)" @@ -19847,9 +19955,6 @@ msgstr "Fizikai nyomtató" msgid "Print Host upload" msgstr "Feltöltés a nyomtatóra" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Válaszd ki a nyomtatóval való kommunikációhoz használt hálózati ügynököt. Az elérhető ügynököket indításkor regisztrálja a rendszer." - # AI Translated msgid "Select a Flashforge printer" msgstr "Válassz egy Flashforge nyomtatót" @@ -20791,9 +20896,6 @@ msgstr "Bejelentkezés közben váratlan hiba történt, próbáld újra." msgid "User canceled." msgstr "Felhasználó által megszakítva." -msgid "Head diameter" -msgstr "Fej átmérő" - msgid "Max angle" msgstr "Maximális szög" @@ -21607,6 +21709,22 @@ msgstr "" "Kunkorodás elkerülése\n" "Tudtad, hogy a kunkorodásra hajlamos anyagok (például ABS) nyomtatásakor az asztal hőmérsékletének növelése csökkentheti a kunkorodás valószínűségét?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "A rétegmagasság túl kicsi.\n" +#~ "A rendszer a min_layer_height értékre állítja.\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "A rétegmagasság meghaladja a Nyomtatóbeállítások -> Extruder -> Rétegmagasság limitek menüpontban megadott értéket, ez minőségbeli problémákat okozhat a nyomtatás során." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Szeretnéd az értéket automatikusan a beállított tartományhoz igazítani?\n" + +#~ msgid "Head diameter" +#~ msgstr "Fej átmérő" + #~ msgid "Print order within a single layer." #~ msgstr "Nyomtatási sorrend egyetlen rétegen belül." diff --git a/localization/i18n/it/OrcaSlicer_it.po b/localization/i18n/it/OrcaSlicer_it.po index 3c43178102..bc36e08d25 100644 --- a/localization/i18n/it/OrcaSlicer_it.po +++ b/localization/i18n/it/OrcaSlicer_it.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -4741,6 +4741,23 @@ msgstr "L'attuale temperatura della camera è superiore alla temperatura di sicu msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "La temperatura minima della camera (%d℃) è superiore alla temperatura target della camera (%d℃). Il valore minimo è la soglia alla quale inizia la stampa mentre la camera continua a riscaldarsi verso il target, quindi non dovrebbe superarlo. Verrà limitato al valore target." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "L'altezza dello strato è troppo piccola. Sarà impostata al valore minimo (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "L'altezza dello strato è fuori dai limiti impostati in Impostazioni stampante -> Estrusore -> Limiti Altezza Strato, ciò potrebbe causare problemi di qualità di stampa." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Regolarla automaticamente al limite (%g mm)?" + +msgid "Adjust" +msgstr "Regola" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4860,6 +4877,13 @@ msgstr "" "Sì - Abilita generatore di pareti Arachne\n" "No - Disabilita generatore di pareti Arachne e imposta la modalità [Spostamento] della Superficie ruvida" +# AI Translated +msgid "Brim ear radius" +msgstr "Raggio della tesa ad orecchio" + +msgid "Brim width" +msgstr "Larghezza tesa" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "La modalità spirale funziona solo quando i perimetri sono 1, il supporto è disabilitato, il rilevamento degli ammassi tramite sondaggio è disabilitato, gli strati superiori della shell sono 0, la densità del riempimento sparso è 0 e il tipo di timelapse è tradizionale." @@ -5114,6 +5138,14 @@ msgstr "Impossibile generare G-code di calibrazione" msgid "Calibration error" msgstr "Errore di calibrazione" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Questa stampante non dispone dell'hardware richiesto da questo controllo." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Questo controllo non è supportato su questa stampante." + # AI Translated msgid "Network unavailable" msgstr "Rete non disponibile" @@ -5973,7 +6005,7 @@ msgstr "Volume:" msgid "Size:" msgstr "Dimensione:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Sono stati trovati conflitti di percorsi nel G-code sullo strato %d, Z = %.2lfmm. Si prega di separare gli oggetti in conflitto (%s <-> %s)." @@ -6154,6 +6186,10 @@ msgstr "Multi-dispositivo" msgid "Project" msgstr "Progetto" +# AI Translated +msgid "Device (Web)" +msgstr "Dispositivo (Web)" + msgid "Yes" msgstr "Sì" @@ -8244,19 +8280,19 @@ msgstr "La directory per la sostituzione non è stata selezionata" msgid "Replaced with 3D files from directory:\n" msgstr "Sostituito con file 3D dalla directory:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Saltato %s: stesso file.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Saltato %s: il file non esiste.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Saltato %s: sostituzione fallita.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Sostituito %s.\n" @@ -8995,6 +9031,18 @@ msgstr "Abilitando questa opzione, puoi inviare un'attività a più dispositivi msgid "Pop up to select filament grouping mode" msgstr "Popup per selezionare la modalità di raggruppamento filamenti" +# AI Translated +msgid "Visible plugin pages" +msgstr "Pagine dei plugin visibili" + +# AI Translated +msgid "pages" +msgstr "pagine" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Numero di pagine dei plugin mostrate come schede fisse prima che le pagine rimanenti vengano raccolte in un menu a discesa nell'ultima scheda." + msgid "Behaviour" msgstr "Comportamento" @@ -9381,6 +9429,18 @@ msgstr "Mostra i profili non supportati" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Mostra i profili incompatibili/non supportati negli elenchi a discesa di stampante e filamento. Questi profili non possono essere selezionati." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Sperimentale) Usa gli agenti stampante invece degli host di stampa" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Instrada i lavori di stampa delle stampanti non Bambu attraverso gli agenti plugin della stampante invece del classico flusso di caricamento sull'host di stampa.\n" +"Quando è disattivato, OrcaSlicer usa il comportamento legacy dell'host di stampa." + # AI Translated msgid "Experimental Features" msgstr "Funzionalità sperimentali" @@ -9650,9 +9710,25 @@ msgstr "Profilo utente" msgid "Preset Inside Project" msgstr "Profilo interno al progetto" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Copia in questo profilo tutti i valori ereditati dal profilo padre e rimuove la relazione di ereditarietà. I profili compatibili solo con il profilo padre potrebbero non essere più supportati." + msgid "Detach from parent" msgstr "Scollega dal genitore" +# AI Translated +msgid "Unique preset" +msgstr "Profilo unico" + +# AI Translated +msgid "Parent preset" +msgstr "Profilo padre" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Questo profilo non eredita da un altro profilo." + msgid "Name is unavailable." msgstr "Nome non disponibile." @@ -10392,22 +10468,6 @@ msgstr "Sei sicuro di voler abilitare questa opzione?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "I pattern di riempimento sono generalmente progettati per gestire automaticamente la rotazione per garantire una stampa corretta e ottenere gli effetti desiderati (ad es. Gyroid, Cubico). La rotazione del pattern di riempimento sparso corrente potrebbe portare a un supporto insufficiente. Procedere con cautela e verificare accuratamente eventuali problemi di stampa. Sei sicuro di voler abilitare questa opzione?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"L'altezza dello strato è troppo piccola.\n" -"Sarà impostato su min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "L'altezza dello strato supera il limite in Impostazioni stampante -> Estrusore -> Limiti Altezza Strato. Ciò potrebbe causare problemi di qualità di stampa." - -msgid "Adjust to the set range automatically?\n" -msgstr "Regolare automaticamente l'intervallo impostato?\n" - -msgid "Adjust" -msgstr "Regola" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Funzionalità sperimentale: ritrazione e taglio del filamento a una distanza maggiore durante i cambi di filamento per ridurre al minimo lo spurgo. Sebbene possa ridurre notevolmente lo spurgo, può anche aumentare il rischio di intasamento degli ugelli o di altre complicazioni di stampa." @@ -10603,6 +10663,9 @@ msgstr "Parole chiave riservate trovate" msgid "Setting Overrides" msgstr "Sovrascrivi impostazioni" +msgid "Retraction when switching material" +msgstr "Retrazione quando si cambia materiale" + msgid "Basic information" msgstr "Informazioni di base" @@ -10734,6 +10797,12 @@ msgstr "Profili di processo compatibili" msgid "Printable space" msgstr "Spazio di stampa" +msgid "Printer Agent" +msgstr "Agente stampante" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Selezionare l'implementazione dell'agente di rete per la comunicazione con la stampante. Gli agenti disponibili vengono registrati all'avvio." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10859,9 +10928,6 @@ msgstr "Limiti altezza strati" msgid "Z-Hop" msgstr "Sollevamento Z" -msgid "Retraction when switching material" -msgstr "Retrazione quando si cambia materiale" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12221,6 +12287,10 @@ msgstr " è troppo vicino all'area di esclusione e si verificheranno collisioni. msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " è troppo vicino all'area di rilevamento ammassi e verranno causate collisioni.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " è parzialmente fuori dall'area stampabile e non può essere stampato.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Le temperature degli ugelli selezionate sono incompatibili. La temperatura dell'ugello per ciascun filamento deve rientrare nell'intervallo di temperatura consigliato per gli altri filamenti. In caso contrario, potrebbero verificarsi ostruzioni degli ugelli o danni alla stampante." @@ -12550,9 +12620,6 @@ msgstr "Usa 3MF invece di G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Abilita questa opzione se la stampante accetta un file 3MF come processo di stampa. Quando è abilitata, Orca Slicer invia il file elaborato come .gcode.3mf, invece di un semplice file .gcode." -msgid "Printer Agent" -msgstr "Agente stampante" - msgid "Select the network agent implementation for printer communication." msgstr "Selezionare l'implementazione dell'agente di rete per la comunicazione con la stampante." @@ -13239,9 +13306,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Velocità dei ponti interni. Se il valore è espresso in percentuale, verrà calcolato in base a bridge_speed. Il valore predefinito è 150%." -msgid "Brim width" -msgstr "Larghezza tesa" - msgid "This is the distance from the model to the outermost brim line." msgstr "Questa è la distanza tra il modello e la linea più esterna della tesa." @@ -13321,6 +13385,14 @@ msgstr "" "La geometria verrà decimata prima di rilevare gli spigoli vivi. Questo parametro indica la lunghezza minima dello scostamento per la decimazione.\n" "0 per disattivare." +# AI Translated +msgid "Brim ears outer only" +msgstr "Tesa ad orecchio solo sul contorno esterno" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Genera gli orecchi di topo solo sul contorno esterno del modello, escludendo fori e sezioni chiuse." + msgid "upward compatible machine" msgstr "macchina compatibile con versioni successive" @@ -14495,6 +14567,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroide" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Fattore di arrotondamento del riempimento sparso" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Controlla quanto vengono arrotondati gli angoli del riempimento sparso. 0% mantiene il percorso originale con angoli vivi, mentre 100% produce le curve più ampie possibili tra linee di riempimento adiacenti." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Accelerazione del riempimento della superficie superiore. L'utilizzo di un valore inferiore può migliorare la qualità della superficie superiore." @@ -15039,6 +15119,14 @@ msgstr "Con quale tipo di G-code la stampante è compatibile." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Ometti il blocco di configurazione del G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Non scrive il CONFIG_BLOCK (le coppie chiave/valore della configurazione dello slicer) nel file G-code. Può essere utile con stampanti il cui firmware va in crash durante l'analisi di queste righe di commento (ad es. Anycubic go-klipper). Nota: il file G-code non conterrà più le impostazioni dello slicer, quindi reimportandolo in OrcaSlicer la configurazione non verrà ripristinata." + msgid "Pellet Modded Printer" msgstr "Stampante modificata per granuli" @@ -16098,6 +16186,14 @@ msgstr "Retrazione lunga al cambio estrusore" msgid "Retraction distance when extruder change" msgstr "Distanza di retrazione al cambio estrusore" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Lunghezza di retrazione (Cambio testina)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Quando la retrazione viene attivata prima di un cambio testina, il filamento viene ritirato della quantità specificata (la lunghezza è misurata sul filamento grezzo, prima che entri nell'estrusore)." + msgid "Z-hop height" msgstr "Altezza sollevamento Z" @@ -16195,6 +16291,10 @@ msgstr "Lunghezza aggiuntiva in ripresa" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Quando la retrazione è compensata dopo uno spostamento, l'estrusore espelle questa quantità aggiuntiva di filamento. Questa impostazione è raramente necessaria." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Lunghezza aggiuntiva in ripresa (Cambio testina)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Quando la retrazione è compensata dopo un cambio di testina, l'estrusore espelle questa quantità aggiuntiva di filamento." @@ -16612,6 +16712,14 @@ msgstr "Cambio utensile sulla torre di spurgo" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Forza la testa di stampa a spostarsi sulla torre di spurgo prima di emettere il comando di cambio utensile (Tx). Rilevante solo per le stampanti multi-estrusore (multi-testa) che utilizzano una torre di spurgo di Tipo 2. Per impostazione predefinita Orca salta lo spostamento sulle macchine multi-testa perché il firmware gestisce il cambio della testa, il che può far sì che il comando Tx venga emesso sopra la parte stampata. Abilita questa opzione se desideri che il cambio utensile venga sempre emesso sopra la torre di spurgo." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Attendi la temperatura sulla torre di spurgo" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Preleva la nuova testina senza attendere che raggiunga la temperatura di stampa, si sposta sulla torre di spurgo e attende lì la temperatura, subito prima dello spurgo. Il trasudo dovuto al riscaldamento finisce sulla torre invece che sul modello, e lo spostamento si sovrappone al riscaldamento. Rilevante solo per stampanti multi-estrusore (multi-testina) che usano una torre di spurgo di tipo 2. Il firmware o la macro di cambio testina non devono attendere la temperatura autonomamente. Quando è disattivato, l'attesa della temperatura viene emessa subito dopo il comando di cambio testina." + msgid "No sparse layers (beta)" msgstr "Nessuno strato sparso (beta)" @@ -19865,9 +19973,6 @@ msgstr "Stampante fisica" msgid "Print Host upload" msgstr "Caricamento host di stampa" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Selezionare l'implementazione dell'agente di rete per la comunicazione con la stampante. Gli agenti disponibili vengono registrati all'avvio." - # AI Translated msgid "Select a Flashforge printer" msgstr "Seleziona una stampante Flashforge" @@ -20810,9 +20915,6 @@ msgstr "Si è verificato un problema imprevisto durante il tentativo di accesso. msgid "User canceled." msgstr "Utente rimosso." -msgid "Head diameter" -msgstr "Diametro testa" - msgid "Max angle" msgstr "Angolo massimo" @@ -21631,6 +21733,22 @@ msgstr "" "Evita le deformazioni\n" "Sapevi che quando si stampano materiali soggetti a deformazioni come l'ABS, aumentare in modo appropriato la temperatura del piano riscaldato può ridurre la probabilità di deformazione?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "L'altezza dello strato è troppo piccola.\n" +#~ "Sarà impostato su min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "L'altezza dello strato supera il limite in Impostazioni stampante -> Estrusore -> Limiti Altezza Strato. Ciò potrebbe causare problemi di qualità di stampa." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Regolare automaticamente l'intervallo impostato?\n" + +#~ msgid "Head diameter" +#~ msgstr "Diametro testa" + #~ msgid "Print order within a single layer." #~ msgstr "Ordine di stampa all'interno di un singolo strato." diff --git a/localization/i18n/ja/OrcaSlicer_ja.po b/localization/i18n/ja/OrcaSlicer_ja.po index 0d9f044060..1b70ddbf67 100644 --- a/localization/i18n/ja/OrcaSlicer_ja.po +++ b/localization/i18n/ja/OrcaSlicer_ja.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -4750,6 +4750,23 @@ msgstr "現在のチャンバー温度が材料の安全温度を超えていま msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "最低庫内温度 (%d℃) が目標庫内温度 (%d℃) を上回っています。最低値は、チャンバーが目標に向けて加熱を続けながら印刷を開始するしきい値であるため、目標値を超えてはいけません。値は目標値に制限されます。" +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "積層ピッチが小さすぎます。最小値 (%g mm) に設定されます。" + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "積層ピッチが、プリンター設定 -> 押出機 -> 積層ピッチの制限 で設定された範囲を外れています。印刷品質の問題が発生する可能性があります。" + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "自動的に制限値 (%g mm) に調整しますか?" + +msgid "Adjust" +msgstr "調整" + # AI Translated msgid "" "Layer height too small\n" @@ -4873,6 +4890,13 @@ msgstr "" "はい - Arachneウォールジェネレーターを有効にする\n" "いいえ - Arachneウォールジェネレーターを無効にし、ファジースキンを[変位]モードに設定する" +# AI Translated +msgid "Brim ear radius" +msgstr "ブリムイヤー半径" + +msgid "Brim width" +msgstr "ブリム幅" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "スパイラルモードは壁ループが1、サポートが無効、プロービングによるクランピング検出が無効、上部シェルレイヤーが0、スパースインフィル密度が0、タイムラプスタイプがトラディショナルの場合のみ機能します。" @@ -5127,6 +5151,14 @@ msgstr "キャリブレーションG-codeの生成に失敗しました" msgid "Calibration error" msgstr "キャリブレーションエラー" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "このプリンターには、このコントロールに必要なハードウェアが設定されていません。" + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "このコントロールはこのプリンターではサポートされていません。" + # AI Translated msgid "Network unavailable" msgstr "ネットワークが利用できません" @@ -5988,7 +6020,7 @@ msgstr "ボリューム" msgid "Size:" msgstr "サイズ:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "レイヤー%d、Z = %.2lfmmでG-codeパスの衝突が検出されました。衝突するオブジェクトをもっと離してください(%s <-> %s)。" @@ -6164,6 +6196,10 @@ msgstr "マルチデバイス" msgid "Project" msgstr "プロジェクト" +# AI Translated +msgid "Device (Web)" +msgstr "デバイス (Web)" + msgid "Yes" msgstr "はい" @@ -8262,19 +8298,19 @@ msgstr "置換用のディレクトリが選択されていません" msgid "Replaced with 3D files from directory:\n" msgstr "ディレクトリの3Dファイルで置換しました:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ スキップ %s: 同一ファイル。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ スキップ %s: ファイルが存在しません。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ スキップ %s: 置換に失敗しました。\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ 置換しました %s。\n" @@ -9015,6 +9051,18 @@ msgstr "このオプションを有効にすると、複数のデバイスに同 msgid "Pop up to select filament grouping mode" msgstr "フィラメントグルーピングモード選択のポップアップ" +# AI Translated +msgid "Visible plugin pages" +msgstr "表示するプラグインページ数" + +# AI Translated +msgid "pages" +msgstr "ページ" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "固定タブとして表示するプラグインページの数です。残りのページは最後のタブのドロップダウンにまとめられます。" + msgid "Behaviour" msgstr "動作" @@ -9404,6 +9452,18 @@ msgstr "非対応のプリセットを表示" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "プリンターとフィラメントのドロップダウンリストに、互換性のない/非対応のプリセットを表示します。これらのプリセットは選択できません。" +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(実験的) プリントホストの代わりにプリンターエージェントを使用" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Bambu 以外のプリンターの印刷ジョブを、従来のプリントホストへのアップロードではなく、プリンターのプラグインエージェント経由で送信します。\n" +"無効の場合、OrcaSlicer は従来のプリントホストの動作を使用します。" + # AI Translated msgid "Experimental Features" msgstr "実験的機能" @@ -9672,9 +9732,25 @@ msgstr "ユーザープリセット" msgid "Preset Inside Project" msgstr "プロジェクト プリセット" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "親プリセットから継承したすべての値をこのプリセットにコピーし、親との継承関係を解除します。親プリセットとのみ互換性のあるプリセットは、サポートされなくなる場合があります。" + msgid "Detach from parent" msgstr "親から分離" +# AI Translated +msgid "Unique preset" +msgstr "独立したプリセット" + +# AI Translated +msgid "Parent preset" +msgstr "親プリセット" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "このプリセットは他のプリセットを継承していません。" + msgid "Name is unavailable." msgstr "名称は使用できません" @@ -10416,22 +10492,6 @@ msgstr "このオプションを有効にしてもよろしいですか?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "インフィルパターンは通常、適切な印刷と意図した効果を確保するために回転を自動的に処理するように設計されています(例: ジャイロイド、キュービック)。現在のスパースインフィルパターンを回転させると、サポートが不十分になる可能性があります。慎重に進め、潜在的な印刷問題を十分に確認してください。このオプションを有効にしてもよろしいですか?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"レイヤー高さが小さすぎます。\n" -"min_layer_heightに設定されます\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "レイヤー高さがプリンター設定 -> エクストルーダー -> レイヤー高さ制限の上限を超えています。印刷品質の問題が発生する可能性があります。" - -msgid "Adjust to the set range automatically?\n" -msgstr "設定範囲に自動調整しますか?\n" - -msgid "Adjust" -msgstr "調整" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "実験的機能: フィラメント交換時により長い距離でフィラメントをリトラクト・カットしてフラッシュを最小化します。フラッシュを大幅に削減できますが、ノズル詰まりやその他の印刷問題のリスクが高まる可能性もあります。" @@ -10621,6 +10681,9 @@ msgstr "保留キーワードが見つかりました" msgid "Setting Overrides" msgstr "上書き設定" +msgid "Retraction when switching material" +msgstr "素材変更時のリトラクション" + msgid "Basic information" msgstr "基本情報" @@ -10751,6 +10814,12 @@ msgstr "互換性のあるプロセスプロファイル" msgid "Printable space" msgstr "造形可能領域" +msgid "Printer Agent" +msgstr "プリンターエージェント" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "プリンター通信用のネットワークエージェント実装を選択します。使用可能なエージェントは起動時に登録されます。" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10877,9 +10946,6 @@ msgstr "積層ピッチの制限" msgid "Z-Hop" msgstr "Z-ホップ" -msgid "Retraction when switching material" -msgstr "素材変更時のリトラクション" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12258,6 +12324,10 @@ msgstr " は除外エリアに近すぎるため、衝突が発生します。\n msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " がクランピング検出エリアに近すぎ、衝突が発生します。\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " は造形可能領域から一部はみ出しているため、印刷できません。\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "選択したノズル温度に互換性がありません。各フィラメントのノズル温度は、他のフィラメントの推奨ノズル温度範囲内に収まる必要があります。そうでない場合、ノズル詰まりやプリンターの損傷が発生する可能性があります。" @@ -12599,9 +12669,6 @@ msgstr "G-codeの代わりに3MFを使用" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "プリンターが印刷ジョブとして3MFファイルを受け付ける場合に有効にします。有効にすると、Orca Slicerはスライス済みファイルを通常の.gcodeファイルではなく.gcode.3mfとして送信します。" -msgid "Printer Agent" -msgstr "プリンターエージェント" - msgid "Select the network agent implementation for printer communication." msgstr "プリンター通信用のネットワークエージェント実装を選択します。" @@ -13320,9 +13387,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "内部ブリッジの速度です。値を%で指定した場合、bridge_speedを基準に計算されます。デフォルト値は150%です。" -msgid "Brim width" -msgstr "ブリム幅" - msgid "This is the distance from the model to the outermost brim line." msgstr "一番外側のブリム線がモデルと距離です。" @@ -13411,6 +13475,14 @@ msgstr "" "鋭角を検出する前にジオメトリが間引かれます。このパラメータは、間引きにおける偏差の最小長さを指定します。\n" "0で無効になります。" +# AI Translated +msgid "Brim ears outer only" +msgstr "ブリムイヤーを外側の輪郭のみ" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "穴や閉じた部分を除き、モデルの外側の輪郭にのみマウスイヤーを生成します。" + msgid "upward compatible machine" msgstr "互換性のあるデバイス" @@ -14634,6 +14706,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "ジャイロイド" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "スパース インフィルの平滑化係数" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "スパース インフィルの角をどの程度丸めるかを設定します。0% では元の鋭い経路のまま、100% では隣接するインフィル線の間で可能な限り大きな曲線になります。" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "トップ面のインフィル加速度です。遅くすると表面の仕上がりが向上させることができます" @@ -15233,6 +15313,14 @@ msgstr "プリンターが対応するG-code" msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code の設定ブロックを省略" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "CONFIG_BLOCK (スライサー設定のキーと値のペア) を G-code ファイルに書き込みません。これらのコメント行の解析でファームウェアがクラッシュするプリンター (例: Anycubic go-klipper) で役立ちます。注意: G-code ファイルにスライサー設定が含まれなくなるため、OrcaSlicer に読み込み直しても設定は復元されません。" + # AI Translated msgid "Pellet Modded Printer" msgstr "ペレット改造プリンター" @@ -16374,6 +16462,14 @@ msgstr "押出機切り替え時のロングリトラクション" msgid "Retraction distance when extruder change" msgstr "押出機切替時のリトラクション距離" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "リトラクション量 (ツール交換)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "ツール交換の前にリトラクションが行われるとき、指定した量だけフィラメントが引き戻されます (長さは押出機に入る前の未加工のフィラメントで測定されます)。" + # AI Translated msgid "Z-hop height" msgstr "Zホップの高さ" @@ -16488,6 +16584,10 @@ msgstr "再開時の追加長さ" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "移動後に引込みが補償されると、エクストルーダーはこの追加量のフィラメントを押し出します。 この設定はほとんど必要ありません。" +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "再開時の追加長さ (ツール交換)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "ツールの交換後に吸込み分が補正されると、エクストルーダーはこの追加量のフィラメントを押し出します。" @@ -16963,6 +17063,14 @@ msgstr "ワイプタワー上でツール交換" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "ツール交換コマンド (Tx) を発行する前に、ツールヘッドを強制的にワイプタワーへ移動させます。タイプ2のワイプタワーを使用するマルチ押出機 (マルチツールヘッド) プリンターにのみ関係します。デフォルトでは、マルチツールヘッド機ではファームウェアがヘッドの交換を処理するためOrcaは移動をスキップしますが、その結果Txコマンドが造形物の上で発行される場合があります。ツール交換を常にワイプタワーの上で発行したい場合は、このオプションを有効にしてください。" +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "ワイプタワーで温度待機" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "印刷温度に達するのを待たずに新しいツールを取り付け、ワイプタワーへ移動し、パージ直前にそこで温度を待ちます。加熱中の垂れ出しはモデルではなくタワーに落ち、移動時間が加熱と重なります。タイプ 2 のワイプタワーを使用するマルチ押出機 (マルチツールヘッド) プリンターでのみ有効です。ファームウェアやツール交換マクロ側で温度待機を行わないようにしてください。無効の場合、温度待機はツール交換コマンドの直後に出力されます。" + # AI Translated msgid "No sparse layers (beta)" msgstr "スパース層なし (ベータ)" @@ -20389,9 +20497,6 @@ msgstr "実物プリンター" msgid "Print Host upload" msgstr "プリントホストのアップロード" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "プリンター通信用のネットワークエージェント実装を選択します。使用可能なエージェントは起動時に登録されます。" - # AI Translated msgid "Select a Flashforge printer" msgstr "Flashforgeプリンターを選択" @@ -21363,9 +21468,6 @@ msgstr "ログイン中に予期しない問題が発生しました。再試行 msgid "User canceled." msgstr "ユーザーがキャンセルしました。" -msgid "Head diameter" -msgstr "直径" - msgid "Max angle" msgstr "最大角度" @@ -22194,6 +22296,22 @@ msgstr "" "反りを避ける\n" "ABSのような反りやすい素材を印刷する場合、ヒートベッドの温度を適切に上げることで、反りが発生する確率を下げることができることをご存知ですか?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "レイヤー高さが小さすぎます。\n" +#~ "min_layer_heightに設定されます\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "レイヤー高さがプリンター設定 -> エクストルーダー -> レイヤー高さ制限の上限を超えています。印刷品質の問題が発生する可能性があります。" + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "設定範囲に自動調整しますか?\n" + +#~ msgid "Head diameter" +#~ msgstr "直径" + #~ msgid "Print order within a single layer." #~ msgstr "単一レイヤー内の印刷順序。" diff --git a/localization/i18n/ko/OrcaSlicer_ko.po b/localization/i18n/ko/OrcaSlicer_ko.po index 5a6ac0438b..767674e525 100644 --- a/localization/i18n/ko/OrcaSlicer_ko.po +++ b/localization/i18n/ko/OrcaSlicer_ko.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2025-06-02 17:12+0900\n" "Last-Translator: crwusiz \n" "Language-Team: \n" @@ -4763,6 +4763,23 @@ msgstr "현재 챔버 온도가 재료의 안전 온도보다 높으므로 재 msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "최소 챔버 온도(%d℃)가 목표 챔버 온도(%d℃)보다 높습니다. 최소값은 챔버가 목표 온도까지 계속 가열되는 동안 출력을 시작하는 기준값이므로 목표값을 초과해서는 안 됩니다. 이 값은 목표값으로 제한됩니다." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "레이어 높이가 너무 작습니다. 최솟값(%g mm)으로 설정됩니다." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "레이어 높이가 프린터 설정 -> 압출기 -> 레이어 높이 한도에서 설정한 범위를 벗어났습니다. 출력 품질 문제가 발생할 수 있습니다." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "한도(%g mm)에 맞게 자동으로 조정할까요?" + +msgid "Adjust" +msgstr "조정" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4884,6 +4901,13 @@ msgstr "" "예 - 아라크네 벽 생성기 활성화\n" "아니오 - 아라크네 벽 생성기 비활성화 및 퍼지 스킨 [변위] 모드 설정" +# AI Translated +msgid "Brim ear radius" +msgstr "브림 귀 반경" + +msgid "Brim width" +msgstr "브림 너비" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "나선형 모드는 벽 루프가 1이고, 서포트가 비활성화되고, 프로빙에 의한 클럼핑 감지가 비활성화되고, 상단 셸 레이어가 0이고, 희소 인필 밀도가 0이고 타임랩스 유형이 전통적인 경우에만 작동합니다." @@ -5138,6 +5162,14 @@ msgstr "교정 Gcode를 생성하지 못했습니다" msgid "Calibration error" msgstr "교정 오류" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "이 프린터에는 이 컨트롤에 필요한 하드웨어가 구성되어 있지 않습니다." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "이 컨트롤은 이 프린터에서 지원되지 않습니다." + # AI Translated msgid "Network unavailable" msgstr "네트워크를 사용할 수 없음" @@ -6001,7 +6033,7 @@ msgstr "용량:" msgid "Size:" msgstr "크기:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "레이어 %d, Z = %.2lf mm에서 Gcode 경로 충돌이 발견되었습니다. 충돌하는 객체를 더 멀리 분리하세요 (%s <-> %s)." @@ -6178,6 +6210,10 @@ msgstr "멀티 디바이스" msgid "Project" msgstr "프로젝트" +# AI Translated +msgid "Device (Web)" +msgstr "장치 (웹)" + msgid "Yes" msgstr "예" @@ -8288,22 +8324,22 @@ msgid "Replaced with 3D files from directory:\n" msgstr "다음 디렉터리의 3D 파일로 교체했습니다:\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ 건너뜀 %s: 동일한 파일입니다.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ 건너뜀 %s: 파일이 존재하지 않습니다.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ 건너뜀 %s: 교체하지 못했습니다.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ %s을(를) 교체했습니다.\n" @@ -9077,6 +9113,18 @@ msgstr "활성화하면 여러 장치에 동시에 작업을 보내고 여러 msgid "Pop up to select filament grouping mode" msgstr "필라멘트 그룹화 모드를 선택하기 위한 팝업" +# AI Translated +msgid "Visible plugin pages" +msgstr "표시할 플러그인 페이지" + +# AI Translated +msgid "pages" +msgstr "페이지" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "고정 탭으로 표시되는 플러그인 페이지 수입니다. 나머지 페이지는 마지막 탭의 드롭다운으로 묶입니다." + # AI Translated msgid "Behaviour" msgstr "동작" @@ -9491,6 +9539,18 @@ msgstr "지원되지 않는 사전 설정 표시" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "프린터 및 필라멘트 드롭다운 목록에 호환되지 않거나 지원되지 않는 사전 설정을 표시합니다. 이러한 사전 설정은 선택할 수 없습니다." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(실험적) 출력 호스트 대신 프린터 에이전트 사용" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Bambu 이외의 프린터 출력 작업을 기존 출력 호스트 업로드 방식 대신 프린터 플러그인 에이전트를 통해 전달합니다.\n" +"비활성화하면 OrcaSlicer는 기존 출력 호스트 동작을 사용합니다." + # AI Translated msgid "Experimental Features" msgstr "실험적 기능" @@ -9762,10 +9822,26 @@ msgstr "사용자 사전 설정" msgid "Preset Inside Project" msgstr "프로젝트 내부 사전 설정" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "상위 사전 설정에서 상속한 모든 값을 이 사전 설정으로 복사하고 상속 관계를 제거합니다. 상위 사전 설정에서만 호환되는 사전 설정은 지원되지 않을 수 있습니다." + # AI Translated msgid "Detach from parent" msgstr "상위 항목에서 분리" +# AI Translated +msgid "Unique preset" +msgstr "독립 사전 설정" + +# AI Translated +msgid "Parent preset" +msgstr "상위 사전 설정" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "이 사전 설정은 다른 사전 설정을 상속하지 않습니다." + msgid "Name is unavailable." msgstr "이름을 사용할 수 없습니다." @@ -10519,22 +10595,6 @@ msgstr "이 옵션을 사용하시겠습니까?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "채우기 패턴은 일반적으로 올바른 출력과 의도한 효과를 위해 회전을 자동으로 처리하도록 설계되어 있습니다(예: 자이로이드, 큐빅). 현재 드문 채우기 패턴을 회전시키면 지지력이 부족해질 수 있습니다. 신중하게 진행하고 출력 문제가 발생하지 않는지 충분히 확인하십시오. 이 옵션을 활성화하시겠습니까?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"레이어 높이가 너무 작습니다.\n" -"min_layer_height로 설정됩니다.\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "레이어 높이가 프린터 설정 -> 압출기 -> 레이어의 제한을 초과합니다.높이 제한으로 인해 출력 품질 문제가 발생할 수 있습니다." - -msgid "Adjust to the set range automatically?\n" -msgstr "설정 범위에 자동으로 맞춰지나요?\n" - -msgid "Adjust" -msgstr "조정" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "실험적 기능: 플러시를 최소화하기 위해 필라멘트 교체 중에 더 먼 거리에서 필라멘트를 집어넣고 절단합니다. 플러시를 눈에 띄게 줄일 수 있지만 노즐 막힘이나 기타 출력 문제의 위험이 높아질 수도 있습니다." @@ -10728,6 +10788,9 @@ msgstr "예약어를 찾았습니다" msgid "Setting Overrides" msgstr "설정 덮어쓰기" +msgid "Retraction when switching material" +msgstr "재료 전환 시 후퇴" + msgid "Basic information" msgstr "기본 정보" @@ -10861,6 +10924,14 @@ msgstr "호환 프로세스 사전설정" msgid "Printable space" msgstr "출력 가능 공간" +# AI Translated +msgid "Printer Agent" +msgstr "프린터 에이전트" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "프린터 통신에 사용할 네트워크 에이전트 구현을 선택합니다. 사용 가능한 에이전트는 시작 시 등록됩니다." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10993,9 +11064,6 @@ msgstr "레이어 높이 한도" msgid "Z-Hop" msgstr "Z올리기" -msgid "Retraction when switching material" -msgstr "재료 전환 시 후퇴" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12388,6 +12456,10 @@ msgstr " 이(가) 제외 영역에 너무 가깝습니다. 출력 시 충돌이 msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " 뭉침 감지 영역에 너무 가까워 충돌이 발생할 수 있습니다.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " 이(가) 출력 가능 영역을 일부 벗어나 출력할 수 없습니다.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "선택한 노즐 온도가 서로 호환되지 않습니다. 각 필라멘트의 노즐 온도는 다른 필라멘트의 권장 노즐 온도 범위 안에 있어야 합니다. 그렇지 않으면 노즐 막힘이나 프린터 손상이 발생할 수 있습니다." @@ -12732,10 +12804,6 @@ msgstr "G-code 대신 3MF 사용" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "프린터가 출력 작업으로 3MF 파일을 허용하는 경우 이 옵션을 활성화하십시오. 활성화하면 Orca Slicer가 슬라이스된 파일을 일반 .gcode 파일 대신 .gcode.3mf로 전송합니다." -# AI Translated -msgid "Printer Agent" -msgstr "프린터 에이전트" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "프린터 통신에 사용할 네트워크 에이전트 구현을 선택합니다." @@ -13446,9 +13514,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "내부 브릿지의 속도. 값을 백분율로 표현하면 bridge_speed를 기준으로 계산됩니다. 기본값은 150%입니다." -msgid "Brim width" -msgstr "브림 너비" - msgid "This is the distance from the model to the outermost brim line." msgstr "모델과 가장 바깥쪽 브림 선까지의 거리" @@ -13533,6 +13598,14 @@ msgstr "" "날카로운 각도를 감지하기 전에 형상이 무시됩니다. 이 매개변수는 무시하는 형상의 최소 길이를 나타냅니다.\n" "0으로 비활성화합니다" +# AI Translated +msgid "Brim ears outer only" +msgstr "브림 귀를 바깥쪽에만" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "구멍과 닫힌 영역을 제외하고 모델의 바깥쪽 윤곽에만 생쥐 귀를 생성합니다." + msgid "upward compatible machine" msgstr "상향 호환 장치" @@ -14729,6 +14802,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "자이로이드" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "드문 채우기 부드러움 계수" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "드문 채우기의 모서리를 얼마나 둥글게 할지 조절합니다. 0%는 원래의 날카로운 경로를 유지하고, 100%는 인접한 채우기 선 사이에 가능한 가장 큰 곡선을 만듭니다." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "상단 표면 가속도. 낮은 값을 사용하면 상단 표면 품질이 향상될 수 있습니다" @@ -15291,6 +15372,14 @@ msgstr "프린터와 호환되는 Gcode 종류" msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code 설정 블록 생략" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "CONFIG_BLOCK(슬라이서 설정의 키/값 쌍)을 G-code 파일에 기록하지 않습니다. 이 주석 줄을 해석할 때 펌웨어가 중단되는 프린터(예: Anycubic go-klipper)에 도움이 될 수 있습니다. 참고: G-code 파일에 슬라이서 설정이 더 이상 포함되지 않으므로, 이 파일을 OrcaSlicer로 다시 가져와도 설정이 복원되지 않습니다." + msgid "Pellet Modded Printer" msgstr "펠릿 프린터" @@ -16400,6 +16489,14 @@ msgstr "압출기 교체 시 긴 수축" msgid "Retraction distance when extruder change" msgstr "압출기 교체 시 수축 거리" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "후퇴 길이 (툴 체인지)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "툴 체인지 전에 후퇴가 실행되면 지정한 양만큼 필라멘트가 뒤로 당겨집니다 (길이는 압출기에 들어가기 전의 원래 필라멘트를 기준으로 측정됩니다)." + msgid "Z-hop height" msgstr "Z올리기 높이" @@ -16498,6 +16595,10 @@ msgstr "재 시작 시 추가 길이" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "이동 후 후퇴가 보상되면 압출기는 이 추가 양의 필라멘트를 밀어냅니다. 이 설정은 거의 필요하지 않습니다." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "재 시작 시 추가 길이 (툴 체인지)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "툴 체인지 후 후퇴가 보상되면 압출기는 이 추가 양의 필라멘트를 밀어냅니다." @@ -16922,6 +17023,14 @@ msgstr "프라임 타워에서 툴 체인지" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "툴 체인지 명령(Tx)을 실행하기 전에 툴헤드가 반드시 프라임 타워로 이동하도록 합니다. 유형 2 프라임 타워를 사용하는 다중 압출기(멀티 툴헤드) 프린터에만 해당됩니다. 기본적으로 Orca는 멀티 툴헤드 장비에서 펌웨어가 헤드 교체를 처리하므로 이동을 생략하는데, 이 때문에 Tx 명령이 출력물 위에서 실행될 수 있습니다. 툴 체인지가 항상 프라임 타워 위에서 실행되도록 하려면 이 옵션을 활성화하십시오." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "프라임 타워에서 온도 대기" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "출력 온도에 도달할 때까지 기다리지 않고 새 툴을 집은 뒤 프라임 타워로 이동하여, 퍼지 직전에 그곳에서 온도를 기다립니다. 가열 중 흘러나온 재료는 모델이 아닌 타워에 떨어지고, 이동 시간이 가열 시간과 겹칩니다. 타입 2 프라임 타워를 사용하는 다중 압출기(다중 툴헤드) 프린터에만 해당합니다. 펌웨어나 툴 체인지 매크로가 직접 온도를 기다려서는 안 됩니다. 비활성화하면 툴 체인지 명령 직후에 온도 대기가 실행됩니다." + msgid "No sparse layers (beta)" msgstr "희소 레이어 없음(베타)" @@ -20261,10 +20370,6 @@ msgstr "물리 프린터" msgid "Print Host upload" msgstr "출력 호스트 업로드" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "프린터 통신에 사용할 네트워크 에이전트 구현을 선택합니다. 사용 가능한 에이전트는 시작 시 등록됩니다." - # AI Translated msgid "Select a Flashforge printer" msgstr "Flashforge 프린터 선택" @@ -21217,9 +21322,6 @@ msgstr "로그인을 시도하는 동안 예기치 않은 문제가 발생했습 msgid "User canceled." msgstr "사용자가 취소했습니다." -msgid "Head diameter" -msgstr "헤드 직경" - msgid "Max angle" msgstr "최대 각도" @@ -22057,6 +22159,22 @@ msgstr "" "뒤틀림 방지\n" "ABS와 같이 뒤틀림이 발생하기 쉬운 소재를 출력할 때, 히트베드 온도를 적절하게 높이면 뒤틀림 가능성을 줄일 수 있다는 사실을 알고 계셨나요?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "레이어 높이가 너무 작습니다.\n" +#~ "min_layer_height로 설정됩니다.\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "레이어 높이가 프린터 설정 -> 압출기 -> 레이어의 제한을 초과합니다.높이 제한으로 인해 출력 품질 문제가 발생할 수 있습니다." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "설정 범위에 자동으로 맞춰지나요?\n" + +#~ msgid "Head diameter" +#~ msgstr "헤드 직경" + #~ msgid "Print order within a single layer." #~ msgstr "단일 레이어 내의 출력 순서" diff --git a/localization/i18n/lt/OrcaSlicer_lt.po b/localization/i18n/lt/OrcaSlicer_lt.po index 9a6b7ae590..ad5edffc9a 100644 --- a/localization/i18n/lt/OrcaSlicer_lt.po +++ b/localization/i18n/lt/OrcaSlicer_lt.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-07-02 14:13+0300\n" "Last-Translator: Gintaras Kučinskas \n" "Language-Team: \n" @@ -4728,6 +4728,23 @@ msgstr "Dabartinė kameros temperatūra yra aukštesnė už saugią medžiagos t msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Minimali kameros temperatūra (%d℃) yra aukštesnė nei tikslinė kameros temperatūra (%d℃). Minimali vertė yra slenkstis, kurį pasiekus pradedamas spausdinimas, kol kamera vis dar kaitinama iki tikslinės temperatūros, todėl ji neturėtų viršyti tikslinės. Vertė bus apribota iki tikslinės temperatūros." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Sluoksnio aukštis per mažas. Jis bus nustatytas į mažiausią reikšmę (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Sluoksnio aukštis yra už ribų, nurodytų Spausdintuvo nustatymai -> Ekstruderis -> Sluoksnio aukščio ribos, tai gali sukelti spausdinimo kokybės problemų." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Automatiškai sureguliuoti iki ribos (%g mm)?" + +msgid "Adjust" +msgstr "Sureguliuoti" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4847,6 +4864,13 @@ msgstr "" "Taip – įjungti „Arachne“ sienelių generatorių\n" "Ne – išjungti „Arachne“ sienelių generatorių ir nustatyti „Šiurkštaus paviršius“ režimą [Slinktis]" +# AI Translated +msgid "Brim ear radius" +msgstr "Apvado „ausies“ spindulys" + +msgid "Brim width" +msgstr "Pado apvado plotis" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Spiralinis režimas veikia tik tada, kai sienelės kilpų skaičius yra 1, atramos išjungtos, sulipimo aptikimas zonduojant išjungtas, viršutinių apvalkalo sluoksnių yra 0, reto užpildo tankis yra 0 %, o laiko intervalų vaizdo įrašo tipas – tradicinis." @@ -5101,6 +5125,14 @@ msgstr "Nepavyko sugeneruoti kalibravimo G-kodo" msgid "Calibration error" msgstr "Kalibravimo klaida" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Šiame spausdintuve nėra sukonfigūruotos įrangos, kurios reikia šiam valdikliui." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Šis valdiklis šiame spausdintuve nepalaikomas." + # AI Translated msgid "Network unavailable" msgstr "Tinklas neprieinamas" @@ -5961,7 +5993,7 @@ msgstr "Tūris:" msgid "Size:" msgstr "Dydis:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Rasta G-kodo trajektorijų konfliktų %d sluoksnyje, Z = %.2lfmm. Prašome labiau atskirti konfliktuojančius objektus (%s <-> %s)." @@ -6142,6 +6174,10 @@ msgstr "Kelių įrenginių valdymas (Multi-device)" msgid "Project" msgstr "Projektas" +# AI Translated +msgid "Device (Web)" +msgstr "Įrenginys (Web)" + msgid "Yes" msgstr "Taip" @@ -8239,19 +8275,19 @@ msgstr "" "Pakeista 3D failais iš katalogo:\n" "\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Praleistas %s: tas pats failas.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Praleistas %s: failas neegzistuoja.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Praleistas %s: nepavyko pakeisti.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Pakeistas %s.\n" @@ -8977,6 +9013,18 @@ msgstr "Kai įjungta ši funkcija, jūs galite siųsti užduotį keliems įrengi msgid "Pop up to select filament grouping mode" msgstr "Iššokantis langas gijų grupavimo režimui pasirinkti" +# AI Translated +msgid "Visible plugin pages" +msgstr "Matomi papildinių puslapiai" + +# AI Translated +msgid "pages" +msgstr "puslapiai" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Papildinių puslapių, rodomų kaip fiksuotos kortelės, skaičius; likę puslapiai sutraukiami į išskleidžiamąjį sąrašą paskutinėje kortelėje." + msgid "Behaviour" msgstr "Elgsena" @@ -9329,6 +9377,18 @@ msgstr "Rodyti nepalaikomus profilius" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Rodyti nesuderinamus / nepalaikomus profilius spausdintuvų ir gijų išskleidžiamuosiuose sąrašuose. Šių profilių pasirinkti negalima." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Eksperimentinė) Naudoti spausdintuvo agentus vietoj spausdinimo serverių" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Nukreipia ne Bambu spausdintuvų spausdinimo užduotis per spausdintuvo papildinių agentus, o ne per klasikinį įkėlimo į spausdinimo serverį srautą.\n" +"Kai išjungta, OrcaSlicer naudoja senąjį spausdinimo serverio veikimą." + msgid "Experimental Features" msgstr "Eksperimentinis" @@ -9590,9 +9650,25 @@ msgstr "Naudotojo profilis" msgid "Preset Inside Project" msgstr "Profilis projekto viduje" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Nukopijuoja į šį profilį visas iš pirminio profilio paveldėtas reikšmes ir pašalina paveldėjimo ryšį. Profiliai, suderinami tik su pirminiu profiliu, gali tapti nepalaikomi." + msgid "Detach from parent" msgstr "Atskirti nuo tėvinio profilio" +# AI Translated +msgid "Unique preset" +msgstr "Savarankiškas profilis" + +# AI Translated +msgid "Parent preset" +msgstr "Pirminis profilis" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Šis profilis nepaveldi iš kito profilio." + msgid "Name is unavailable." msgstr "Nėra pavadinimo." @@ -10330,24 +10406,6 @@ msgstr "Ar tikrai norite įjungti šią parinktį?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Užpildymo modeliai paprastai yra suprojektuoti taip, kad automatiškai tvarkytų sukimąsi, siekiant užtikrinti tinkamą spausdinimą ir pasiekti numatytus efektus (pvz., Gyroid, Cubic). Sukant esamą retą užpildymo modelį, gali atsirasti nepakankamas atraminis paviršius. Prašome elgtis atsargiai ir atidžiai patikrinti, ar nėra galimų spausdinimo problemų. Ar tikrai norite įjungti šią parinktį?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Per mažas sluoksnio aukštis.\n" -"Jis bus nustatytas į min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Sluoksnio aukštis viršija ribą, nurodytą Spausdintuvo nustatymai -> Ekstruderis -> Sluoksnio aukščio ribos, tai gali sukelti spausdinimo kokybės problemų." - -msgid "Adjust to the set range automatically?\n" -msgstr "" -"Sureguliuoti pagal nustatytą diapazoną automatiškai?\n" -"\n" - -msgid "Adjust" -msgstr "Sureguliuoti" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Eksperimentinė funkcija: gijos įtraukimas ir nukirpimas didesniu atstumu keičiant giją, siekiant sumažinti išvalymą (flush). Nors tai gali pastebimai sumažinti išvalymą, taip pat gali padidėti purkštuko užsikimšimo ar kitų spausdinimo komplikacijų rizika." @@ -10547,6 +10605,9 @@ msgstr "Rasti rezervuoti raktažodžiai" msgid "Setting Overrides" msgstr "Nustatymų perrašymas" +msgid "Retraction when switching material" +msgstr "Įtraukimas keičiant medžiagą" + msgid "Basic information" msgstr "Pagrindinė informacija" @@ -10673,6 +10734,12 @@ msgstr "Suderinami apdorojimo profiliai" msgid "Printable space" msgstr "Erdvė spausdinimui" +msgid "Printer Agent" +msgstr "Spausdintuvo agentas" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Pasirinkite tinklo agento modulį ryšiui su spausdintuvu palaikyti. Prieinami agentai užregistruojami paleidimo metu." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10798,9 +10865,6 @@ msgstr "Sluoksnio aukščio ribos" msgid "Z-Hop" msgstr "Z šuolis" -msgid "Retraction when switching material" -msgstr "Įtraukimas keičiant medžiagą" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12146,6 +12210,10 @@ msgstr "" " yra per arti sulipimo aptikimo zonos, todėl įvyks susidūrimai.\n" "\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " yra iš dalies už spausdinimo srities ribų ir negali būti atspausdintas.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Pasirinktos purkštuko temperatūros yra nesuderinamos. Kiekvienos gijos purkštuko temperatūra turi patekti į kitų gijų rekomenduojamos temperatūros diapazoną. Priešingu atveju gali užsikimšti purkštukas arba sugesti spausdintuvas." @@ -12459,9 +12527,6 @@ msgstr "Vietoj G-kodo naudoti 3MF" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Įjunkite, jei spausdintuvas spausdinimo užduotims priima 3MF failus. Kai įjungta, „Orca Slicer“ sugeneruotą failą siunčia kaip „.gcode.3mf“, o ne kaip paprastą „.gcode“ failą." -msgid "Printer Agent" -msgstr "Spausdintuvo agentas" - msgid "Select the network agent implementation for printer communication." msgstr "Pasirinkite tinklo agento modulį ryšiui su spausdintuvu palaikyti." @@ -13134,9 +13199,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Vidinių tiltelių spausdinimo greitis. Jei reikšmė nurodoma procentais, ji apskaičiuojama pagal „bridge_speed“ (tiltelių greitį). Numatytoji reikšmė – 150 %." -msgid "Brim width" -msgstr "Pado apvado plotis" - msgid "This is the distance from the model to the outermost brim line." msgstr "Atstumas nuo modelio iki išorinės krašto linijos" @@ -13217,6 +13279,14 @@ msgstr "" "Prieš aptinkant aštrius kampus, geometrija yra supaprastinama (decimuojama). Šis parametras nurodo minimalų nuokrypio ilgį supaprastinimui atlikti.\n" "Įrašykite 0, kad išjungtumėte." +# AI Translated +msgid "Brim ears outer only" +msgstr "Apvado „ausys“ tik išorėje" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Kuria peliukų ausis tik ant išorinio modelio kontūro, praleidžiant skyles ir uždaras sritis." + msgid "upward compatible machine" msgstr "atgaliniu būdu suderinamas įrenginys" @@ -14370,6 +14440,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroidas" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Reto užpildo glotninimo koeficientas" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Nustato, kaip stipriai suapvalinami reto užpildo kampai. 0% palieka pradinę aštrią trajektoriją, o 100% sukuria didžiausias įmanomas kreives tarp gretimų užpildo linijų." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Viršutinio paviršiaus užpildo pagreitis. Naudojant mažesnę vertę gali pagerėti viršutinio paviršiaus kokybė." @@ -14914,6 +14992,14 @@ msgstr "Su kokiu G kodu suderinamas spausdintuvas." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Praleisti G-code konfigūracijos bloką" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Neįrašo CONFIG_BLOCK (pjaustyklės konfigūracijos raktų ir reikšmių porų) į G-code failą. Tai gali padėti su spausdintuvais, kurių programinė įranga stringa apdorodama šias komentarų eilutes (pvz., Anycubic go-klipper). Pastaba: G-code faile nebeliks pjaustyklės nustatymų, todėl importavus jį atgal į OrcaSlicer konfigūracija nebus atkurta." + msgid "Pellet Modded Printer" msgstr "Modifikuotas granulinis spausdintuvas" @@ -15955,6 +16041,14 @@ msgstr "Ilgas įtraukimas keičiant ekstruderį" msgid "Retraction distance when extruder change" msgstr "Įtraukimo atstumas keičiant ekstruderį" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Atitraukimo ilgis (Įrankio keitimas)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Kai atitraukimas suaktyvinamas prieš įrankio keitimą, gija atitraukiama nurodytu atstumu (ilgis matuojamas ant neapdorotos gijos, prieš jai patenkant į ekstruderį)." + msgid "Z-hop height" msgstr "„Z-hop“ (pakėlimo) aukštis" @@ -16049,6 +16143,10 @@ msgstr "Papildomas ilgis po sugrąžinimo" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Kai po judėjimo kompensuojamas gijos įtraukimas, ekstruderis papildomai išstums šį gijos kiekį. Šis nustatymas reikalingas retai." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Papildomas ilgis po sugrąžinimo (Įrankio keitimas)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Kai po įrankio pakeitimo kompensuojamas gijos įtraukimas, ekstruderis papildomai išstums šį gijos kiekį." @@ -16461,6 +16559,14 @@ msgstr "Įrankio keitimas virš valymo bokšto" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Priverstinai nukreipti spausdinimo galvutę prie valymo bokšto prieš vykdant įrankio keitimo komandą (Tx). Aktualu tik spausdintuvams su keliais ekstruderiais (keliomis galvutėmis), naudojantiems 2 tipo valymo bokštą. Pagal numatytuosius nustatymus „OrcaSlicer“ praleidžia šį judesį kelių galvučių įrenginiuose, nes galvučių sukeitimą valdo aparatinė programinė įranga, todėl Tx komanda gali būti įvykdyta virš spausdinamos detalės. Įjunkite šią parinktį, jei norite, kad įrankio keitimas visada vyktų virš valymo bokšto." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Laukti temperatūros ant valymo bokšto" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Paima naują įrankį nelaukdamas, kol jis pasieks spausdinimo temperatūrą, nuvažiuoja prie valymo bokšto ir ten laukia temperatūros, prieš pat pravalymą. Kaitinant ištekėjusi medžiaga patenka ant bokšto, o ne ant modelio, o pervažiavimas persidengia su kaitinimu. Aktualu tik daugiaekstruderiams (kelių spausdinimo galvučių) spausdintuvams, naudojantiems 2 tipo valymo bokštą. Programinė įranga ar įrankio keitimo makrokomanda neturi pati laukti temperatūros. Kai išjungta, laukimo temperatūros komanda pateikiama iškart po įrankio keitimo komandos." + msgid "No sparse layers (beta)" msgstr "Nėra retų sluoksnių (beta)" @@ -19702,9 +19808,6 @@ msgstr "Fizinis spausdintuvas" msgid "Print Host upload" msgstr "Įkėlimas spausdinimui tinkle" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Pasirinkite tinklo agento modulį ryšiui su spausdintuvu palaikyti. Prieinami agentai užregistruojami paleidimo metu." - msgid "Select a Flashforge printer" msgstr "Pasirinkite „Flashforge“ spausdintuvą" @@ -20552,9 +20655,6 @@ msgstr "Bandant prisijungti įvyko kažkas netikėto. Bandykite dar kartą." msgid "User canceled." msgstr "Vartotojas atšaukė." -msgid "Head diameter" -msgstr "Galvutės skersmuo" - msgid "Max angle" msgstr "Maksimalus kampas" @@ -21336,6 +21436,24 @@ msgstr "" "Venkite deformacijų (warping)\n" "Ar žinojote, kad spausdinant medžiagas, kurios yra linkusios trauktis ir riestis (pvz., ABS), tinkamas kaitinamojo pagrindo temperatūros padidinimas gali sumažinti deformacijų (warping) tikimybę?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Per mažas sluoksnio aukštis.\n" +#~ "Jis bus nustatytas į min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Sluoksnio aukštis viršija ribą, nurodytą Spausdintuvo nustatymai -> Ekstruderis -> Sluoksnio aukščio ribos, tai gali sukelti spausdinimo kokybės problemų." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "" +#~ "Sureguliuoti pagal nustatytą diapazoną automatiškai?\n" +#~ "\n" + +#~ msgid "Head diameter" +#~ msgstr "Galvutės skersmuo" + #~ msgid "Print order within a single layer." #~ msgstr "Elementų spausdinimo eiliškumas vieno sluoksnio ribose." diff --git a/localization/i18n/nl/OrcaSlicer_nl.po b/localization/i18n/nl/OrcaSlicer_nl.po index 1ae53b2888..28ad63d455 100644 --- a/localization/i18n/nl/OrcaSlicer_nl.po +++ b/localization/i18n/nl/OrcaSlicer_nl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -5150,6 +5150,23 @@ msgstr "De huidige kamertemperatuur is hoger dan de veilige temperatuur van het msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "De minimale kamertemperatuur (%d℃) is hoger dan de doelkamertemperatuur (%d℃). De minimale waarde is de drempel waarbij het printen start terwijl de kamer verder opwarmt naar de doelwaarde; deze mag die dus niet overschrijden. De waarde wordt begrensd tot de doelwaarde." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "De laaghoogte is te klein. Deze wordt ingesteld op het minimum (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "De laaghoogte valt buiten de limieten die zijn ingesteld in Printerinstellingen -> Extruder -> Laaghoogtelimieten, dit kan problemen met de afdrukkwaliteit veroorzaken." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Automatisch aanpassen naar de limiet (%g mm)?" + +msgid "Adjust" +msgstr "Aanpassen" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -5277,6 +5294,13 @@ msgstr "" "Ja - Arachne-wandgenerator inschakelen\n" "Nee - Arachne-wandgenerator uitschakelen en de modus [Displacement] van Vage buitenkant instellen" +# AI Translated +msgid "Brim ear radius" +msgstr "Straal van randoren" + +msgid "Brim width" +msgstr "Rand breedte" + # AI Translated msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "De spiraalmodus werkt alleen wanneer Wanden 1 is, ondersteuning is uitgeschakeld, klontdetectie via aftasten is uitgeschakeld, het aantal bovenste buitenlagen 0 is, de dichtheid van de dunne vulling (infill) 0 is en het timelapse-type traditioneel is." @@ -5582,6 +5606,14 @@ msgstr "Cali G-code niet gegenereerd" msgid "Calibration error" msgstr "Kalibratiefout" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Deze printer beschikt niet over de hardware die dit besturingselement nodig heeft." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Dit besturingselement wordt niet ondersteund op deze printer." + # AI Translated msgid "Network unavailable" msgstr "Netwerk niet beschikbaar" @@ -6513,7 +6545,7 @@ msgid "Size:" msgstr "Maat:" # AI Translated -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Er zijn conflicten tussen G-code-paden gevonden op laag %d, Z = %.2lfmm. Plaats de conflicterende objecten verder uit elkaar (%s <-> %s)." @@ -6714,6 +6746,10 @@ msgstr "Meerdere apparaten" msgid "Project" msgstr "Project" +# AI Translated +msgid "Device (Web)" +msgstr "Apparaat (Web)" + msgid "Yes" msgstr "Ja" @@ -8999,22 +9035,22 @@ msgid "Replaced with 3D files from directory:\n" msgstr "Vervangen door 3D-bestanden uit de map:\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Overgeslagen %s: hetzelfde bestand.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Overgeslagen %s: bestand bestaat niet.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Overgeslagen %s: vervangen is mislukt.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Vervangen %s.\n" @@ -9827,6 +9863,18 @@ msgstr "Met deze optie ingeschakeld kunt u een taak tegelijkertijd naar meerdere msgid "Pop up to select filament grouping mode" msgstr "Pop-up om de filamentgroeperingsmodus te kiezen" +# AI Translated +msgid "Visible plugin pages" +msgstr "Zichtbare plug-inpagina's" + +# AI Translated +msgid "pages" +msgstr "pagina's" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Aantal plug-inpagina's dat als vaste tabbladen wordt getoond voordat de overige pagina's worden samengevouwen in een vervolgkeuzelijst op het laatste tabblad." + msgid "Behaviour" msgstr "Gedrag" @@ -10243,6 +10291,18 @@ msgstr "Niet-ondersteunde voorinstellingen tonen" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Toon incompatibele/niet-ondersteunde voorinstellingen in de keuzelijsten voor printer en filament. Deze voorinstellingen kunnen niet worden geselecteerd." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimenteel) Printeragents gebruiken in plaats van printhosts" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Stuurt printtaken voor niet-Bambu-printers via printer-plug-inagents in plaats van via de klassieke uploadstroom naar de printhost.\n" +"Wanneer dit is uitgeschakeld, gebruikt OrcaSlicer het oude printhostgedrag." + # AI Translated msgid "Experimental Features" msgstr "Experimentele functies" @@ -10523,10 +10583,26 @@ msgstr "Gebruikersvoorinstelling" msgid "Preset Inside Project" msgstr "Voorinstelling binnen project" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Kopieert alle overgeërfde waarden van de bovenliggende voorinstelling naar deze voorinstelling en verwijdert de overervingsrelatie. Voorinstellingen die alleen met de bovenliggende voorinstelling compatibel zijn, kunnen daardoor niet meer worden ondersteund." + # AI Translated msgid "Detach from parent" msgstr "Losmaken van bovenliggend element" +# AI Translated +msgid "Unique preset" +msgstr "Unieke voorinstelling" + +# AI Translated +msgid "Parent preset" +msgstr "Bovenliggende voorinstelling" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Deze voorinstelling erft niet van een andere voorinstelling." + msgid "Name is unavailable." msgstr "Naam is niet beschikbaar." @@ -11336,22 +11412,6 @@ msgstr "Weet u zeker dat u deze optie wilt inschakelen?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Vulpatronen zijn doorgaans ontworpen om rotatie automatisch af te handelen, zodat ze goed printen en hun beoogde effect bereiken (bijv. Gyroide, Kubisch). Het roteren van het huidige patroon voor de dunne vulling (infill) kan tot onvoldoende ondersteuning leiden. Ga voorzichtig te werk en controleer grondig op mogelijke printproblemen. Weet u zeker dat u deze optie wilt inschakelen?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Laaghoogte is te klein.\n" -"Het zal worden ingesteld op min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "De laaghoogte overschrijdt de limiet in Printerinstellingen -> Extruder -> Laaghoogtelimieten, dit kan problemen met de afdrukkwaliteit veroorzaken." - -msgid "Adjust to the set range automatically?\n" -msgstr "Automatisch aanpassen aan het ingestelde bereik?\n" - -msgid "Adjust" -msgstr "Aanpassen" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Experimentele functie: Het filament op grotere afstand terugtrekken en afsnijden tijdens filamentwisselingen om flush te minimaliseren. Hoewel het het doorspoelen aanzienlijk kan verminderen, kan het ook het risico op een verstopt mondstuk of andere printcomplicaties vergroten." @@ -11551,6 +11611,9 @@ msgstr "Gereserveerde zoekworden gevonden" msgid "Setting Overrides" msgstr "Overschrijvingen instellen" +msgid "Retraction when switching material" +msgstr "Terugtrekken (retraction) bij het wisselen van filament" + msgid "Basic information" msgstr "Basisinformatie" @@ -11689,6 +11752,14 @@ msgstr "Geschikte proces profielen" msgid "Printable space" msgstr "Ruimte waarbinnen geprint kan worden" +# AI Translated +msgid "Printer Agent" +msgstr "Printeragent" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Selecteer de implementatie van de netwerkagent voor de communicatie met de printer. Beschikbare agenten worden bij het opstarten geregistreerd." + # AI Translated #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format @@ -11829,9 +11900,6 @@ msgstr "Limieten voor laaghoogte" msgid "Z-Hop" msgstr "Z-hop" -msgid "Retraction when switching material" -msgstr "Terugtrekken (retraction) bij het wisselen van filament" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -13323,6 +13391,10 @@ msgstr " bevindt zich te dicht bij het uitsluitingsgebied en er zullen botsingen msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " ligt te dicht bij het gebied voor klontdetectie, waardoor er botsingen zullen ontstaan.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " ligt gedeeltelijk buiten het printbare gebied en kan niet worden geprint.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "De geselecteerde mondstuktemperaturen zijn niet compatibel. De mondstuktemperatuur van elk filament moet binnen het aanbevolen mondstuktemperatuurbereik van de andere filamenten vallen. Anders kan het mondstuk verstopt raken of kan de printer beschadigd raken." @@ -13686,10 +13758,6 @@ msgstr "3MF gebruiken in plaats van G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Schakel dit in als de printer een 3MF-bestand als printopdracht accepteert. Indien ingeschakeld verzendt Orca Slicer het geslicede bestand als een .gcode.3mf in plaats van als een gewoon .gcode-bestand." -# AI Translated -msgid "Printer Agent" -msgstr "Printeragent" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Selecteer de implementatie van de netwerkagent voor de communicatie met de printer." @@ -14443,9 +14511,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Snelheid van interne bruggen. Als de waarde als percentage wordt uitgedrukt, wordt deze berekend op basis van bridge_speed. De standaardwaarde is 150%." -msgid "Brim width" -msgstr "Rand breedte" - msgid "This is the distance from the model to the outermost brim line." msgstr "Dit is de afstand van het model tot de buitenste randlijn." @@ -14537,6 +14602,14 @@ msgstr "" "De geometrie wordt vereenvoudigd voordat scherpe hoeken worden gedetecteerd. Deze parameter geeft de minimale lengte van de afwijking voor die vereenvoudiging aan.\n" "0 om uit te schakelen." +# AI Translated +msgid "Brim ears outer only" +msgstr "Randoren alleen aan de buitenzijde" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Genereert alleen muisoren op de buitencontour van het model, met uitsluiting van gaten en gesloten secties." + msgid "upward compatible machine" msgstr "opwaarts compatibele machine" @@ -15846,6 +15919,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroide" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Afvlakkingsfactor voor dunne vulling" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Bepaalt hoe sterk de hoeken van de dunne vulling worden afgerond. 0% behoudt het oorspronkelijke scherpe pad, terwijl 100% de grootst mogelijke bochten tussen aangrenzende vullijnen oplevert." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Versnelling van de topoppervlakte-invulling. Gebruik van een lagere waarde kan de kwaliteit van de bovenlaag verbeteren." @@ -16456,6 +16537,14 @@ msgstr "Het type G-code waarmee de printer compatibel is." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code-configuratieblok overslaan" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Schrijft het CONFIG_BLOCK (de sleutel/waarde-paren van de slicerconfiguratie) niet naar het G-code-bestand. Dit kan helpen bij printers waarvan de firmware vastloopt bij het verwerken van deze commentaarregels (bijv. Anycubic go-klipper). Let op: het G-code-bestand bevat dan geen slicerinstellingen meer, dus door het weer in OrcaSlicer te importeren wordt de configuratie niet hersteld." + # AI Translated msgid "Pellet Modded Printer" msgstr "Printer omgebouwd voor pellets" @@ -17653,6 +17742,14 @@ msgstr "Lange terugtrekking bij extruderwissel" msgid "Retraction distance when extruder change" msgstr "Terugtrekafstand bij extruderwissel" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Terugtreklengte (Gereedschapswissel)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Wanneer het terugtrekken vóór een gereedschapswissel wordt geactiveerd, wordt het filament met de opgegeven hoeveelheid teruggetrokken (de lengte wordt gemeten op het onbewerkte filament, voordat het de extruder ingaat)." + # AI Translated msgid "Z-hop height" msgstr "Z-hop-hoogte" @@ -17763,6 +17860,10 @@ msgstr "Extra lengte bij herstart" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Als retracten wordt gecompenseerd na een beweging, wordt deze extra hoeveelheid filament geëxtrudeerd. Deze instelling is zelden van toepassing." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Extra lengte bij herstart (Gereedschapswissel)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Als retracten wordt gecompenseerd na een toolwisseling, wordt deze extra hoeveelheid filament geëxtrudeerd." @@ -18255,6 +18356,14 @@ msgstr "Toolwissel op het afveegblok" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Dwing de printkop naar het afveegblok te bewegen voordat de opdracht voor de toolwissel (Tx) wordt gegeven. Alleen relevant voor printers met meerdere extruders (meerdere printkoppen) die een afveegblok van type 2 gebruiken. Standaard slaat Orca deze verplaatsing op machines met meerdere printkoppen over, omdat de firmware de kopwissel afhandelt, waardoor de Tx-opdracht boven het geprinte onderdeel kan worden gegeven. Schakel deze optie in als u wilt dat de toolwissel altijd boven het afveegblok wordt uitgevoerd." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Wachten op temperatuur bij het afveegblok" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Pakt het nieuwe gereedschap op zonder te wachten tot het de printtemperatuur bereikt, verplaatst zich naar het afveegblok en wacht daar op de temperatuur, vlak voor het spoelen. Het materiaal dat tijdens het opwarmen uitloopt komt op het blok terecht in plaats van op het model, en de verplaatsing overlapt met het opwarmen. Alleen relevant voor printers met meerdere extruders (meerdere printkoppen) die een afveegblok van type 2 gebruiken. De firmware of de gereedschapswisselmacro mag niet zelf op de temperatuur wachten. Wanneer dit is uitgeschakeld, wordt het wachten op de temperatuur direct na het gereedschapswisselcommando uitgevoerd." + # AI Translated msgid "No sparse layers (beta)" msgstr "Geen dunne lagen (bèta)" @@ -21860,10 +21969,6 @@ msgstr "Fysieke printer" msgid "Print Host upload" msgstr "Host-upload afdrukken" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Selecteer de implementatie van de netwerkagent voor de communicatie met de printer. Beschikbare agenten worden bij het opstarten geregistreerd." - # AI Translated msgid "Select a Flashforge printer" msgstr "Selecteer een Flashforge-printer" @@ -22918,9 +23023,6 @@ msgstr "Er is iets onverwachts gebeurd bij het inloggen. Probeer het opnieuw." msgid "User canceled." msgstr "Gebruiker geannuleerd." -msgid "Head diameter" -msgstr "Kopdiameter" - # AI Translated msgid "Max angle" msgstr "Maximale hoek" @@ -23781,6 +23883,22 @@ msgstr "" "Kromtrekken voorkomen\n" "Wist je dat bij het printen van materialen die gevoelig zijn voor kromtrekken, zoals ABS, een juiste verhoging van de temperatuur van het warmtebed de kans op kromtrekken kan verkleinen?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Laaghoogte is te klein.\n" +#~ "Het zal worden ingesteld op min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "De laaghoogte overschrijdt de limiet in Printerinstellingen -> Extruder -> Laaghoogtelimieten, dit kan problemen met de afdrukkwaliteit veroorzaken." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Automatisch aanpassen aan het ingestelde bereik?\n" + +#~ msgid "Head diameter" +#~ msgstr "Kopdiameter" + # AI Translated #~ msgid "Print order within a single layer." #~ msgstr "Printvolgorde binnen één laag." diff --git a/localization/i18n/pl/OrcaSlicer_pl.po b/localization/i18n/pl/OrcaSlicer_pl.po index a0701dca09..e8acc62a9b 100644 --- a/localization/i18n/pl/OrcaSlicer_pl.po +++ b/localization/i18n/pl/OrcaSlicer_pl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer 2.3.0-rc\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: \n" "Last-Translator: Krzysztof Morga <>\n" "Language-Team: \n" @@ -4843,6 +4843,23 @@ msgstr "Obecna temperatura komory jest wyższa niż bezpieczna temperatura dla f msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Minimalna temperatura komory (%d℃) jest wyższa niż docelowa temperatura komory (%d℃). Wartość minimalna to próg, przy którym rozpoczyna się druk, podczas gdy komora nadal nagrzewa się do wartości docelowej, więc nie powinna jej przekraczać. Zostanie ograniczona do wartości docelowej." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Wysokość warstwy jest zbyt mała. Zostanie ustawiona na minimum (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Wysokość warstwy wykracza poza limity ustawione w Ustawieniach Drukarki -> Ekstruder -> Limity wysokości warstwy, co może powodować problemy z jakością druku." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Dostosować ją automatycznie do limitu (%g mm)?" + +msgid "Adjust" +msgstr "Dostosuj" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4965,6 +4982,13 @@ msgstr "" "Tak — włącz generator ścian Arachne\n" "Nie — wyłącz generator ścian Arachne i ustaw tryb [Przesunięcie] skóry fuzzy" +# AI Translated +msgid "Brim ear radius" +msgstr "Promień ucha brim" + +msgid "Brim width" +msgstr "Szerokość brimu" + # AI Translated msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Tryb spiralny działa tylko wtedy, gdy liczba pętli ściany wynosi 1, podpory są wyłączone, wykrywanie zlepiania przez sondowanie jest wyłączone, liczba warstw górnej powłoki wynosi 0, gęstość wypełnienia wynosi 0, a typ timelapse jest tradycyjny." @@ -5226,6 +5250,14 @@ msgstr "Nie udało się wygenerować kodu kalibracji" msgid "Calibration error" msgstr "Błąd kalibracji" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Ta drukarka nie ma skonfigurowanego sprzętu wymaganego przez ten element sterujący." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Ten element sterujący nie jest obsługiwany przez tę drukarkę." + # AI Translated msgid "Network unavailable" msgstr "Sieć niedostępna" @@ -6109,7 +6141,7 @@ msgstr "Objętość:" msgid "Size:" msgstr "Rozmiar:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Wykryto konflikty ścieżek G-code na warstwie %d, Z = %.2lfmm. Proszę oddalić od siebie obiekty będące w konflikcie (%s <-> %s)." @@ -6295,6 +6327,10 @@ msgstr "Wiele urządzeń" msgid "Project" msgstr "Projekt" +# AI Translated +msgid "Device (Web)" +msgstr "Urządzenie (Web)" + msgid "Yes" msgstr "Tak" @@ -8444,22 +8480,22 @@ msgid "Replaced with 3D files from directory:\n" msgstr "Zastąpiono plikami 3D z katalogu:\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Pominięto %s: ten sam plik.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Pominięto %s: plik nie istnieje.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Pominięto %s: nie udało się zastąpić.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Zastąpiono %s.\n" @@ -9232,6 +9268,18 @@ msgstr "Umożliwia wysyłanie zadania do wielu urządzeń jednocześnie i zarzą msgid "Pop up to select filament grouping mode" msgstr "Okno dialogowe do wyboru trybu grupowania filamentów" +# AI Translated +msgid "Visible plugin pages" +msgstr "Widoczne strony wtyczek" + +# AI Translated +msgid "pages" +msgstr "stron" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Liczba stron wtyczek wyświetlanych jako stałe karty, zanim pozostałe strony zostaną zwinięte do listy rozwijanej na ostatniej karcie." + # AI Translated msgid "Behaviour" msgstr "Zachowanie" @@ -9647,6 +9695,18 @@ msgstr "Pokaż nieobsługiwane profile" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Pokazuj niekompatybilne/nieobsługiwane profile na listach rozwijanych drukarek i filamentów. Tych profili nie można wybrać." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Eksperymentalne) Używaj agentów drukarki zamiast serwerów druku" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Kieruje zadania druku dla drukarek innych niż Bambu przez agentów wtyczek drukarki zamiast klasycznego przesyłania do serwera druku.\n" +"Gdy opcja jest wyłączona, OrcaSlicer korzysta z dotychczasowego działania serwera druku." + # AI Translated msgid "Experimental Features" msgstr "Funkcje eksperymentalne" @@ -9918,10 +9978,26 @@ msgstr "Profil użytkownika" msgid "Preset Inside Project" msgstr "Profil wewnątrz projektu" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Kopiuje do tego profilu wszystkie wartości odziedziczone z profilu nadrzędnego i usuwa relację dziedziczenia. Profile zgodne wyłącznie z profilem nadrzędnym mogą przestać być obsługiwane." + # AI Translated msgid "Detach from parent" msgstr "Odłącz od elementu nadrzędnego" +# AI Translated +msgid "Unique preset" +msgstr "Profil niezależny" + +# AI Translated +msgid "Parent preset" +msgstr "Profil nadrzędny" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Ten profil nie dziedziczy z innego profilu." + msgid "Name is unavailable." msgstr "Nazwa jest niedostępna." @@ -10684,22 +10760,6 @@ msgstr "Czy na pewno włączyć tę opcję?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Wzory wypełnienia są zwykle projektowane tak, aby samodzielnie obsługiwać obrót, co zapewnia prawidłowy druk i zamierzony efekt (np. Gyroidalny, Sześcienny). Obracanie bieżącego wzoru wypełnienia może prowadzić do niewystarczającego podparcia. Zachowaj ostrożność i dokładnie sprawdź, czy nie występują problemy z drukiem. Czy na pewno chcesz włączyć tę opcję?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Wysokość warstwy jest zbyt mała.\n" -"Ustawione zostanie na min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Wysokość warstwy przekracza limit w Ustawieniach Drukarki -> Extruder -> Limity wysokości warstwy, co może powodować problemy z jakością druku." - -msgid "Adjust to the set range automatically?\n" -msgstr "Dostosować automatycznie do ustawionego zakresu?\n" - -msgid "Adjust" -msgstr "Dostosuj" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Funkcja eksperymentalna: Polega na wycofywaniu filamentu na większą odległość w celu zminimalizowania płukania, a następne jego odcięcie. Choć może to znacząco zmniejszyć ilość zużytego filamentu, może również zwiększyć ryzyko zatknięcia dyszy lub innych problemów z drukowaniem." @@ -10899,6 +10959,9 @@ msgstr "Znaleziono zarezerwowane słowa kluczowe" msgid "Setting Overrides" msgstr "Nadpisywane Ustawień" +msgid "Retraction when switching material" +msgstr "Retrakcja podczas zmiany filamentu" + msgid "Basic information" msgstr "Podstawowe informacje" @@ -11033,6 +11096,14 @@ msgstr "Kompatybilne profile procesów" msgid "Printable space" msgstr "Przestrzeń do druku" +# AI Translated +msgid "Printer Agent" +msgstr "Agent drukarki" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Wybierz implementację agenta sieciowego do komunikacji z drukarką. Dostępni agenci są rejestrowani przy uruchamianiu." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -11165,9 +11236,6 @@ msgstr "Ograniczenia wysokości warstwy" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Retrakcja podczas zmiany filamentu" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12559,6 +12627,10 @@ msgstr " jest zbyt blisko obszaru wykluczenia, mogą wystąpić kolizje.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " jest zbyt blisko obszaru wykrywania zalepienia dyszy, co doprowadzi do kolizji.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " znajduje się częściowo poza obszarem druku i nie może zostać wydrukowany.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Wybrane temperatury dyszy są niezgodne. Temperatura dyszy każdego filamentu musi mieścić się w zalecanym zakresie temperatur dyszy pozostałych filamentów. W przeciwnym razie może dojść do zatkania dyszy lub uszkodzenia drukarki." @@ -12901,10 +12973,6 @@ msgstr "Użyj 3MF zamiast G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Włącz tę opcję, jeśli drukarka przyjmuje plik 3MF jako zadanie druku. Po włączeniu Orca Slicer wysyła plik po cięciu jako .gcode.3mf zamiast zwykłego pliku .gcode." -# AI Translated -msgid "Printer Agent" -msgstr "Agent drukarki" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Wybierz implementację agenta sieciowego do komunikacji z drukarką." @@ -13617,9 +13685,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Prędkość wewnętrznych mostów. Jeśli wartość jest wyrażona w procentach, będzie obliczana na podstawie prędkości mostu. Wartość domyślna wynosi 150%." -msgid "Brim width" -msgstr "Szerokość brimu" - msgid "This is the distance from the model to the outermost brim line." msgstr "Odległość od modelu do najbardziej zewnętrznej linii brimu" @@ -13703,6 +13768,14 @@ msgstr "" "Kształt zostanie zredukowany przed wykryciem ostrych kątów. Ten parametr wskazuje minimalną długość odchylenia dla redukcji.\n" "0, aby dezaktywować" +# AI Translated +msgid "Brim ears outer only" +msgstr "Uszy brim tylko na zewnątrz" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Generuje uszy myszy tylko na zewnętrznym obrysie modelu, z pominięciem otworów i zamkniętych sekcji." + msgid "upward compatible machine" msgstr "drukarka kompatybilna i wzwyż" @@ -14896,6 +14969,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroidalny" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Współczynnik wygładzania wypełnienia" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Określa, jak mocno zaokrąglane są narożniki wypełnienia. 0% zachowuje oryginalną ostrą ścieżkę, a 100% tworzy największe możliwe łuki pomiędzy sąsiednimi liniami wypełnienia." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Przyspieszenie dla wypełnienia górnej powierzchni. Użycie niższej wartości może poprawić jakość górnej powierzchni" @@ -15459,6 +15540,14 @@ msgstr "Z jakim rodzajem G-code drukarka jest kompatybilna." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Pomiń blok konfiguracyjny G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Nie zapisuje bloku CONFIG_BLOCK (par klucz/wartość z konfiguracją slicera) do pliku G-code. Może to pomóc w przypadku drukarek, których firmware ulega awarii podczas przetwarzania tych linii komentarza (np. Anycubic go-klipper). Uwaga: plik G-code nie będzie już zawierał ustawień slicera, więc ponowne zaimportowanie go do OrcaSlicer nie przywróci konfiguracji." + msgid "Pellet Modded Printer" msgstr "Drukarka do druku granulatem" @@ -16571,6 +16660,14 @@ msgstr "Długa retrakcja podczas zmian ekstruderów" msgid "Retraction distance when extruder change" msgstr "Długość retrakcji podczas zmian ekstruderów" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Długość retrakcji (Zmiana narzędzia)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Gdy retrakcja jest wyzwalana przed zmianą narzędzia, filament zostaje wycofany o określoną wartość (długość mierzona jest na surowym filamencie, przed wejściem do ekstrudera)." + msgid "Z-hop height" msgstr "Wysokość Z-hop" @@ -16669,6 +16766,10 @@ msgstr "Dodatkowa ilość dla powrotu" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Gdy retrakcja jest kompensowana po przemieszczeniu, ekstruder przepycha tę dodatkową ilość filamentu. To opcja jest rzadko potrzebna." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Dodatkowa ilość dla powrotu (Zmiana narzędzia)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Jeśli retrakcja jest korygowana po zmianie narzędzia, extruder przepchnie taką dodatkową ilość filamentu." @@ -17099,6 +17200,14 @@ msgstr "Zmiana narzędzia na wieży czyszczącej" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Wymusza przemieszczenie głowicy do wieży czyszczącej przed wydaniem polecenia zmiany narzędzia (Tx). Dotyczy tylko drukarek wieloekstruderowych (wielogłowicowych) korzystających z wieży czyszczącej typu 2. Domyślnie Orca pomija to przemieszczenie na maszynach wielogłowicowych, ponieważ zamianą głowic zajmuje się oprogramowanie sprzętowe, przez co polecenie Tx może zostać wydane nad drukowaną częścią. Włącz tę opcję, jeśli chcesz, aby zmiana narzędzia zawsze następowała nad wieżą czyszczącą." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Czekaj na temperaturę na wieży czyszczącej" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Pobiera nowe narzędzie bez czekania, aż osiągnie temperaturę druku, przejeżdża do wieży czyszczącej i tam czeka na temperaturę, tuż przed płukaniem. Materiał wyciekający podczas nagrzewania trafia na wieżę zamiast na model, a przejazd nakłada się na nagrzewanie. Dotyczy wyłącznie drukarek z wieloma ekstruderami (wieloma głowicami) używających wieży czyszczącej typu 2. Firmware ani makro zmiany narzędzia nie mogą samodzielnie czekać na temperaturę. Gdy opcja jest wyłączona, oczekiwanie na temperaturę jest wysyłane bezpośrednio po poleceniu zmiany narzędzia." + msgid "No sparse layers (beta)" msgstr "Warstwy bez czyszczenia (beta)" @@ -20445,10 +20554,6 @@ msgstr "Fizyczna drukarka" msgid "Print Host upload" msgstr "Przesyłanie do hosta drukowania" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Wybierz implementację agenta sieciowego do komunikacji z drukarką. Dostępni agenci są rejestrowani przy uruchamianiu." - # AI Translated msgid "Select a Flashforge printer" msgstr "Wybierz drukarkę Flashforge" @@ -21401,9 +21506,6 @@ msgstr "Wystąpił problem podczas próby logowania, proszę spróbować ponowni msgid "User canceled." msgstr "Anulowane przez użytkownika." -msgid "Head diameter" -msgstr "Średnica łącznika" - msgid "Max angle" msgstr "Maksymalny kąt" @@ -22234,6 +22336,22 @@ msgstr "" "Unikaj odkształceń\n" "Czy wiesz, że podczas drukowania filamentami podatnymi na odkształcenia, takimi jak ABS, odpowiednie zwiększenie temperatury podgrzewanej płyty może zmniejszyć prawdopodobieństwo odkształceń?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Wysokość warstwy jest zbyt mała.\n" +#~ "Ustawione zostanie na min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Wysokość warstwy przekracza limit w Ustawieniach Drukarki -> Extruder -> Limity wysokości warstwy, co może powodować problemy z jakością druku." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Dostosować automatycznie do ustawionego zakresu?\n" + +#~ msgid "Head diameter" +#~ msgstr "Średnica łącznika" + #~ msgid "Print order within a single layer." #~ msgstr "Kolejność druku obiektów w obrębie jednej warstwy. Domyślnie lub według listy obiektów" diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index 595e46b8cf..1b39d4a159 100644 --- a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po +++ b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-07-26 11:14-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: Portuguese, Brazilian\n" @@ -4577,6 +4577,23 @@ msgstr "A temperatura da câmara atual está mais alta do que a temperatura segu msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "A temperatura mínima da câmara (%d℃) é superior à temperatura alvo da câmara (%d℃). O valor mínimo é o limite no qual a impressão começa enquanto a câmara continua aquecendo em direção ao alvo; portanto, não deve execedê-lo. O valor será limitado ao alvo." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "A altura da camada é muito pequena. Ela será definida para o mínimo (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "A altura da camada está fora dos limites definidos em Configurações da Impressora -> Extrusora -> Limites de altura da camada, isso pode causar problemas de qualidade de impressão." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Ajustar automaticamente para o limite (%g mm)?" + +msgid "Adjust" +msgstr "Ajustar" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4696,6 +4713,13 @@ msgstr "" "Sim - Habilitar Gerador de Parede Arachne\n" "Não - Desabilitar Gerador de Parede Arachne e setar o modo [Deslocamento] da Textura Difusa" +# AI Translated +msgid "Brim ear radius" +msgstr "Raio da orelha da borda" + +msgid "Brim width" +msgstr "Largura da borda" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "O modo espiral só funciona quando as voltas da parede são 1, o suporte está desativado, a detecção de aglomeração por sondagem está desativada, as camadas da casca de topo são 0, a densidade de preenchimento esparso é 0 e o tipo de timelapse é tradicional." @@ -4950,6 +4974,14 @@ msgstr "Falha ao gerar o G-code de calibração" msgid "Calibration error" msgstr "Erro de calibração" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Esta impressora não está configurada com o hardware que este controle requer." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Este controle não é suportado nesta impressora." + msgid "Network unavailable" msgstr "Rede indisponível" @@ -5793,7 +5825,7 @@ msgstr "Volume:" msgid "Size:" msgstr "Tamanho:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Foram encontrados conflitos de caminhos de G-code na camada %d, Z = %.2lfmm. Por favor, separe mais os objetos em conflito (%s <-> %s)." @@ -5974,6 +6006,10 @@ msgstr "Multi-dispositivo" msgid "Project" msgstr "Projeto" +# AI Translated +msgid "Device (Web)" +msgstr "Dispositivo (Web)" + msgid "Yes" msgstr "Sim" @@ -8020,19 +8056,19 @@ msgstr "Diretório para substituição não foi selecionado" msgid "Replaced with 3D files from directory:\n" msgstr "Substituído por arquivos 3D do diretório:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ %s Ignorados: mesmo arquivo.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ %s Ignorados: arquivo não existe.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ %s Ignorados: falha ao substituir.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ %s Substituídos.\n" @@ -8759,6 +8795,18 @@ msgstr "Com esta opção habilitada, você pode enviar uma tarefa para vários d msgid "Pop up to select filament grouping mode" msgstr "Abrir seleção do modo de agrupamento de filamento" +# AI Translated +msgid "Visible plugin pages" +msgstr "Páginas de plugin visíveis" + +# AI Translated +msgid "pages" +msgstr "páginas" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Número de páginas de plugin exibidas como abas fixas antes que as páginas restantes sejam agrupadas em um menu suspenso na última aba." + msgid "Behaviour" msgstr "Comportamento" @@ -9113,6 +9161,18 @@ msgstr "Mostrar predefinições não suportadas" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Exibir predefinições incompatíveis e não suportadas nas listas de impressora e filamento. Essas predefinições não podem ser selecionadas." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimental) Usar agentes de impressora em vez de hosts de impressão" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Encaminha os trabalhos de impressão de impressoras que não são Bambu pelos agentes de plugin de impressora em vez do fluxo clássico de envio ao host de impressão.\n" +"Quando desativado, o OrcaSlicer usa o comportamento antigo do host de impressão." + msgid "Experimental Features" msgstr "Recursos Experimentais" @@ -9374,9 +9434,25 @@ msgstr "Predefinição do Usuário" msgid "Preset Inside Project" msgstr "Predefinição Dentro do Projeto" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Copia para esta predefinição todos os valores herdados da predefinição pai e remove a relação de herança. Predefinições compatíveis apenas com a predefinição pai podem deixar de ser suportadas." + msgid "Detach from parent" msgstr "Separar do pai" +# AI Translated +msgid "Unique preset" +msgstr "Predefinição única" + +# AI Translated +msgid "Parent preset" +msgstr "Predefinição pai" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Esta predefinição não herda de outra predefinição." + msgid "Name is unavailable." msgstr "O nome não está disponível." @@ -10096,24 +10172,6 @@ msgstr "Tem certeza de que deseja habilitar esta opção?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Padrões de preenchimento são projetados para lidar com a rotação automaticamente para garantir a impressão adequada e atingir os efeitos pretendidos (Ex. Giroide, Cúbico). Girar o padrão de preenchimento esparso atual pode causar suporte insuficiente. Prossiga com cautela e verifique cuidadosamente se há possíveis problemas de impressão. Tem certeza de que deseja habilitar esta opção?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"A altura da camada é muito pequena.\n" -"Ela será definida como altura mínima da camada\n" -"A altura da camada é muito pequena.\n" -"Ela será definida como altura mínima da camada\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "A altura da camada excede o limite em Configurações da Impressora -> Extrusora -> Limites de altura da camada, isso pode causar problemas de qualidade de impressão." - -msgid "Adjust to the set range automatically?\n" -msgstr "Ajustar automaticamente à faixa definida?\n" - -msgid "Adjust" -msgstr "Ajustar" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Funcionalidade experimental: Retrair e cortar o filamento a uma distância maior durante mudanças de filamento para minimizar a purga. Embora possa reduzir notavelmente a purga, ele também pode elevar o risco de bolhas no bico ou outras complicações de impressão." @@ -10308,6 +10366,9 @@ msgstr "Palavras-chave reservadas encontradas" msgid "Setting Overrides" msgstr "Sobrescrever configurações" +msgid "Retraction when switching material" +msgstr "Retração ao trocar material" + msgid "Basic information" msgstr "Informações básicas" @@ -10435,6 +10496,12 @@ msgstr "Perfis de processo compatíveis" msgid "Printable space" msgstr "Espaço de impressão" +msgid "Printer Agent" +msgstr "Agente de Impressora" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Selecione a implementação do agente de rede para comunicação com a impressora. Os agentes disponíveis são registrados na inicialização." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10560,9 +10627,6 @@ msgstr "Limites de altura da camada" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Retração ao trocar material" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -11893,6 +11957,10 @@ msgstr " está muito perto de uma área de exclusão, e colisões vão ocorrer.\ msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " está muito perto da área de detecção de aglomeração, e ocorrerão colisões.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " está parcialmente fora da área imprimível, e não pode ser impresso.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "As temperaturas dos bicos selecionadas são incompatíveis. A temperatura do bico de cada filamento deve estar dentro da faixa de temperatura recomendada para os demais filamentos. Caso contrário, pode ocorrer entupimento do bico ou danos à impressora." @@ -12206,9 +12274,6 @@ msgstr "Usar 3MF em vez de G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Ative esta opção se a impressora aceitar um arquivo 3MF como trabalho de impressão. Quando ativada, o OrcaSlicer envia o arquivo fatiado como .gcode.3mf, em vez de um arquivo .gcode comum." -msgid "Printer Agent" -msgstr "Agente de Impressora" - msgid "Select the network agent implementation for printer communication." msgstr "Selecione a implementação do agente de rede para comunicação com a impressora." @@ -12888,9 +12953,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Velocidade de pontes internas. Se o valor for expresso como uma porcentagem, ele será calculado com base na bridge_speed. O valor padrão é 150%." -msgid "Brim width" -msgstr "Largura da borda" - msgid "This is the distance from the model to the outermost brim line." msgstr "Essa é a distância do modelo até a linha da borda mais externa." @@ -12970,6 +13032,14 @@ msgstr "" "A geometria será decimada antes de detectar ângulos agudos. Este parâmetro indica o comprimento mínimo da divergência para a decimação.\n" "0 para desativar." +# AI Translated +msgid "Brim ears outer only" +msgstr "Orelhas da borda apenas externas" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Gera orelhas de rato apenas no contorno externo do modelo, excluindo furos e seções fechadas." + msgid "upward compatible machine" msgstr "uáquina compatível ascendente" @@ -14104,6 +14174,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroide" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Fator de suavização do preenchimento esparso" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Controla o quanto os cantos do preenchimento esparso são arredondados. 0% mantém o trajeto original com cantos vivos, enquanto 100% produz as maiores curvas possíveis entre linhas de preenchimento adjacentes." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Esta é a aceleração do preenchimento da superfície superior. Usar um valor menor pode melhorar a qualidade da superfície superior." @@ -14639,6 +14717,14 @@ msgstr "Com que tipo de G-code a impressora é compatível." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Omitir o bloco de configuração do G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Não grava o CONFIG_BLOCK (os pares chave/valor da configuração do fatiador) no arquivo G-code. Isso pode ajudar com impressoras cujo firmware trava ao interpretar essas linhas de comentário (por exemplo, Anycubic go-klipper). Observação: o arquivo G-code não conterá mais as configurações do fatiador, então importá-lo de volta no OrcaSlicer não restaurará a configuração." + msgid "Pellet Modded Printer" msgstr "Impressora Modificada para Pellets" @@ -15157,7 +15243,6 @@ msgstr "Força máxima do eixo Y" msgid "The allowed maximum output force of Y axis" msgstr "A força máxima de saída permitida do eixo Y" -#, fuzzy msgid "N" msgstr "N" @@ -15167,9 +15252,9 @@ msgstr "Massa da mesa do eixo Y" msgid "The machine bed mass load of Y axis" msgstr "A carga de massa da mesa do equipamento no eixo Y" -#, fuzzy +# AI Translated msgid "g" -msgstr "G" +msgstr "g" msgid "The allowed max printed mass" msgstr "Massa máxima de impressão permitida" @@ -15681,6 +15766,14 @@ msgstr "Retração longa na troca de extrusora" msgid "Retraction distance when extruder change" msgstr "Distância de retração na troca de extrusora" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Comprimento da retração (Troca de ferramenta)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Quando a retração é acionada antes da troca de ferramenta, o filamento é puxado de volta na quantidade especificada (o comprimento é medido no filamento bruto, antes de entrar na extrusora)." + msgid "Z-hop height" msgstr "Altura de Z-hop" @@ -15774,6 +15867,10 @@ msgstr "Comprimento extra na retração" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Quando a retração é compensada após o movimento de deslocamento, a extrusora empurrará essa quantidade adicional de filamento. Esta configuração é raramente necessária." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Comprimento extra na retração (Troca de ferramenta)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Quando a retração é compensada após a troca de ferramenta, a extrusora empurrará essa quantidade adicional de filamento." @@ -16182,6 +16279,14 @@ msgstr "Troca de ferramenta na torre de purga" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Força o cabeçote de impressão a se deslocar até a torre de purga antes de emitir o comando de troca de ferramenta (Tx). Relevante apenas para impressoras com múltiplas extrusoras (múltiplos cabeçotes de impressão) que utilizam uma torre de purga Tipo 2. Por padrão, o Orca ignora o deslocamento em máquinas com múltiplos cabeçotes de impressão, pois o firmware gerencia a troca do cabeçote, o que pode resultar na emissão do comando Tx acima da peça impressa. Habilite esta opção se desejar que a troca de ferramenta seja sempre emitida acima da torre de purga." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Aguardar a temperatura na torre de purga" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Pega a nova ferramenta sem esperar que ela atinja a temperatura de impressão, desloca-se até a torre de purga e aguarda a temperatura ali, logo antes de purgar. O vazamento causado pelo aquecimento cai na torre em vez do modelo, e o deslocamento acontece durante o aquecimento. Relevante apenas para impressoras multiextrusora (multicabeça) que usam uma torre de purga do tipo 2. O firmware ou a macro de troca de ferramenta não devem aguardar a temperatura por conta própria. Quando desativado, a espera de temperatura é emitida logo após o comando de troca de ferramenta." + msgid "No sparse layers (beta)" msgstr "Sem camadas esparsas (beta)" @@ -16733,7 +16838,6 @@ msgstr "Volume de preparo" msgid "This is the volume of material to prime the extruder with on the tower." msgstr "Este é o volume de material para preparar a extrusora na torre." -#,fuzzy msgid "Prime volume mode" msgstr "Modo de volume de preparação" @@ -19369,9 +19473,6 @@ msgstr "Impressora Física" msgid "Print Host upload" msgstr "Upload do Host de Impressão" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Selecione a implementação do agente de rede para comunicação com a impressora. Os agentes disponíveis são registrados na inicialização." - msgid "Select a Flashforge printer" msgstr "Selecione uma impressora Flashforge" @@ -20213,9 +20314,6 @@ msgstr "Algo inesperado aconteceu ao tentar conectar, por favor tente novamente. msgid "User canceled." msgstr "Cancelado pelo usuário." -msgid "Head diameter" -msgstr "Diâmetro da cabeça" - msgid "Max angle" msgstr "Ângulo máx" @@ -20949,6 +21047,24 @@ msgstr "" "Evitar empenamento\n" "Você sabia que ao imprimir materiais propensos ao empenamento como ABS, aumentar adequadamente a temperatura da mesa aquecida pode reduzir a probabilidade de empenamento?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "A altura da camada é muito pequena.\n" +#~ "Ela será definida como altura mínima da camada\n" +#~ "A altura da camada é muito pequena.\n" +#~ "Ela será definida como altura mínima da camada\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "A altura da camada excede o limite em Configurações da Impressora -> Extrusora -> Limites de altura da camada, isso pode causar problemas de qualidade de impressão." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Ajustar automaticamente à faixa definida?\n" + +#~ msgid "Head diameter" +#~ msgstr "Diâmetro da cabeça" + #~ msgid "Print order within a single layer." #~ msgstr "Ordem de impressão dentro de uma única camada." diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index c2fbcb54be..2372471707 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer V2.5.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-02-25 13:38+0300\n" "Last-Translator: Felix14_v2\n" "Language-Team: Felix14_v2 (ДС/ТГ: @felix14_v2, почта: aleks111001@list.ru), Andylg \n" @@ -4715,6 +4715,23 @@ msgstr "Текущая температура внутри термокамер msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Стартовая температура внутри термокамеры (%d℃) превышает целевую (%d℃). Подразумевается, что печать начинается заранее, поэтому стартовая температура не должна превышать её. Значение будет уменьшено." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Высота слоя слишком мала. Будет установлено минимальное значение (%g мм)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Высота слоя выходит за пределы, заданные в настройках принтера → Экструдер → Ограничение высоты слоя. Это может вызвать проблемы с качеством печати." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Автоматически подстроить под предел (%g мм)?" + +msgid "Adjust" +msgstr "Подстроиться" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4839,6 +4856,13 @@ msgid "" "No - Disable Arachne Wall Generator and set [Displacement] mode of the Fuzzy Skin" msgstr "Использовать нечёткую оболочку с движком Arachne?" +# AI Translated +msgid "Brim ear radius" +msgstr "Радиус ушек каймы" + +msgid "Brim width" +msgstr "Ширина каймы" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "" "Для печати в режиме вазы необходимы следующие настройки:\n" @@ -5107,6 +5131,14 @@ msgstr "Не удалось сгенерировать калибровочны msgid "Calibration error" msgstr "Ошибка калибровки" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "На этом принтере не настроено оборудование, необходимое для этого элемента управления." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Этот элемент управления не поддерживается на этом принтере." + msgid "Network unavailable" msgstr "Сеть недоступна" @@ -5991,7 +6023,7 @@ msgstr "Объём:" msgid "Size:" msgstr "Размер:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "В G-коде на %d слое (z = %.2lf мм) обнаружен конфликт путей. Пожалуйста, разместите конфликтующие модели дальше друг от друга (%s <-> %s)." @@ -6198,6 +6230,10 @@ msgstr "Принтеры" msgid "Project" msgstr "Проект" +# AI Translated +msgid "Device (Web)" +msgstr "Принтер (веб)" + msgid "Yes" msgstr "Да" @@ -8299,19 +8335,19 @@ msgstr "Расположение для замены не указано" msgid "Replaced with 3D files from directory:\n" msgstr "Заменено файлами из расположения:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Пропущен %s: идентичный файл.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Пропущен %s: файл не существует.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Пропущен %s: не удалось заменить.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Заменён %s.\n" @@ -9040,6 +9076,18 @@ msgstr "Если включено, вы сможете управлять нес msgid "Pop up to select filament grouping mode" msgstr "Всплывающее окно для выбора режима группировки материалов" +# AI Translated +msgid "Visible plugin pages" +msgstr "Видимые страницы плагинов" + +# AI Translated +msgid "pages" +msgstr "стр." + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Количество страниц плагинов, отображаемых как закреплённые вкладки, прежде чем остальные страницы будут свёрнуты в выпадающий список на последней вкладке." + msgid "Behaviour" msgstr "Автоматизация" @@ -9400,6 +9448,18 @@ msgstr "" "\n" "Примечание: профили остаются недоступными для выбора." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Экспериментально) Использовать агентов принтера вместо хостов печати" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Отправлять задания печати для принтеров, отличных от Bambu, через агентов плагинов принтера вместо классической загрузки на хост печати.\n" +"Если отключено, OrcaSlicer использует прежнее поведение хоста печати." + msgid "Experimental Features" msgstr "Экспериментальные настройки" @@ -9666,9 +9726,25 @@ msgstr "Пользовательский профиль" msgid "Preset Inside Project" msgstr "Профиль внутри проекта" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Копирует в этот профиль все значения, унаследованные от родительского профиля, и удаляет связь наследования. Профили, совместимые только с родительским, могут стать неподдерживаемыми." + msgid "Detach from parent" msgstr "Сделать независимым" +# AI Translated +msgid "Unique preset" +msgstr "Независимый профиль" + +# AI Translated +msgid "Parent preset" +msgstr "Родительский профиль" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Этот профиль не наследуется от другого профиля." + msgid "Name is unavailable." msgstr "Имя недоступно." @@ -9686,7 +9762,9 @@ msgstr "" "несовместим с текущим принтером." msgid "Please note that saving will overwrite the current preset." -msgstr "Обратите внимание: при сохранении произойдёт\nперезапись текущего профиля." +msgstr "" +"Обратите внимание: при сохранении произойдёт\n" +"перезапись текущего профиля." msgid "The name cannot be the same as a preset alias name." msgstr "Имя не должно совпадать с именем предустановленного профиля." @@ -10389,22 +10467,6 @@ msgstr "Вы действительно хотите задействовать msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Многие шаблоны заполнения разработаны на основе автоматического поворота по определённым правилам для поддержания правильной печати и желаемого эффекта (например, «Гироид» или «Куб»). Изменение правила поворота текущего шаблона может привести к его провисанию. Будьте осторожны и внимательно проверяйте результат на наличие потенциальных проблем с печатью." -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Высота слоя слишком мала.\n" -"Будет установлено значение min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Высота слоя не может превышать ограничения, установленные в настройках принтера → Экструдер → Ограничение высоты слоя. Это может вызвать проблемы с качеством печати." - -msgid "Adjust to the set range automatically?\n" -msgstr "Автоматически подстроиться под заданный в настройках диапазон?\n" - -msgid "Adjust" -msgstr "Подстроиться" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "[Экспериментальная функция] Втягивание и обрезка прутка на большем расстоянии во время его замены для минимизации очистки. Хотя это значительно сокращает величину очистки, это может повысить риск возникновения затора или вызвать другие проблемы при печати." @@ -10604,6 +10666,9 @@ msgstr "Найдены зарезервированные ключевые сл msgid "Setting Overrides" msgstr "Замещение настроек" +msgid "Retraction when switching material" +msgstr "Откат при смене материала" + msgid "Basic information" msgstr "Основные" @@ -10751,6 +10816,12 @@ msgstr "Совместимые настройки" msgid "Printable space" msgstr "Область печати" +msgid "Printer Agent" +msgstr "Сетевой агент" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Реализация сетевого агента для обмена информацией с принтером. Доступные реализации определяются при запуске." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10879,9 +10950,6 @@ msgstr "Ограничение высоты слоя" msgid "Z-Hop" msgstr "Подъём головы при откате" -msgid "Retraction when switching material" -msgstr "Откат при смене материала" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12218,6 +12286,10 @@ msgstr " находится слишком близко к области иск msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " находится слишком близко к зоне обнаружения налипаний, столкновения неизбежны.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " частично находится за пределами области печати и не может быть напечатан.\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Обнаружен недопустимый перепад температур. Каждый из используемых материалов должен иметь в профиле температуру печати в пределах допустимого диапазона других материалов. В противном случае сопло может забиться и повредить принтер." @@ -12539,9 +12611,6 @@ msgstr "Сжатие G-кода перед отправкой" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Рекомендуется для принтеров, поддерживающих печать из архивов 3MF. Файлы печати будут отправляться с расширением \".gcode.3mf\"." -msgid "Printer Agent" -msgstr "Сетевой агент" - msgid "Select the network agent implementation for printer communication." msgstr "Реализация сетевого агента для обмена информацией с принтером." @@ -13232,9 +13301,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Скорость печати внутреннего моста. Можно указать процент от скорости внешнего моста (bridge_speed). По умолчанию – 150%." -msgid "Brim width" -msgstr "Ширина каймы" - msgid "This is the distance from the model to the outermost brim line." msgstr "Расстояние от модели до внешней линии каймы." @@ -13316,6 +13382,14 @@ msgstr "" "Геометрия модели будет упрощена перед обнаружением острых углов. Этот параметр задаёт минимальную длину отклонения для её упрощения.\n" "Установите 0 для отключения." +# AI Translated +msgid "Brim ears outer only" +msgstr "Ушки каймы только снаружи" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Создавать мышиные ушки только на внешнем контуре модели, исключая отверстия и замкнутые участки." + msgid "upward compatible machine" msgstr "условия для совместимых принтеров" @@ -14308,13 +14382,19 @@ msgid "Interface layer pre-extrusion distance" msgstr "Дистанция избыточной подачи при смене" msgid "Pre-extrusion distance for prime tower interface layer (where different materials meet)." -msgstr "Протяжённость первичного движения прочистки после смены материала. Позволяет быстро набрать давление в сопле и сбросить перегретый материал.\n\nПримечание: фактическая длина может быть ограничена шириной башни." +msgstr "" +"Протяжённость первичного движения прочистки после смены материала. Позволяет быстро набрать давление в сопле и сбросить перегретый материал.\n" +"\n" +"Примечание: фактическая длина может быть ограничена шириной башни." msgid "Interface layer pre-extrusion length" msgstr "Длина прутка для избыточной подачи" msgid "Pre-extrusion length for prime tower interface layer (where different materials meet)." -msgstr "Длина прутка, которую необходимо продавить на этапе избыточной подачи.\n\n0 – отключить этот этап." +msgstr "" +"Длина прутка, которую необходимо продавить на этапе избыточной подачи.\n" +"\n" +"0 – отключить этот этап." msgid "Tower ironing area" msgstr "Разглаживание кончиков" @@ -14626,6 +14706,14 @@ msgstr "ТПМП Фишера-Коха S" msgid "Gyroid" msgstr "Гироид" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Коэффициент сглаживания заполнения" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Определяет, насколько сильно скругляются углы заполнения. 0% сохраняет исходную траекторию с острыми углами, а 100% создаёт максимально возможные скругления между соседними линиями заполнения." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Ускорение на верхней поверхности. Использование меньшего значения может улучшить качество верхней поверхности." @@ -15213,6 +15301,14 @@ msgstr "Выбор типа G-кода для совместимости с пр msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Пропустить блок конфигурации в G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Не записывать CONFIG_BLOCK (пары ключ/значение с настройками слайсера) в файл G-code. Это может помочь с принтерами, прошивка которых аварийно завершается при разборе этих строк комментариев (например, Anycubic go-klipper). Примечание: файл G-code больше не будет содержать настройки слайсера, поэтому при обратном импорте в OrcaSlicer конфигурация не восстановится." + msgid "Pellet Modded Printer" msgstr "Гранульная модификация принтера" @@ -15396,8 +15492,7 @@ msgstr "Наклон опор" msgid "" "Controls how aggressively short or unsupported Lightning branches are pruned.\n" "This angle is converted internally to a per-layer distance." -msgstr "" -"Допустимый наклон опор молнии. Чем выше, тем быстрее и экономичнее распространяются её ветви." +msgstr "Допустимый наклон опор молнии. Чем выше, тем быстрее и экономичнее распространяются её ветви." # "Выпрямление" здесь, вопреки первой мысли – это как раз-таки наоборот искажение шаблона по ходу печати для сокращения количества ветвей. Короче, опять путаница из-за того, что генерация ветвей происходит сверху вниз. При печати снизу вверх шаблон именно что искажается. msgid "Straightening angle" @@ -16309,8 +16404,7 @@ msgid "" "The length of fast retraction after wipe, relative to retraction length.\n" "The value will be clamped by 100% minus the retract amount before the wipe value." msgstr "" -"Быстрый откат после очистки, выраженный в процентах от общей длины отката. В некоторых случаях позволяет значительно снизить количество «паутины»." -"\n" +"Быстрый откат после очистки, выраженный в процентах от общей длины отката. В некоторых случаях позволяет значительно снизить количество «паутины».\n" "Примечание: суммарное значение не должно превышать 100% и будет скорректировано автоматически." msgid "Retract on layer change" @@ -16344,6 +16438,14 @@ msgstr "Длинный откат перед сменой экструдера" msgid "Retraction distance when extruder change" msgstr "Длина отката перед сменой экструдера" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Длина отката (смена инструмента)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "При срабатывании отката перед сменой инструмента материал втягивается на указанную величину (длина измеряется по прутку материала до его входа в экструдер)." + msgid "Z-hop height" msgstr "Высота подъёма" @@ -16461,6 +16563,10 @@ msgstr "Доп. подача после отката" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Дополнительная длина подачи при возврате прутка после отката. Требуется крайне редко (например, для компенсации багов прошивки принтера)." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Доп. подача после отката (смена инструмента)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Дополнительная длина подачи после смены насадки." @@ -16474,7 +16580,9 @@ msgid "Deretraction speed" msgstr "Скорость возврата" msgid "Speed for reloading filament into the nozzle. Zero means same speed of retraction." -msgstr "Скорость возврата материала в сопло после отката.\n0 – использовать скорость отката." +msgstr "" +"Скорость возврата материала в сопло после отката.\n" +"0 – использовать скорость отката." msgid "Deretraction speed (extruder change)" msgstr "Скорость возврата (смена экструдера)" @@ -16945,6 +17053,14 @@ msgstr "" "\n" "Внимание: применимо только к многоэкструдерным принтерам с черновой башней 2 типа." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Ожидание температуры на черновой башне" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Забирает новый инструмент, не дожидаясь достижения температуры печати, перемещается к черновой башне и ждёт нагрева там, непосредственно перед прочисткой. Подтёки при нагреве попадают на башню, а не на модель, а перемещение совмещается с нагревом. Актуально только для принтеров с несколькими экструдерами (несколькими печатающими головами), использующих черновую башню типа 2. Прошивка или макрос смены инструмента не должны сами ждать нагрева. Если отключено, команда ожидания температуры выдаётся сразу после команды смены инструмента." + msgid "No sparse layers (beta)" msgstr "Без разреженных слоёв (beta)" @@ -17942,13 +18058,21 @@ msgid "To prevent oozing, the nozzle temperature will be cooled during ramming. msgstr "Во избежание подтёков температура сопла будет снижена на время рэмминга. Поэтому время рэмминга должно быть больше времени охлаждения. 0 значит отключено." msgid "The maximum volumetric speed for ramming before extruder change, where -1 means using the maximum volumetric speed." -msgstr "Максимальный объёмный расход для рэмминга перед сменой экструдера.\n-1 – использовать максимальный расход." +msgstr "" +"Максимальный объёмный расход для рэмминга перед сменой экструдера.\n" +"-1 – использовать максимальный расход." msgid "To prevent oozing, the nozzle temperature will be cooled during ramming. Note: only a cooldown command and fan activation are triggered, reaching the target temperature is not guaranteed. 0 means disabled." -msgstr "Во избежание подтёков температура сопла будет снижена на время рэмминга.\n0 – не менять температуру.\n\nПримечание: срабатывают только команда охлаждения и включение вентилятора; достижение целевой температуры не гарантируется." +msgstr "" +"Во избежание подтёков температура сопла будет снижена на время рэмминга.\n" +"0 – не менять температуру.\n" +"\n" +"Примечание: срабатывают только команда охлаждения и включение вентилятора; достижение целевой температуры не гарантируется." msgid "The maximum volumetric speed for ramming before a hotend change, where -1 means using the maximum volumetric speed." -msgstr "Максимальный объёмный расход для рэмминга перед сменой хотэнда.\n-1 – использовать максимальный расход." +msgstr "" +"Максимальный объёмный расход для рэмминга перед сменой хотэнда.\n" +"-1 – использовать максимальный расход." msgid "length when change hotend" msgstr "Откат при смене хотэнда" @@ -19414,10 +19538,14 @@ msgid "Continue anyway?" msgstr "Всё равно продолжить?" msgid "Enable \"Auto-adjust\" to fix this automatically, or continue anyway?" -msgstr "Включить адаптацию к расходу для автоматического исправления?\nНет – игнорировать предупреждение." +msgstr "" +"Включить адаптацию к расходу для автоматического исправления?\n" +"Нет – игнорировать предупреждение." msgid "Enable \"Auto-scale for nozzle\" and \"Auto-adjust\" to fix this automatically, or continue anyway?" -msgstr "Включить адаптацию к соплу и расходу для автоматического исправления?\nНет – игнорировать предупреждение." +msgstr "" +"Включить адаптацию к соплу и расходу для автоматического исправления?\n" +"Нет – игнорировать предупреждение." msgid "Start retraction length: " msgstr "Начальная длина отката: " @@ -20341,9 +20469,6 @@ msgstr "Физический принтер" msgid "Print Host upload" msgstr "Загрузка на хост печати" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Реализация сетевого агента для обмена информацией с принтером. Доступные реализации определяются при запуске." - msgid "Select a Flashforge printer" msgstr "Выберите принтер Flashforge" @@ -21202,9 +21327,6 @@ msgstr "При попытке войти произошла какая-то ош msgid "User canceled." msgstr "Отменено пользователем." -msgid "Head diameter" -msgstr "Диаметр уха" - msgid "Max angle" msgstr "Макс. угол" @@ -21959,6 +22081,22 @@ msgstr "" "Предотвращение коробления материала\n" "Знаете ли вы, что при печати материалами, склонными к короблению, таких как ABS, повышение температуры подогреваемого стола может снизить эту вероятность?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Высота слоя слишком мала.\n" +#~ "Будет установлено значение min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Высота слоя не может превышать ограничения, установленные в настройках принтера → Экструдер → Ограничение высоты слоя. Это может вызвать проблемы с качеством печати." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Автоматически подстроиться под заданный в настройках диапазон?\n" + +#~ msgid "Head diameter" +#~ msgstr "Диаметр уха" + #~ msgid "Print order within a single layer." #~ msgstr "Последовательность печати моделей в пределах одного слоя." diff --git a/localization/i18n/sv/OrcaSlicer_sv.po b/localization/i18n/sv/OrcaSlicer_sv.po index 5686fb7d8f..432000f96d 100644 --- a/localization/i18n/sv/OrcaSlicer_sv.po +++ b/localization/i18n/sv/OrcaSlicer_sv.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -5213,6 +5213,23 @@ msgstr "Kammarens aktuella temperatur är högre än materialets säkra temperat msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Kammarens minimitemperatur (%d℃) är högre än kammarens måltemperatur (%d℃). Minimivärdet är tröskeln där utskriften startar medan kammaren fortsätter värmas mot målet, så det bör inte överstiga målet. Värdet begränsas till måltemperaturen." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Lagerhöjden är för liten. Den kommer att sättas till minimivärdet (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Lagerhöjden ligger utanför gränserna som anges i Skrivarinställningar -> Extruder -> Lagerhöjds gränser, detta kan orsaka problem med utskriftskvaliteten." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Justera automatiskt till gränsvärdet (%g mm)?" + +msgid "Adjust" +msgstr "Justera" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -5339,6 +5356,13 @@ msgstr "" "Ja – Aktivera Arachne-väggeneratorn\n" "Nej – Inaktivera Arachne-väggeneratorn och ställ in läget [Förskjutning] för ojämn yta" +# AI Translated +msgid "Brim ear radius" +msgstr "Radie för brim-öra" + +msgid "Brim width" +msgstr "Brim bredd" + # AI Translated msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Spiralläget fungerar bara när antal väggar är 1, support är avstängt, detektering av klumpbildning med sondering är avstängd, antal översta skallager är 0, sparsam ifyllnadsdensitet är 0 och timelapse-typen är traditionell." @@ -5645,6 +5669,14 @@ msgstr "Misslyckades med att generera cali G kod" msgid "Calibration error" msgstr "Fel vid kalibrering" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Den här skrivaren är inte konfigurerad med den maskinvara som den här kontrollen kräver." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Den här kontrollen stöds inte på den här skrivaren." + # AI Translated msgid "Network unavailable" msgstr "Nätverket är inte tillgängligt" @@ -6596,7 +6628,7 @@ msgid "Size:" msgstr "Storlek:" # AI Translated -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Konflikter mellan G-code-banor hittades på lager %d, Z = %.2lfmm. Placera de objekt som krockar längre ifrån varandra (%s <-> %s)." @@ -6798,6 +6830,10 @@ msgstr "Flera enheter" msgid "Project" msgstr "Projekt" +# AI Translated +msgid "Device (Web)" +msgstr "Enhet (Webb)" + msgid "Yes" msgstr "Ja" @@ -9088,22 +9124,22 @@ msgid "Replaced with 3D files from directory:\n" msgstr "Ersatt med 3D-filer från mappen:\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Hoppade över %s: samma fil.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Hoppade över %s: filen finns inte.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Hoppade över %s: det gick inte att ersätta.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Ersatte %s.\n" @@ -9933,6 +9969,18 @@ msgstr "Med det här alternativet aktiverat kan du skicka en uppgift till flera msgid "Pop up to select filament grouping mode" msgstr "Visa dialogruta för val av filamentgrupperingsläge" +# AI Translated +msgid "Visible plugin pages" +msgstr "Synliga insticksmodulsidor" + +# AI Translated +msgid "pages" +msgstr "sidor" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Antal insticksmodulsidor som visas som fasta flikar innan de återstående sidorna fälls ihop i en rullgardinsmeny på den sista fliken." + # AI Translated msgid "Behaviour" msgstr "Beteende" @@ -10357,6 +10405,18 @@ msgstr "Visa förinställningar som inte stöds" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Visa inkompatibla förinställningar och förinställningar som inte stöds i rullgardinslistorna för skrivare och filament. Dessa förinställningar kan inte väljas." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Experimentellt) Använd skrivaragenter i stället för utskriftsvärdar" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Skickar utskriftsjobb för icke-Bambu-skrivare via skrivarens insticksmodulagenter i stället för det klassiska uppladdningsflödet till utskriftsvärden.\n" +"När detta är avaktiverat använder OrcaSlicer det äldre beteendet för utskriftsvärdar." + # AI Translated msgid "Experimental Features" msgstr "Experimentella funktioner" @@ -10637,10 +10697,26 @@ msgstr "Användar förinställning" msgid "Preset Inside Project" msgstr "Projekt förinställning" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Kopierar alla ärvda värden från den överordnade förinställningen till den här förinställningen och tar bort arvsrelationen. Förinställningar som endast är kompatibla med den överordnade förinställningen kan sluta stödjas." + # AI Translated msgid "Detach from parent" msgstr "Koppla loss från överordnad" +# AI Translated +msgid "Unique preset" +msgstr "Unik förinställning" + +# AI Translated +msgid "Parent preset" +msgstr "Överordnad förinställning" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Den här förinställningen ärver inte från någon annan förinställning." + msgid "Name is unavailable." msgstr "Namnet ej tillgängligt." @@ -11459,23 +11535,6 @@ msgstr "Är du säker på att du vill aktivera det här alternativet?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Ifyllnadsmönster är oftast konstruerade för att hantera rotation automatiskt så att de skrivs ut korrekt och ger avsedd effekt (t.ex. Gyroid, Kubisk). Att rotera det aktuella sparsamma ifyllnadsmönstret kan ge otillräckligt stöd. Var försiktig och kontrollera noga om det uppstår utskriftsproblem. Är du säker på att du vill aktivera det här alternativet?" -# AI Translated -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Lagerhöjden är för liten.\n" -"Den ställs in på min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Lagerhöjden överskrider gränsen i Skrivarinställningar -> Extruder -> Lagerhöjds gränser, detta kan orsaka problem med utskriftskvaliteten." - -msgid "Adjust to the set range automatically?\n" -msgstr "Justera automatiskt till det inställda området?\n" - -msgid "Adjust" -msgstr "Justera" - # AI Translated msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Experimentell funktion: Filamentet dras tillbaka och kapas på ett längre avstånd vid filamentbyten för att minimera rensningen. Det kan minska rensningen avsevärt, men kan också öka risken för igensatt nozzel eller andra utskriftsproblem." @@ -11707,6 +11766,9 @@ msgstr "Hittade reserverade nyckelord" msgid "Setting Overrides" msgstr "Åsidosätter inställningar" +msgid "Retraction when switching material" +msgstr "Reduktion vid material byte" + msgid "Basic information" msgstr "Allmän information" @@ -11848,6 +11910,14 @@ msgstr "Kompatibla process profiler" msgid "Printable space" msgstr "Utskriftsbar yta" +# AI Translated +msgid "Printer Agent" +msgstr "Skrivaragent" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Välj vilken nätverksagentimplementation som ska användas för kommunikation med skrivaren. Tillgängliga agenter registreras vid start." + # AI Translated #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format @@ -11992,9 +12062,6 @@ msgstr "Lagerhöjds begränsning" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Reduktion vid material byte" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -13486,6 +13553,10 @@ msgstr " är för nära uteslutningsområdet, och kollisioner kommer att orsakas msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " ligger för nära området för klumpdetektering, vilket kommer att orsaka kollisioner.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " är delvis utanför det utskrivbara området och kan inte skrivas ut.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "De valda nozzeltemperaturerna är inkompatibla. Varje filaments nozzeltemperatur måste ligga inom de andra filamentens rekommenderade nozzeltemperaturintervall. Annars kan nozzeln sättas igen eller skrivaren skadas." @@ -13856,10 +13927,6 @@ msgstr "Använd 3MF i stället för G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Aktivera detta om skrivaren tar emot en 3MF-fil som utskriftsjobb. När det är aktiverat skickar Orca Slicer den beredda filen som en .gcode.3mf i stället för en vanlig .gcode-fil." -# AI Translated -msgid "Printer Agent" -msgstr "Skrivaragent" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Välj vilken nätverksagentimplementation som ska användas för kommunikation med skrivaren." @@ -14616,9 +14683,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Hastighet för inre bridges. Om värdet anges i procent beräknas det utifrån bridge_speed. Standardvärdet är 150 %." -msgid "Brim width" -msgstr "Brim bredd" - msgid "This is the distance from the model to the outermost brim line." msgstr "Avståndet från modellen till yttersta brim linjen" @@ -14707,6 +14771,14 @@ msgstr "" "Geometrin decimeras innan skarpa vinklar detekteras. Den här parametern anger avvikelsens minsta längd för decimeringen.\n" "0 för att avaktivera." +# AI Translated +msgid "Brim ears outer only" +msgstr "Brim-öron endast utvändigt" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Genererar musöron endast på modellens yttre kontur, exklusive hål och slutna sektioner." + msgid "upward compatible machine" msgstr "uppåt kompatibel maskin" @@ -16039,6 +16111,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Utjämningsfaktor för sparsam ifyllnad" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Styr hur kraftigt hörnen i den sparsamma ifyllnaden rundas av. 0% behåller den ursprungliga skarpa banan, medan 100% ger största möjliga kurvor mellan intilliggande ifyllnadslinjer." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Acceleration av fyllning av toppytan. Att använda ett lägre värde kan förbättra ytkvaliteten" @@ -16651,6 +16731,14 @@ msgstr "Vilken typ av G-kod är skrivaren kompatibel med" msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Hoppa över G-code-konfigurationsblocket" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Skriver inte CONFIG_BLOCK (nyckel/värde-paren för slicerkonfigurationen) till G-code-filen. Detta kan hjälpa med skrivare vars firmware kraschar när dessa kommentarrader tolkas (t.ex. Anycubic go-klipper). Obs: G-code-filen kommer inte längre att innehålla slicerinställningarna, så att importera den tillbaka till OrcaSlicer återställer inte konfigurationen." + # AI Translated msgid "Pellet Modded Printer" msgstr "Skrivare ombyggd för pellets" @@ -17868,6 +17956,14 @@ msgstr "Lång reduktion vid extruderbyte" msgid "Retraction distance when extruder change" msgstr "Reduktionssträcka vid extruderbyte" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Reduktionslängd (Verktygsbyte)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "När reduktionen utlöses före ett verktygsbyte dras filamentet tillbaka med den angivna mängden (längden mäts på det obearbetade filamentet, innan det når extrudern)." + # AI Translated msgid "Z-hop height" msgstr "Z-hop-höjd" @@ -17983,6 +18079,10 @@ msgstr "Extra längd vid omstart" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "När reduktionen kompenseras efter flyttrörelsen trycker extrudern fram den här extra mängden filament. Den här inställningen behövs sällan." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Extra längd vid omstart (Verktygsbyte)" + # AI Translated msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "När reduktionen kompenseras efter verktygsbyte trycker extrudern fram den här extra mängden filament." @@ -18477,6 +18577,14 @@ msgstr "Verktygsbyte vid prime tornet" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Tvinga verktygshuvudet att flytta till prime tornet innan verktygsbyteskommandot (Tx) skickas. Endast relevant för skrivare med flera extrudrar (flera verktygshuvuden) som använder ett prime torn av typ 2. Som standard hoppar Orca över flytten på maskiner med flera verktygshuvuden, eftersom den fasta programvaran hanterar huvudbytet, vilket kan leda till att Tx-kommandot skickas ovanför den utskrivna delen. Aktivera det här alternativet om du vill att verktygsbytet alltid ska ske ovanför prime tornet i stället." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Vänta på temperatur vid prime tornet" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Hämtar det nya verktyget utan att vänta på att det ska nå utskriftstemperatur, förflyttar sig till prime tornet och väntar på temperaturen där, precis före rensningen. Materialet som droppar under uppvärmningen hamnar på tornet i stället för på modellen, och förflyttningen sker samtidigt som uppvärmningen. Endast relevant för skrivare med flera extrudrar (flera verktygshuvuden) som använder ett prime torn av typ 2. Firmware eller verktygsbytesmakrot får inte vänta på temperaturen själv. När detta är avaktiverat utfärdas temperaturväntan direkt efter verktygsbyteskommandot." + # AI Translated msgid "No sparse layers (beta)" msgstr "Inga glesa lager (beta)" @@ -22101,10 +22209,6 @@ msgstr "Fysisk printer" msgid "Print Host upload" msgstr "Uppladdning utskriftsvärd" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Välj vilken nätverksagentimplementation som ska användas för kommunikation med skrivaren. Tillgängliga agenter registreras vid start." - # AI Translated msgid "Select a Flashforge printer" msgstr "Välj en Flashforge-skrivare" @@ -23181,10 +23285,6 @@ msgstr "Något oväntat hände vid inloggningen, försök igen." msgid "User canceled." msgstr "Användaren avbröt." -# AI Translated -msgid "Head diameter" -msgstr "Huvuddiameter" - # AI Translated msgid "Max angle" msgstr "Maxvinkel" @@ -24071,6 +24171,24 @@ msgstr "" "Undvik vridning\n" "Visste du att när du skriver ut material som är benägna att vrida, såsom ABS, kan en lämplig ökning av värmebäddens temperatur minska sannolikheten för vridning?" +# AI Translated +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Lagerhöjden är för liten.\n" +#~ "Den ställs in på min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Lagerhöjden överskrider gränsen i Skrivarinställningar -> Extruder -> Lagerhöjds gränser, detta kan orsaka problem med utskriftskvaliteten." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Justera automatiskt till det inställda området?\n" + +# AI Translated +#~ msgid "Head diameter" +#~ msgstr "Huvuddiameter" + # AI Translated #~ msgid "Print order within a single layer." #~ msgstr "Utskriftsordning inom ett enskilt lager." diff --git a/localization/i18n/th/OrcaSlicer_th.po b/localization/i18n/th/OrcaSlicer_th.po index a419ba320e..a0c0079125 100644 --- a/localization/i18n/th/OrcaSlicer_th.po +++ b/localization/i18n/th/OrcaSlicer_th.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-06-19 13:40+0700\n" "Last-Translator: Icezaza\n" "Language-Team: Thai\n" @@ -4720,6 +4720,23 @@ msgstr "อุณหภูมิห้องพิมพ์ปัจจุบั msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "อุณหภูมิห้องพิมพ์ต่ำสุด (%d℃) สูงกว่าอุณหภูมิห้องพิมพ์เป้าหมาย (%d℃) ค่าต่ำสุดคือเกณฑ์ที่การพิมพ์จะเริ่มต้นในขณะที่ห้องพิมพ์ยังคงร้อนขึ้นไปสู่เป้าหมาย จึงไม่ควรเกินค่าเป้าหมาย ระบบจะจำกัดค่าให้เท่ากับเป้าหมาย" +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "ความสูงเลเยอร์น้อยเกินไป จะถูกตั้งค่าเป็นค่าต่ำสุด (%g mm)" + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "ความสูงเลเยอร์อยู่นอกขีดจำกัดที่ตั้งไว้ใน การตั้งค่าเครื่องพิมพ์ -> ชุดดันเส้น -> การจำกัดความสูงของเลเยอร์ ซึ่งอาจทำให้เกิดปัญหาคุณภาพการพิมพ์" + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "ปรับเป็นค่าขีดจำกัด (%g mm) โดยอัตโนมัติหรือไม่?" + +msgid "Adjust" +msgstr "ปรับ" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4840,6 +4857,13 @@ msgstr "" "ใช่ - เปิดใช้งาน Arachne Wall Generator\n" "ไม่ - ปิดการใช้งาน Arachne Wall Generator และตั้งค่าโหมด [Displacement] ของ Fuzzy Skin" +# AI Translated +msgid "Brim ear radius" +msgstr "รัศมีของหูขอบยึดชิ้นงาน" + +msgid "Brim width" +msgstr "ความกว้าง ขอบยึดชิ้นงาน" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "โหมดเกลียวจะทำงานเฉพาะเมื่อลูปติดผนังเป็น 1, ปิดใช้งานส่วนรองรับ, การตรวจจับการจับตัวเป็นก้อนโดยการตรวจวัดถูกปิดใช้งาน, ชั้นเปลือกด้านบนเป็น 0, ความหนาแน่นของไส้ในแบบกระจายเป็น 0 และประเภทไทม์แลปส์เป็นแบบดั้งเดิม" @@ -5094,6 +5118,14 @@ msgstr "ไม่สามารถสร้าง cali G-code" msgid "Calibration error" msgstr "ข้อผิดพลาดในการสอบเทียบ" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "เครื่องพิมพ์นี้ไม่ได้ตั้งค่าฮาร์ดแวร์ที่ตัวควบคุมนี้ต้องการ" + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "ตัวควบคุมนี้ไม่รองรับบนเครื่องพิมพ์นี้" + # AI Translated msgid "Network unavailable" msgstr "เครือข่ายไม่พร้อมใช้งาน" @@ -5952,7 +5984,7 @@ msgstr "ปริมาณ:" msgid "Size:" msgstr "ขนาด:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "พบความขัดแย้งของเส้นทางรหัส G ที่เลเยอร์ %d, Z = %.2lfmm โปรดแยกวัตถุที่ขัดแย้งกันให้ไกลออกไป (%s <-> %s)" @@ -6133,6 +6165,10 @@ msgstr "หลายอุปกรณ์" msgid "Project" msgstr "โปรเจกต์" +# AI Translated +msgid "Device (Web)" +msgstr "อุปกรณ์ (เว็บ)" + msgid "Yes" msgstr "ใช่" @@ -8199,19 +8235,19 @@ msgstr "ไม่ได้เลือกไดเรกทอรีสำหร msgid "Replaced with 3D files from directory:\n" msgstr "แทนที่ด้วยไฟล์ 3D จากไดเรกทอรี:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ ข้าม %s: ไฟล์เดียวกัน\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ ข้าม %s: ไม่มีไฟล์อยู่\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ ข้าม %s: ไม่สามารถแทนที่ได้\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔แทนที่ %s\n" @@ -8945,6 +8981,18 @@ msgstr "เมื่อเปิดใช้งานตัวเลือกน msgid "Pop up to select filament grouping mode" msgstr "ปรากฏขึ้นเพื่อเลือกโหมดการจัดกลุ่มเส้นพลาสติก" +# AI Translated +msgid "Visible plugin pages" +msgstr "หน้าปลั๊กอินที่แสดง" + +# AI Translated +msgid "pages" +msgstr "หน้า" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "จำนวนหน้าปลั๊กอินที่แสดงเป็นแท็บถาวร ก่อนที่หน้าที่เหลือจะถูกยุบรวมเป็นเมนูแบบเลื่อนลงในแท็บสุดท้าย" + msgid "Behaviour" msgstr "พฤติกรรม" @@ -9299,6 +9347,18 @@ msgstr "แสดงค่าที่ตั้งไว้ล่วงหน้ msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "แสดงค่าที่ตั้งไว้ล่วงหน้าที่ไม่เข้ากันหรือไม่รองรับในรายการเลือกเครื่องพิมพ์และเส้นพลาสติก ไม่สามารถเลือกค่าที่ตั้งไว้ล่วงหน้าเหล่านี้ได้" +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(ทดลอง) ใช้เอเจนต์เครื่องพิมพ์แทนโฮสต์การพิมพ์" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"ส่งงานพิมพ์ของเครื่องพิมพ์ที่ไม่ใช่ Bambu ผ่านเอเจนต์ปลั๊กอินของเครื่องพิมพ์ แทนการอัพโหลดไปยังโฮสต์การพิมพ์แบบเดิม\n" +"เมื่อปิดใช้ OrcaSlicer จะใช้พฤติกรรมโฮสต์การพิมพ์แบบเดิม" + # AI Translated msgid "Experimental Features" msgstr "ฟีเจอร์ทดลอง" @@ -9563,9 +9623,25 @@ msgstr "พรีเซ็ตผู้ใช้" msgid "Preset Inside Project" msgstr "พรีเซ็ตภายในโปรเจ็กต์" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "คัดลอกค่าที่สืบทอดมาจากพรีเซ็ตแม่ทั้งหมดมาไว้ในพรีเซ็ตนี้ และตัดความสัมพันธ์กับพรีเซ็ตแม่ พรีเซ็ตที่เข้ากันได้กับพรีเซ็ตแม่เท่านั้นอาจไม่ได้รับการรองรับอีกต่อไป" + msgid "Detach from parent" msgstr "แยกออกจากพรีเซ็ตแม่" +# AI Translated +msgid "Unique preset" +msgstr "พรีเซ็ตอิสระ" + +# AI Translated +msgid "Parent preset" +msgstr "พรีเซ็ตแม่" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "พรีเซ็ตนี้ไม่ได้สืบทอดมาจากพรีเซ็ตอื่น" + msgid "Name is unavailable." msgstr "ชื่อไม่พร้อมใช้งาน" @@ -10305,22 +10381,6 @@ msgstr "คุณแน่ใจหรือไม่ว่าต้องกา msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "โดยทั่วไปรูปแบบไส้ในได้รับการออกแบบให้รองรับการหมุนโดยอัตโนมัติเพื่อให้แน่ใจว่าการพิมพ์ถูกต้องและบรรลุผลตามที่ต้องการ (เช่น Gyroid, ลูกบาศก์) การหมุนรูปแบบ ไส้ใน แบบกระจัดกระจายในปัจจุบันอาจทำให้ส่วนรองรับไม่เพียงพอ โปรดดำเนินการด้วยความระมัดระวังและตรวจสอบปัญหาการพิมพ์ที่อาจเกิดขึ้นอย่างละเอียด คุณแน่ใจหรือไม่ว่าต้องการเปิดใช้งานตัวเลือกนี้" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"ความสูงของเลเยอร์น้อยเกินไป\n" -"มันจะตั้งค่าเป็น min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "ความสูงของเลเยอร์เกินขีดจำกัดในการตั้งค่าเครื่องพิมพ์ -> ชุดดันเส้น -> ขีดจำกัดความสูงของเลเยอร์ ซึ่งอาจทำให้เกิดปัญหาคุณภาพการพิมพ์" - -msgid "Adjust to the set range automatically?\n" -msgstr "ปรับเป็นช่วงที่ตั้งไว้อัตโนมัติ?\n" - -msgid "Adjust" -msgstr "ปรับ" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "คุณลักษณะการทดลอง: การดึงกลับและตัดเส้นพลาสติกออกในระยะห่างที่มากขึ้นระหว่างการเปลี่ยนเส้นพลาสติกเพื่อลดการไล่เส้น แม้ว่าจะสามารถลดการไล่เส้นได้อย่างเห็นได้ชัด แต่ก็อาจเพิ่มความเสี่ยงของการอุดตันของหัวฉีดหรือภาวะแทรกซ้อนในการพิมพ์อื่นๆ อีกด้วย" @@ -10513,6 +10573,9 @@ msgstr "พบคีย์เวิร์ดที่สงวนไว้" msgid "Setting Overrides" msgstr "การตั้งค่าการแทนที่" +msgid "Retraction when switching material" +msgstr "การร่นกลับเมื่อเปลี่ยนวัสดุ" + msgid "Basic information" msgstr "ข้อมูลพื้นฐาน" @@ -10642,6 +10705,12 @@ msgstr "โปรไฟล์กระบวนการที่เข้าก msgid "Printable space" msgstr "พื้นที่ที่สามารถพิมพ์ได้" +msgid "Printer Agent" +msgstr "ตัวแทนเครื่องพิมพ์" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "เลือกการใช้งานตัวแทนเครือข่ายสำหรับการสื่อสารของเครื่องพิมพ์ ตัวแทนที่มีอยู่จะได้รับการลงทะเบียนเมื่อเริ่มต้น" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10767,9 +10836,6 @@ msgstr "การจำกัดความสูงของเลเยอร msgid "Z-Hop" msgstr "ยกแกน Z" -msgid "Retraction when switching material" -msgstr "การร่นกลับเมื่อเปลี่ยนวัสดุ" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12111,6 +12177,10 @@ msgstr "อยู่ใกล้เขตหวงห้ามมากเกิ msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr "อยู่ใกล้พื้นที่การตรวจจับการจับตัวกันมากเกินไป และจะเกิดการชนกัน\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr "อยู่นอกพื้นที่การพิมพ์บางส่วน จึงไม่สามารถพิมพ์ได้\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "อุณหภูมิหัวฉีดที่เลือกเข้ากันไม่ได้ อุณหภูมิหัวฉีดของเส้นพลาสติกแต่ละเส้นต้องอยู่ในช่วงอุณหภูมิหัวฉีดที่แนะนำของเส้นพลาสติกอื่นๆ มิฉะนั้นอาจเกิดการอุดตันของหัวฉีดหรือเครื่องพิมพ์เสียหายได้" @@ -12426,9 +12496,6 @@ msgstr "ใช้ 3MF แทน G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "เปิดใช้งานหากเครื่องพิมพ์รับไฟล์ 3MF เป็นงานพิมพ์ เมื่อเปิดใช้งาน OrcaSlicer จะส่งไฟล์ที่สไลซ์แล้วเป็น .gcode.3mf แทนไฟล์ .gcode ธรรมดา" -msgid "Printer Agent" -msgstr "ตัวแทนเครื่องพิมพ์" - msgid "Select the network agent implementation for printer communication." msgstr "เลือกการใช้งานตัวแทนเครือข่ายสำหรับการสื่อสารของเครื่องพิมพ์" @@ -13103,9 +13170,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "ความเร็วของสะพานภายใน หากค่าแสดงเป็นเปอร์เซ็นต์ ค่าดังกล่าวจะถูกคำนวณตาม bridge_speed ค่าเริ่มต้นคือ 150%" -msgid "Brim width" -msgstr "ความกว้าง ขอบยึดชิ้นงาน" - msgid "This is the distance from the model to the outermost brim line." msgstr "ระยะห่างจากแบบจำลองถึงเส้นขอบยึดชิ้นงานด้านนอกสุด" @@ -13185,6 +13249,14 @@ msgstr "" "รูปทรงจะถูกทำลายก่อนที่จะตรวจจับมุมแหลม พารามิเตอร์นี้ระบุความยาวขั้นต่ำของการเบี่ยงเบนสำหรับการทำลาย\n" "0 เพื่อปิดการใช้งาน" +# AI Translated +msgid "Brim ears outer only" +msgstr "หูขอบยึดชิ้นงานเฉพาะด้านนอก" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "สร้างหูหนูเฉพาะบนคอนทัวร์ด้านนอกของโมเดล โดยไม่รวมรูและส่วนที่ปิดล้อม" + msgid "upward compatible machine" msgstr "เครื่องที่รองรับขึ้นไป" @@ -14351,6 +14423,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "ไจรอยด์" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "ค่าความเรียบของไส้ในแบบโปร่ง" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "ควบคุมระดับความมนของมุมไส้ในแบบโปร่ง ค่า 0% จะคงเส้นทางเดิมที่เป็นมุมแหลม ส่วน 100% จะสร้างส่วนโค้งที่ใหญ่ที่สุดเท่าที่เป็นไปได้ระหว่างเส้นไส้ในที่อยู่ติดกัน" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "ความเร่งของไส้ในพื้นผิวด้านบน การใช้ค่าที่ต่ำกว่าอาจปรับปรุงคุณภาพพื้นผิวด้านบนได้" @@ -14893,6 +14973,14 @@ msgstr "เครื่องพิมพ์ G-code ชนิดใดที่ msgid "Klipper" msgstr "คลิปเปอร์" +# AI Translated +msgid "Skip G-code config block" +msgstr "ข้ามบล็อกการตั้งค่าใน G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "ไม่เขียน CONFIG_BLOCK (คู่คีย์/ค่าของการตั้งค่าโปรแกรมสไลซ์) ลงในไฟล์ G-code ซึ่งอาจช่วยได้กับเครื่องพิมพ์ที่เฟิร์มแวร์ขัดข้องเมื่ออ่านบรรทัดคอมเมนต์เหล่านี้ (เช่น Anycubic go-klipper) หมายเหตุ: ไฟล์ G-code จะไม่มีการตั้งค่าโปรแกรมสไลซ์อีกต่อไป ดังนั้นการนำเข้ากลับมาใน OrcaSlicer จะไม่คืนค่าการตั้งค่า" + msgid "Pellet Modded Printer" msgstr "เครื่องพิมพ์ Modded เม็ด" @@ -15945,6 +16033,14 @@ msgstr "การถอยกลับนานเมื่อเปลี่ย msgid "Retraction distance when extruder change" msgstr "ระยะการดึงกลับเมื่อชุดดันเส้นเปลี่ยน" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "ความยาวการดึงกลับ (การเปลี่ยนเครื่องมือ)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "เมื่อการดึงกลับทำงานก่อนการเปลี่ยนเครื่องมือ เส้นพลาสติกจะถูกดึงกลับตามระยะที่กำหนด (วัดความยาวบนเส้นพลาสติกดิบ ก่อนเข้าสู่ชุดดันเส้น)" + msgid "Z-hop height" msgstr "ความสูงยกแกน Z" @@ -16039,6 +16135,10 @@ msgstr "ความยาวพิเศษเมื่อรีสตาร์ msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "เมื่อชดเชยการดึงกลับหลังการเคลื่อนที่เดินทาง ชุดดันเส้นจะดันเส้นพลาสติกเพิ่มเติมในปริมาณนี้ การตั้งค่านี้ไม่ค่อยจำเป็น" +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "ความยาวพิเศษเมื่อรีสตาร์ท (การเปลี่ยนเครื่องมือ)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "เมื่อชดเชยการดึงกลับหลังเปลี่ยนเครื่องมือ ชุดดันเส้นจะดันเส้นพลาสติกเพิ่มเติมในปริมาณนี้" @@ -16451,6 +16551,14 @@ msgstr "การเปลี่ยนเครื่องมือบน Wipe msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "บังคับให้หัวเครื่องมือเคลื่อนที่ไปที่ Wipe Tower ก่อนที่จะออกคำสั่งเปลี่ยนเครื่องมือ (Tx) เกี่ยวข้องเฉพาะกับเครื่องพิมพ์ที่มีชุดดันเส้นหลายเครื่อง (หลายหัวเครื่องมือ) ที่ใช้แผ่นเช็ดแบบ Type 2 ตามค่าเริ่มต้น Orca จะข้ามการเดินทางบนเครื่องที่มีหัวเครื่องมือหลายหัวเนื่องจากเฟิร์มแวร์จัดการการสลับหัว ซึ่งอาจส่งผลให้มีการออกคำสั่ง Tx เหนือส่วนที่พิมพ์ เปิดใช้งานตัวเลือกนี้หากคุณต้องการให้ทำการเปลี่ยนแปลงเครื่องมือเหนือ Wipe Tower แทนเสมอ" +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "รอให้ถึงอุณหภูมิที่ Wipe Tower" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "รับเครื่องมือใหม่โดยไม่รอให้ถึงอุณหภูมิการพิมพ์ แล้วเคลื่อนที่ไปยัง Wipe Tower และรออุณหภูมิที่นั่นก่อนไล่เส้นทันที เส้นพลาสติกที่ซึมออกมาระหว่างการอุ่นจะตกลงบน Wipe Tower แทนที่จะตกบนโมเดล และการเคลื่อนที่จะเกิดขึ้นพร้อมกับการอุ่น ใช้ได้เฉพาะกับเครื่องพิมพ์แบบหลายชุดดันเส้น (หลายหัวพิมพ์) ที่ใช้ Wipe Tower ชนิดที่ 2 เฟิร์มแวร์หรือแมโครการเปลี่ยนเครื่องมือต้องไม่รออุณหภูมิเอง เมื่อปิดใช้ คำสั่งรออุณหภูมิจะถูกส่งทันทีหลังคำสั่งเปลี่ยนเครื่องมือ" + msgid "No sparse layers (beta)" msgstr "ไม่มีชั้นกระจัดกระจาย (เบต้า)" @@ -19681,9 +19789,6 @@ msgstr "เครื่องพิมพ์ทางกายภาพ" msgid "Print Host upload" msgstr "อัพโหลดโฮสต์การพิมพ์" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "เลือกการใช้งานตัวแทนเครือข่ายสำหรับการสื่อสารของเครื่องพิมพ์ ตัวแทนที่มีอยู่จะได้รับการลงทะเบียนเมื่อเริ่มต้น" - msgid "Select a Flashforge printer" msgstr "เลือกเครื่องพิมพ์ Flashforge" @@ -20575,9 +20680,6 @@ msgstr "เกิดสิ่งที่ไม่คาดคิดขณะพ msgid "User canceled." msgstr "ผู้ใช้ยกเลิก" -msgid "Head diameter" -msgstr "เส้นผ่านศูนย์กลางหัว" - msgid "Max angle" msgstr "มุมสูงสุด" @@ -21361,6 +21463,22 @@ msgstr "" "หลีกเลี่ยงการบิดเบี้ยว\n" "คุณรู้หรือไม่ว่าเมื่อพิมพ์วัสดุที่มีแนวโน้มที่จะเกิดการบิดเบี้ยว เช่น ABS การเพิ่มอุณหภูมิฐานพิมพ์อย่างเหมาะสมสามารถลดความน่าจะเป็นของการบิดเบี้ยวได้" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "ความสูงของเลเยอร์น้อยเกินไป\n" +#~ "มันจะตั้งค่าเป็น min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "ความสูงของเลเยอร์เกินขีดจำกัดในการตั้งค่าเครื่องพิมพ์ -> ชุดดันเส้น -> ขีดจำกัดความสูงของเลเยอร์ ซึ่งอาจทำให้เกิดปัญหาคุณภาพการพิมพ์" + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "ปรับเป็นช่วงที่ตั้งไว้อัตโนมัติ?\n" + +#~ msgid "Head diameter" +#~ msgstr "เส้นผ่านศูนย์กลางหัว" + #~ msgid "Print order within a single layer." #~ msgstr "สั่งพิมพ์ภายในชั้นเดียว" diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 63d1e5bc75..a31d2216f4 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-08-04 19:36+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" @@ -4808,6 +4808,23 @@ msgstr "Mevcut hazne sıcaklığı malzemenin güvenli sıcaklığından yüksek msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Minimum oda sıcaklığı (%d℃), hedef oda sıcaklığından (%d℃) yüksek. Minimum değer, oda hedefe doğru ısınmaya devam ederken baskının başladığı eşiktir; bu nedenle hedefi aşmamalıdır. Değer hedefe sınırlandırılacak." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Katman yüksekliği çok küçük. Minimum değere (%g mm) ayarlanacak." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Katman yüksekliği, Yazıcı Ayarları -> Ekstruder -> Katman yüksekliği sınırları bölümünde ayarlanan sınırların dışında, bu durum baskı kalitesi sorunlarına neden olabilir." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Otomatik olarak sınır değerine (%g mm) ayarlansın mı?" + +msgid "Adjust" +msgstr "Ayarla" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4928,6 +4945,13 @@ msgstr "" "Evet - Arachne Duvarı Oluşturucusunu Etkinleştir\n" "Hayır - Arachne Duvarı Oluşturucusunu Devre Dışı Bırak ve Pütürlü Yüzey [Yer Değiştirme] modunu ayarla" +# AI Translated +msgid "Brim ear radius" +msgstr "Kenar kulak yarıçapı" + +msgid "Brim width" +msgstr "Kenar genişliği" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Spiral mod yalnızca duvar döngüleri 1 olduğunda, destek devre dışı bırakıldığında, problama yoluyla topaklanma tespiti devre dışı bırakıldığında, üst kabuk katmanları 0 olduğunda, seyrek dolgu yoğunluğu 0 olduğunda ve hızlandırılmış tip geleneksel olduğunda çalışır." @@ -5182,6 +5206,14 @@ msgstr "Cali G-code oluşturma başarısız oldu" msgid "Calibration error" msgstr "Kalibrasyon hatası" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Bu yazıcı, bu denetimin ihtiyaç duyduğu donanımla yapılandırılmamış." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Bu denetim bu yazıcıda desteklenmiyor." + # AI Translated msgid "Network unavailable" msgstr "Ağ kullanılamıyor" @@ -6045,7 +6077,7 @@ msgstr "Hacim:" msgid "Size:" msgstr "Boyut:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "%d katmanında gcode yollarında çakışmalar bulundu, Z = %.2lfmm. Lütfen çakışan nesneleri daha uzağa ayırın (%s <-> %s)." @@ -6227,6 +6259,10 @@ msgstr "Çoklu cihaz" msgid "Project" msgstr "Proje" +# AI Translated +msgid "Device (Web)" +msgstr "Cihaz (Web)" + msgid "Yes" msgstr "Evet" @@ -8324,19 +8360,19 @@ msgstr "Değiştirme için dizin seçilmedi" msgid "Replaced with 3D files from directory:\n" msgstr "Dizindeki 3D dosyalarla değiştirildi:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ %s atlandı: aynı dosya.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ %s atlandı: dosya mevcut değil.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ %s atlandı: değiştirilemedi.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ %s değiştirildi.\n" @@ -9076,6 +9112,18 @@ msgstr "Bu seçenek etkinleştirildiğinde, aynı anda birden fazla cihaza bir g msgid "Pop up to select filament grouping mode" msgstr "Filament gruplama modunu seçmek için açılır pencere" +# AI Translated +msgid "Visible plugin pages" +msgstr "Görünür eklenti sayfaları" + +# AI Translated +msgid "pages" +msgstr "sayfa" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Kalan sayfalar son sekmedeki açılır listeye toplanmadan önce sabit sekme olarak gösterilen eklenti sayfalarının sayısı." + msgid "Behaviour" msgstr "Davranış" @@ -9466,6 +9514,18 @@ msgstr "Desteklenmeyen ön ayarları göster" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Yazıcı ve filament açılır listelerinde uyumsuz/desteklenmeyen ön ayarları gösterir. Bu ön ayarlar seçilemez." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Deneysel) Baskı sunucuları yerine yazıcı aracılarını kullan" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Bambu olmayan yazıcıların baskı işlerini, klasik baskı sunucusuna yükleme akışı yerine yazıcı eklenti aracıları üzerinden yönlendirir.\n" +"Devre dışı bırakıldığında OrcaSlicer eski baskı sunucusu davranışını kullanır." + # AI Translated msgid "Experimental Features" msgstr "Deneysel Özellikler" @@ -9733,9 +9793,25 @@ msgstr "Kullanıcı Ön Ayarı" msgid "Preset Inside Project" msgstr "Ön ayar içerisinde proje" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Üst ön ayardan devralınan tüm değerleri bu ön ayara kopyalar ve üst ön ayarla olan ilişkiyi kaldırır. Yalnızca üst ön ayarla uyumlu olan ön ayarlar desteklenmeyebilir." + msgid "Detach from parent" msgstr "Ebeveynden ayrıl" +# AI Translated +msgid "Unique preset" +msgstr "Bağımsız ön ayar" + +# AI Translated +msgid "Parent preset" +msgstr "Üst ön ayar" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Bu ön ayar başka bir ön ayardan devralmıyor." + msgid "Name is unavailable." msgstr "Ad kullanılamıyor." @@ -10485,22 +10561,6 @@ msgstr "Bu seçeneği etkinleştirmek istediğinizden emin misiniz?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Dolgu desenleri genellikle, doğru baskı alınmasını ve istenen etkilerin (ör. Gyroid, Kübik) elde edilmesini sağlamak için döndürme işlemini otomatik olarak yapacak şekilde tasarlanmıştır. Mevcut seyrek dolgu desenini döndürmek, yetersiz destekle sonuçlanabilir. Lütfen dikkatli ilerleyin ve olası baskı sorunlarını iyice kontrol edin. Bu seçeneği etkinleştirmek istediğinizden emin misiniz?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Katman yüksekliği çok küçük.\n" -"min_layer_height olarak ayarlanacak\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Katman yüksekliği, Yazıcı Ayarları -> Ekstruder -> Katman yüksekliği sınırları bölümündeki sınırı aşıyor bu durum baskı kalitesi sorunlarına neden olabilir." - -msgid "Adjust to the set range automatically?\n" -msgstr "Ayarlanan aralığa otomatik olarak ayarlansın mı?\n" - -msgid "Adjust" -msgstr "Ayarla" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Deneysel özellik: Filament değişiklikleri sırasında, floşu en aza indirmek için filamenti daha büyük bir mesafeden geri çekmek ve kesmek. Flush’u önemli ölçüde azaltabilmesine rağmen, aynı zamanda nozul tıkanmaları veya diğer yazdırma komplikasyonları riskini de artırabilir." @@ -10699,6 +10759,9 @@ msgstr "Ayrılmış anahtar kelimeler bulundu" msgid "Setting Overrides" msgstr "Ayarların Üzerine Yaz" +msgid "Retraction when switching material" +msgstr "Malzemeyi Değiştirirken Geri Çekme" + msgid "Basic information" msgstr "Temel Bilgiler" @@ -10832,6 +10895,12 @@ msgstr "Uyumlu süreç profilleri" msgid "Printable space" msgstr "Plaka Ayarı" +msgid "Printer Agent" +msgstr "Yazıcı Aracısı" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Yazıcı iletişimi için ağ aracısı uygulamasını seçin. Kullanılabilir aracılar başlangıçta kaydedilir." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10962,9 +11031,6 @@ msgstr "Katman Yüksekliği Sınırları" msgid "Z-Hop" msgstr "Z Sıçraması" -msgid "Retraction when switching material" -msgstr "Malzemeyi Değiştirirken Geri Çekme" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12342,6 +12408,10 @@ msgstr " dışlama alanına çok yakın ve çarpışmalara neden olacak.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " topaklanma algılama alanına çok yakın, çarpışmalar meydana gelecektir.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " yazdırılabilir alanın kısmen dışında ve yazdırılamaz.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Seçilen nozul sıcaklıkları uyumsuz. Her filamentin nozul sıcaklığı, diğer filamentlerin önerilen nozul sıcaklığı aralığında olmalıdır. Aksi hâlde nozul tıkanması veya yazıcıda hasar oluşabilir." @@ -12677,9 +12747,6 @@ msgstr "G-code yerine 3MF kullan" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Yazıcı, baskı işi olarak 3MF dosyası kabul ediyorsa bunu etkinleştirin. Etkinleştirildiğinde Orca Slicer, dilimlenmiş dosyayı düz bir .gcode dosyası yerine .gcode.3mf olarak gönderir." -msgid "Printer Agent" -msgstr "Yazıcı Aracısı" - msgid "Select the network agent implementation for printer communication." msgstr "Yazıcı iletişimi için ağ aracısı uygulamasını seçin." @@ -13362,9 +13429,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "İç köprülerin hızı. Değer yüzde olarak ifade edilirse köprü hızına göre hesaplanacaktır. Varsayılan değer %150’dir." -msgid "Brim width" -msgstr "Kenar genişliği" - msgid "This is the distance from the model to the outermost brim line." msgstr "Modelden en dış kenar çizgisine kadar olan mesafe." @@ -13449,6 +13513,14 @@ msgstr "" "Keskin açılar algılanmadan önce geometri azaltılacaktır. Bu parametre, azaltma için minimum sapma uzunluğunu belirtir.\n" "Devre dışı bırakmak için 0." +# AI Translated +msgid "Brim ears outer only" +msgstr "Kenar kulakları yalnızca dışta" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Fare kulaklarını yalnızca modelin dış konturunda oluşturur, delikleri ve kapalı bölümleri hariç tutar." + msgid "upward compatible machine" msgstr "yukarı doğru uyumlu makine" @@ -14633,6 +14705,14 @@ msgstr "Tpms-fk" msgid "Gyroid" msgstr "Jiroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Dolgu yumuşatma faktörü" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Dolgu köşelerinin ne kadar yuvarlatılacağını belirler. 0% özgün keskin yolu korur, 100% ise komşu dolgu çizgileri arasında mümkün olan en büyük eğrileri üretir." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Üst yüzey dolgusunun hızlandırılması. Daha düşük bir değerin kullanılması üst yüzey kalitesini iyileştirebilir." @@ -15191,6 +15271,14 @@ msgstr "Yazıcının ne tür bir gcode ile uyumlu olduğu." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "G-code yapılandırma bloğunu atla" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "CONFIG_BLOCK bloğunu (dilimleyici yapılandırmasının anahtar/değer çiftlerini) G-code dosyasına yazmaz. Bu, bu yorum satırlarını ayrıştırırken donanım yazılımı çöken yazıcılarda yardımcı olabilir (ör. Anycubic go-klipper). Not: G-code dosyası artık dilimleyici ayarlarını içermeyeceğinden, dosyayı OrcaSlicer'a geri aktarmak yapılandırmayı geri yüklemez." + msgid "Pellet Modded Printer" msgstr "Pelet modlu yazıcı" @@ -16278,6 +16366,14 @@ msgstr "Ekstruder değiştiğinde uzun geri çekilme" msgid "Retraction distance when extruder change" msgstr "Ekstruder değiştiğinde geri çekilme mesafesi" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Geri çekme uzunluğu (Takım değişimi)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Takım değişiminden önce geri çekme tetiklendiğinde, filament belirtilen miktarda geri çekilir (uzunluk, ekstrudere girmeden önce ham filament üzerinde ölçülür)." + msgid "Z-hop height" msgstr "Z-Sıçrama yüksekliği" @@ -16377,6 +16473,10 @@ msgstr "Yeniden başlatma sırasında ekstra uzunluk" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "İlerleme hareketinden sonra geri çekilme telafi edildiğinde, ekstruder bu ek filament miktarını itecektir. Bu ayara nadiren ihtiyaç duyulur." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Yeniden başlatma sırasında ekstra uzunluk (Takım değişimi)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Takım değiştirildikten sonra geri çekilme telafi edildiğinde, ekstruder bu ilave filament miktarını itecektir." @@ -16794,6 +16894,14 @@ msgstr "Silme kulesinde takım değişimi" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Takım değişimi komutu (Tx) verilmeden önce baskı kafasını silme kulesine gitmeye zorlar. Yalnızca Tip 2 silme kulesi kullanan çok ekstruderli (çok baskı kafalı) yazıcılar için geçerlidir. Orca, çok baskı kafalı makinelerde bu seyahati varsayılan olarak atlar çünkü kafa değişimini ürün yazılımı yönetir; bu da Tx komutunun yazdırılan parçanın üzerinde verilmesine yol açabilir. Takım değişiminin her zaman silme kulesinin üzerinde verilmesini istiyorsanız bu seçeneği etkinleştirin." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Silme kulesinde sıcaklığı bekle" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Yeni takımı baskı sıcaklığına ulaşmasını beklemeden alır, silme kulesine gider ve sıcaklığı orada, yıkamadan hemen önce bekler. Isınma sırasında sızan malzeme modele değil kuleye düşer ve hareket ısınmayla çakışır. Yalnızca 2. tip silme kulesi kullanan çok ekstruderli (çok baskı kafalı) yazıcılar için geçerlidir. Donanım yazılımı veya takım değişimi makrosu sıcaklığı kendisi beklememelidir. Devre dışı bırakıldığında, sıcaklık bekleme komutu takım değişimi komutundan hemen sonra verilir." + msgid "No sparse layers (beta)" msgstr "Seyrek katman yok (beta)" @@ -20078,9 +20186,6 @@ msgstr "Fiziksel Yazıcı" msgid "Print Host upload" msgstr "Yazıcı Bağlantı Ayarları" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Yazıcı iletişimi için ağ aracısı uygulamasını seçin. Kullanılabilir aracılar başlangıçta kaydedilir." - # AI Translated msgid "Select a Flashforge printer" msgstr "Bir Flashforge yazıcısı seçin" @@ -21022,9 +21127,6 @@ msgstr "Giriş yapmaya çalışırken beklenmeyen bir şey oldu, lütfen tekrar msgid "User canceled." msgstr "Kullanıcı iptal edildi." -msgid "Head diameter" -msgstr "Kafa çapı" - msgid "Max angle" msgstr "Maksimum açı" @@ -21753,7 +21855,8 @@ msgstr "" "Baskılarınızı plakalara ayırın\n" "Çok sayıda parçası olan bir modeli baskıya hazır ayrı kalıplara bölebileceğinizi biliyor muydunuz? Bu, tüm parçaları takip etme sürecini basitleştirecektir." -#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer Height] +#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer +#: Height] msgid "" "Speed up your print with Adaptive Layer Height\n" "Did you know that you can print a model even faster by using the Adaptive Layer Height option? Check it out!" @@ -21826,7 +21929,8 @@ msgstr "" "Gücü artırın\n" "Modelin gücünü artırmak için daha fazla duvar halkası ve daha yüksek seyrek dolgu yoğunluğu kullanabileceğinizi biliyor muydunuz?" -#: resources/data/hints.ini: [hint:When do you need to print with the printer door opened] +#: resources/data/hints.ini: [hint:When do you need to print with the printer +#: door opened] msgid "" "When do you need to print with the printer door opened?\n" "Did you know that opening the printer door can reduce the probability of extruder/hotend clogging when printing lower temperature filament with a higher enclosure temperature? There is more info about this in the Wiki." @@ -21842,6 +21946,22 @@ msgstr "" "Eğilmeyi önleyin\n" "ABS gibi bükülmeye yatkın malzemelere baskı yaparken, ısıtma yatağı sıcaklığının uygun şekilde arttırılmasının bükülme olasılığını azaltabileceğini biliyor muydunuz?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Katman yüksekliği çok küçük.\n" +#~ "min_layer_height olarak ayarlanacak\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Katman yüksekliği, Yazıcı Ayarları -> Ekstruder -> Katman yüksekliği sınırları bölümündeki sınırı aşıyor bu durum baskı kalitesi sorunlarına neden olabilir." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Ayarlanan aralığa otomatik olarak ayarlansın mı?\n" + +#~ msgid "Head diameter" +#~ msgstr "Kafa çapı" + #~ msgid "Print order within a single layer." #~ msgstr "Tek bir katmanda yazdırma sırası." diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 9204a67ec3..4c3cc1d56c 100644 --- a/localization/i18n/uk/OrcaSlicer_uk.po +++ b/localization/i18n/uk/OrcaSlicer_uk.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: orcaslicerua\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-07-17 16:25+0300\n" "Last-Translator: Andrij Mizyk \n" "Language-Team: Ukrainian\n" @@ -4716,6 +4716,23 @@ msgstr "Поточна температура камери вища, ніж бе msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Мінімальна температура камери (%d℃) вища за цільову температуру камери (%d℃). Мінімальне значення — це поріг, за якого починається друк, поки камера продовжує нагріватися до цільової температури, тому воно не повинно її перевищувати. Значення буде обмежено цільовим." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Висота шару занадто мала. Буде встановлено мінімальне значення (%g мм)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Висота шару виходить за межі, задані в Налаштуваннях принтера -> Екструдер -> Ліміти висоти шару, це може призвести до проблем з якістю друку." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Автоматично налаштувати до межі (%g мм)?" + +msgid "Adjust" +msgstr "Налаштувати" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4839,6 +4856,13 @@ msgstr "" "Так - Увімкнути генератор стінок Arachne\n" "Ні - Вимкнути генератор стінок Arachne і встановити режим [Зміщення] для шорсткої поверхні" +# AI Translated +msgid "Brim ear radius" +msgstr "Радіус вушка кайми" + +msgid "Brim width" +msgstr "Ширина кайми" + # AI Translated msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Спіральний режим працює лише тоді, коли кількість стінок дорівнює 1, підтримки вимкнено, виявлення налипання зондуванням вимкнено, кількість верхніх шарів оболонки дорівнює 0, щільність часткового заповнення дорівнює 0, а тип таймлапсу — традиційний." @@ -5104,6 +5128,14 @@ msgstr "Не вдалося згенерувати калібрувальний msgid "Calibration error" msgstr "Помилка калібрування" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "На цьому принтері не налаштовано обладнання, потрібне для цього елемента керування." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Цей елемент керування не підтримується на цьому принтері." + # AI Translated msgid "Network unavailable" msgstr "Мережа недоступна" @@ -5978,7 +6010,7 @@ msgid "Size:" msgstr "Розмір:" # AI Translated -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Виявлено конфлікти шляхів G-коду на шарі %d, Z = %.2lf мм. Будь ласка, рознесіть конфліктуючі обʼєкти далі один від одного (%s <-> %s)." @@ -6170,6 +6202,10 @@ msgstr "Багато пристроїв" msgid "Project" msgstr "Проєкт" +# AI Translated +msgid "Device (Web)" +msgstr "Пристрій (Веб)" + msgid "Yes" msgstr "Так" @@ -8306,19 +8342,19 @@ msgstr "Каталог для заміни не вибрано" msgid "Replaced with 3D files from directory:\n" msgstr "Замінено 3D-файлами з каталогу:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Пропущено %s: той самий файл.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Пропущено %s: файл не існує.\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Пропущено %s: не вдалося замінити.\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Замінено %s.\n" @@ -9069,6 +9105,18 @@ msgstr "З цією опцією ввімкненою, ви можете від msgid "Pop up to select filament grouping mode" msgstr "Показувати вікно вибору режиму групування філаментів" +# AI Translated +msgid "Visible plugin pages" +msgstr "Видимі сторінки плагінів" + +# AI Translated +msgid "pages" +msgstr "стор." + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Кількість сторінок плагінів, що показуються як закріплені вкладки, перш ніж решта сторінок згорнеться у випадний список на останній вкладці." + msgid "Behaviour" msgstr "Поведінка" @@ -9446,6 +9494,18 @@ msgstr "Показати непідтримувані пресети" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Показати несумісні/непідтримувані пресети у випадаючому списку принтера і філаменту. Ці пресети не можна вибрати." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Експериментально) Використовувати агентів принтера замість хостів друку" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Спрямовує завдання друку для принтерів, відмінних від Bambu, через агентів плагінів принтера замість класичного завантаження на хост друку.\n" +"Коли вимкнено, OrcaSlicer використовує попередню поведінку хоста друку." + msgid "Experimental Features" msgstr "Експериментальні функції" @@ -9710,10 +9770,26 @@ msgstr "Пресети користувача" msgid "Preset Inside Project" msgstr "Налаштування проекту всередині" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Копіює в цей пресет усі значення, успадковані від батьківського пресета, і видаляє звʼязок успадкування. Пресети, сумісні лише з батьківським, можуть стати непідтримуваними." + # AI Translated msgid "Detach from parent" msgstr "Відʼєднати від батьківського" +# AI Translated +msgid "Unique preset" +msgstr "Незалежний пресет" + +# AI Translated +msgid "Parent preset" +msgstr "Батьківський пресет" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Цей пресет не успадковується від іншого пресета." + msgid "Name is unavailable." msgstr "Назва недоступна." @@ -10492,22 +10568,6 @@ msgstr "Ви впевнені, що хочете ввімкнути цю опц msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Шаблони заповнення зазвичай розроблені так, щоб автоматично враховувати обертання, забезпечувати належний друк і досягати задуманого ефекту (наприклад, Гіроїд, Кубічний). Обертання поточного шаблону часткового заповнення може призвести до недостатньої підтримки. Дійте обережно та ретельно перевіряйте можливі проблеми друку. Ви впевнені, що хочете увімкнути цю опцію?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Висота шару занадто мала.\n" -"Буде встановлено значення min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Висота шару перевищує ліміт у Налаштуваннях принтера -> Екструдер -> Ліміти висоти шару, це може призвести до проблем з якістю друку." - -msgid "Adjust to the set range automatically?\n" -msgstr "Автоматично налаштувати на встановлений діапазон?\n" - -msgid "Adjust" -msgstr "Налаштувати" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Експериментальна функція: Втягування та відрізання філаменту на більшій відстані під час зміни філаменту для мінімізації промивання. Хоча це може помітно зменшити промивання, це також може підвищити ризик засмічення сопла або інших ускладнень друку." @@ -10711,6 +10771,9 @@ msgstr "Знайдено зарезервовані ключові слова" msgid "Setting Overrides" msgstr "Налаштування перевизначень" +msgid "Retraction when switching material" +msgstr "Втягування під час зміни матеріалу" + msgid "Basic information" msgstr "Базова інформація" @@ -10848,6 +10911,13 @@ msgstr "Сумісні профілі процесів" msgid "Printable space" msgstr "Місце для друку" +msgid "Printer Agent" +msgstr "Агент принтера" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Виберіть реалізацію мережевого агента для звʼязку з принтером. Доступні агенти реєструються під час запуску." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10978,9 +11048,6 @@ msgstr "Обмеження висоти шару" msgid "Z-Hop" msgstr "Стрибок-Z" -msgid "Retraction when switching material" -msgstr "Втягування під час зміни матеріалу" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12376,6 +12443,10 @@ msgstr " знаходиться надто близько до зони відч msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " розташовано занадто близько до зони виявлення налипання, і це спричинить зіткнення.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " частково знаходиться за межами області друку, і його неможливо надрукувати.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Вибрані температури сопла несумісні. Температура сопла кожного філаменту має входити в рекомендований діапазон температур сопла інших філаментів. Інакше можливе засмічення сопла або пошкодження принтера." @@ -12722,9 +12793,6 @@ msgstr "Використовувати 3MF замість G-коду" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Увімкніть, якщо принтер приймає файл 3MF як завдання друку. Якщо увімкнено, Orca Slicer надсилає нарізаний файл як .gcode.3mf замість звичайного файлу .gcode." -msgid "Printer Agent" -msgstr "Агент принтера" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Виберіть реалізацію мережевого агента для звʼязку з принтером." @@ -13438,9 +13506,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Швидкість внутрішніх мостів. Якщо значення вказано у відсотках, воно буде розраховане на основі bridge_speed. Значення за замовчуванням: 150%." -msgid "Brim width" -msgstr "Ширина кайми" - msgid "This is the distance from the model to the outermost brim line." msgstr "Відстань від моделі до останньої зовнішньої лінії кайми" @@ -13525,6 +13590,14 @@ msgstr "" "Геометрія буде оброблена перед детектуванням гострих кутів. Цей параметр вказує мінімальну довжину відхилення для обробки.\n" "0 для вимкнення" +# AI Translated +msgid "Brim ears outer only" +msgstr "Вушка кайми лише ззовні" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Створювати мишачі вушка лише на зовнішньому контурі моделі, за винятком отворів і замкнених ділянок." + msgid "upward compatible machine" msgstr "висхідна сумісна машина" @@ -14734,6 +14807,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Гіроїд" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Коефіцієнт згладжування часткового заповнення" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Визначає, наскільки сильно заокруглюються кути часткового заповнення. 0% зберігає початкову траєкторію з гострими кутами, а 100% створює максимально можливі заокруглення між сусідніми лініями заповнення." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Прискорення заповнення верхньої поверхні. Використання меншого значенняможе покращити якість верхньої поверхні" @@ -15300,6 +15381,14 @@ msgstr "З яким gcode сумісний принтер" msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Пропустити блок конфігурації G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Не записувати CONFIG_BLOCK (пари ключ/значення з конфігурацією слайсера) у файл G-code. Це може допомогти з принтерами, прошивка яких аварійно завершується під час розбору цих рядків коментарів (напр. Anycubic go-klipper). Примітка: файл G-code більше не міститиме налаштувань слайсера, тож зворотний імпорт до OrcaSlicer не відновить конфігурацію." + msgid "Pellet Modded Printer" msgstr "Принтер модифікований гранулами" @@ -16438,6 +16527,14 @@ msgstr "Довге втягування при зміні екструдера" msgid "Retraction distance when extruder change" msgstr "Відстань втягування при зміні екструдера" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Довжина втягування (Зміна інструменту)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Коли втягування спрацьовує перед зміною інструменту, філамент відтягується на вказану величину (довжина вимірюється на необробленому філаменті, до його входу в екструдер)." + msgid "Z-hop height" msgstr "Висота Z-підйому" @@ -16534,6 +16631,10 @@ msgstr "Додаткова довжина під час перезавантаж msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Коли втягування компенсується після переміщення, екструдер проштовхуєЦе додаткова кількість нитки. Ця установка рідко потрібна." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Додаткова довжина під час перезавантаження (Зміна інструменту)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Коли втягування компенсується після заміни інструменту, екструдерпроштовхує цю додаткову кількість нитки." @@ -16960,6 +17061,14 @@ msgstr "Зміна інструмента на вежі протирання" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Примусово переміщати головку до вежі протирання перед видачею команди зміни інструмента (Tx). Стосується лише багатоекструдерних (багатоінструментальних) принтерів з вежею протирання типу 2. Типово Orca пропускає це переміщення на багатоінструментальних машинах, оскільки заміну головки виконує прошивка, через що команда Tx може бути видана над надрукованою деталлю. Увімкніть цю опцію, якщо хочете, щоб зміна інструмента завжди відбувалася над вежею протирання." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Очікувати температуру на вежі протирання" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Бере новий інструмент, не чекаючи, доки він досягне температури друку, переміщується до вежі протирання й чекає на температуру там, безпосередньо перед промивкою. Матеріал, що витікає під час нагрівання, потрапляє на вежу, а не на модель, а переміщення збігається з нагріванням. Актуально лише для принтерів із кількома екструдерами (кількома головками), які використовують вежу протирання типу 2. Прошивка або макрос зміни інструменту не повинні самі чекати на температуру. Коли вимкнено, команда очікування температури видається одразу після команди зміни інструменту." + msgid "No sparse layers (beta)" msgstr "Без розріджених шарів (бета)" @@ -20304,10 +20413,6 @@ msgstr "Фізичний принтер" msgid "Print Host upload" msgstr "Завантаження хоста друку" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Виберіть реалізацію мережевого агента для звʼязку з принтером. Доступні агенти реєструються під час запуску." - msgid "Select a Flashforge printer" msgstr "Вибрати принтер Flashforge" @@ -21181,9 +21286,6 @@ msgstr "Під час спроби входу трапилося щось нес msgid "User canceled." msgstr "Користувача скасовано." -msgid "Head diameter" -msgstr "Діаметр голови" - msgid "Max angle" msgstr "Максимальний кут" @@ -21979,6 +22081,22 @@ msgstr "" "Уникнення деформації\n" "Чи знаєте ви, що при друку матеріалами, схильними до деформації, такими як ABS, відповідне підвищення температури столу може зменшити ймовірність деформації?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Висота шару занадто мала.\n" +#~ "Буде встановлено значення min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Висота шару перевищує ліміт у Налаштуваннях принтера -> Екструдер -> Ліміти висоти шару, це може призвести до проблем з якістю друку." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Автоматично налаштувати на встановлений діапазон?\n" + +#~ msgid "Head diameter" +#~ msgstr "Діаметр голови" + #~ msgid "Print order within a single layer." #~ msgstr "Друк замовлення в один шар" diff --git a/localization/i18n/vi/OrcaSlicer_vi.po b/localization/i18n/vi/OrcaSlicer_vi.po index e6e7adf43d..00a9a558ba 100644 --- a/localization/i18n/vi/OrcaSlicer_vi.po +++ b/localization/i18n/vi/OrcaSlicer_vi.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2025-10-02 17:43+0700\n" "Last-Translator: \n" "Language-Team: hainguyen.ts13@gmail.com\n" @@ -4975,6 +4975,23 @@ msgstr "Nhiệt độ buồng hiện tại cao hơn nhiệt độ an toàn của msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "Nhiệt độ buồng tối thiểu (%d℃) cao hơn nhiệt độ buồng mục tiêu (%d℃). Giá trị tối thiểu là ngưỡng để bắt đầu in trong khi buồng vẫn tiếp tục gia nhiệt tới mục tiêu, nên nó không được vượt quá giá trị mục tiêu. Nó sẽ được giới hạn về mức mục tiêu." +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "Chiều cao lớp quá nhỏ. Nó sẽ được đặt về giá trị tối thiểu (%g mm)." + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "Chiều cao lớp nằm ngoài giới hạn được đặt trong Cài đặt máy in -> Extruder -> Giới hạn chiều cao lớp, điều này có thể gây ra vấn đề chất lượng in." + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "Tự động điều chỉnh về giới hạn (%g mm)?" + +msgid "Adjust" +msgstr "Điều chỉnh" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -5095,6 +5112,13 @@ msgstr "" "Yes - Bật trình tạo wall Arachne\n" "No - Tắt trình tạo wall Arachne và đặt chế độ [Displacement] của Fuzzy Skin" +# AI Translated +msgid "Brim ear radius" +msgstr "Bán kính tai brim" + +msgid "Brim width" +msgstr "Độ rộng brim" + # AI Translated msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "Chế độ xoắn ốc chỉ hoạt động khi vòng wall bằng 1, support bị tắt, phát hiện vón cục bằng dò bị tắt, số lớp vỏ trên bằng 0, mật độ infill thưa bằng 0 và loại timelapse là truyền thống." @@ -5399,6 +5423,14 @@ msgstr "Không thể tạo G-code hiệu chỉnh" msgid "Calibration error" msgstr "Lỗi hiệu chỉnh" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "Máy in này không được cấu hình phần cứng mà điều khiển này cần." + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "Điều khiển này không được hỗ trợ trên máy in này." + # AI Translated msgid "Network unavailable" msgstr "Mạng không khả dụng" @@ -6317,7 +6349,7 @@ msgstr "Thể tích:" msgid "Size:" msgstr "Kích thước:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "Đã tìm thấy xung đột đường đi G-code tại lớp %d, Z = %.2lfmm. Vui lòng tách các vật thể xung đột ra xa hơn (%s <-> %s)." @@ -6516,6 +6548,10 @@ msgstr "Nhiều thiết bị" msgid "Project" msgstr "Dự án" +# AI Translated +msgid "Device (Web)" +msgstr "Thiết bị (Web)" + msgid "Yes" msgstr "Có" @@ -8721,22 +8757,22 @@ msgid "Replaced with 3D files from directory:\n" msgstr "Đã thay thế bằng file 3D từ thư mục:\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ Đã bỏ qua %s: cùng một file.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ Đã bỏ qua %s: file không tồn tại.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ Đã bỏ qua %s: thay thế thất bại.\n" # AI Translated -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ Đã thay thế %s.\n" @@ -9532,6 +9568,18 @@ msgstr "Với tùy chọn này được bật, bạn có thể gửi tác vụ msgid "Pop up to select filament grouping mode" msgstr "Hiện cửa sổ để chọn chế độ nhóm filament" +# AI Translated +msgid "Visible plugin pages" +msgstr "Số trang plugin hiển thị" + +# AI Translated +msgid "pages" +msgstr "trang" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "Số trang plugin được hiển thị dưới dạng tab cố định trước khi các trang còn lại được gom vào danh sách thả xuống ở tab cuối cùng." + # AI Translated msgid "Behaviour" msgstr "Hành vi" @@ -9947,6 +9995,18 @@ msgstr "Hiện cài đặt sẵn không được hỗ trợ" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "Hiện các cài đặt sẵn không tương thích/không được hỗ trợ trong danh sách thả xuống máy in và filament. Không thể chọn các cài đặt sẵn này." +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(Thử nghiệm) Dùng tác nhân máy in thay cho máy chủ in" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"Định tuyến các tác vụ in của máy in không phải Bambu qua các tác nhân plugin máy in thay vì luồng tải lên máy chủ in cổ điển.\n" +"Khi tắt, OrcaSlicer sẽ dùng hành vi máy chủ in cũ." + # AI Translated msgid "Experimental Features" msgstr "Tính năng thử nghiệm" @@ -10223,10 +10283,26 @@ msgstr "Preset người dùng" msgid "Preset Inside Project" msgstr "Preset bên trong dự án" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "Sao chép tất cả các giá trị kế thừa từ preset cha vào preset này và gỡ bỏ quan hệ kế thừa. Các preset chỉ tương thích với preset cha có thể không còn được hỗ trợ." + # AI Translated msgid "Detach from parent" msgstr "Tách khỏi vật thể cha" +# AI Translated +msgid "Unique preset" +msgstr "Preset độc lập" + +# AI Translated +msgid "Parent preset" +msgstr "Preset cha" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "Preset này không kế thừa từ preset khác." + msgid "Name is unavailable." msgstr "Tên không khả dụng." @@ -11026,22 +11102,6 @@ msgstr "Bạn có chắc chắn muốn bật tùy chọn này?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "Mẫu infill thường được thiết kế để xử lý xoay tự động nhằm đảm bảo in đúng cách và đạt được hiệu quả dự kiến (ví dụ: Gyroid, Cubic). Xoay mẫu infill thưa hiện tại có thể dẫn đến support không đủ . Vui lòng tiến hành thận trọng và kiểm tra kỹ bất kỳ vấn đề in tiềm ẩn nào. Bạn có chắc chắn muốn bật tùy chọn này?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"Chiều cao lớp quá nhỏ.\n" -"Nó sẽ được đặt thành min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "Chiều cao lớp vượt quá giới hạn trong Cài đặt máy in -> Extruder -> Giới hạn chiều cao lớp, điều này có thể gây ra vấn đề chất lượng in." - -msgid "Adjust to the set range automatically?\n" -msgstr "Điều chỉnh về phạm vi đặt tự động?\n" - -msgid "Adjust" -msgstr "Điều chỉnh" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Tính năng thử nghiệm: Rút và cắt filament ở khoảng cách lớn hơn trong quá trình thay filament để giảm thiểu xả. Mặc dù có thể giảm đáng kể lượng xả, nó cũng có thể làm tăng nguy cơ tắc đầu phun hoặc các vấn đề in khác." @@ -11235,6 +11295,9 @@ msgstr "Tìm thấy từ khóa dành riêng" msgid "Setting Overrides" msgstr "Ghi đè cài đặt" +msgid "Retraction when switching material" +msgstr "Rút khi chuyển vật liệu" + msgid "Basic information" msgstr "Thông tin cơ bản" @@ -11366,6 +11429,14 @@ msgstr "Hồ sơ quy trình tương thích" msgid "Printable space" msgstr "Không gian in" +# AI Translated +msgid "Printer Agent" +msgstr "Tác nhân máy in" + +# AI Translated +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "Chọn cách triển khai tác nhân mạng cho việc giao tiếp với máy in. Các tác nhân khả dụng được đăng ký khi khởi động." + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -11498,9 +11569,6 @@ msgstr "Giới hạn chiều cao lớp" msgid "Z-Hop" msgstr "Z-Hop" -msgid "Retraction when switching material" -msgstr "Rút khi chuyển vật liệu" - # AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" @@ -12950,6 +13018,10 @@ msgstr " quá gần vùng loại trừ, và sẽ gây va chạm.\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " ở quá gần vùng phát hiện vón cục, và sẽ gây ra va chạm.\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr " nằm một phần ngoài vùng in được, và không thể in.\n" + # AI Translated msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "Nhiệt độ đầu phun đã chọn không tương thích. Nhiệt độ đầu phun của mỗi filament phải nằm trong dải nhiệt độ đầu phun được khuyến nghị của các filament còn lại. Nếu không, có thể xảy ra tắc đầu phun hoặc hư hỏng máy in." @@ -13291,10 +13363,6 @@ msgstr "Dùng 3MF thay cho G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "Bật tùy chọn này nếu máy in nhận file 3MF làm tác vụ in. Khi bật, Orca Slicer sẽ gửi file đã slice dưới dạng .gcode.3mf thay vì file .gcode thuần." -# AI Translated -msgid "Printer Agent" -msgstr "Tác nhân máy in" - # AI Translated msgid "Select the network agent implementation for printer communication." msgstr "Chọn cách triển khai tác nhân mạng cho việc giao tiếp với máy in." @@ -14002,9 +14070,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "Tốc độ của cầu bên trong. Nếu giá trị được biểu thị dưới dạng phần trăm, nó sẽ được tính dựa trên bridge_speed. Giá trị mặc định là 150%." -msgid "Brim width" -msgstr "Độ rộng brim" - msgid "This is the distance from the model to the outermost brim line." msgstr "Khoảng cách từ model đến đường brim ngoài cùng." @@ -14088,6 +14153,14 @@ msgstr "" "Hình học sẽ được giảm trước khi phát hiện góc sắc. Tham số này chỉ ra độ dài tối thiểu của độ lệch cho việc giảm.\n" "0 để vô hiệu hóa." +# AI Translated +msgid "Brim ears outer only" +msgstr "Tai brim chỉ ở mặt ngoài" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "Chỉ tạo tai chuột trên đường viền ngoài của mô hình, không tính các lỗ và phần khép kín." + msgid "upward compatible machine" msgstr "máy tương thích ngược" @@ -15305,6 +15378,14 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Gyroid" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "Hệ số làm mượt infill thưa" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "Điều chỉnh mức độ bo tròn các góc của infill thưa. 0% giữ nguyên đường đi sắc cạnh ban đầu, còn 100% tạo ra các đường cong lớn nhất có thể giữa các đường infill liền kề." + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "Gia tốc của infill bề mặt trên. Sử dụng giá trị thấp hơn có thể cải thiện chất lượng bề mặt trên." @@ -15868,6 +15949,14 @@ msgstr "Loại G-code mà máy in tương thích." msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "Bỏ qua khối cấu hình G-code" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "Không ghi CONFIG_BLOCK (các cặp khóa/giá trị cấu hình của phần mềm slice) vào tệp G-code. Điều này có thể hữu ích với các máy in có firmware bị treo khi phân tích những dòng chú thích này (ví dụ Anycubic go-klipper). Lưu ý: tệp G-code sẽ không còn chứa các thiết lập slice, nên việc nhập lại tệp vào OrcaSlicer sẽ không khôi phục được cấu hình." + msgid "Pellet Modded Printer" msgstr "Máy in Pellet đã chỉnh sửa" @@ -16971,6 +17060,14 @@ msgstr "Rút dài khi đổi extruder" msgid "Retraction distance when extruder change" msgstr "Khoảng cách rút khi đổi extruder" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "Độ dài rút (Đổi công cụ)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "Khi rút được kích hoạt trước khi đổi công cụ, filament sẽ bị kéo lùi lại theo lượng đã chỉ định (độ dài được đo trên filament thô, trước khi nó đi vào extruder)." + msgid "Z-hop height" msgstr "Chiều cao Z-hop" @@ -17069,6 +17166,10 @@ msgstr "Độ dài bổ sung khi khởi động lại" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "Khi rút được bù sau khi di chuyển, extruder sẽ đẩy lượng filament bổ sung này. Cài đặt này hiếm khi cần thiết." +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "Độ dài bổ sung khi khởi động lại (Đổi công cụ)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "Khi rút được bù sau khi thay công cụ, extruder sẽ đẩy lượng filament bổ sung này." @@ -17489,6 +17590,14 @@ msgstr "Đổi công cụ trên wipe tower" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "Buộc đầu công cụ di chuyển đến wipe tower trước khi phát lệnh đổi công cụ (Tx). Chỉ liên quan đến máy in nhiều extruder (nhiều đầu công cụ) dùng wipe tower Loại 2. Theo mặc định, Orca bỏ qua bước di chuyển này trên máy nhiều đầu công cụ vì firmware tự xử lý việc đổi đầu, điều này có thể khiến lệnh Tx được phát ra ngay phía trên phần đang in. Hãy bật tùy chọn này nếu bạn muốn việc đổi công cụ luôn diễn ra phía trên wipe tower." +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "Chờ nhiệt độ tại wipe tower" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "Lấy công cụ mới mà không chờ nó đạt nhiệt độ in, di chuyển đến wipe tower và chờ nhiệt độ tại đó, ngay trước khi xả. Nhựa chảy ra trong lúc gia nhiệt sẽ rơi lên wipe tower thay vì lên mô hình, và quãng di chuyển diễn ra đồng thời với quá trình gia nhiệt. Chỉ áp dụng cho máy in nhiều extruder (nhiều đầu công cụ) dùng wipe tower loại 2. Firmware hoặc macro đổi công cụ không được tự chờ nhiệt độ. Khi tắt, lệnh chờ nhiệt độ sẽ được phát ngay sau lệnh đổi công cụ." + msgid "No sparse layers (beta)" msgstr "Không có lớp thưa (beta)" @@ -20849,10 +20958,6 @@ msgstr "Máy in vật lý" msgid "Print Host upload" msgstr "Tải lên máy chủ in" -# AI Translated -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "Chọn cách triển khai tác nhân mạng cho việc giao tiếp với máy in. Các tác nhân khả dụng được đăng ký khi khởi động." - # AI Translated msgid "Select a Flashforge printer" msgstr "Chọn một máy in Flashforge" @@ -21832,9 +21937,6 @@ msgstr "Đã xảy ra điều gì đó không mong đợi khi cố gắng đăng msgid "User canceled." msgstr "Người dùng đã hủy." -msgid "Head diameter" -msgstr "Đường kính đầu" - msgid "Max angle" msgstr "Góc tối đa" @@ -22702,6 +22804,22 @@ msgstr "" "Tránh cong vênh\n" "Bạn có biết rằng khi in vật liệu dễ cong vênh như ABS, tăng nhiệt độ bàn nóng một cách thích hợp có thể giảm xác suất cong vênh không?" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "Chiều cao lớp quá nhỏ.\n" +#~ "Nó sẽ được đặt thành min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "Chiều cao lớp vượt quá giới hạn trong Cài đặt máy in -> Extruder -> Giới hạn chiều cao lớp, điều này có thể gây ra vấn đề chất lượng in." + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "Điều chỉnh về phạm vi đặt tự động?\n" + +#~ msgid "Head diameter" +#~ msgstr "Đường kính đầu" + #~ msgid "Print order within a single layer." #~ msgstr "Thứ tự in trong một lớp đơn." diff --git a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po index 345891f250..133e0815a4 100644 --- a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po +++ b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Slic3rPE\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2026-06-11 12:37-0300\n" "Last-Translator: Handle \n" "Language-Team: \n" @@ -4574,6 +4574,23 @@ msgstr "当前腔体温度高于材料的安全温度,这可能导致材料软 msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "最低机箱温度(%d℃)高于目标机箱温度(%d℃)。最低值是开始打印的阈值,此时机箱会持续朝目标温度加热,因此它不应超过目标值。该值将被限制到目标值。" +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "层高太小,将设置为最小值(%g mm)。" + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "层高超出了打印机设置 -> 挤出机 -> 层高限制中设置的范围,这可能导致打印质量问题。" + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "是否自动调整到限制值(%g mm)?" + +msgid "Adjust" +msgstr "调整" + # AI Translated msgid "" "Layer height too small\n" @@ -4696,6 +4713,13 @@ msgstr "" "是 - 启用Arachne墙生成器\n" "否 - 禁用Arachne墙生成器并将绒毛表面设置为[位移]模式" +# AI Translated +msgid "Brim ear radius" +msgstr "圆盘半径" + +msgid "Brim width" +msgstr "Brim宽度" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "螺旋模式仅在壁环为 1、支撑被禁用、探测结块检测被禁用、顶部壳层为 0、稀疏填充密度为 0 且延时类型为传统时才起作用。" @@ -4950,6 +4974,14 @@ msgstr "生成校准gcode失败" msgid "Calibration error" msgstr "校准错误" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "此打印机未配置该控件所需的硬件。" + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "此打印机不支持该控件。" + # AI Translated msgid "Network unavailable" msgstr "网络不可用" @@ -5807,7 +5839,7 @@ msgstr "体积:" msgid "Size:" msgstr "尺寸:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "发现G-code路径在层%d,高度为%.2lf mm处有冲突。请将有冲突的对象分离得更远(%s <-> %s)。" @@ -5988,6 +6020,10 @@ msgstr "多设备" msgid "Project" msgstr "项目" +# AI Translated +msgid "Device (Web)" +msgstr "设备(网页)" + msgid "Yes" msgstr "是" @@ -8028,19 +8064,19 @@ msgstr "未选择替换目录" msgid "Replaced with 3D files from directory:\n" msgstr "替换为目录中的 3D 文件:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ 跳过 %s:同一文件。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ 跳过%s:文件不存在。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ 跳过%s:替换失败。\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ 替换了 %s。\n" @@ -8767,6 +8803,18 @@ msgstr "启用此选项后,您可以同时向多个设备发送任务并管理 msgid "Pop up to select filament grouping mode" msgstr "弹出选择耗材丝分组模式" +# AI Translated +msgid "Visible plugin pages" +msgstr "可见插件页数" + +# AI Translated +msgid "pages" +msgstr "页" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "作为固定标签显示的插件页数量,其余页面将折叠到最后一个标签的下拉菜单中。" + msgid "Behaviour" msgstr "行为" @@ -9121,6 +9169,18 @@ msgstr "显示不受支持的预设" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "在打印机和耗材下拉列表中显示不兼容/不受支持的预设。这些预设无法被选择。" +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(实验性)使用打印机代理替代打印主机" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"非 Bambu 打印机的打印任务将通过打印机插件代理发送,而不是经典的打印主机上传流程。\n" +"禁用时,OrcaSlicer 使用旧的打印主机行为。" + # AI Translated msgid "Experimental Features" msgstr "实验性功能" @@ -9385,9 +9445,25 @@ msgstr "用户预设" msgid "Preset Inside Project" msgstr "项目预设" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "将父预设继承的所有数值复制到当前预设,并解除继承关系。仅与父预设兼容的预设可能会变为不受支持。" + msgid "Detach from parent" msgstr "与父级分离" +# AI Translated +msgid "Unique preset" +msgstr "独立预设" + +# AI Translated +msgid "Parent preset" +msgstr "父预设" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "此预设未继承自其它预设。" + msgid "Name is unavailable." msgstr "名称不可用。" @@ -10093,24 +10169,6 @@ msgstr "您确定要启用此选项吗?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "填充图案通常设计为自动处理旋转,以确保正确打印并实现其预期效果(例如,Gyroid、Cubic)。旋转当前的稀疏填充图案可能会导致支撑不足。请谨慎操作并彻底检查是否存在任何潜在的打印问题。您确定要启用此选项吗?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"层高太小。\n" -"将设置为min_layer_height\n" -"层高太小。\n" -"将自动设置为min_layer_height的值\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "层高超出了打印机设置->挤出机->层高限制中的范围,这可能导致打印质量问题。" - -msgid "Adjust to the set range automatically?\n" -msgstr "是否自动调整到范围内?\n" - -msgid "Adjust" -msgstr "调整" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "实验性选项。在更换耗材丝时,将耗材丝回抽一段距离后再切断以最小化冲刷。虽然这可以显著减少冲刷,但也可能增加喷嘴堵塞或其他打印问题的风险。" @@ -10303,6 +10361,9 @@ msgstr "检测到保留的关键字" msgid "Setting Overrides" msgstr "参数覆盖" +msgid "Retraction when switching material" +msgstr "切换材料时的回抽量" + msgid "Basic information" msgstr "基础信息" @@ -10433,6 +10494,12 @@ msgstr "兼容的切片配置" msgid "Printable space" msgstr "可打印区域" +msgid "Printer Agent" +msgstr "打印机代理" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "为打印机通信选择网络代理。可用的代理将在启动时列出。" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10558,9 +10625,6 @@ msgstr "层高限制" msgid "Z-Hop" msgstr "Z轴抬升" -msgid "Retraction when switching material" -msgstr "切换材料时的回抽量" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11911,6 +11975,10 @@ msgstr "离不可打印区域太近,会发生碰撞。\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr "距离聚集检测区域太近,会引起碰撞。\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr "有部分超出可打印区域,无法打印。\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "所选的喷嘴温度不兼容。每种耗材的喷嘴温度都必须落在其他耗材的推荐喷嘴温度范围内。否则可能会发生喷嘴堵塞或打印机损坏。" @@ -12224,9 +12292,6 @@ msgstr "使用 3MF 代替 G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "如果打印机接受 3MF 文件作为打印任务,请启用此选项。启用后,Orca Slicer 将以 .gcode.3mf 格式发送切片文件,而不是普通的 .gcode 文件。" -msgid "Printer Agent" -msgstr "打印机代理" - msgid "Select the network agent implementation for printer communication." msgstr "选择打印机通信的网络代理实施。" @@ -12861,9 +12926,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "内部桥接的速度。如果该值以百分比表示,将基于桥接速度计算。默认值为150%。" -msgid "Brim width" -msgstr "Brim宽度" - msgid "This is the distance from the model to the outermost brim line." msgstr "从模型到最外圈brim走线的距离" @@ -12944,6 +13006,14 @@ msgstr "" "在检测尖锐角度之前,几何形状将被简化。此参数表示简化的最小偏差长度。\n" "设为0以停用" +# AI Translated +msgid "Brim ears outer only" +msgstr "仅外轮廓生成圆盘" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "仅在模型的外轮廓上生成圆盘,不包括孔洞和封闭区域。" + msgid "upward compatible machine" msgstr "向上兼容的机器" @@ -14119,6 +14189,14 @@ msgstr "TPMS-FK结构" msgid "Gyroid" msgstr "螺旋体" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "稀疏填充平滑系数" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "控制稀疏填充拐角的圆滑程度。0% 保持原有的尖锐路径,100% 则在相邻填充线之间生成尽可能大的圆弧。" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "顶面填充的加速度。使用较低值可能会改善顶面质量" @@ -14659,6 +14737,14 @@ msgstr "打印机兼容的G-code风格'" msgid "Klipper" msgstr "Klipper固件" +# AI Translated +msgid "Skip G-code config block" +msgstr "跳过 G-code 配置块" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "不将 CONFIG_BLOCK(切片软件配置的键值对)写入 G-code 文件。这对固件在解析这些注释行时会崩溃的打印机(例如 Anycubic go-klipper)有帮助。注意:G-code 文件将不再包含切片设置,因此重新导入到 OrcaSlicer 时无法恢复配置。" + msgid "Pellet Modded Printer" msgstr "颗粒改装打印机" @@ -15704,6 +15790,14 @@ msgstr "更换挤出机时长回缩" msgid "Retraction distance when extruder change" msgstr "更换挤出机时的回缩距离" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "回抽长度(换工具头)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "在换工具头之前触发回抽时,耗材丝会按指定的长度回抽(长度是在耗材丝进入挤出机之前,以原始耗材丝测量的)。" + msgid "Z-hop height" msgstr "Z抬升高度" @@ -15797,6 +15891,10 @@ msgstr "额外回填长度" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "每当空驶后回抽被补偿时,挤出机将推入额外数量的耗材丝。很少需要此设置。" +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "额外回填长度(换工具头)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "当换色后回抽被补偿时,挤出机将推入额外数量的耗材丝。" @@ -16211,6 +16309,14 @@ msgstr "在擦拭塔上换头" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "在发出换头命令 (Tx) 之前,强制打印头先移动到擦拭塔。仅与使用第 2 类擦拭塔的多挤出机(多打印头)打印机相关。默认情况下,Orca 会在多打印头机器上跳过此移动,因为固件会处理换头,这可能导致 Tx 命令在打印件上方发出。如果您希望换头命令始终在擦拭塔上方发出,请启用此选项。" +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "在擦拭塔上等待温度" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "拾取新工具头后不等待其达到打印温度,直接移动到擦拭塔,并在冲刷前于擦拭塔上等待温度。升温过程中渗出的耗材丝会落在擦拭塔上而不是模型上,且移动时间与加热过程重叠。仅适用于使用 2 型擦拭塔的多挤出机(多工具头)打印机。固件或换工具头宏本身不得等待温度。禁用时,等待温度的指令将在换工具头命令之后立即发出。" + msgid "No sparse layers (beta)" msgstr "无稀疏层 (实验功能)" @@ -19433,9 +19539,6 @@ msgstr "物理打印机" msgid "Print Host upload" msgstr "打印主机上传" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "为打印机通信选择网络代理。可用的代理将在启动时列出。" - msgid "Select a Flashforge printer" msgstr "选择一台 Flashforge 打印机" @@ -20325,9 +20428,6 @@ msgstr "在尝试登录时发生了异常,请重试。" msgid "User canceled." msgstr "用户已取消。" -msgid "Head diameter" -msgstr "Brim 直径" - msgid "Max angle" msgstr "最大角度" @@ -21111,6 +21211,24 @@ msgstr "" "避免翘曲\n" "您知道吗?打印ABS这类易翘曲材料时,适当提高热床温度可以降低翘曲的概率。" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "层高太小。\n" +#~ "将设置为min_layer_height\n" +#~ "层高太小。\n" +#~ "将自动设置为min_layer_height的值\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "层高超出了打印机设置->挤出机->层高限制中的范围,这可能导致打印质量问题。" + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "是否自动调整到范围内?\n" + +#~ msgid "Head diameter" +#~ msgstr "Brim 直径" + #~ msgid "Print order within a single layer." #~ msgstr "同一层内的打印顺序" diff --git a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po index 9b37009978..cf6a2519c3 100644 --- a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po +++ b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 17:40-0300\n" +"POT-Creation-Date: 2026-08-19 14:07-0300\n" "PO-Revision-Date: 2025-11-28 13:48-0600\n" "Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n" "Language-Team: \n" @@ -4691,6 +4691,23 @@ msgstr "目前列印裝置內部溫度高於線材的安全溫度,可能會導 msgid "The minimal chamber temperature (%d℃) is higher than the target chamber temperature (%d℃). The minimal value is the threshold at which printing starts while the chamber keeps heating toward the target, so it should not exceed it. It will be clamped to the target." msgstr "最低倉室溫度(%d℃)高於目標倉室溫度(%d℃)。最低值是列印開始的門檻,此時倉室會持續朝目標溫度加熱,因此不應超過目標值。系統會將其限制在目標值。" +# AI Translated +#, c-format, boost-format +msgid "Layer height is too small. It will be set to the minimum (%g mm)." +msgstr "層高過小,將設定為最小值(%g mm)。" + +# AI Translated +msgid "Layer height is outside the limits set in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +msgstr "層高超出了印表裝置設定 -> 擠出機 -> 層高限制中設定的範圍,這可能會導致列印品質問題。" + +# AI Translated +#, c-format, boost-format +msgid "Adjust it to the limit (%g mm) automatically?" +msgstr "是否自動調整至限制值(%g mm)?" + +msgid "Adjust" +msgstr "調整" + msgid "" "Layer height too small\n" "It has been reset to 0.2" @@ -4825,6 +4842,13 @@ msgstr "" "是 - 啟用 Arachne Wall 產生器\n" "否 - 停用 Arachne Wall 產生器,並將 Fuzzy Skin 設定為 [位移] 模式" +# AI Translated +msgid "Brim ear radius" +msgstr "耳狀 Brim 半徑" + +msgid "Brim width" +msgstr "Brim 寬度" + msgid "Spiral mode only works when wall loops is 1, support is disabled, clumping detection by probing is disabled, top shell layers is 0, sparse infill density is 0 and timelapse type is traditional." msgstr "花瓶模式僅適用於牆體圈數為 1、停用支撐、停用偵測堵塞、頂部外殼層數為 0、稀疏填充密度為 0,且延時攝影類型為傳統模式時。" @@ -5079,6 +5103,14 @@ msgstr "產生校正代碼失敗" msgid "Calibration error" msgstr "校正錯誤" +# AI Translated +msgid "This printer is not configured with the hardware this control needs." +msgstr "此列印裝置未配置此控制項所需的硬體。" + +# AI Translated +msgid "This control is not supported on this printer." +msgstr "此列印裝置不支援此控制項。" + # AI Translated msgid "Network unavailable" msgstr "網路無法使用" @@ -5936,7 +5968,7 @@ msgstr "體積:" msgid "Size:" msgstr "尺寸:" -#, c-format, boost-format +#, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." msgstr "發現 G-code 路徑在 %d 層,Z = %.2lf mm 處的衝突。請將有衝突的物件分離得更遠(%s <-> %s)。" @@ -6118,6 +6150,10 @@ msgstr "多臺裝置" msgid "Project" msgstr "專案" +# AI Translated +msgid "Device (Web)" +msgstr "裝置(網頁)" + msgid "Yes" msgstr "是" @@ -8193,19 +8229,19 @@ msgstr "未選擇替換的目錄" msgid "Replaced with 3D files from directory:\n" msgstr "已從目錄替換為 3D 檔案:\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: same file.\n" msgstr "✖ 已跳過 %s:相同檔案。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: file does not exist.\n" msgstr "✖ 已跳過 %s:檔案不存在。\n" -#, c-format +#, c-format, boost-format msgid "✖ Skipped %s: failed to replace.\n" msgstr "✖ 已跳過 %s:無法替換。\n" -#, c-format +#, c-format, boost-format msgid "✔ Replaced %s.\n" msgstr "✔ 已替換 %s。\n" @@ -8940,6 +8976,18 @@ msgstr "啟用時可以同時傳送到並管理多個機臺。" msgid "Pop up to select filament grouping mode" msgstr "彈出視窗選擇線材分組模式" +# AI Translated +msgid "Visible plugin pages" +msgstr "可見的外掛頁面數" + +# AI Translated +msgid "pages" +msgstr "頁" + +# AI Translated +msgid "Number of plugin pages shown as fixed tabs before the remaining pages collapse into a dropdown on the last tab." +msgstr "以固定分頁顯示的外掛頁面數量,其餘頁面會收合至最後一個分頁的下拉選單中。" + msgid "Behaviour" msgstr "行為" @@ -9294,6 +9342,18 @@ msgstr "顯示不支援的預設" msgid "Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected." msgstr "在列印裝置和線材下拉選單中顯示不相容/不支援的預設。這些預設無法選取。" +# AI Translated +msgid "(Experimental) Use printer agents instead of print hosts" +msgstr "(實驗性)使用列印裝置代理程式取代列印主機" + +# AI Translated +msgid "" +"Route print jobs for non-Bambu printers through printer plug-in agents instead of the classic print-host upload flow.\n" +"When disabled, OrcaSlicer uses the legacy print-host behavior." +msgstr "" +"將非 Bambu 列印裝置的列印工作透過列印裝置外掛代理程式傳送,而非傳統的列印主機上傳流程。\n" +"停用時,OrcaSlicer 會使用舊有的列印主機行為。" + # AI Translated msgid "Experimental Features" msgstr "實驗性功能" @@ -9558,9 +9618,25 @@ msgstr "使用者預設" msgid "Preset Inside Project" msgstr "項目預設" +# AI Translated +msgid "Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported." +msgstr "將父配置繼承的所有數值複製到目前的配置,並解除繼承關係。僅與父配置相容的配置可能會變成不受支援。" + msgid "Detach from parent" msgstr "從父預設分離" +# AI Translated +msgid "Unique preset" +msgstr "獨立配置" + +# AI Translated +msgid "Parent preset" +msgstr "父配置" + +# AI Translated +msgid "This preset does not inherit from another preset." +msgstr "此配置未繼承自其他配置。" + msgid "Name is unavailable." msgstr "名稱不可用。" @@ -10299,22 +10375,6 @@ msgstr "您確認要啟用此選項嗎?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" msgstr "填充模式通常設計為自動處理旋轉,以確保正確列印並實現其預期效果(例如:Gyroid、Cubic)。旋轉目前的稀疏填充模式可能會導致支撐不足。請謹慎操作,並仔細檢查任何潛在的列印問題。您確定要啟用此選項嗎?" -msgid "" -"Layer height is too small.\n" -"It will set to min_layer_height\n" -msgstr "" -"層高過薄\n" -"將改為 min_layer_height\n" - -msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." -msgstr "層高超過了印表裝置設定 -> 擠出機 -> 層高限制,這可能會導致列印品質問題。" - -msgid "Adjust to the set range automatically?\n" -msgstr "是否自動調整至設定範圍?\n" - -msgid "Adjust" -msgstr "調整" - msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "實驗性功能:在換線過程中以更大的距離收回並切斷線材,以減少沖洗量。儘管這可以顯著減少沖洗,但也可能增加噴嘴堵塞或其他列印問題的風險。" @@ -10507,6 +10567,9 @@ msgstr "偵測到保留的關鍵字" msgid "Setting Overrides" msgstr "參數覆蓋" +msgid "Retraction when switching material" +msgstr "切換線材時的回抽量" + msgid "Basic information" msgstr "基本資訊" @@ -10637,6 +10700,12 @@ msgstr "相容的切片設定" msgid "Printable space" msgstr "可列印區域" +msgid "Printer Agent" +msgstr "列印裝置代理" + +msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." +msgstr "選擇列印裝置通訊的網路代理實施。可用代理在啟動時註冊。" + #. TRN: The first argument is the parameter's name; the second argument is its value. #, boost-format msgid "Invalid value provided for parameter %1%: %2%" @@ -10762,9 +10831,6 @@ msgstr "層高限制" msgid "Z-Hop" msgstr "Z 軸抬升" -msgid "Retraction when switching material" -msgstr "切換線材時的回抽量" - msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -12113,6 +12179,10 @@ msgstr "離淨空區域太近,會發生碰撞。\n" msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr "離堵塞偵測區域太近,會發生碰撞。\n" +# AI Translated +msgid " is partially outside the printable area, and it cannot be printed.\n" +msgstr "有部分超出可列印區域,無法列印。\n" + msgid "Selected nozzle temperatures are incompatible. Each filament's nozzle temperature must fall within the recommended nozzle temperature range of the other filaments. Otherwise, nozzle clogging or printer damage may occur." msgstr "所選的噴嘴溫度不相容。每種線材的噴嘴溫度都必須落在其他線材的建議噴嘴溫度範圍內。否則可能會發生噴嘴堵塞或列印裝置損壞。" @@ -12426,9 +12496,6 @@ msgstr "使用 3MF 取代 G-code" msgid "Enable this if the printer accepts a 3MF file as the print job. When enabled, Orca Slicer sends the sliced file as a .gcode.3mf, instead of a plain .gcode file." msgstr "若列印裝置接受 3MF 檔案作為列印作業,請啟用此選項。啟用後,Orca Slicer 會將切片後的檔案以 .gcode.3mf 形式傳送,而非單純的 .gcode 檔案。" -msgid "Printer Agent" -msgstr "列印裝置代理" - msgid "Select the network agent implementation for printer communication." msgstr "選擇用於列印裝置通訊的網路代理實作。" @@ -13074,9 +13141,6 @@ msgstr "" msgid "Speed of internal bridges. If the value is expressed as a percentage, it will be calculated based on the bridge_speed. Default value is 150%." msgstr "內部橋接速度。如果該值以百分比表示,將基於 bridge_speed 進行計算。預設值為 150%。" -msgid "Brim width" -msgstr "Brim 寬度" - msgid "This is the distance from the model to the outermost brim line." msgstr "從模型到 Brim 最外圈的距離" @@ -13157,6 +13221,14 @@ msgstr "" "在偵測尖銳角度之前,幾何形狀將被簡化。此參數表示簡化的最小偏差長度。\n" "設為 0 以停用" +# AI Translated +msgid "Brim ears outer only" +msgstr "僅外輪廓產生耳狀 Brim" + +# AI Translated +msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." +msgstr "僅在模型的外輪廓上產生耳狀 Brim,不包含孔洞與封閉區域。" + msgid "upward compatible machine" msgstr "向上相容的裝置" @@ -14316,6 +14388,14 @@ msgstr "TPMS-FK結構" msgid "Gyroid" msgstr "螺旋體" +# AI Translated +msgid "Sparse infill smooth factor" +msgstr "稀疏填充平滑係數" + +# AI Translated +msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." +msgstr "控制稀疏填充轉角的圓滑程度。0% 保持原有的銳利路徑,100% 則在相鄰填充線之間產生盡可能大的圓弧。" + msgid "This is the acceleration of top surface infill. Using a lower value may improve top surface quality." msgstr "頂面填充的加速度。使用較低值可能會改善頂面列印品質" @@ -14856,6 +14936,14 @@ msgstr "列印裝置相容的 G-code 樣式" msgid "Klipper" msgstr "Klipper" +# AI Translated +msgid "Skip G-code config block" +msgstr "略過 G-code 設定區塊" + +# AI Translated +msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." +msgstr "不將 CONFIG_BLOCK(切片軟體設定的鍵值對)寫入 G-code 檔案。這對於韌體在解析這些註解行時會當機的列印裝置(例如 Anycubic go-klipper)有幫助。注意:G-code 檔案將不再包含切片設定,因此重新匯入 OrcaSlicer 時無法還原設定。" + msgid "Pellet Modded Printer" msgstr "顆粒改裝列印裝置" @@ -15909,6 +15997,14 @@ msgstr "更換擠出機時長回抽" msgid "Retraction distance when extruder change" msgstr "更換擠出機時的回抽距離" +# AI Translated +msgid "Retraction Length (Toolchange)" +msgstr "回抽長度(換工具)" + +# AI Translated +msgid "When retraction is triggered before changing tool, filament is pulled back by the specified amount (the length is measured on raw filament, before it enters the extruder)." +msgstr "在換工具之前觸發回抽時,線材會依指定的長度回抽(長度是在線材進入擠出機之前,以原始線材測量)。" + msgid "Z-hop height" msgstr "Z 抬升高度" @@ -16002,6 +16098,10 @@ msgstr "額外回填長度" msgid "When the retraction is compensated after the travel move, the extruder will push this additional amount of filament. This setting is rarely needed." msgstr "每當空駛後回抽被補償時,擠出機將推入額外長度的線材。很少需要此設定。" +# AI Translated +msgid "Extra length on restart (Toolchange)" +msgstr "額外回填長度(換工具)" + msgid "When the retraction is compensated after changing tool, the extruder will push this additional amount of filament." msgstr "當換色後回抽被補償時,擠出機將推入額外長度的線材。" @@ -16405,6 +16505,14 @@ msgstr "在換料塔上換刀" msgid "Force the toolhead to travel to the wipe tower before issuing the tool change command (Tx). Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. By default Orca skips the travel on multi-toolhead machines because the firmware handles the head swap, which can result in the Tx command being issued above the printed part. Enable this option if you want the tool change to always be issued above the wipe tower instead." msgstr "強制工具頭在發出換刀指令 (Tx) 之前先移動到換料塔。僅適用於使用 Type 2 換料塔的多擠出機(多工具頭)列印裝置。預設情況下,Orca 會在多工具頭機器上略過此空駛,因為韌體會處理工具頭交換,這可能導致 Tx 指令在已列印零件上方發出。若您希望換刀一律改在換料塔上方發出,請啟用此選項。" +# AI Translated +msgid "Wait for temperature on wipe tower" +msgstr "在換料塔上等待溫度" + +# AI Translated +msgid "Pick up the new tool without waiting for it to reach printing temperature, travel to the wipe tower, and wait for the temperature there, right before purging. Ooze from the heat-up lands on the tower instead of the model, and the travel overlaps with the heating. Only relevant for multi-extruder (multi-toolhead) printers using a Type 2 wipe tower. The firmware or tool change macro must not wait for the temperature itself. When disabled, the temperature wait is issued right after the tool change command." +msgstr "取用新工具時不等待其達到列印溫度,先移動到換料塔,並在清理前於換料塔上等待溫度。升溫過程中滲出的線材會落在換料塔上而非模型上,且移動時間與加熱過程重疊。僅適用於使用第 2 型換料塔的多擠出機(多工具頭)列印裝置。韌體或換工具巨集本身不得等待溫度。停用時,等待溫度的指令會在換工具命令之後立即發出。" + msgid "No sparse layers (beta)" msgstr "取消稀疏層(Beta)" @@ -19622,9 +19730,6 @@ msgstr "實體列印裝置" msgid "Print Host upload" msgstr "列印主機上傳" -msgid "Select the network agent implementation for printer communication. Available agents are registered at startup." -msgstr "選擇列印裝置通訊的網路代理實施。可用代理在啟動時註冊。" - msgid "Select a Flashforge printer" msgstr "選取 Flashforge 列印裝置" @@ -20516,9 +20621,6 @@ msgstr "嘗試登入時發生了意外錯誤,請再試一次。" msgid "User canceled." msgstr "使用者取消。" -msgid "Head diameter" -msgstr "頭直徑" - msgid "Max angle" msgstr "最大角度" @@ -21323,6 +21425,22 @@ msgstr "" "避免翹曲\n" "您知道嗎?當列印容易翹曲的材料(如 ABS)時,適當提高熱床溫度可以降低翹曲的機率。" +#~ msgid "" +#~ "Layer height is too small.\n" +#~ "It will set to min_layer_height\n" +#~ msgstr "" +#~ "層高過薄\n" +#~ "將改為 min_layer_height\n" + +#~ msgid "Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, this may cause printing quality issues." +#~ msgstr "層高超過了印表裝置設定 -> 擠出機 -> 層高限制,這可能會導致列印品質問題。" + +#~ msgid "Adjust to the set range automatically?\n" +#~ msgstr "是否自動調整至設定範圍?\n" + +#~ msgid "Head diameter" +#~ msgstr "頭直徑" + #~ msgid "Print order within a single layer." #~ msgstr "每一層的列印順序" From ba229739198dfbb2b4982a57e1f6c4195ea47f11 Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:34:12 -0300 Subject: [PATCH 064/138] Revert "Fix assembly parts omitted by height range modifiers" (#15301) --- src/libslic3r/PrintApply.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 6d5dbb05f5..e2e9bc737d 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -559,11 +559,9 @@ static inline bool model_volume_solid_or_modifier(const ModelVolume &mv) static inline Transform3f trafo_for_bbox(const Transform3d &object_trafo, const Transform3d &volume_trafo) { - // Orca: Keep the volume's local XY offset for multipart overlap checks, but remove the object's bed placement. - Transform3d object_trafo_local = object_trafo; - object_trafo_local.translation().x() = 0.; - object_trafo_local.translation().y() = 0.; - Transform3d m = object_trafo_local * volume_trafo; + Transform3d m = object_trafo * volume_trafo; + m.translation().x() = 0.; + m.translation().y() = 0.; return m.cast(); } From aaa8e98bb0ead79d5edc9c368dd8b80201ff14ea Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Thu, 20 Aug 2026 09:16:02 -0300 Subject: [PATCH 065/138] Time estimator fixes (#15304) * Plan corners with junction deviation where the firmware uses it The time estimator only ever had the classic per-axis jerk model, which limits a corner by the largest single-axis component of the velocity change. That is anisotropic: the same corner is allowed sqrt(2) more speed on a diagonal than on an axis, which paints a four-lobed ripple around every circular wall in the actual speed and actual flow views, worst on small parts whose walls are made of short segments. Klipper has no classic jerk at all and Marlin 2 has none while M205 J is in use; both plan corners with junction deviation, which sees only the corner angle. Add that model and use it for those machines: - Klipper: derived from the square corner velocity, as the firmware does (jd = scv^2 * (sqrt(2) - 1) / max_accel), reading the scv from machine_max_jerk_x, where process_SET_VELOCITY_LIMIT() already stores SQUARE_CORNER_VELOCITY. - Marlin 2: machine_max_junction_deviation, which was already loaded into the machine limits but never reached the planner. - Every other flavor keeps the classic jerk path unchanged. The model has no per-axis jerk floor, so this also drops the hard slow spot the estimator drew at the start of every loop from machine_max_jerk_e. Toolpaths are unaffected: on a full export the only lines that change are M73. The junction deviation maths, including Marlin's JD_HANDLE_SMALL_SEGMENTS arc approximation, is ported from PrusaSlicer's src/libslic3r/GCode/GCodeProcessor.cpp. The Klipper mapping is not in PrusaSlicer, which ignores SET_VELOCITY_LIMIT. * Add tests for junction deviation corner planning Cover the three properties the change rests on: - a right angle on Klipper is planned at exactly the square corner velocity, the identity that makes the scv to junction deviation mapping correct, and a shallow corner is planned far faster than per-axis jerk allows; - junction deviation gives the same speed whatever the corner's orientation, while classic jerk keeps its sqrt(2) spread, which is the four-lobed ripple; - machines that do not plan with junction deviation are provably untouched, including a Marlin 2 printer that has it disabled. --- src/libslic3r/GCode/GCodeProcessor.cpp | 149 +++++++++++++++++++--- src/libslic3r/GCode/GCodeProcessor.hpp | 14 +++ tests/fff_print/test_gcode_timing.cpp | 164 +++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 20 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index cebfe486cb..93621648ff 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -298,6 +298,7 @@ void GCodeProcessor::TimeMachine::State::reset() //BBS enter_direction = { 0.0f, 0.0f, 0.0f }; exit_direction = { 0.0f, 0.0f, 0.0f }; + jd_unit_vec = { 0.0f, 0.0f, 0.0f, 0.0f }; } void GCodeProcessor::TimeMachine::CustomGCodeTime::reset() @@ -5036,6 +5037,10 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes if (!is_extrusion_only_move(delta_pos)) curr.enter_direction = curr.enter_direction / norm; curr.exit_direction = curr.enter_direction; + curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]) * inv_distance, + static_cast(delta_pos[Y]) * inv_distance, + static_cast(delta_pos[Z]) * inv_distance, + static_cast(delta_pos[E]) * inv_distance); TimeBlock block; block.move_type = type; @@ -5118,22 +5123,32 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes block.acceleration = acceleration; - // calculates block exit feedrate - curr.safe_feedrate = block.feedrate_profile.cruise; + static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; + const bool has_prev_move = !blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD; - for (unsigned char a = X; a <= E; ++a) { - float axis_max_jerk = get_axis_max_jerk(static_cast(i), static_cast(a)); - if (curr.abs_axis_feedrate[a] > axis_max_jerk) - curr.safe_feedrate = std::min(curr.safe_feedrate, axis_max_jerk); + // Orca: junction deviation where the firmware uses it (Klipper always, Marlin 2 with M205 J). + // Negative leaves the classic jerk path below unchanged. + const float vmax_junction_jd = calc_vmax_junction_deviation(block, prev, curr, has_prev_move, + static_cast(i)); + const bool use_junction_deviation = vmax_junction_jd >= 0.0f; + + // calculates block exit feedrate. Junction deviation has no per axis jerk floor, so a move is + // free to start from rest. + curr.safe_feedrate = use_junction_deviation ? 0.0f : block.feedrate_profile.cruise; + + if (!use_junction_deviation) { + for (unsigned char a = X; a <= E; ++a) { + float axis_max_jerk = get_axis_max_jerk(static_cast(i), static_cast(a)); + if (curr.abs_axis_feedrate[a] > axis_max_jerk) + curr.safe_feedrate = std::min(curr.safe_feedrate, axis_max_jerk); + } } block.feedrate_profile.exit = curr.safe_feedrate; - static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; - // calculates block entry feedrate - float vmax_junction = curr.safe_feedrate; - if (!blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD) { + float vmax_junction = use_junction_deviation ? vmax_junction_jd : curr.safe_feedrate; + if (!use_junction_deviation && has_prev_move) { bool prev_speed_larger = prev.feedrate > block.feedrate_profile.cruise; float smaller_speed_factor = prev_speed_larger ? (block.feedrate_profile.cruise / prev.feedrate) : (prev.feedrate / block.feedrate_profile.cruise); // Pick the smaller of the nominal speeds. Higher speed shall not be achieved at the junction during coasting. @@ -5400,6 +5415,10 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line) if (!is_extrusion_only_move(delta_pos)) curr.enter_direction = curr.enter_direction / norm; curr.exit_direction = curr.enter_direction; + curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]) * inv_distance, + static_cast(delta_pos[Y]) * inv_distance, + static_cast(delta_pos[Z]) * inv_distance, + static_cast(delta_pos[E]) * inv_distance); TimeBlock block; block.move_type = type; @@ -5480,22 +5499,32 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line) block.acceleration = acceleration; - // calculates block exit feedrate - curr.safe_feedrate = block.feedrate_profile.cruise; + static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; + const bool has_prev_move = !blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD; - for (unsigned char a = X; a <= E; ++a) { - float axis_max_jerk = get_axis_max_jerk(static_cast(i), static_cast(a)); - if (curr.abs_axis_feedrate[a] > axis_max_jerk) - curr.safe_feedrate = std::min(curr.safe_feedrate, axis_max_jerk); + // Orca: junction deviation where the firmware uses it (Klipper always, Marlin 2 with M205 J). + // Negative leaves the classic jerk path below unchanged. + const float vmax_junction_jd = calc_vmax_junction_deviation(block, prev, curr, has_prev_move, + static_cast(i)); + const bool use_junction_deviation = vmax_junction_jd >= 0.0f; + + // calculates block exit feedrate. Junction deviation has no per axis jerk floor, so a move is + // free to start from rest. + curr.safe_feedrate = use_junction_deviation ? 0.0f : block.feedrate_profile.cruise; + + if (!use_junction_deviation) { + for (unsigned char a = X; a <= E; ++a) { + float axis_max_jerk = get_axis_max_jerk(static_cast(i), static_cast(a)); + if (curr.abs_axis_feedrate[a] > axis_max_jerk) + curr.safe_feedrate = std::min(curr.safe_feedrate, axis_max_jerk); + } } block.feedrate_profile.exit = curr.safe_feedrate; - static const float PREVIOUS_FEEDRATE_THRESHOLD = 0.0001f; - // calculates block entry feedrate - float vmax_junction = curr.safe_feedrate; - if (!blocks.empty() && prev.feedrate > PREVIOUS_FEEDRATE_THRESHOLD) { + float vmax_junction = use_junction_deviation ? vmax_junction_jd : curr.safe_feedrate; + if (!use_junction_deviation && has_prev_move) { bool prev_speed_larger = prev.feedrate > block.feedrate_profile.cruise; float smaller_speed_factor = prev_speed_larger ? (block.feedrate_profile.cruise / prev.feedrate) : (prev.feedrate / block.feedrate_profile.cruise); // Pick the smaller of the nominal speeds. Higher speed shall not be achieved at the junction during coasting. @@ -7168,6 +7197,86 @@ float GCodeProcessor::get_axis_max_jerk_with_jd(PrintEstimatedStatistics::ETimeM return get_axis_max_jerk_with_jd(mode, axis, get_acceleration(mode)); } +float GCodeProcessor::get_junction_deviation(PrintEstimatedStatistics::ETimeMode mode, float acceleration) const +{ + const size_t id = static_cast(mode); + + // Klipper has no classic jerk: jd = scv^2 * (sqrt(2) - 1) / max_accel + // (toolhead.py::_calc_junction_deviation). Passing the block acceleration back in makes it cancel + // in calc_vmax_junction_deviation(), leaving the identity v == scv at a 90 degree corner. + if (m_flavor == gcfKlipper) { + // machine_max_jerk_x holds the square corner velocity; process_SET_VELOCITY_LIMIT() writes it. + const float scv = get_option_value(m_time_processor.machine_limits.machine_max_jerk_x, id); + if (scv <= 0.0f || acceleration <= 0.0f) + return 0.0f; + return sqr(scv) * (std::sqrt(2.0f) - 1.0f) / acceleration; + } + + // Marlin 2 plans with junction deviation only when M205 J > 0; classic jerk leaves it at 0. + if (m_flavor == gcfMarlinFirmware) + return get_option_value(m_time_processor.machine_limits.machine_max_junction_deviation, id); + + return 0.0f; +} + +float GCodeProcessor::calc_junction_acceleration(const TimeBlock& block, const Vec4f& junction_unit_vec, + PrintEstimatedStatistics::ETimeMode mode) const +{ + float junction_acceleration = block.acceleration; + for (unsigned char a = X; a <= E; ++a) { + if (junction_unit_vec[a] == 0.0f) + continue; + const float axis_max_acceleration = get_axis_max_acceleration(mode, static_cast(a), m_machine_config_idx); + if (axis_max_acceleration > 0.0f) + junction_acceleration = std::min(junction_acceleration, std::abs(axis_max_acceleration / junction_unit_vec[a])); + } + return junction_acceleration; +} + +// Ported from PrusaSlicer (src/libslic3r/GCode/GCodeProcessor.cpp). +float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const TimeMachine::State& prev, + const TimeMachine::State& curr, bool has_prev_move, + PrintEstimatedStatistics::ETimeMode mode) const +{ + const float junction_deviation = get_junction_deviation(mode, block.acceleration); + if (junction_deviation <= 0.0f) + return -1.0f; // classic jerk machine, the caller keeps its own computation + if (!has_prev_move) + return 0.0f; // starts from rest, the planner raises this on the reverse pass + + // -1 for a straight continuation, +1 for a full reversal. Half angle identity, no acos()/sin(). + float junction_cos_theta = (-prev.jd_unit_vec).dot(curr.jd_unit_vec); + if (junction_cos_theta > 0.999999f) + return 0.0f; // the path doubles back, the machine has to stop + junction_cos_theta = std::max(junction_cos_theta, -0.999999f); // guards the division below + + const float sin_theta_d2 = std::sqrt(0.5f * (1.0f - junction_cos_theta)); // always positive + const Vec4f junction_vec = curr.jd_unit_vec - prev.jd_unit_vec; + const float junction_vec_norm = junction_vec.norm(); + const Vec4f junction_unit_vec = (junction_vec_norm > 0.0f) ? Vec4f(junction_vec / junction_vec_norm) + : Vec4f(0.0f, 0.0f, 0.0f, 0.0f); + const float junction_acceleration = calc_junction_acceleration(block, junction_unit_vec, mode); + + float vmax_junction_sqr = (junction_acceleration * junction_deviation * sin_theta_d2) / (1.0f - sin_theta_d2); + + // Marlin's JD_HANDLE_SMALL_SEGMENTS: a short move through a shallow corner is treated as an arc and + // capped by the centripetal acceleration it needs. Klipper has no equivalent. + if (m_flavor != gcfKlipper && block.distance < 1.0f && junction_cos_theta < -0.7071067812f) { + // Fast acos(-t), max. error +-0.033rad. MinMax polynomial by W. Randolph Franklin: + // https://wrf.ecse.rpi.edu/Research/Short_Notes/arcsin/onlyelem.html + const float neg = junction_cos_theta < 0.0f ? -1.0f : 1.0f; + const float t = neg * junction_cos_theta; + const float asinx = 0.032843707f + t * (-1.451838349f + t * (29.66153956f + t * (-131.1123477f + + t * (262.8130562f + t * (-242.7199627f + t * (84.31466202f)))))); + const float junction_theta = float(0.5 * M_PI) + neg * asinx; // acos(-t), bottoms out at 0.033 + vmax_junction_sqr = std::min(vmax_junction_sqr, (block.distance * junction_acceleration) / junction_theta); + } + + // Never faster than either of the two moves the junction joins. + vmax_junction_sqr = std::min(vmax_junction_sqr, std::min(sqr(block.feedrate_profile.cruise), sqr(prev.feedrate))); + return std::sqrt(vmax_junction_sqr); +} + float GCodeProcessor::get_axis_max_jerk(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const { const size_t id = static_cast(mode); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index f5bec9e826..e968986695 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -637,6 +637,10 @@ class Print; //For line move, there are same. For arc move, there are different. Vec3f enter_direction; Vec3f exit_direction; + // Orca: move direction over all four axes, scaled by 1 / block.distance. Used by + // calc_vmax_junction_deviation(), which needs E to see extrusion-rate changes + // between collinear moves the way Marlin and Klipper do. + Vec4f jd_unit_vec; void reset(); }; @@ -1488,6 +1492,16 @@ class Print; float get_axis_max_acceleration(PrintEstimatedStatistics::ETimeMode mode, Axis axis, int machine_idx) const; float get_axis_max_jerk_with_jd(PrintEstimatedStatistics::ETimeMode mode, Axis axis, float acceleration) const; float get_axis_max_jerk_with_jd(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; + // Orca: junction deviation for a block at the given acceleration, 0 for a classic jerk machine. + float get_junction_deviation(PrintEstimatedStatistics::ETimeMode mode, float acceleration) const; + // Orca: acceleration along the junction direction, clamped by the per axis limits. + float calc_junction_acceleration(const TimeBlock& block, const Vec4f& junction_unit_vec, + PrintEstimatedStatistics::ETimeMode mode) const; + // Orca: entry speed from the junction deviation model, which limits a corner by its angle alone + // and is therefore isotropic, unlike per axis jerk. Negative means classic jerk applies instead. + float calc_vmax_junction_deviation(const TimeBlock& block, const TimeMachine::State& prev, + const TimeMachine::State& curr, bool has_prev_move, + PrintEstimatedStatistics::ETimeMode mode) const; float get_axis_max_jerk(PrintEstimatedStatistics::ETimeMode mode, Axis axis) const; Vec3f get_xyz_max_jerk(PrintEstimatedStatistics::ETimeMode mode) const; float get_retract_acceleration(PrintEstimatedStatistics::ETimeMode mode) const; diff --git a/tests/fff_print/test_gcode_timing.cpp b/tests/fff_print/test_gcode_timing.cpp index 4570f253bb..9802bcc8f7 100644 --- a/tests/fff_print/test_gcode_timing.cpp +++ b/tests/fff_print/test_gcode_timing.cpp @@ -7,9 +7,14 @@ #include "test_utils.hpp" +#include #include +#include #include #include +#include +#include +#include using namespace Slic3r; using Catch::Matchers::WithinAbs; @@ -418,3 +423,162 @@ TEST_CASE("Per-slot machine limits follow the active nozzle", "[GCodeTiming][Mul REQUIRE_THAT(times[2], Catch::Matchers::WithinRel(101.0 / 200.0, 0.10)); } } + +// Junction planning decides the speeds the "actual speed" / "actual flow" preview shows. Per-axis +// jerk limits a corner by the largest single-axis component of the velocity change, allowing sqrt(2) +// more speed on a diagonal than on an axis -- a four-lobed ripple around every circle. Klipper and +// Marlin 2 with M205 J plan with junction deviation instead, which sees only the corner angle. +namespace { + +// One acceleration everywhere and axis limits far above it, so only the junction model under test +// can slow a corner down. +FullPrintConfig make_junction_config(GCodeFlavor flavor, double corner_velocity, double junction_deviation) +{ + FullPrintConfig config; + config.gcode_flavor.value = flavor; + config.filament_diameter.values = {1.75}; + config.filament_map.values = {1}; + + const std::vector accel = {1000.0, 1000.0}; + const std::vector axis = {20000.0, 20000.0}; + const std::vector speed = {500.0, 500.0}; + config.machine_max_acceleration_extruding.values = accel; + config.machine_max_acceleration_travel.values = accel; + config.machine_max_acceleration_retracting.values = accel; + config.machine_max_acceleration_x.values = axis; + config.machine_max_acceleration_y.values = axis; + config.machine_max_acceleration_z.values = axis; + config.machine_max_acceleration_e.values = axis; + config.machine_max_speed_x.values = speed; + config.machine_max_speed_y.values = speed; + config.machine_max_speed_z.values = speed; + config.machine_max_speed_e.values = speed; + // Klipper reads this as the square corner velocity, Marlin as classic jerk. + config.machine_max_jerk_x.values = {corner_velocity, corner_velocity}; + config.machine_max_jerk_y.values = {corner_velocity, corner_velocity}; + config.machine_max_jerk_z.values = {corner_velocity, corner_velocity}; + // Kept out of the way so it never binds in the classic-jerk comparisons. + config.machine_max_jerk_e.values = {100.0, 100.0}; + config.machine_max_junction_deviation.values = {junction_deviation, junction_deviation}; + config.machine_min_extruding_rate.values = {0.0, 0.0}; + config.machine_min_travel_rate.values = {0.0, 0.0}; + return config; +} + +constexpr double junction_x = 60.0; +constexpr double junction_y = 60.0; + +// Two 40mm travels meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`. +// 40mm is long enough to reach the commanded 150mm/s and brake back to any corner speed these tests +// produce. Travels (no E) keep the junction vector purely geometric, as the formulas below assume. +std::string corner_gcode(double turn_deg, double orientation_deg) +{ + const double len = 40.0; + const double a_in = orientation_deg * M_PI / 180.0; + const double a_out = (orientation_deg + turn_deg) * M_PI / 180.0; + + std::ostringstream os; + os << std::fixed << std::setprecision(4) + << "M83\n" + << "G1 Z0.2 F1200\n" + << "G1 X" << junction_x - len * std::cos(a_in) << " Y" << junction_y - len * std::sin(a_in) << " F6000\n" + << "G1 X" << junction_x << " Y" << junction_y << " F9000\n" + << "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out) << " F9000\n"; + return os.str(); +} + +// Speed allowed through the corner: the vertex ending the incoming move carries that block's exit +// speed, and the vertices the actual-speed pass inserts are all strictly interior. +double corner_speed(const GCodeProcessorResult& r) +{ + for (const auto& mv : r.moves) + if (mv.type == EMoveType::Travel && + std::abs(mv.position.x() - junction_x) < 1e-3 && + std::abs(mv.position.y() - junction_y) < 1e-3) + return mv.actual_feedrate; + return -1.0; +} + +double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double junction_deviation, + double turn_deg, double orientation_deg = 0.0) +{ + GCodeProcessor proc; + run_processor(proc, make_junction_config(flavor, corner_velocity, junction_deviation), + corner_gcode(turn_deg, orientation_deg).c_str()); + return corner_speed(proc.get_result()); +} + +} // namespace + +TEST_CASE("Klipper corners are planned with junction deviation derived from the square corner velocity", + "[GCodeTiming][JunctionDeviation]") +{ + // jd = scv^2 * (sqrt(2) - 1) / max_accel, then v^2 = jd * accel * sin(t/2) / (1 - sin(t/2)). + // The acceleration cancels: the corner speed depends only on the scv and the angle. + const double scv = 5.0; + + SECTION("a right angle is taken at exactly the square corner velocity") { + // sin(t/2) = sqrt(0.5) at 90 degrees, so v == scv -- the definition of the square corner + // velocity, and what makes the mapping above the right one. + REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, 90.0), Catch::Matchers::WithinRel(scv, 0.02)); + } + + SECTION("a shallow corner is taken far faster than the per-axis jerk model allows") { + // 6 degrees: sin(t/2) = cos(3 deg), so v = 5 * sqrt((sqrt(2) - 1) * 728.68) = 86.9mm/s. Per-axis + // jerk ignores the angle and caps the velocity *change* (2v*sin(3 deg)), giving 47.8mm/s. + const double jd_speed = planned_corner_speed(gcfKlipper, scv, 0.0, 6.0); + const double jerk_speed = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, 6.0); + REQUIRE_THAT(jd_speed, Catch::Matchers::WithinRel(86.87, 0.02)); + REQUIRE_THAT(jerk_speed, Catch::Matchers::WithinRel(47.75, 0.02)); + } +} + +TEST_CASE("Junction deviation limits a corner by its angle alone, not by its orientation", + "[GCodeTiming][JunctionDeviation]") +{ + // The four-lobed ripple on circular walls is per-axis jerk being anisotropic: a velocity change + // lying on an axis gets sqrt(2) less headroom than the same change on the diagonal. + const double scv = 5.0; + const double turn = 6.0; + + SECTION("Klipper plans both orientations identically") { + const double on_axis = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0); + const double diagonal = planned_corner_speed(gcfKlipper, scv, 0.0, turn, 45.0); + REQUIRE(on_axis > 0.0); + REQUIRE_THAT(diagonal, Catch::Matchers::WithinRel(on_axis, 0.02)); + } + + SECTION("the classic jerk model keeps its orientation dependence") { + const double on_axis = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 0.0); + const double diagonal = planned_corner_speed(gcfMarlinLegacy, scv, 0.0, turn, 45.0); + REQUIRE(on_axis > 0.0); + REQUIRE(diagonal / on_axis > 1.2); + } +} + +TEST_CASE("Junction deviation is only used where the firmware actually plans with it", + "[GCodeTiming][JunctionDeviation]") +{ + const double jerk = 5.0; + + SECTION("Marlin 2 with M205 J disabled keeps the classic jerk planning") { + // machine_max_junction_deviation == 0 is how a Marlin 2 printer says it runs classic jerk. + const double classic = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0); + REQUIRE(classic > 0.0); + REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.0, 90.0), + Catch::Matchers::WithinRel(classic, 1e-4)); + } + + SECTION("Marlin 2 with M205 J enabled switches to junction deviation") { + // sqrt(1000 * 0.05 * 2.4142136) = 11.0mm/s, independent of the jerk values it no longer reads. + REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, jerk, 0.05, 90.0), + Catch::Matchers::WithinRel(10.99, 0.02)); + } + + SECTION("machines without junction deviation are untouched by the jerk values it would ignore") { + // A flavor that never enters the junction deviation path must ignore the setting entirely. + const double without = planned_corner_speed(gcfMarlinLegacy, jerk, 0.0, 90.0); + REQUIRE_THAT(planned_corner_speed(gcfMarlinLegacy, jerk, 0.05, 90.0), + Catch::Matchers::WithinRel(without, 1e-4)); + } +} From aa233a82a5a5defa32d790588428a2456bbcfa89 Mon Sep 17 00:00:00 2001 From: pbannykh Date: Fri, 21 Aug 2026 01:47:38 +0500 Subject: [PATCH 066/138] fix: pass douglas_peucker tolerance in scaled units so the cancel-object outline is actually simplified (#15291) Co-authored-by: bannykh --- src/libslic3r/Print.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 1af28255ee..509744abe2 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -5827,7 +5827,7 @@ BoundingBoxf3 PrintInstance::get_bounding_box() const { Polygon PrintInstance::get_convex_hull_2d() { Polygon poly = print_object->model_object()->convex_hull_2d(model_instance->get_matrix()); - poly.douglas_peucker(0.1); + poly.douglas_peucker(scale_(0.1)); return poly; } From 87ca2bc42ed15772677f310f87d536a52eee3e92 Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:03:38 +0200 Subject: [PATCH 067/138] Fix unstable contours from triangulated planar faces (#15313) --- src/libslic3r/TriangleMeshSlicer.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index 2c1c0da23f..738965d75b 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -1461,6 +1461,13 @@ static Polygons make_loops( chain_open_polylines_close_gaps(open_polylines, loops, max_gap, true); #endif + // Orca: A planar quad represented by two triangles contributes a point where the + // slicing plane crosses the shared diagonal. After rounding to coord_t this + // point may be very slightly off the otherwise straight contour edge. Apart + // from being redundant, such points make the subsequent contour + // simplification depend on the slice height (and may move seam candidates). + remove_collinear(loops); + #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { static int iRun = 0; From ca65f0fd8e657cf99c9cf80a035244a4f0f12efe Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Thu, 20 Aug 2026 18:50:23 -0300 Subject: [PATCH 068/138] Normalize the junction direction vector over XYZE (#15308) * Normalize the junction direction vector over XYZE calc_vmax_junction_deviation() treats the dot product of two jd_unit_vec as a cosine, but the vectors were scaled by 1 / block.distance, which is the XYZ length. On an extruding move the E component then pushes the 4D norm above 1 and the dot product below -1, so the corner reads as straighter than it is and is planned too fast -- the more so the higher the flow. Measured on a 6 degree corner at scv 5: 86.9mm/s with no extrusion, 94.4mm/s at 0.029mm/mm, 150.0mm/s at 0.1mm/mm. Neither firmware does that. Marlin normalizes over XYZE for any extruding move (planner.cpp: `if (... || esteps > 0) normalize_junction_vector(unit_vec)`) and Klipper leaves E out of the cosine entirely, dotting only axes_r[0..2] (toolhead.py::Move.calc_junction). Normalizing satisfies both: with E normalized in, the cosine differs from the XYZ-only one by ~1e-5 at printing flow rates. This is a deliberate divergence from PrusaSlicer, which still scales by 1 / distance -- it carries an older Marlin's behaviour. Travel moves are unaffected, their vector was already unit length. Reported by Copilot in review of #15304. * Test that extrusion rate does not change corner planning The junction deviation tests were all travel-only, which is exactly why the E component of the junction vector went unchecked. Cover it: the same corner has to be planned the same whether nothing, an ordinary 0.42 x 0.2 line, or a fat large-nozzle line is extruded through it, on both Klipper and Marlin 2. Reported by Copilot in review of #15304. --- src/libslic3r/GCode/GCodeProcessor.cpp | 21 ++++++----- src/libslic3r/GCode/GCodeProcessor.hpp | 5 ++- tests/fff_print/test_gcode_timing.cpp | 48 +++++++++++++++++++++----- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 93621648ff..b13273d696 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -5037,10 +5037,10 @@ void GCodeProcessor::process_G1(const std::array, 4>& axes if (!is_extrusion_only_move(delta_pos)) curr.enter_direction = curr.enter_direction / norm; curr.exit_direction = curr.enter_direction; - curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]) * inv_distance, - static_cast(delta_pos[Y]) * inv_distance, - static_cast(delta_pos[Z]) * inv_distance, - static_cast(delta_pos[E]) * inv_distance); + curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]), + static_cast(delta_pos[Y]), + static_cast(delta_pos[Z]), + static_cast(delta_pos[E])).normalized(); TimeBlock block; block.move_type = type; @@ -5415,10 +5415,10 @@ void GCodeProcessor::process_VG1(const GCodeReader::GCodeLine& line) if (!is_extrusion_only_move(delta_pos)) curr.enter_direction = curr.enter_direction / norm; curr.exit_direction = curr.enter_direction; - curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]) * inv_distance, - static_cast(delta_pos[Y]) * inv_distance, - static_cast(delta_pos[Z]) * inv_distance, - static_cast(delta_pos[E]) * inv_distance); + curr.jd_unit_vec = Vec4f(static_cast(delta_pos[X]), + static_cast(delta_pos[Y]), + static_cast(delta_pos[Z]), + static_cast(delta_pos[E])).normalized(); TimeBlock block; block.move_type = type; @@ -7245,6 +7245,11 @@ float GCodeProcessor::calc_vmax_junction_deviation(const TimeBlock& block, const return 0.0f; // starts from rest, the planner raises this on the reverse pass // -1 for a straight continuation, +1 for a full reversal. Half angle identity, no acos()/sin(). + // Both vectors are unit length over XYZE, so this really is a cosine: scaling by 1 / distance + // instead, as PrusaSlicer does, leaves an E term that makes extruding corners look straighter + // than they are. Marlin normalizes over XYZE for any extruding move (planner.cpp, esteps > 0) + // and Klipper keeps E out of the cosine entirely (toolhead.py::Move.calc_junction); both agree + // that the corner is planned by its geometry, and normalizing matches them to within 1e-5. float junction_cos_theta = (-prev.jd_unit_vec).dot(curr.jd_unit_vec); if (junction_cos_theta > 0.999999f) return 0.0f; // the path doubles back, the machine has to stop diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index e968986695..505f7c06a0 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -637,9 +637,8 @@ class Print; //For line move, there are same. For arc move, there are different. Vec3f enter_direction; Vec3f exit_direction; - // Orca: move direction over all four axes, scaled by 1 / block.distance. Used by - // calc_vmax_junction_deviation(), which needs E to see extrusion-rate changes - // between collinear moves the way Marlin and Klipper do. + // Orca: move direction over all four axes, unit length. Used by + // calc_vmax_junction_deviation(); see there for why E is normalized in. Vec4f jd_unit_vec; void reset(); diff --git a/tests/fff_print/test_gcode_timing.cpp b/tests/fff_print/test_gcode_timing.cpp index 9802bcc8f7..8c08fc4f03 100644 --- a/tests/fff_print/test_gcode_timing.cpp +++ b/tests/fff_print/test_gcode_timing.cpp @@ -468,22 +468,27 @@ FullPrintConfig make_junction_config(GCodeFlavor flavor, double corner_velocity, constexpr double junction_x = 60.0; constexpr double junction_y = 60.0; -// Two 40mm travels meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`. +// Two 40mm moves meeting at (junction_x, junction_y) with the given turn, rotated by `orientation`. // 40mm is long enough to reach the commanded 150mm/s and brake back to any corner speed these tests -// produce. Travels (no E) keep the junction vector purely geometric, as the formulas below assume. -std::string corner_gcode(double turn_deg, double orientation_deg) +// produce. `e_per_mm` of zero makes them travels, which keeps the junction vector purely geometric +// as the formulas below assume. +std::string corner_gcode(double turn_deg, double orientation_deg, double e_per_mm = 0.0) { const double len = 40.0; const double a_in = orientation_deg * M_PI / 180.0; const double a_out = (orientation_deg + turn_deg) * M_PI / 180.0; + std::ostringstream extrude; + if (e_per_mm > 0.0) + extrude << std::fixed << std::setprecision(4) << " E" << len * e_per_mm; std::ostringstream os; os << std::fixed << std::setprecision(4) << "M83\n" << "G1 Z0.2 F1200\n" << "G1 X" << junction_x - len * std::cos(a_in) << " Y" << junction_y - len * std::sin(a_in) << " F6000\n" - << "G1 X" << junction_x << " Y" << junction_y << " F9000\n" - << "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out) << " F9000\n"; + << "G1 X" << junction_x << " Y" << junction_y << extrude.str() << " F9000\n" + << "G1 X" << junction_x + len * std::cos(a_out) << " Y" << junction_y + len * std::sin(a_out) + << extrude.str() << " F9000\n"; return os.str(); } @@ -492,7 +497,7 @@ std::string corner_gcode(double turn_deg, double orientation_deg) double corner_speed(const GCodeProcessorResult& r) { for (const auto& mv : r.moves) - if (mv.type == EMoveType::Travel && + if ((mv.type == EMoveType::Travel || mv.type == EMoveType::Extrude) && std::abs(mv.position.x() - junction_x) < 1e-3 && std::abs(mv.position.y() - junction_y) < 1e-3) return mv.actual_feedrate; @@ -500,11 +505,11 @@ double corner_speed(const GCodeProcessorResult& r) } double planned_corner_speed(GCodeFlavor flavor, double corner_velocity, double junction_deviation, - double turn_deg, double orientation_deg = 0.0) + double turn_deg, double orientation_deg = 0.0, double e_per_mm = 0.0) { GCodeProcessor proc; run_processor(proc, make_junction_config(flavor, corner_velocity, junction_deviation), - corner_gcode(turn_deg, orientation_deg).c_str()); + corner_gcode(turn_deg, orientation_deg, e_per_mm).c_str()); return corner_speed(proc.get_result()); } @@ -582,3 +587,30 @@ TEST_CASE("Junction deviation is only used where the firmware actually plans wit Catch::Matchers::WithinRel(without, 1e-4)); } } + +TEST_CASE("How fast a corner is taken does not depend on how much is extruded through it", + "[GCodeTiming][JunctionDeviation]") +{ + // The junction cosine is taken over XYZE, so the direction vectors have to be unit length or the + // E term makes the two paths look more parallel than they are and the corner comes out too fast, + // the more so the higher the flow. Marlin normalizes over XYZE on any extruding move + // (planner.cpp, esteps > 0) and Klipper leaves E out of the cosine altogether + // (toolhead.py::Move.calc_junction); on both, this corner is planned by its geometry alone. + const double scv = 5.0; + const double turn = 6.0; + const double geometric = planned_corner_speed(gcfKlipper, scv, 0.0, turn); + REQUIRE(geometric > 0.0); + + // 0.029mm/mm is an ordinary 0.42 x 0.2 line on 1.75mm filament; 0.1 is a fat large-nozzle one. + // Unnormalized these came out at 94.4 and 150.0mm/s against a geometric 86.9. + for (double e_per_mm : {0.029, 0.1}) + REQUIRE_THAT(planned_corner_speed(gcfKlipper, scv, 0.0, turn, 0.0, e_per_mm), + Catch::Matchers::WithinRel(geometric, 0.02)); + + SECTION("and the same holds on Marlin 2") { + const double marlin = planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn); + REQUIRE(marlin > 0.0); + REQUIRE_THAT(planned_corner_speed(gcfMarlinFirmware, scv, 0.05, turn, 0.0, 0.029), + Catch::Matchers::WithinRel(marlin, 0.02)); + } +} From 6ef02a67dbb22ae1a019d9f485f46bfc3e1b44aa Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:35:28 -0300 Subject: [PATCH 069/138] Revert "Fix unstable contours from triangulated planar faces" (#15315) --- src/libslic3r/TriangleMeshSlicer.cpp | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index 738965d75b..2c1c0da23f 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -1461,13 +1461,6 @@ static Polygons make_loops( chain_open_polylines_close_gaps(open_polylines, loops, max_gap, true); #endif - // Orca: A planar quad represented by two triangles contributes a point where the - // slicing plane crosses the shared diagonal. After rounding to coord_t this - // point may be very slightly off the otherwise straight contour edge. Apart - // from being redundant, such points make the subsequent contour - // simplification depend on the slice height (and may move seam candidates). - remove_collinear(loops); - #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { static int iRun = 0; From 5ed56eb876ab8112e2d58961a9005f022905d1d8 Mon Sep 17 00:00:00 2001 From: ExPikaPaka <112851715+ExPikaPaka@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:56:52 +0200 Subject: [PATCH 070/138] Cache system presets to eliminate startup and wizard load times (#14217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add caching system for presets * Removing user\bundle serialization and keeping it only for system presets * Integrate caching into WebGuideDialog which speeds up time of SetupWizzard and PrinterSelection dialog * Add CI\CD step to prepare cache file in ahead of time so user does not need to wait * Add partial cache generation when only one of the vendros is changed to speed up recalculation time * Handle corrupted files * Add cache to GuideDialog as previos version didn't work as expected * Add inspecting tool and fix CI cache generation * Generate cache per vendor * Simplify code by mergin it in PresetBundle * Simplify code a bit more * Add cereal serialize() to VendorProfile, PrinterModel, Preset, and Semver * Remove CachedPrinterModel/VendorProfile/Preset mirror structs from VendorCache * Fix use-after-free in CallAfter lambda; replace raw thread pointer with unique_ptr * Use get_vendor_cache_key() to match cache keys written by the app * Remove BOM added by VSC * Skip invalid vendors * Remove leftover cache file * Fix build for windows arm64 * Revert json cache back * Update check for stale cache * Serealize all value fields for Preset class to minimize regression later * Minimize field duplication by moving Cache thing into PresetBundle * Add tests for Cache system * Add a bit more tests * Merge branch 'main' into feature/cache_profiles_and_optimize_loading_speed * Rvert from per-verndor to single cache file Replace N per-vendor .cache files with a single system_presets.cache that holds all vendors and presets in one serialized blob. Cache load is now all-or-nothing: on hit all vendors are applied from the bundle (sub-second); on miss all vendors are parsed from JSON and a fresh bundle is written to the user cache dir. Invalidation is driven by bundle_key - a sorted concatenation of all vendor JSON version strings. Any vendor update invalidates the whole cache and triggers re-parse on next launch. Guide wizard (WebGuideDialog) loads the bundled cache into a plain PresetBundle instead of a separate VendorGuideData struct, removing the duplicate data model. generate_system_cache simplified from a per-vendor loop to a single save_system_presets_cache() call producing one output file. * Transfer all Preset fields from cache via move assignmet apply_vendor_preset_group was copying fields manually and missed bundle_id, user_id, base_id, sync_info, updated_time, key_values, ini_str. Replace field-by-field copy with move assignment of the fully-deserialized Preset, then restore the vendor pointer which is excluded from serialization. * Ignore cache for future * Remove not used files * Ship one preset cache per vendor in place of the profile JSONs Each vendor's system presets serialize into a single .opc built at package time, and a shipped build carries that file alone — the profile JSON and its sub-file tree are pruned. The vendor loader, the setup wizard's profile list and the resource installer all read a vendor through its cache, falling back to parsing whenever one is absent, stale or unreadable, so the cache stays an optimization and never a source of truth. Caches hold presets in source form and resolve inheritance at load, through the same code the JSON path uses. * Make the preset cache self-describing and load each vendor from the system folder alone The cached DynamicPrintConfig is keyed by name, through a per-file dictionary of the distinct opt_keys, the type each was written as, and the distinct enum value names, instead of by serialization_key_ordinal — a position assigned by declaration order at static init, where inserting one option shifts every later ordinal and the lookup then succeeds on the wrong option. Because a name-keyed payload drops the options this build cannot place rather than being rejected wholesale, the schema fingerprint goes, and with it the two fallbacks that existed only because an installed cache died on every app upgrade: the second lookup tier into resources/profiles and the parse fallback to the same place. A vendor is loaded from /system/ and nowhere else, as on main — which is what makes the app write its .opc files there again. * Simplify the preset cache internals after review * Use the shared temp-dir helper in the preset bundle loading test * Bound stamp string reads in the preset cache * Speed up the setup wizard with a profile-data cache The wizard's per-vendor fast path threw on vendors present only in resources, falling back to a ~29 s raw JSON scan on every open. Each vendor now loads from the directory it was found in, and the derived model/machine/filament/process catalog is cached whole in /cache/wizard_profile_data.json, stamped by each vendor's name and version - a fresh cache makes an open one file read, with no bundle built and no presets installed (~0.2 s vs ~2 s). * Remove debug SVG dump from a geometry test * Move the per-vendor cache file format into PresetCacheFormat * Move the vendor install helpers from PresetBundle into Utils * rename * fix flatpak * change cache version to 1 --------- Co-authored-by: SoftFever --- .gitattributes | 5 + .github/workflows/build_all.yml | 2 + .github/workflows/build_orca.yml | 29 + .gitignore | 1 + build_linux.sh | 2 + docs/HLSD/preset-cache.md | 402 ++++ scripts/build_preset_cache.bat | 141 ++ scripts/build_preset_cache.sh | 161 ++ scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 11 + src/dev-utils/CMakeLists.txt | 10 + src/dev-utils/generate_system_cache.cpp | 84 + src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/Config.hpp | 3 + src/libslic3r/Preset.cpp | 35 +- src/libslic3r/Preset.hpp | 29 +- src/libslic3r/PresetBundle.cpp | 871 +++++---- src/libslic3r/PresetBundle.hpp | 73 +- src/libslic3r/PresetCacheFormat.cpp | 588 ++++++ src/libslic3r/PresetCacheFormat.hpp | 192 ++ src/libslic3r/PrintConfig.hpp | 3 +- src/libslic3r/Semver.hpp | 13 + src/libslic3r/Utils.hpp | 43 +- src/libslic3r/utils.cpp | 154 +- src/slic3r/Config/Snapshot.cpp | 11 +- src/slic3r/GUI/ConfigWizard.cpp | 68 +- src/slic3r/GUI/ConfigWizard_private.hpp | 4 +- src/slic3r/GUI/CreatePresetsDialog.cpp | 19 +- src/slic3r/GUI/GUI_App.cpp | 6 + src/slic3r/GUI/WebGuideDialog.cpp | 460 ++++- src/slic3r/GUI/WebGuideDialog.hpp | 18 +- src/slic3r/Utils/PresetUpdater.cpp | 61 +- tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_geometry.cpp | 5 - .../libslic3r/test_preset_bundle_loading.cpp | 28 +- tests/libslic3r/test_vendor_cache.cpp | 1620 +++++++++++++++++ 35 files changed, 4585 insertions(+), 570 deletions(-) create mode 100644 docs/HLSD/preset-cache.md create mode 100644 scripts/build_preset_cache.bat create mode 100755 scripts/build_preset_cache.sh create mode 100644 src/dev-utils/generate_system_cache.cpp create mode 100644 src/libslic3r/PresetCacheFormat.cpp create mode 100644 src/libslic3r/PresetCacheFormat.hpp create mode 100644 tests/libslic3r/test_vendor_cache.cpp diff --git a/.gitattributes b/.gitattributes index 4cab1f4d26..441bdfe1eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto + +# Shell scripts are run by Git Bash on Windows CI, which cannot read a script +# with CRLF line endings: it fails on the first line. Windows checkouts default +# to core.autocrlf=true, so keep these LF whatever the platform. +*.sh text eol=lf diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 1c392e4bc6..3de2a9184b 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -14,6 +14,7 @@ on: - 'localization/**' - 'resources/**' - ".github/workflows/build_*.yml" + - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' - 'scripts/msix/**' - 'tests/**' @@ -33,6 +34,7 @@ on: - 'build_release_vs.bat' - 'build_release_vs2022.bat' - 'build_release_macos.sh' + - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' - 'scripts/msix/**' - 'tests/**' diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index a7652c3bd6..112c0b279b 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -162,6 +162,14 @@ jobs: retention-days: 5 if-no-files-found: error + - name: Build system preset cache (macOS) + if: runner.os == 'macOS' && !inputs.macos-combine-only + working-directory: ${{ github.workspace }} + shell: bash + # The bundle was already packed from resources/, so the caches have to be + # installed into it here; the source tree keeps its JSONs for later jobs. + run: ./scripts/build_preset_cache.sh -b build/${{ inputs.arch }} build/${{ inputs.arch }}/OrcaSlicer/OrcaSlicer.app/Contents/Resources/profiles + - name: Pack macOS app bundle ${{ inputs.arch }} if: runner.os == 'macOS' && !inputs.macos-combine-only working-directory: ${{ github.workspace }} @@ -390,6 +398,13 @@ jobs: if ($arch -eq "arm64") { .\build_release_vs.bat slicer arm64 tests } else { .\build_release_vs.bat slicer tests } shell: pwsh + - name: Build system preset cache (Windows) + if: runner.os == 'Windows' + shell: cmd + # Shipped into both the already-installed tree (portable zip, MSIX) and + # the checkout cpack re-installs from when it builds the NSIS installer. + run: scripts\build_preset_cache.bat --prune-source "%BUILD_DIR%" "resources\profiles" "%BUILD_DIR%\OrcaSlicer\resources\profiles" + - name: Pack unit tests Win if: runner.os == 'Windows' working-directory: ${{ github.workspace }} @@ -539,6 +554,20 @@ jobs: retention-days: 5 if-no-files-found: error + - name: Build system preset cache (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + # Both were packed from resources/ before the caches existed, so the + # AppImage is unpacked first and the caches shipped into it and into + # the package tree; the source tree keeps its JSONs for later steps. + appimage=$(find build -maxdepth 1 -name "OrcaSlicer_Linux_AppImage*.AppImage" | head -1) + chmod +x "$appimage" + "$appimage" --appimage-extract + ./scripts/build_preset_cache.sh -b build build/package/resources/profiles squashfs-root/resources/profiles + appimagetool=$(find build -name "appimagetool.AppImage" | head -1) + ARCH=$(uname -m) "$appimagetool" --appimage-extract-and-run squashfs-root "$appimage" + rm -rf squashfs-root # Ship the freshly-built validator so slice_check_linux (build_all.yml) # can slice-sweep the shipped profiles with this PR's engine. Taken from # the aarch64 leg so the sweep also exercises the arm build; x86_64 on diff --git a/.gitignore b/.gitignore index 916c7207b7..4d3ccb5c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ internal_docs/ # Python bytecode __pycache__/ *.pyc +*.opc diff --git a/build_linux.sh b/build_linux.sh index 72ea742f1a..6d65a10e41 100755 --- a/build_linux.sh +++ b/build_linux.sh @@ -567,6 +567,8 @@ if [[ -n "${BUILD_ORCA}" ]] || [[ -n "${BUILD_TESTS}" ]] ; then print_and_run cmake --build $BUILD_DIR --config "${BUILD_CONFIG}" --target OrcaSlicer echo "Building OrcaSlicer_profile_validator .." print_and_run cmake --build $BUILD_DIR --config "${BUILD_CONFIG}" --target OrcaSlicer_profile_validator + echo "Building generate_system_cache ..." + print_and_run cmake --build $BUILD_DIR --config "${BUILD_CONFIG}" --target generate_system_cache ./scripts/run_gettext.sh fi if [[ -n "${BUILD_TESTS}" ]] ; then diff --git a/docs/HLSD/preset-cache.md b/docs/HLSD/preset-cache.md new file mode 100644 index 0000000000..6e693dbd6f --- /dev/null +++ b/docs/HLSD/preset-cache.md @@ -0,0 +1,402 @@ +# System Preset Cache — High Level Design + +## Why it exists + +OrcaSlicer ships tens of thousands of system preset JSON files. Every launch used to +parse all of them: read each vendor profile, walk its machine, process and filament +sub-files, resolve inheritance, and build the preset collections from scratch. That +parse dominated startup, and it produced the same result every time, because system +presets only change when the app is updated or a profile update is installed. + +The preset cache replaces that parse with a read. Each vendor's presets are serialized +once — at build time, in CI — into a single binary file the app reads in one pass. The +read replaces the file walk and the JSON parsing, which is where the time went; +resolving inheritance and registering the presets still runs at load, through the same +code the JSON path uses, so the result is the parse's result without the parse. + +The cache is **only ever an optimization**. Every rule below exists to guarantee that a +cache is either provably equivalent to parsing the JSONs, or rejected. There is no +"mostly right" cache. + +## The unit is one vendor + +A cache covers exactly one vendor. `BBL.opc` sits beside `BBL.json` and holds +everything `BBL.json` and the `BBL/` sub-file tree would have produced. + +Per-vendor granularity is what makes the system practical: + +- A vendor whose profile is bumped invalidates only its own cache. The other 60-odd + vendors keep theirs — even when the bumped vendor is the shared Orca filament + library everyone else inherits from. +- The setup wizard, which loads vendors one at a time, gets the same speedup as + startup without a second code path. +- A vendor with no cache, or a broken one, costs only that vendor a parse. + +A cache holds *system* presets only. User presets, project settings and modified +presets are never serialized — they have their own storage and their own lifecycle. + +## Where the files live + +| Location | Contents on a shipped build | Role | +|---|---|---| +| `resources/profiles/` | `.opc` alone — the profile and its preset JSONs both pruned | What the app ships with; what installing copies from, and the only thing it is read for | +| `/system/` | `.opc` alone, or `.json` + `/` after an update | What the user has installed | +| `/system/` (dev build) | `.json` + `/` + `.opc` written at runtime | A developer tree caches as it parses | +| `/cache/wizard_profile_data.json` | The wizard's derived vendor catalog plus the stamps it was built from | Written and read by the setup wizard only; never shipped (see "The wizard's profile-data cache") | + +Two forms of the same vendor therefore exist, and the system's central rule is that +**a vendor's cache is the whole of it**. Where a cache ships or is installed, no profile +and no preset JSONs sit beside it: the cache carries the presets, the vendor profile, +and the version stamp that says which release it came from. A vendor is "installed" if +either form is present *and usable*, and its installed version is read from whichever +form a load would serve. + +What stays beside the caches in `resources/profiles/` is everything that is not a +preset: each vendor's directory of printer thumbnails, cover images, bed models and +hotend meshes, which are read from disk by path and were never part of the cache. Files +that are not vendors at all, `blacklist.json` chief among them, are untouched. + +The alternative — shipping both and treating the cache as a sidecar — was rejected. It +doubles the installed size, and it creates a class of bug where the two disagree and +the app's behavior depends on which one a given code path happened to read. + +## What a cache file is + +A fixed-size header followed by one binary stream. + +The header carries a magic number, the cache format version, the payload size and a +CRC32 of the payload. It exists so that a truncated download, a half-written file or a +file from an entirely different program is rejected in microseconds, before anything +tries to interpret it. + +The payload opens with the stamps that decide whether the cache may be used at all — +format version, vendor name, vendor version — then a dictionary, and then the vendor's +data: its vendor profile, three lists of preset entries (process, filament, machine), +and the count of errors the original parse hit. + +Each entry is one preset **in source form**: what its JSON sub-file states and nothing +that resolving it derives — the preset's own config diff, the name of the preset it +inherits, and the parse metadata (name, sub-path, description, instantiation, setting +and filament ids, renames). Non-instantiated base presets are stored too; the children +that inherit from them cannot resolve without them. + +**The payload names its own keys.** The dictionary holds the distinct `opt_key`s the +file uses, the `ConfigOptionType` each was written as, and the distinct enum *value +names*; an option in an entry's config is then a `uint16` index into that dictionary +plus its value. Names are written once per file rather than once per occurrence, and a +reader resolves the dictionary against this build's `print_config_def` once, after +which reading an option is a vector index. + +This is what makes the cache survive config-schema drift. The alternative — keying an +option by its `serialization_key_ordinal`, the position `ConfigDef::add` assigns by +declaration order at static init — cannot: inserting one option into the middle of +`PrintConfig.cpp` shifts every later ordinal, and the lookup on the way back in then +*succeeds on the wrong option*, silently, wherever the two share a type. Because a +name-keyed payload instead drops the individual options this build cannot place, the +file as a whole stays readable, and there is no schema fingerprint — no checksum over +the option schema that would reject every cache on every release. An option this build +no longer defines, or now defines with a different type, gets exactly what it gets from +a JSON profile: read, dropped, and the rest of the preset loads. + +The ordinal-keyed cereal hooks in `PrintConfig.hpp` are untouched — they are also the +undo/redo wire format, where the process cannot change underneath them. The cache has +its own serialization in `PresetCacheFormat.{hpp,cpp}`. + +Three deliberate choices in the layout: + +- **Stamps come first**, so the question "what version is this vendor installed at?" + can be answered by reading the first kilobyte. The updater asks that question for + every vendor on every launch; reading tens of megabytes to answer it would give back + the startup time the cache saved. The dictionary sits behind them, ahead of the + entries, so a reader that does go on resolves it once and then indexes. +- **Nothing inherited is baked in.** A filament preset that inherits from the shared + library is stored as its own diff plus its parent's name, and the parent is looked up + when the entry is installed, against whatever library is loaded then. A cache + therefore carries no other vendor's values, and no other vendor's update — the + library's included — can make it stale. +- **Nothing derived is stored.** Default presets, flattened configs, aliases and + lookup maps are all reconstructed at load by the same code the JSON path runs, and + state that path never fills (obsolete-preset lists) is not stored either. This keeps + the cache a record of the vendor's data, not a memory image of the program's state. + +## When a cache may be used + +A cache is accepted only if every gate below passes. Any failure means "parse the +JSONs instead" — never a hard error, never a partial load. + +**1. Integrity.** Magic number, a declared body size that is exactly the rest of the +file, CRC32 over the payload. The size is checked against the file's real length before +anything is allocated on the strength of it, so an eight-byte field in an unauthenticated +file cannot ask for a gigabyte. + +**2. Cache format version.** A single integer bumped by hand whenever the binary layout +changes in a way nothing else would catch: reordering or retyping a hand-written +serialized field, or changing what the cache's own stamps mean. Config-schema drift is +explicitly *not* such a change — the dictionary handles it — so this no longer moves +every release. + +**3. Vendor identity and version.** The cache names the vendor it holds and the profile +version it was built from. It is accepted only if that version is at least as new as +the profile now on disk. Where no profile sits beside the cache — the shipped, +cache-only form — the comparison is skipped, because nothing on disk can be newer than +a cache that is the installation. + +**4. Every entry installs.** Entries are installed as they are read, and an entry that +cannot be — typically one that inherits a parent the currently loaded filament library +no longer provides — rejects the whole cache, never just the entry. A partial vendor is +not a vendor. + +There is deliberately no stamp for the shared filament library. A cache stores its +filaments' inheritance by name and resolves it at load, so a library update changes +what a cache load *produces*, never whether the cache is *valid* — the same file yields +the updated result. This matters most on a shipped build, where a vendor is its cache +and nothing else: a profile update that delivered only the library would otherwise have +stranded every other vendor with a cache it invalidated and no JSONs to fall back on. + +A vendor profile with no parsable version is never cached and never served from a +cache. There would be no way to tell later whether the cache had gone stale, and a +cache nothing can invalidate is worse than no cache. + +## How a vendor is loaded + +Vendors load in a fixed order, because filament inheritance crosses exactly one +boundary: any vendor's filament may inherit from the shared Orca filament library, +and nothing else reaches across vendors. The library therefore goes first, alone; +every other vendor follows in parallel, resolving against it; and the results are +merged in a stable order: + +```mermaid +flowchart LR + lib["1 · OrcaFilamentLibrary
loaded first, synchronously"] --> par["2 · every other vendor in parallel,
each into its own bundle, filaments
resolving against the loaded library"] --> merge["3 · bundles merged into one,
sequentially, in stable vendor order"] +``` + +Whether a vendor comes from its cache or from a parse changes nothing in that +order — both produce the same bundle, so cached and parsed vendors mix freely in +one startup. + +**A vendor is loaded from where it is installed and nowhere else.** For startup that +is `/system/`; resources reaches the app by being *installed* into that +directory first, never by being loaded from. (The setup wizard is the one caller with +a different notion of "where": it also shows vendors the user has not installed, and +loads those from `resources/profiles` — see "The wizard's profile-data cache".) There +is one lookup tier and one parse source: + +``` +load vendor V from /system: + system/V.opc passes CACHE_VERSION + size + CRC + vendor name + version gate? + yes -> serve from it + no -> parse system/V.json, then write system/V.opc back +``` + +The same decision drawn out — "the gates" are the four acceptance checks above: + +```mermaid +flowchart TB + start["load vendor V from a directory dir
— normally <data_dir>/system/"] + start --> stamp["installed version = version of dir/V.json
— or ∞ with no profile there,
the cache then being the installation"] + stamp --> g1{"dir/V.opc
passes all four gates?"} + g1 -- "yes" --> hit(["served from the
installed cache"]) + g1 -- "no" --> pd["parse the JSONs in dir"] + pd --> ver{"profile version
parsable?"} + ver -- "yes" --> save(["loaded; dir/V.opc written back —
the next load takes the top path"]) + ver -- "no" --> raw(["loaded, never cached"]) +``` + +A second tier into `resources/profiles/` used to sit between those two, and a parse +fallback to the same place behind them. Both existed only because an installed cache +died on every app upgrade, when the schema fingerprint rejected it; with the fingerprint +gone there is nothing for them to rescue. They also had a cost: on a developer tree the +shipped cache answered first, so the profile in `/system/` was never parsed +and its cache was never written back. + +Serving from a cache is not a memory-image restore. The entries are deserialized and +then installed one by one — inheritance resolved against the presets installed before +them and the currently loaded filament library, configs flattened onto the collection +defaults, validated and registered — by the same function the JSON path calls straight +after parsing a sub-file. The two paths share everything below the parse, which is what +makes a cache-loaded bundle indistinguishable from a JSON-loaded one by construction +rather than by test coverage. Installation also rebuilds each preset's file path from +the local data directory, so a shipped cache never carries the generating machine's +paths. + +App upgrades work because a cache normally survives one. Only a deliberate +`CACHE_VERSION` bump makes an installed cache unreadable, and that is handled at +install time rather than at load: a vendor whose cache this build cannot read counts +as **not installed**, so the updater lays down a working copy on the next launch (see +below). A vendor that still has its profile JSONs beside the cache is simply parsed +and re-cached. + +If a parse does happen and the vendor's profile carries a version, the app writes the +cache back beside where it looked for the vendor. That is how a developer build warms +itself up on second launch, and how a vendor delivered by a profile update becomes +cached without waiting for the next release. + +## The wizard's profile-data cache + +The setup wizard's printer and filament pages want every vendor in one bundle — the +installed ones *and* the shipped ones the user has not installed yet, because the +wizard is where installing is chosen. Its set therefore spans two directories: +`/system/` for installed vendors (shadowing resources on a name collision), +`resources/profiles` for the rest, each vendor loaded from its own directory. + +What the wizard actually consumes from that bundle is one derived JSON — the model / +machine / filament / process catalog its web pages render — and that JSON is a pure +function of the vendor set: each vendor's name and version, in load order. A profile +change requires a version bump, so name and version determine a vendor's content +wherever its copy sits; which directory served it is deliberately **not** stamped, +and installing or removing a copy at an unchanged version leaves the cache valid. So +the wizard caches the *derived JSON*, not another form of the inputs: +`/cache/wizard_profile_data.json` holds the stamp list and the catalog. On +open, the wizard computes the current stamps (one version peek per vendor) and, when +they match, serves the catalog from the file — no bundle built, no preset installed. +Caching bundle inputs instead was tried and measured: rebuilding the bundle from +per-vendor caches costs ~2 s of preset installation whatever feeds it, so only +skipping the rebuild entirely wins. + +Any change to the set — a vendor added, removed or updated, or its cache-only +`.opc` replaced by a newer one — changes the stamps and retires the whole file; +the wizard then rebuilds the bundle vendor by vendor (per-vendor caches serving where +they cover) and writes the catalog back. Selections, region and per-open decorations +are applied downstream of the cache either way, so a served catalog is +indistinguishable from a rebuilt one. Nothing ships this file and the updater never +touches it; it is a locally written artifact, re-derived whenever stale, written +through a temp file and rename so half a cache is never readable. + +The cache lives under `/cache/`, not beside the vendors: everything that +scans `/system/` treats any `.opc` there as a vendor, so a non-vendor +cache file must not sit in that directory. Relatedly, the stamp reader is hardened: +`read_cache_stamps` validates the cache version before reading anything +variable-length and bounds the stamp strings' lengths, so a reader pointed at a +foreign or damaged `.opc` rejects it cleanly instead of aborting on a garbage +64-bit allocation. + +## How a vendor is installed + +Installing copies from `resources/profiles/` into `/system/`. A shipped build +offers only a cache and a source tree only JSONs, but a partially-generated tree can +have both, at different versions, so the installer picks the form that ships at the +**newer version** and installs only that one: + +- Cache newer or equal, and readable → copy the `.opc`, verify the *copy* is one this + build can read, and only then delete any profile and vendor directory a previous + install left behind, so nothing can shadow it. +- Profile newer, or the cache unreadable or absent → copy the profile and the vendor's + preset JSONs exactly as the app did before caches existed, and delete any stale `.opc` + once the profile is safely in place. + +One vendor that cannot be installed is one vendor missing, not a reason to leave the +rest uninstalled: the installer skips it, records the failure, and carries on with the +batch. A vendor whose cache arrives unreadable falls back to installing its profile, +which is decided by reading the copy rather than by the kilobyte peek that chose the +form. + +**"Installed" means present and usable.** Where the cache is the whole of a vendor's +installation, a `.opc` this build cannot read is not an installation — counted as one, +the vendor would be stranded with nothing to load and the updater would never repair +it. The installed version is likewise whichever form a load would actually serve: the +cache's stamp while it covers the profile beside it, the profile's own version once it +does not. + +The result is that only one form of a vendor is ever present, and it is the newest one +the build has. This matters most for the update check, which compares what is installed +against what installing *would* lay down: if those two disagreed about which form +counts, a vendor could reinstall on every launch forever, or silently never update. + +Profile updates delivered over the air always arrive as JSONs, and they win — an +updated vendor's real profile lands in the data directory, the installed cache beside it +is older and gets rejected, and the vendor is parsed and re-cached. An update that touches only +the filament library needs nothing more: every other vendor's cache stays valid and +simply resolves against the new library on its next load. + +## How the caches are produced + +Cache generation is a build step, not something a user ever runs. + +One script per platform does the whole job, and CI calls it once on each. It builds a +small dev-utility that loads a profiles directory exactly as the app would, with cache +writing enabled, dropping a `.opc` beside every vendor profile it parses; then +it copies those caches into each packaged application it was pointed at and deletes +every preset JSON they replace — the vendor's own profile included. Only a vendor that +actually has a cache is pruned, so a vendor the generator skipped keeps its JSONs and is +simply parsed at startup. + +Caches are generated into the checkout's own `resources/profiles`, because that is what +cpack re-installs from when it builds the NSIS installer — so that directory is also a +prune target in CI. Pruning it deletes the checkout's preset JSONs, which is a packaging +step, not something a build should do to a working tree by surprise: the Windows script +refuses that target unless given `--prune-source`, and CI passes it. + +Generation runs after the build, in the same job, so the caches ship with a build that +can read them. + +The flatpak differs only in where the script is called from. Nothing outside +flatpak-builder ever builds it, so there is no packaged tree for the workflow to point +the script at afterwards: the manifest runs it as a build step instead, against the +profiles the install has already copied into `/app`. + +## Behavior when things go wrong + +The system is designed so that no cache problem is fatal: + +- **Corrupt, truncated or foreign file** — rejected at the header, vendor parsed. A + cache is written to a temp file beside its target and moved into place, so a write + that dies partway leaves the previous cache intact rather than a truncated one. +- **An option this build no longer has, or now types differently** — that option alone + is dropped, exactly as a JSON profile's would be. The preset and the file load. +- **Cache from a build with a different cache layout** — rejected on `CACHE_VERSION`. + A vendor with JSONs beside it is parsed and re-cached; a cache-only vendor reads as + not installed and the updater reinstalls it. +- **Stale cache** — rejected on the vendor version stamp, vendor parsed and re-cached. +- **Failure part-way through loading** — a deserialization error, or any entry that + fails to install — rejects the whole cache, and the bundle is reset to a clean state + before falling back, so a half-loaded cache can never leak into the parsed result. +- **A vendor that can be neither read nor parsed** — logged, and left out. The setup + wizard drops that vendor from its list and opens with the rest; startup records the + error alongside the vendors that did load. One broken vendor never takes the app down. + +The one genuine limit: on a shipped build a vendor is its cache and nothing else, so a +rejected cache has nothing to fall back to for that vendor. This is by design — the +alternative is shipping every preset twice — and it is why the acceptance gates are +conservative and why CI generates the caches with the same build that ships them. The +recovery path is a profile update, which delivers real JSONs. + +It also means nothing may quietly assume a `.json` exists. Discovery, version +checks and the update decision all read whichever form is present, and a code path that +enumerates only `*.json` will find no vendors at all in a packaged build. + +## Maintenance rules + +- **Adding, removing, retyping or reordering a config option** needs nothing. The + payload names its keys and its enum values, so an option a cache carries and this + build does not is dropped; one this build has and the cache does not is simply + absent, as it would be from a JSON that predates it. +- **Changing a hand-written `serialize()`** — `VendorProfile` or its nested types — or + the `CachedPreset` field list — written and read by `visit_entry` in + `PresetCacheFormat.cpp`, one list for the save, the load and the name peek alike — or + the cache's own layout or stamps, requires bumping `CACHE_VERSION` by hand. +- **The dictionary indexes with a `uint16`**, so `print_config_def` may hold at most + 65535 options and one cache at most 65535 distinct enum value names. + `CacheDictionary::save` throws past that, which surfaces when CI generates the + caches rather than on a user's machine. +- **Bumping `CACHE_VERSION` is safe without a resources fallback** because + `is_vendor_installed` means *present and usable*: cache-only vendors read as not + installed after a bump, and the updater reinstalls them from resources. +- **Bumping a vendor profile's version** invalidates that vendor's cache and nothing + else — the filament library's included. Other vendors' caches resolve against the + new library the next time they load. +- **Caches are never committed.** They are build artifacts, generated per build, + ignored by git. + +## Where this lives in the tree + +| Area | Files | +|---|---| +| Everything about the bytes on disk — the dictionary, one config's wire format, the file framing and stamps, entry serialization, `VendorCacheFile` save/load/peeks | `src/libslic3r/PresetCacheFormat.{hpp,cpp}` | +| Serve-or-parse decision, installing cache entries into a bundle, cache write-back | `src/libslic3r/PresetBundle.{hpp,cpp}` | +| Vendor profile serialization | `src/libslic3r/Preset.hpp` | +| Vendor discovery, installed/shipped versions, installation | `src/libslic3r/utils.cpp` (declared in `Utils.hpp`) | +| Update and reinstall decisions | `src/slic3r/Utils/PresetUpdater.cpp` | +| Setup wizard and printer-selection dialog | `src/slic3r/GUI/ConfigWizard.cpp`, `src/slic3r/GUI/WebGuideDialog.cpp` | +| Generator tool | `src/dev-utils/generate_system_cache.cpp` | +| Build and packaging script | `scripts/build_preset_cache.{sh,bat}` | +| Tests | `tests/libslic3r/test_vendor_cache.cpp` | diff --git a/scripts/build_preset_cache.bat b/scripts/build_preset_cache.bat new file mode 100644 index 0000000000..82f3e02723 --- /dev/null +++ b/scripts/build_preset_cache.bat @@ -0,0 +1,141 @@ +@echo off +rem Build the per-vendor system preset caches (one .opc per vendor) by +rem running the generate_system_cache.exe dev tool against a profiles directory, +rem and make every profiles directory named on the command line ship-ready: +rem install the caches into it and delete the preset JSONs they replace, so a +rem build ships one copy of its presets instead of two. +rem +rem scripts\build_preset_cache.bat [build_dir] [target_dir ...] +rem +rem build_dir defaults to "build" +rem target_dir profiles directories to ship into. Caches are generated into +rem the source tree's resources\profiles, which is what every +rem packaging step copies from; a target may be that same +rem directory, which then only gets pruned. +rem --prune-source +rem allow a target that is the directory the caches were generated +rem into (resources\profiles). Pruning it deletes the checkout's +rem own preset JSONs, which is a packaging step - not something a +rem build should do to a working tree by surprise. CI passes it. +rem +rem Shipping deletes, so it is a CI packaging step. A vendor's own .json +rem goes along with its preset JSONs: the cache carries the vendor profile and +rem the version it was built at, so discovery, version checks and installing all +rem read it there. Only a vendor that has a cache is pruned, so non-vendor JSONs +rem (blacklist.json) are left alone, as are the vendor directories themselves - +rem thumbnails, covers and bed models still live there. +rem +rem set CONFIG= to pin the build config for multi-config generators +rem (default: the config of the tool already in the build tree, else Release) +setlocal enabledelayedexpansion + +set "REPO_ROOT=%~dp0.." + +set "PRUNE_SOURCE=" +:parse_flags +if /i "%~1"=="--prune-source" ( + set "PRUNE_SOURCE=1" + shift + goto :parse_flags +) + +set "BUILD_DIR=%~1" +if "%BUILD_DIR%"=="" set "BUILD_DIR=build" +if not exist "%BUILD_DIR%\" ( + echo ERROR: build tree not found: %BUILD_DIR% 1>&2 + exit /b 1 +) +if not "%~1"=="" shift + +rem Newest match wins: a stale binary silently produces a stale cache layout. +call :find_tool +if not defined CONFIG ( + for %%c in (Debug Release RelWithDebInfo MinSizeRel) do ( + echo !TOOL! | findstr /i "\\%%c\\" >nul && set "CONFIG=%%c" + ) +) +if not defined CONFIG set "CONFIG=Release" + +echo Building generate_system_cache in %BUILD_DIR% (%CONFIG%) +cmake --build "%BUILD_DIR%" --config %CONFIG% --target generate_system_cache +if errorlevel 1 ( + echo ERROR: could not build generate_system_cache - configure the build tree with -DORCA_TOOLS=ON: 1>&2 + echo cmake -S "%REPO_ROOT%" -B "%BUILD_DIR%" -DORCA_TOOLS=ON 1>&2 + exit /b 1 +) +call :find_tool +if not defined TOOL ( + echo ERROR: generate_system_cache.exe not found under %BUILD_DIR% - build with -DORCA_TOOLS=ON 1>&2 + exit /b 1 +) + +set "PROFILES=%REPO_ROOT%\resources\profiles" +if not exist "%PROFILES%\" ( + echo ERROR: profiles directory not found: %PROFILES% 1>&2 + exit /b 1 +) +for %%d in ("%PROFILES%") do set "PROFILES=%%~fd" + +rem Add the slicer's runtime DLL directory to PATH so generate_system_cache.exe +rem can resolve its dependencies (TKernel.dll etc.) without a full install step. +set "DLL_DIR=" +for /f "delims=" %%f in ('dir /s /b "%BUILD_DIR%\TKernel.dll" 2^>nul') do ( + if not defined DLL_DIR set "DLL_DIR=%%~dpf" +) +if defined DLL_DIR set "PATH=%DLL_DIR%;%PATH%" + +echo Generating per-vendor preset caches in %PROFILES% +rem Start clean so vendors that went away - and caches written by older tool +rem versions - don't linger next to the freshly generated ones. +del /q "%PROFILES%\*.opc" 2>nul +del /q "%PROFILES%\*.cache" 2>nul +"%TOOL%" --path "%PROFILES%" --log_level 2 +if errorlevel 1 exit /b %errorlevel% + +:next_target +if "%~1"=="" exit /b 0 +call :ship "%~1" +if errorlevel 1 exit /b 1 +shift +goto :next_target + +:ship +set "TARGET=%~1" +if not exist "%TARGET%\" ( + echo ERROR: profiles directory not found: %TARGET% 1>&2 + exit /b 1 +) +for %%d in ("%TARGET%") do set "TARGET=%%~fd" +if /i "%TARGET%"=="%PROFILES%" if not defined PRUNE_SOURCE ( + echo %TARGET%: skipped - this is where the caches were generated. + echo Pass --prune-source to prune it; that deletes this checkout's preset JSONs. + exit /b 0 +) +if /i not "%TARGET%"=="%PROFILES%" copy /y "%PROFILES%\*.opc" "%TARGET%\" >nul + +set /a SHIPPED=0 +set /a PRUNED=0 +for %%c in ("%PROFILES%\*.opc") do ( + set /a SHIPPED+=1 + set "VENDOR=%%~nc" + if exist "%TARGET%\!VENDOR!.json" ( + del /q "%TARGET%\!VENDOR!.json" + set /a PRUNED+=1 + ) + if exist "%TARGET%\!VENDOR!\" ( + for /f %%n in ('dir /s /b "%TARGET%\!VENDOR!\*.json" 2^>nul ^| find /c /v ""') do set /a PRUNED+=%%n + del /s /q "%TARGET%\!VENDOR!\*.json" >nul 2>&1 + rem Deepest first, so a directory the delete above emptied goes too; rd + rem refuses the ones still holding covers or meshes. + for /f "delims=" %%d in ('dir /s /b /ad "%TARGET%\!VENDOR!" 2^>nul ^| sort /r') do rd "%%d" 2>nul + ) +) +echo %TARGET%: !SHIPPED! caches, dropped !PRUNED! preset JSONs +exit /b 0 + +:find_tool +set "TOOL=" +for /f "delims=" %%f in ('dir /s /b /o-d "%BUILD_DIR%\generate_system_cache.exe" 2^>nul') do ( + if not defined TOOL set "TOOL=%%f" +) +exit /b 0 diff --git a/scripts/build_preset_cache.sh b/scripts/build_preset_cache.sh new file mode 100755 index 0000000000..7a874f7e06 --- /dev/null +++ b/scripts/build_preset_cache.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Build the per-vendor system preset caches (one .opc per vendor) by +# running the generate_system_cache dev tool against a profiles directory, and +# make every profiles directory named on the command line ship-ready: install +# the caches into it and delete the preset JSONs they replace, so a build ships +# one copy of its presets instead of two. +# +# ./scripts/build_preset_cache.sh # caches into resources/profiles +# ./scripts/build_preset_cache.sh -b build/arm64 # search this build tree for the tool +# ./scripts/build_preset_cache.sh

[ ...] # and ship into these profiles dirs +# +# Caches are generated into the source tree's resources/profiles, which is what +# every packaging step copies from. Shipping deletes, so it is a CI packaging +# step: pass packaged output directories, or the checkout of a build that is +# about to be packaged from it. +# +# A vendor's own .json goes along with its preset JSONs: the cache +# carries the vendor profile and the version it was built at, so discovery, +# version checks and installing all read it there. A shipped vendor is its cache +# and nothing else. Only a vendor that has a cache is pruned, so an ungenerated +# vendor keeps its JSONs and is simply parsed at startup; non-vendor JSONs +# (blacklist.json) are left alone, as are the vendor directories themselves — +# thumbnails, covers and bed models still live there. +# +# -b build tree holding the tool +# (default: build/arm64, build/x86_64, or build — first that exists) +# -p profiles directory to generate caches into +# (default: /resources/profiles) +# -c build config for multi-config generators +# (default: the config of the tool already in the build tree, else +# the build tree's CMAKE_BUILD_TYPE) +# -n skip the rebuild and run the tool already in the build tree +# -l tool log level (default: 2) +# --prune-source +# allow a target that is the directory the caches were generated +# into (resources/profiles). Pruning it deletes the checkout's own +# preset JSONs, which is a packaging step - not something a build +# should do to a working tree by surprise. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd -P)" +build_dir="" +profiles_dir="" +config="" +build_tool=1 +log_level=2 +prune_source=0 + +# getopts does not do long options; pull this one out first. +args=() +for arg in "$@"; do + if [ "$arg" = "--prune-source" ]; then prune_source=1; else args+=("$arg"); fi +done +set -- ${args+"${args[@]}"} + +while getopts "b:p:c:l:nh" opt; do + case $opt in + b) build_dir="$OPTARG" ;; + p) profiles_dir="$OPTARG" ;; + c) config="$OPTARG" ;; + n) build_tool=0 ;; + l) log_level="$OPTARG" ;; + h) sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) exit 1 ;; + esac +done +shift $((OPTIND - 1)) + +if [ -z "$build_dir" ]; then + for candidate in "$repo_root/build/arm64" "$repo_root/build/x86_64" "$repo_root/build"; do + if [ -d "$candidate" ]; then build_dir="$candidate"; break; fi + done +fi +if [ -z "$build_dir" ] || [ ! -d "$build_dir" ]; then + echo "ERROR: build tree not found (pass -b )" >&2 + exit 1 +fi + +# Newest match wins: multi-config trees keep one binary per config, and a stale +# one silently produces a stale cache layout. +find_tool() { + local best="" f + while IFS= read -r f; do + [ -n "$f" ] || continue + if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best="$f"; fi + done < <(find "$build_dir" -name generate_system_cache -type f 2>/dev/null) + printf '%s' "$best" +} + +tool=$(find_tool) +if [ -z "$config" ]; then + case "$tool" in + */Debug/*) config=Debug ;; + */Release/*) config=Release ;; + */RelWithDebInfo/*) config=RelWithDebInfo ;; + */MinSizeRel/*) config=MinSizeRel ;; + *) config=$(sed -n 's/^CMAKE_BUILD_TYPE:[A-Z]*=\(.\+\)$/\1/p' "$build_dir/CMakeCache.txt" 2>/dev/null | head -1 || true) ;; + esac +fi + +if [ "$build_tool" = 1 ]; then + echo "Building generate_system_cache in $build_dir${config:+ ($config)}" + build_args=(--build "$build_dir" --target generate_system_cache) + if [ -n "$config" ]; then build_args+=(--config "$config"); fi + if ! cmake "${build_args[@]}"; then + echo "ERROR: could not build generate_system_cache — configure the build tree with -DORCA_TOOLS=ON:" >&2 + echo " cmake -S \"$repo_root\" -B \"$build_dir\" -DORCA_TOOLS=ON" >&2 + exit 1 + fi + tool=$(find_tool) +fi + +if [ -z "$tool" ]; then + echo "ERROR: generate_system_cache not found under $build_dir — build with -DORCA_TOOLS=ON" >&2 + exit 1 +fi + +if [ -z "$profiles_dir" ]; then profiles_dir="$repo_root/resources/profiles"; fi +if [ ! -d "$profiles_dir" ]; then + echo "ERROR: profiles directory not found: $profiles_dir" >&2 + exit 1 +fi +profiles_dir=$(cd "$profiles_dir" && pwd -P) + +# Start clean so vendors that went away — and caches written by older tool +# versions — don't linger next to the freshly generated ones. +echo "Generating per-vendor preset caches in $profiles_dir" +rm -f "$profiles_dir"/*.opc "$profiles_dir"/*.cache +"$tool" --path "$profiles_dir" --log_level "$log_level" + +for target in "$@"; do + resolved=$(cd "$target" 2>/dev/null && pwd -P) || { + echo "ERROR: profiles directory not found: $target" >&2 + exit 1 + } + if [ "$resolved" = "$profiles_dir" ] && [ "$prune_source" -eq 0 ]; then + echo "$resolved: skipped - this is where the caches were generated." + echo " Pass --prune-source to prune it; that deletes this checkout's preset JSONs." + continue + fi + if [ "$resolved" != "$profiles_dir" ]; then + cp "$profiles_dir"/*.opc "$resolved"/ + fi + + pruned=0 + shipped=0 + for cache in "$profiles_dir"/*.opc; do + vendor=$(basename "$cache" .opc) + shipped=$(( shipped + 1 )) + if [ -f "$resolved/$vendor.json" ]; then + rm -f "$resolved/$vendor.json" + pruned=$(( pruned + 1 )) + fi + [ -d "$resolved/$vendor" ] || continue + n=$(find "$resolved/$vendor" -name '*.json' | wc -l) + find "$resolved/$vendor" -name '*.json' -delete + find "$resolved/$vendor" -type d -empty -delete + pruned=$(( pruned + n )) + done + echo "$resolved: $shipped caches, dropped $pruned preset JSONs" +done diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index c33425f23f..94c98121ec 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -347,6 +347,7 @@ modules: - | cmake . -B build_flatpak \ -DFLATPAK=ON \ + -DORCA_TOOLS=ON \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_PREFIX_PATH=/app \ -DCMAKE_INSTALL_PREFIX=/app \ @@ -357,6 +358,13 @@ modules: - ./scripts/run_gettext.sh - cmake --build build_flatpak --target install -j$FLATPAK_BUILDER_N_JOBS + # Per-vendor preset caches. On the other platforms CI runs this script + # itself; the flatpak is built inside flatpak-builder and the generator + # only exists in here, so the swap is a build step instead, against the + # profiles the install above copied into /app. + - cmake --build build_flatpak --target generate_system_cache -j$FLATPAK_BUILDER_N_JOBS + - ./scripts/build_preset_cache.sh -n -b build_flatpak /app/share/OrcaSlicer/profiles + cleanup: - /include @@ -403,6 +411,9 @@ modules: - type: file path: ../run_gettext.sh dest: scripts + - type: file + path: ../build_preset_cache.sh + dest: scripts # AppData metainfo for GNOME Software & Co. - type: file diff --git a/src/dev-utils/CMakeLists.txt b/src/dev-utils/CMakeLists.txt index e3534a024a..2cfce6a7c5 100644 --- a/src/dev-utils/CMakeLists.txt +++ b/src/dev-utils/CMakeLists.txt @@ -20,6 +20,16 @@ if (SLIC3R_ENC_CHECK) ) endif() +if (ORCA_TOOLS) + set(_DEV_DEFS -DBOOST_ALL_NO_LIB -DBOOST_USE_WINAPI_VERSION=0x602 -DBOOST_SYSTEM_USE_UTF8) + + # generate_system_cache: pre-generates per-vendor .opc files under resources/profiles for CI bundling. + add_executable(generate_system_cache generate_system_cache.cpp) + target_link_libraries(generate_system_cache libslic3r boost_headeronly) + target_compile_definitions(generate_system_cache PRIVATE ${_DEV_DEFS}) + +endif() + # Function that adds source file encoding check to a target # using the above encoding-check binary diff --git a/src/dev-utils/generate_system_cache.cpp b/src/dev-utils/generate_system_cache.cpp new file mode 100644 index 0000000000..426ccee997 --- /dev/null +++ b/src/dev-utils/generate_system_cache.cpp @@ -0,0 +1,84 @@ +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/Utils.hpp" + +#include +#include +#include +#include +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; +namespace po = boost::program_options; + +int main(int argc, char* argv[]) +{ + po::options_description desc("OrcaSlicer System Cache Generator\nUsage"); + // clang-format off + desc.add_options() + ("help,h", "Show help") +#ifdef __APPLE__ + ("path,p", po::value()->default_value("../../../../../../../resources/profiles"), "Path to profiles directory") +#else + ("path,p", po::value()->default_value("../../../resources/profiles"), "Path to profiles directory") +#endif + ("log_level,l", po::value()->default_value(2), "Log level (0=trace, 2=info, 4=error)"); + // clang-format on + + po::variables_map vm; + try { + po::store(po::parse_command_line(argc, argv, desc), vm); + if (vm.count("help")) { std::cout << desc << "\n"; return 0; } + po::notify(vm); + } catch (const po::error& e) { + std::cerr << "Error: " << e.what() << "\n" << desc << "\n"; + return 1; + } + + const std::string profiles_path = vm["path"].as(); + const int log_level = vm["log_level"].as(); + + if (!fs::exists(profiles_path) || !fs::is_directory(profiles_path)) { + std::cerr << "Error: '" << profiles_path << "' is not a valid directory\n"; + return 1; + } + + set_logging_level(log_level); + set_data_dir(profiles_path); + set_resources_dir(fs::path(profiles_path).parent_path().make_preferred().string()); + + const fs::path user_dir = fs::path(data_dir()) / PRESET_USER_DIR; + if (!fs::exists(user_dir)) + fs::create_directories(user_dir); + + AppConfig app_config; + app_config.set("preset_folder", "default"); + + auto preset_bundle = std::make_unique(); + preset_bundle->set_is_validation_mode(true); + preset_bundle->set_default_suppressed(true); + preset_bundle->set_generate_vendor_caches(true); + + std::cout << "Loading system presets from: " << profiles_path << "\n"; + + try { + // In validation mode data_dir() is the profiles directory set above, so the + // loader writes each .opc next to its .json as it parses it. + preset_bundle->load_presets(app_config, ForwardCompatibilitySubstitutionRule::EnableSilent); + } catch (const std::exception& ex) { + std::cerr << "Failed to load presets: " << ex.what() << "\n"; + return 1; + } + + size_t cache_count = 0; + for (auto& entry : fs::directory_iterator(profiles_path)) + if (boost::iends_with(entry.path().string(), ".opc")) + ++ cache_count; + if (cache_count == 0) { + std::cerr << "No vendor cache files were generated under " << profiles_path << "\n"; + return 1; + } + std::cout << "Generated " << cache_count << " vendor cache file(s) under " << profiles_path << "\n"; + return 0; +} diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index f7b4de6e25..333f43a68c 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -348,6 +348,8 @@ set(lisbslic3r_sources Polyline.hpp PresetBundle.cpp PresetBundle.hpp + PresetCacheFormat.cpp + PresetCacheFormat.hpp Preset.cpp Preset.hpp PrincipalComponents2D.cpp diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index ef93f0d509..9e4344820d 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -28,6 +28,9 @@ #include #include +// The serialize() members below archive ConfigOption hierarchies through +// cereal::base_class, whose registration machinery lives in polymorphic.hpp. +#include namespace Slic3r { struct FloatOrPercent diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 9ae8b86fd8..1334bd4e7a 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -147,6 +147,9 @@ Semver get_version_from_json(std::string file_path) return Semver(); //throw ConfigurationError(format("Failed loading configuration file \"%1%\": %2%", file_path, err.what())); } + catch(...) { + return Semver(); + } } //BBS: add a function to load the key-values from xxx.json @@ -261,18 +264,28 @@ void extend_default_config_length(DynamicPrintConfig& config, const bool set_nil } }; + // The four variant sets are immutable after static init and probed for every + // key of every preset loaded; one merged map makes that a single lookup. + // emplace keeps the first insertion, preserving the first-set-wins priority + // of the else-if chain this replaces. + static const std::unordered_map variant_class = [] { + std::unordered_map m; + for (const std::string& k : print_options_with_variant) m.emplace(k, 0); + for (const std::string& k : filament_options_with_variant) m.emplace(k, 1); + for (const std::string& k : printer_options_with_variant_1) m.emplace(k, 2); + for (const std::string& k : printer_options_with_variant_2) m.emplace(k, 3); + return m; + }(); + for(auto& key :config.keys()){ - if(auto iter = print_options_with_variant.find(key); iter != print_options_with_variant.end()){ - replace_nil_and_resize(key, process_variant_length); - } - else if(auto iter = filament_options_with_variant.find(key); iter != filament_options_with_variant.end()){ - replace_nil_and_resize(key, filament_variant_length); - } - else if(auto iter = printer_options_with_variant_1.find(key); iter != printer_options_with_variant_1.end()){ - replace_nil_and_resize(key, machine_variant_length); - } - else if(auto iter = printer_options_with_variant_2.find(key); iter != printer_options_with_variant_2.end()){ - replace_nil_and_resize(key, machine_variant_length * 2); + auto iter = variant_class.find(key); + if (iter == variant_class.end()) + continue; + switch (iter->second) { + case 0: replace_nil_and_resize(key, process_variant_length); break; + case 1: replace_nil_and_resize(key, filament_variant_length); break; + case 2: replace_nil_and_resize(key, machine_variant_length); break; + case 3: replace_nil_and_resize(key, machine_variant_length * 2); break; } } } diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index b88b5a5ed5..c9b3197a6f 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -131,6 +131,10 @@ public: PrinterVariant() {} PrinterVariant(const std::string &name) : name(name) {} std::string name; + + // All fields, declaration order — keep in sync; bump CACHE_VERSION on change. + template + void serialize(Archive& ar) { ar(name); } // PrinterVariant }; struct PrinterModel { @@ -139,7 +143,7 @@ public: std::string name; //BBS: this is internal id for the printer. Currently only used for searching in database std::string model_id; - PrinterTechnology technology; + PrinterTechnology technology = ptFFF; std::string family; std::vector variants; std::vector default_materials; @@ -162,6 +166,17 @@ public: } const PrinterVariant* variant(const std::string &name) const { return const_cast(this)->variant(name); } + + // All fields, declaration order — keep in sync; bump CACHE_VERSION on change. + template + void serialize(Archive& ar) // PrinterModel + { + ar(id, name, model_id, technology, family, variants, default_materials, + not_support_bed_types, bed_model, bed_texture, image_bed_type, + bottom_texture_end_name, use_double_extruder_default_texture, + bottom_texture_rect, bottom_texture_rect_longer, middle_texture_rect, + hotend_model); + } }; std::vector models; @@ -173,6 +188,14 @@ public: bool valid() const { return ! name.empty() && ! id.empty() && config_version.valid(); } + // All fields, declaration order — keep in sync; bump CACHE_VERSION on change. + template + void serialize(Archive& ar) // VendorProfile + { + ar(name, id, config_version, config_update_url, changelog_url, + models, default_filaments, default_sla_materials); + } + // Load VendorProfile from an ini file. // If `load_all` is false, only the header with basic info (name, version, URLs) is loaded. static VendorProfile from_ini(const boost::filesystem::path &path, bool load_all=true); @@ -427,10 +450,10 @@ public: Preset(Type type, const std::string &name, bool is_default = false) : type(type), is_default(is_default), name(name) {} protected: - Preset() = default; - friend class PresetCollection; friend class PresetBundle; + + Preset() = default; }; bool is_compatible_with_print (const PresetWithVendorProfile &preset, const PresetWithVendorProfile &active_print, const PresetWithVendorProfile &active_printer); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index e66ae064f0..6fcc6e05c1 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -1,7 +1,11 @@ #include +#include #include +#include #include "PresetBundle.hpp" + +#include "PresetCacheFormat.hpp" #include "PrintConfig.hpp" #include "libslic3r.h" #include "I18N.hpp" @@ -307,16 +311,20 @@ std::string PresetBundle::find_preset_vendor(const std::string &preset_name, Pre return ""; } - // Iterate through vendor JSON files in the system directory - for (auto& dir_entry : fs::directory_iterator(system_dir)) { - std::string vendor_file = dir_entry.path().string(); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Checking vendor: " << vendor_file; - if (!Slic3r::is_json_file(vendor_file)) + // A vendor is named by its profile or, where the build ships preset caches + // instead of the raw profile JSONs, by its cache alone. + for (const std::string& vendor_name : vendor_names_in(system_dir)) { + const fs::path vendor_json = system_dir / (vendor_name + ".json"); + if (! fs::exists(vendor_json)) { + if (VendorCacheFile::carries_preset((system_dir / (vendor_name + ".opc")).string(), vendor_name, type, preset_name)) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Found preset " << preset_name + << " in vendor cache " << vendor_name; + return vendor_name; + } continue; - - // Get vendor name (filename without .json extension) - std::string vendor_name = dir_entry.path().filename().string(); - vendor_name.erase(vendor_name.size() - 5); // Remove ".json" + } + const std::string vendor_file = vendor_json.string(); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " Checking vendor: " << vendor_file; try { // Load and parse the vendor JSON file @@ -563,6 +571,8 @@ PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, Forward //BBS: add config related logs BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" enter, substitution_rule %1%, preferred printer_model_id %2%")%substitution_rule%preferred_selection.printer_model_id; + const auto startup_t0 = std::chrono::steady_clock::now(); + //BBS: change system config to json std::tie(substitutions, errors_cummulative) = this->load_system_presets_from_json(substitution_rule); @@ -588,6 +598,12 @@ PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, Forward set_calibrate_printer(""); + { + const auto total_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - startup_t0).count(); + BOOST_LOG_TRIVIAL(info) << "PresetBundle: all presets loaded in " << total_ms << " ms"; + } + //BBS: add config related logs BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" finished, returned substitutions %1%")%substitutions.size(); return substitutions; @@ -1000,6 +1016,8 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For bundles.m_bundles.clear(); bundles.WriteUnlock(); + const auto user_load_t0 = std::chrono::steady_clock::now(); + // Load bundle metadata from _local directory first fs::path local_dir(folder / PRESET_LOCAL_DIR); if (fs::exists(local_dir)) { @@ -1018,7 +1036,6 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For metadata.filament_presets.clear(); metadata.printer_presets.clear(); - // Add the profiles this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); @@ -1055,7 +1072,6 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For metadata.printer_presets.clear(); metadata.is_subscribed = true; - // Load presets from bundle (same logic as __local__) this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); @@ -1076,34 +1092,41 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For } } - // BBS do not load sla_print - // BBS: change directoties by design - try { - std::string print_selected_preset_name = prints.get_selected_preset().name; - this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule); - prints.select_preset_by_name(print_selected_preset_name, false); - } catch (const std::runtime_error &err) { - errors_cummulative += err.what(); + // BBS: change directories by design + + { + const auto json_t0 = std::chrono::steady_clock::now(); + try { + std::string sel = prints.get_selected_preset().name; + this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule); + prints.select_preset_by_name(sel, false); + } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } + try { + std::string sel = filaments.get_selected_preset().name; + this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule); + filaments.select_preset_by_name(sel, false); + } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } + try { + std::string sel = printers.get_selected_preset().name; + this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule); + printers.select_preset_by_name(sel, false); + } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } + if (!errors_cummulative.empty()) throw Slic3r::RuntimeError(errors_cummulative); + + const auto json_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - json_t0).count(); + BOOST_LOG_TRIVIAL(info) << "PresetBundle: user presets loaded from JSON in " << json_ms << " ms"; } - try { - std::string filament_selected_preset_name = filaments.get_selected_preset().name; - this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule); - filaments.select_preset_by_name(filament_selected_preset_name, false); - } catch (const std::runtime_error &err) { - errors_cummulative += err.what(); + + { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - user_load_t0).count(); + BOOST_LOG_TRIVIAL(info) << "PresetBundle: user + bundle presets loaded in " << ms << " ms"; } - try { - std::string printer_selected_preset_name = printers.get_selected_preset().name; - this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule); - printers.select_preset_by_name(printer_selected_preset_name, false); - } catch (const std::runtime_error &err) { - errors_cummulative += err.what(); - } - if (!errors_cummulative.empty()) throw Slic3r::RuntimeError(errors_cummulative); + this->update_multi_material_filament_presets(); this->update_compatible(PresetSelectCompatibleType::Never); - set_calibrate_printer(""); return PresetsConfigSubstitutions(); @@ -1209,13 +1232,10 @@ bool PresetBundle::apply_vendor_config( : std::map(); // Find vendors that need installation - const auto vendor_dir = (fs::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).make_preferred(); - std::vector install_bundles; for (const auto &it : new_vendors) { if (it.second.size() > 0) { - auto vendor_file = vendor_dir / (it.first + ".json"); - if (!fs::exists(vendor_file)) { + if (!is_vendor_installed(it.first)) { install_bundles.emplace_back(it.first); } } @@ -2223,6 +2243,16 @@ void PresetBundle::remove_users_preset(AppConfig &config, std::mapprints.m_printer_hold_alias.clear(); + this->sla_prints.m_printer_hold_alias.clear(); + this->filaments.m_printer_hold_alias.clear(); + this->sla_materials.m_printer_hold_alias.clear(); + this->printers.m_printer_hold_alias.clear(); +} //BBS: add json related logic, load system presets from json std::pair PresetBundle::load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule) @@ -2242,22 +2272,19 @@ std::pair PresetBundle::load_system_pre if (validation_mode) dir = (boost::filesystem::path(data_dir())).make_preferred(); + const auto load_t0 = std::chrono::steady_clock::now(); + + // The vendors below are loaded whole and against each other — the filament + // library first, then every other vendor with it as the base — so each parse + // is complete enough to be worth caching. + m_generate_vendor_caches = m_generate_vendor_caches || ! validation_mode; + PresetsConfigSubstitutions substitutions; std::string errors_cummulative; - bool first = true; - std::vector vendor_names; - // store all vendor names in vendor_names - for (auto& dir_entry : boost::filesystem::directory_iterator(dir)) { - std::string vendor_file = dir_entry.path().string(); - if (!Slic3r::is_json_file(vendor_file)) - continue; - - std::string vendor_name = dir_entry.path().filename().string(); - - // Remove the .json suffix. - vendor_name.erase(vendor_name.size() - 5); - vendor_names.push_back(vendor_name); - } + bool first = true; + // Sorted, so any duplicate-preset warning below comes out in the same order on + // every run. + const std::set vendor_names = vendor_names_in(dir); // Separate ORCA_FILAMENT_LIBRARY from other vendors. It must be loaded // first because other vendors' filaments may inherit from it via the // `base_bundle` lookup in parse_subfile. The remaining vendors are @@ -2273,8 +2300,13 @@ std::pair PresetBundle::load_system_pre } // Step 1: Load ORCA_FILAMENT_LIBRARY into `this` synchronously. - if (!orca_lib_vendor.empty()) { + if (! orca_lib_vendor.empty()) { try { + // Match a fresh launch before parsing: hold aliases and the error + // counter survive reset(), and would otherwise carry prior-cycle + // state into this load. + this->clear_printer_hold_aliases(); + this->m_errors = 0; append(substitutions, this->load_vendor_configs_from_json(dir.string(), orca_lib_vendor, PresetBundle::LoadSystem, compatibility_rule).first); first = false; } catch (const std::runtime_error &err) { @@ -2297,10 +2329,10 @@ std::pair PresetBundle::load_system_pre for (size_t i = range.begin(); i < range.end(); ++i) { auto bundle = std::make_unique(); bundle->set_is_validation_mode(validation_mode); + bundle->set_generate_vendor_caches(m_generate_vendor_caches); try { auto result = bundle->load_vendor_configs_from_json( - dir.string(), other_vendors[i], PresetBundle::LoadSystem, - compatibility_rule, this); + dir.string(), other_vendors[i], PresetBundle::LoadSystem, compatibility_rule, this); parallel_substitutions[i] = std::move(result.first); parallel_bundles[i] = std::move(bundle); } catch (const std::runtime_error &err) { @@ -2345,6 +2377,11 @@ std::pair PresetBundle::load_system_pre } this->update_system_maps(); + + const auto load_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - load_t0).count(); + BOOST_LOG_TRIVIAL(info) << "PresetBundle: " << vendor_names.size() << " vendor(s) loaded in " << load_ms << " ms"; + //BBS: add config related logs BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" finished, errors_cummulative %1%")%errors_cummulative; return std::make_pair(std::move(substitutions), errors_cummulative); @@ -4759,30 +4796,287 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": finished"); } +// Orca: load one source-form preset entry — parsed from its JSON subfile just +// now, or deserialized from the vendor's cache; the code is shared so a +// cache-loaded bundle cannot come out different from a JSON-loaded one. +// Resolves `inherits` against the presets loaded before this one +// (config_maps) or against base_bundle's filament library, flattens, validates +// and registers the preset. Returns the reason loading failed, empty on +// success. +std::string PresetBundle::load_vendor_preset( + const CachedPreset& entry, + const std::string& path, const std::string& vendor_name, + const PresetBundle* base_bundle, + LoadConfigBundleAttributes flags, + ConfigSubstitutionContext& substitution_context, PresetsConfigSubstitutions& substitutions, + std::map& config_maps, std::map& filament_id_maps, + PresetCollection* presets_collection, size_t& count, bool is_from_lib, + const std::set* retain_configs) +{ + const VendorProfile* current_vendor_profile = &this->vendors.at(vendor_name); + const std::string subfile = path + "/" + vendor_name + "/" + entry.sub_path; + const std::string& preset_name = entry.name; + std::string alias_name, filament_id = entry.filament_id; + std::vector renamed_from = entry.renamed_from; + DynamicPrintConfig config; + const DynamicPrintConfig* default_config = nullptr; + std::string reason; + + //check whether it inherits other preset or not + if (! entry.inherits.empty()) { + auto it2 = config_maps.find(entry.inherits); + if (it2 != config_maps.end()) + default_config = &(it2->second); + if (default_config == nullptr && base_bundle != nullptr) { + auto base_it2 = base_bundle->m_config_maps.find(entry.inherits); + if (base_it2 != base_bundle->m_config_maps.end()) + default_config = &(base_it2->second); + } + if (default_config != nullptr) { + if (filament_id.empty() && (presets_collection->type() == Preset::TYPE_FILAMENT)) { + auto filament_id_map_iter = filament_id_maps.find(entry.inherits); + if (filament_id_map_iter != filament_id_maps.end()) { + filament_id = filament_id_map_iter->second; + } + if (filament_id.empty() && base_bundle != nullptr) { + auto base_filament_id_map_iter = base_bundle->m_filament_id_maps.find(entry.inherits); + if (base_filament_id_map_iter != base_bundle->m_filament_id_maps.end()) { + filament_id = base_filament_id_map_iter->second; + } + } + } + } + else { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": can not find inherits " << entry.inherits << " for " << preset_name; + // throw ConfigurationError(format("can not find inherits %1% for %2%", inherits, preset_name)); + reason = "Can not find inherits: " + entry.inherits; + return reason; + } + } + else { + if (presets_collection->type() == Preset::TYPE_PRINTER) + default_config = &presets_collection->default_preset_for(entry.config_src).config; + else + default_config = &presets_collection->default_preset().config; + } + config = *default_config; + config.apply(entry.config_src); + extend_default_config_length(config, true, *default_config); + if (entry.instantiation == "false" && "Template" != vendor_name) { + // Report configuration fields, which are misplaced into a wrong group. + std::string incorrect_keys = Preset::remove_invalid_keys(config, *default_config); + if (!incorrect_keys.empty()) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": The config " << subfile << " contains incorrect keys: " << incorrect_keys + << ", which were removed"; + } + + if (retain_configs == nullptr || retain_configs->count(preset_name) != 0) + config_maps.emplace(preset_name, std::move(config)); + if ((presets_collection->type() == Preset::TYPE_FILAMENT) && (!filament_id.empty())) + filament_id_maps.emplace(preset_name, filament_id); + return reason; + } + if (config.has("alias")) + alias_name = (dynamic_cast(config.option("alias")))->value; + Preset::normalize(config); + + // Report configuration fields, which are misplaced into a wrong group. + std::string incorrect_keys = Preset::remove_invalid_keys(config, *default_config); + if (!incorrect_keys.empty()) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": The config " << subfile << " contains incorrect keys: " << incorrect_keys + << ", which were removed"; + } + + if (presets_collection->type() == Preset::TYPE_PRINTER) { + // Filter out printer presets, which are not mentioned in the vendor profile. + // These presets are considered not installed. + auto printer_model = config.opt_string("printer_model"); + if (printer_model.empty()) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" defines no printer model, it will be ignored."; + reason = std::string("can not find printer_model"); + return reason; + } + auto printer_variant = config.opt_string("printer_variant"); + if (printer_variant.empty()) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" defines no printer variant, it will be ignored."; + reason = std::string("can not find printer_variant"); + return reason; + } + auto it_model = std::find_if(current_vendor_profile->models.cbegin(), current_vendor_profile->models.cend(), + [&](const VendorProfile::PrinterModel &m) { return m.id == printer_model; } + ); + if (it_model == current_vendor_profile->models.end()) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" defines invalid printer model \"" << printer_model << "\", it will be ignored."; + reason = std::string("can not find printer model in vendor profile"); + return reason; + } + auto it_variant = it_model->variant(printer_variant); + if (it_variant == nullptr) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" defines invalid printer variant \"" << printer_variant << "\", it will be ignored."; + reason = std::string("can not find printer_variant in vendor profile"); + return reason; + } + // An instantiation printer profile's nozzle_diameter must match the numeric (diameter) + // prefix of its printer_variant: "0.4" -> {0.4}, "0.8HF" -> {0.8} (a trailing + // non-numeric suffix such as "HF"/"HS" distinguishes a hardware sub-variant and is + // ignored here), and for multi-nozzle printers "0.4+0.6" -> {0.4, 0.6}. + // Note: a variant may legitimately repeat across presets of the same model (e.g. speed + // modes, IDEX copy/mirror, or different control boards), so only the diameter is + // validated, not variant uniqueness. Validation-only so the app keeps loading existing + // profiles unchanged. + if (validation_mode && entry.instantiation == "true") { + const auto *nd = config.option("nozzle_diameter"); + std::set nozzles, variant_nozzles; + if (nd != nullptr) + nozzles.insert(nd->values.begin(), nd->values.end()); + std::vector variant_tokens; + boost::algorithm::split(variant_tokens, printer_variant, boost::algorithm::is_any_of("+")); + bool variant_ok = true; // printer_variant is already guaranteed non-empty above + for (const std::string &tok : variant_tokens) { + size_t consumed = 0; + double d = string_to_double_decimal_point(tok, &consumed); + // Require a leading numeric diameter; a trailing suffix (e.g. "HF") is allowed. + if (consumed == 0) { variant_ok = false; break; } + variant_nozzles.insert(d); + } + if (!variant_ok || variant_nozzles != nozzles) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" has printer_variant \"" << printer_variant << + "\" that does not match its nozzle_diameter \"" << (nd ? nd->serialize() : std::string()) << "\". " + "printer_variant must begin with the nozzle diameter, optionally followed by a non-numeric suffix " + "(e.g. \"0.4\", \"0.8HF\"); for multi-nozzle printers, join the per-nozzle diameters with \"+\" in " + "nozzle order (e.g. \"0.4+0.6\")."; + } + } + } + const Preset *preset_existing = presets_collection->find_preset(preset_name, false); + if (preset_existing != nullptr) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << + preset_name << "\" has already been loaded from another Config Bundle."; + reason = std::string("duplicated defines"); + return reason; + } + + auto file_path = (boost::filesystem::path(data_dir()) /PRESET_SYSTEM_DIR/ vendor_name / entry.sub_path).make_preferred(); + if(validation_mode) + file_path = (boost::filesystem::path(data_dir()) / vendor_name / entry.sub_path).make_preferred(); + + // Load the preset into the list of presets, save it to disk. + Preset &loaded = presets_collection->load_preset(file_path.string(), preset_name, std::move(config), false); + if (flags.has(LoadConfigBundleAttribute::LoadSystem)) { + loaded.is_system = true; + loaded.vendor = current_vendor_profile; + loaded.version = current_vendor_profile->config_version; + loaded.description = entry.description; + loaded.setting_id = entry.setting_id; + // Derive the preset setting_id on the fly when a profile ships without one, + // matching scripts/assign_vendor_setting_ids.py. Only instantiated presets + // carry an id; non-instantiated base profiles return earlier above. This never + // touches the per-user cloud-sync setting_id written into user .info files. + if (loaded.setting_id.empty() && entry.instantiation == "true") + loaded.setting_id = generate_preset_setting_id( + vendor_name, Preset::get_type_string(presets_collection->type()), preset_name); + loaded.filament_id = filament_id; + loaded.m_from_orca_filament_lib = is_from_lib; + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " " << __LINE__ << ", " << loaded.name << " load filament_id: " << filament_id; + if (presets_collection->type() == Preset::TYPE_FILAMENT) { + if (filament_id.empty() && "Template" != vendor_name) { + ++m_errors; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": can not find filament_id for " << preset_name; + //throw ConfigurationError(format("can not find inherits %1% for %2%", inherits, preset_name)); + reason = "Can not find filament_id for " + preset_name; + return reason; + } + else { + filament_id_maps.emplace(preset_name, filament_id); + } + } + } + + // Derive the profile logical name aka alias from the preset name if the alias was not stated explicitely. + if (alias_name.empty()) { + size_t end_pos = preset_name.find_first_of("@"); + if (end_pos != std::string::npos) { + alias_name = preset_name.substr(0, end_pos); + if (renamed_from.empty()) + // Add the preset name with the '@' character removed into the "renamed_from" list. + renamed_from.emplace_back(alias_name + preset_name.substr(end_pos + 1)); + boost::trim_right(alias_name); + } + } + if (alias_name.empty()) + loaded.alias = preset_name; + else { + loaded.alias = std::move(alias_name); + filaments.set_printer_hold_alias(loaded.alias, loaded); + } + loaded.renamed_from = std::move(renamed_from); + if (! substitution_context.empty()) + substitutions.push_back({ + preset_name, presets_collection->type(), PresetConfigSubstitutions::Source::ConfigBundle, + std::string(), std::move(substitution_context.substitutions) }); + if (retain_configs == nullptr || retain_configs->count(preset_name) != 0) + config_maps.emplace(preset_name, loaded.config); + ++count; + //BBS: add config related logs + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(", got preset %1%, from %2%")%loaded.name %subfile; + return reason; +} + //BBS: Load a config bundle file from json std::pair PresetBundle::load_vendor_configs_from_json( - const std::string &path, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle) + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle) { // Enable substitutions for user config bundle, throw an exception when loading a system profile. ConfigSubstitutionContext substitution_context { compatibility_rule }; PresetsConfigSubstitutions substitutions; + // Errors already on this bundle when the load began; the cache stamp below + // counts only what this parse adds. + const int errors_at_entry = m_errors; //BBS: add config related logs - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" enter, path %1%, compatibility_rule %2%")%path.c_str()%compatibility_rule; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(" enter, path %1%, compatibility_rule %2%")%dir.c_str()%compatibility_rule; if (flags.has(LoadConfigBundleAttribute::ResetUserProfile) || flags.has(LoadConfigBundleAttribute::LoadSystem)) // Reset this bundle, delete user profile files if SaveImported. this->reset(flags.has(LoadConfigBundleAttribute::SaveImported)); + // Orca: only a whole-vendor load has a cache — the vendor-only and filament-only + // scans want a slice of one. Validation reads the JSONs whatever is cached. + const boost::filesystem::path dir_path(dir); + const bool cacheable = flags.has(LoadConfigBundleAttribute::LoadSystem) && ! flags.has(LoadConfigBundleAttribute::LoadFilamentOnly); + if (cacheable && ! validation_mode && this->load_vendor_cache(dir_path, vendor_name, base_bundle)) { + size_t presets_loaded = 0; + for (const PresetCollection* coll : std::initializer_list{ + &this->prints, &this->sla_prints, &this->filaments, &this->sla_materials, &this->printers }) + presets_loaded += coll->m_presets.size() - coll->m_num_default_presets; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", %1% served from its preset cache, %2% presets")%vendor_name%presets_loaded; + return std::make_pair(std::move(substitutions), presets_loaded); + } + // 1) load the vroot json and construct the vendor profile VendorProfile vendor_profile(vendor_name); - std::string root_file = path + "/" + vendor_name + ".json"; + std::string root_file = dir + "/" + vendor_name + ".json"; std::vector> machine_model_subfiles; std::vector> process_subfiles; std::vector> filament_subfiles; std::vector> machine_subfiles; auto get_name_and_subpath = [this](json::iterator& it, std::vector>& subfile_map) { if (it.value().is_array()) { - for (auto iter1 = it.value().begin(); iter1 != it.value().end(); iter1++) { + size_t index = 0; + for (auto iter1 = it.value().begin(); iter1 != it.value().end(); iter1++, index++) { if (iter1.value().is_object()) { std::string name, subpath; for (auto iter2 = iter1.value().begin(); iter2 != iter1.value().end(); iter2++) { @@ -4802,7 +5096,10 @@ std::pair PresetBundle::load_vendor_configs_ subfile_map.push_back(std::make_pair(name, subpath)); } else { ++m_errors; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid type for " << iter1.key(); + // An array element has no key, and asking one for it throws + // nlohmann's invalid_iterator — not a parse_error, so it would + // escape the catch around this parse. Say where it is instead. + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": invalid type for " << it.key() << "[" << index << "]"; } } } else { @@ -4823,7 +5120,7 @@ std::pair PresetBundle::load_vendor_configs_ if (! config_version) { ++m_errors; throw ConfigurationError((boost::format("vendor %1%'s config version: %2% invalid\nSuggest cleaning the directory %3% firstly") - % vendor_name % version_str % path).str()); + % vendor_name % version_str % dir).str()); } else { vendor_profile.config_version = std::move(*config_version); } @@ -4861,7 +5158,7 @@ std::pair PresetBundle::load_vendor_configs_ catch(nlohmann::detail::parse_error &err) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": parse "< PresetBundle::load_vendor_configs_ //2) paste the machine model for (auto& machine_model : machine_model_subfiles) { - std::string subfile = path + "/" + vendor_name + "/" + machine_model.second; + std::string subfile = dir + "/" + vendor_name + "/" + machine_model.second; VendorProfile::PrinterModel model; model.id = machine_model.first; try { @@ -4976,7 +5273,7 @@ std::pair PresetBundle::load_vendor_configs_ catch(nlohmann::detail::parse_error &err) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": parse "<< subfile <<" got a nlohmann::detail::parse_error, reason = " << err.what(); throw ConfigurationError((boost::format("Failed loading configuration file %1%: %2%\nSuggest cleaning the directory %3% firstly") - %subfile %err.what() % path).str()); + %subfile %err.what() % dir).str()); } if (! model.id.empty() && ! model.variants.empty()) @@ -4985,7 +5282,6 @@ std::pair PresetBundle::load_vendor_configs_ //insert the vendor profile this->vendors.emplace(vendor_name, vendor_profile); - const VendorProfile* current_vendor_profile = &this->vendors[vendor_name]; BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(", loaded vendor profile, name %1%, id %2%, version %3%")%vendor_profile.name%vendor_profile.id%vendor_profile.config_version.to_string(); @@ -4996,123 +5292,65 @@ std::pair PresetBundle::load_vendor_configs_ PresetCollection *presets = nullptr; size_t presets_loaded = 0; - auto parse_subfile = [this, path, vendor_name, presets_loaded, current_vendor_profile, base_bundle]( + // Parse one subfile into a source-form entry — everything the JSON states, + // nothing resolved. Loading the entry (load_vendor_preset) is the + // same code whether the entry was parsed just now or deserialized from the + // vendor's cache. + auto parse_subfile = [this, dir, vendor_name]( ConfigSubstitutionContext& substitution_context, - PresetsConfigSubstitutions& substitutions, - LoadConfigBundleAttributes& flags, - std::pair& subfile_iter, - std::map& config_maps, - std::map& filament_id_maps, - PresetCollection* presets_collection, - size_t& count, bool is_from_lib = false) -> std::string { + const std::pair& subfile_iter, + CachedPreset& entry) -> std::string { - std::string subfile = path + "/" + vendor_name + "/" + subfile_iter.second; - // Load the print, filament or printer preset. - std::string preset_name; - DynamicPrintConfig config; - std::string alias_name, inherits, description, instantiation, setting_id, filament_id; - std::vector renamed_from; - const DynamicPrintConfig* default_config = nullptr; - std::string reason; + std::string subfile = dir + "/" + vendor_name + "/" + subfile_iter.second; + std::string reason; try { std::map key_values; substitution_context.substitutions.clear(); //parse the json elements - DynamicPrintConfig config_src; - std::string _renamed_from_str; - config_src.load_from_json(subfile, substitution_context, false, key_values, reason); + entry.sub_path = subfile_iter.second; + entry.config_src.load_from_json(subfile, substitution_context, false, key_values, reason); if (!reason.empty()) { ++m_errors; BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": load config file "<second; + entry.setting_id = setting_it->second; auto filament_it = key_values.find(BBL_JSON_KEY_FILAMENT_ID); if (filament_it != key_values.end()) - filament_id = filament_it->second; - //check whether it inherits other preset or not + entry.filament_id = filament_it->second; auto it1 = key_values.find(BBL_JSON_KEY_INHERITS); if (it1 != key_values.end()) { - inherits = it1->second; - auto it2 = config_maps.find(inherits); - default_config = nullptr; - if (it2 != config_maps.end()) - default_config = &(it2->second); - if(default_config == nullptr && base_bundle != nullptr) { - auto base_it2 = base_bundle->m_config_maps.find(inherits); - if (base_it2 != base_bundle->m_config_maps.end()) - default_config = &(base_it2->second); - } - if (default_config != nullptr) { - if (filament_id.empty() && (presets_collection->type() == Preset::TYPE_FILAMENT)) { - auto filament_id_map_iter = filament_id_maps.find(inherits); - if (filament_id_map_iter != filament_id_maps.end()) { - filament_id = filament_id_map_iter->second; - } - if (filament_id.empty() && base_bundle != nullptr) { - auto filament_id_map_iter = base_bundle->m_filament_id_maps.find(inherits); - if (filament_id_map_iter != base_bundle->m_filament_id_maps.end()) { - filament_id = filament_id_map_iter->second; - } - } - } - } - else { + entry.inherits = it1->second; + // An `inherits` key naming nothing can never resolve; fail it + // here so install can key off the empty string as "no inherits". + if (entry.inherits.empty()) { ++m_errors; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": can not find inherits " << inherits << " for " << preset_name; - // throw ConfigurationError(format("can not find inherits %1% for %2%", inherits, preset_name)); - reason = "Can not find inherits: " + inherits; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": can not find inherits " << entry.inherits << " for " << entry.name; + reason = "Can not find inherits: " + entry.inherits; return reason; } } - else { - if (presets_collection->type() == Preset::TYPE_PRINTER) - default_config = &presets_collection->default_preset_for(config_src).config; - else - default_config = &presets_collection->default_preset().config; - } - config = *default_config; - config.apply(config_src); - extend_default_config_length(config, true, *default_config); - if (instantiation == "false" && "Template" != vendor_name) { - // Report configuration fields, which are misplaced into a wrong group. - std::string incorrect_keys = Preset::remove_invalid_keys(config, *default_config); - if (!incorrect_keys.empty()) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": The config " << subfile << " contains incorrect keys: " << incorrect_keys - << ", which were removed"; - } - - config_maps.emplace(preset_name, std::move(config)); - if ((presets_collection->type() == Preset::TYPE_FILAMENT) && (!filament_id.empty())) - filament_id_maps.emplace(preset_name, filament_id); - return reason; - } - if (config.has("alias")) - alias_name = (dynamic_cast(config.option("alias")))->value; - if (key_values.find(ORCA_JSON_KEY_RENAMED_FROM) != key_values.end()) { - if (!unescape_strings_cstyle(key_values[ORCA_JSON_KEY_RENAMED_FROM], renamed_from)) { - BOOST_LOG_TRIVIAL(error) << "Error in a Config \"" << path << "\": The preset \"" << preset_name + if (!unescape_strings_cstyle(key_values[ORCA_JSON_KEY_RENAMED_FROM], entry.renamed_from)) { + BOOST_LOG_TRIVIAL(error) << "Error in a Config \"" << dir << "\": The preset \"" << entry.name << "\" contains invalid \"renamed_from\" key, which is being ignored."; } } - Preset::normalize(config); } catch(nlohmann::detail::parse_error &err) { ++m_errors; @@ -5120,195 +5358,60 @@ std::pair PresetBundle::load_vendor_configs_ reason = std::string("json parse error") + err.what(); return reason; } - - // Report configuration fields, which are misplaced into a wrong group. - std::string incorrect_keys = Preset::remove_invalid_keys(config, *default_config); - if (!incorrect_keys.empty()) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": The config " << subfile << " contains incorrect keys: " << incorrect_keys - << ", which were removed"; - } - - if (presets_collection->type() == Preset::TYPE_PRINTER) { - // Filter out printer presets, which are not mentioned in the vendor profile. - // These presets are considered not installed. - auto printer_model = config.opt_string("printer_model"); - if (printer_model.empty()) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" defines no printer model, it will be ignored."; - reason = std::string("can not find printer_model"); - return reason; - } - auto printer_variant = config.opt_string("printer_variant"); - if (printer_variant.empty()) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" defines no printer variant, it will be ignored."; - reason = std::string("can not find printer_variant"); - return reason; - } - auto it_model = std::find_if(current_vendor_profile->models.cbegin(), current_vendor_profile->models.cend(), - [&](const VendorProfile::PrinterModel &m) { return m.id == printer_model; } - ); - if (it_model == current_vendor_profile->models.end()) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" defines invalid printer model \"" << printer_model << "\", it will be ignored."; - reason = std::string("can not find printer model in vendor profile"); - return reason; - } - auto it_variant = it_model->variant(printer_variant); - if (it_variant == nullptr) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" defines invalid printer variant \"" << printer_variant << "\", it will be ignored."; - reason = std::string("can not find printer_variant in vendor profile"); - return reason; - } - // An instantiation printer profile's nozzle_diameter must match the numeric (diameter) - // prefix of its printer_variant: "0.4" -> {0.4}, "0.8HF" -> {0.8} (a trailing - // non-numeric suffix such as "HF"/"HS" distinguishes a hardware sub-variant and is - // ignored here), and for multi-nozzle printers "0.4+0.6" -> {0.4, 0.6}. - // Note: a variant may legitimately repeat across presets of the same model (e.g. speed - // modes, IDEX copy/mirror, or different control boards), so only the diameter is - // validated, not variant uniqueness. Validation-only so the app keeps loading existing - // profiles unchanged. - if (validation_mode && instantiation == "true") { - const auto *nd = config.option("nozzle_diameter"); - std::set nozzles, variant_nozzles; - if (nd != nullptr) - nozzles.insert(nd->values.begin(), nd->values.end()); - std::vector variant_tokens; - boost::algorithm::split(variant_tokens, printer_variant, boost::algorithm::is_any_of("+")); - bool variant_ok = true; // printer_variant is already guaranteed non-empty above - for (const std::string &tok : variant_tokens) { - size_t consumed = 0; - double d = string_to_double_decimal_point(tok, &consumed); - // Require a leading numeric diameter; a trailing suffix (e.g. "HF") is allowed. - if (consumed == 0) { variant_ok = false; break; } - variant_nozzles.insert(d); - } - if (!variant_ok || variant_nozzles != nozzles) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" has printer_variant \"" << printer_variant << - "\" that does not match its nozzle_diameter \"" << (nd ? nd->serialize() : std::string()) << "\". " - "printer_variant must begin with the nozzle diameter, optionally followed by a non-numeric suffix " - "(e.g. \"0.4\", \"0.8HF\"); for multi-nozzle printers, join the per-nozzle diameters with \"+\" in " - "nozzle order (e.g. \"0.4+0.6\")."; - } - } - } - const Preset *preset_existing = presets_collection->find_preset(preset_name, false); - if (preset_existing != nullptr) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << "Error in a Vendor Config Bundle \"" << path << "\": The printer preset \"" << - preset_name << "\" has already been loaded from another Config Bundle."; - reason = std::string("duplicated defines"); - return reason; - } - - auto file_path = (boost::filesystem::path(data_dir()) /PRESET_SYSTEM_DIR/ vendor_name / subfile_iter.second).make_preferred(); - if(validation_mode) - file_path = (boost::filesystem::path(data_dir()) / vendor_name / subfile_iter.second).make_preferred(); - - // Load the preset into the list of presets, save it to disk. - Preset &loaded = presets_collection->load_preset(file_path.string(), preset_name, std::move(config), false); - if (flags.has(LoadConfigBundleAttribute::LoadSystem)) { - loaded.is_system = true; - loaded.vendor = current_vendor_profile; - loaded.version = current_vendor_profile->config_version; - loaded.description = description; - loaded.setting_id = setting_id; - // Derive the preset setting_id on the fly when a profile ships without one, - // matching scripts/assign_vendor_setting_ids.py. Only instantiated presets - // carry an id; non-instantiated base profiles return earlier above. This never - // touches the per-user cloud-sync setting_id written into user .info files. - if (loaded.setting_id.empty() && instantiation == "true") - loaded.setting_id = generate_preset_setting_id( - vendor_name, Preset::get_type_string(presets_collection->type()), preset_name); - loaded.filament_id = filament_id; - loaded.m_from_orca_filament_lib = is_from_lib; - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " " << __LINE__ << ", " << loaded.name << " load filament_id: " << filament_id; - if (presets_collection->type() == Preset::TYPE_FILAMENT) { - if (filament_id.empty() && "Template" != vendor_name) { - ++m_errors; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": can not find filament_id for " << preset_name; - //throw ConfigurationError(format("can not find inherits %1% for %2%", inherits, preset_name)); - reason = "Can not find filament_id for " + preset_name; - return reason; - } - else { - filament_id_maps.emplace(preset_name, filament_id); - } - } - } - - // Derive the profile logical name aka alias from the preset name if the alias was not stated explicitely. - if (alias_name.empty()) { - size_t end_pos = preset_name.find_first_of("@"); - if (end_pos != std::string::npos) { - alias_name = preset_name.substr(0, end_pos); - if (renamed_from.empty()) - // Add the preset name with the '@' character removed into the "renamed_from" list. - renamed_from.emplace_back(alias_name + preset_name.substr(end_pos + 1)); - boost::trim_right(alias_name); - } - } - if (alias_name.empty()) - loaded.alias = preset_name; - else { - loaded.alias = std::move(alias_name); - filaments.set_printer_hold_alias(loaded.alias, loaded); - } - loaded.renamed_from = std::move(renamed_from); - if (! substitution_context.empty()) - substitutions.push_back({ - preset_name, presets_collection->type(), PresetConfigSubstitutions::Source::ConfigBundle, - std::string(), std::move(substitution_context.substitutions) }); - config_maps.emplace(preset_name, loaded.config); - ++count; - //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(", got preset %1%, from %2%")%loaded.name %subfile; return reason; }; std::map configs; std::map filament_id_maps; + // Orca: whether to (re)write the vendor's cache after this parse, leaving it + // in step with the profile so the next run reads it instead. It is written + // where the vendor was looked for, even when the profile came from resources, + // and stamped with the version that profile claims — a profile without one + // cannot be judged for staleness later, and a cache nothing can invalidate is + // worse than none. + const bool will_cache = cacheable && m_generate_vendor_caches && vendor_profile.config_version.valid(); + VendorCacheData cache_data; + // Errors added by install are counted apart: a cache load runs install again, + // so the parse_errors stamped into the cache must hold only what a cache load + // will not recount. + int install_errors = 0; + auto load_subfiles = [&](std::vector>& subfiles, + std::vector& entries, const char* kind, bool is_from_lib = false) { + configs.clear(); + filament_id_maps.clear(); + for (auto& subfile : subfiles) { + CachedPreset entry; + std::string reason = parse_subfile(substitution_context, subfile, entry); + if (reason.empty()) { + const int errors_before_install = m_errors; + reason = load_vendor_preset(entry, dir, vendor_name, base_bundle, flags, + substitution_context, substitutions, configs, filament_id_maps, presets, + presets_loaded, is_from_lib); + install_errors += m_errors - errors_before_install; + } + if (!reason.empty()) { + ++m_errors; + //parse error + std::string subfile_path = dir + "/" + vendor_name + "/" + subfile.second; + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(", got error when parse %1% setting from %2%") % kind % subfile_path; + throw ConfigurationError((boost::format("Failed loading configuration file %1%\nSuggest cleaning the directory %2% firstly") % subfile_path % dir).str()); + } + if (will_cache) + entries.emplace_back(std::move(entry)); + } + }; + + // The section order below — process, filaments (with the ORCA-lib map copy), + // printers — is mirrored by load_vendor_cache's install loops; keep the two + // in lockstep. //3.1) paste the process presets = &this->prints; - configs.clear(); - filament_id_maps.clear(); - for (auto& subfile : process_subfiles) - { - std::string reason = parse_subfile(substitution_context, substitutions, flags, subfile, configs, filament_id_maps, presets, presets_loaded); - if (!reason.empty()) { - ++m_errors; - //parse error - std::string subfile_path = path + "/" + vendor_name + "/" + subfile.second; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(", got error when parse process setting from %1%") % subfile_path; - throw ConfigurationError((boost::format("Failed loading configuration file %1%\nSuggest cleaning the directory %2% firstly") % subfile_path % path).str()); - } - } + load_subfiles(process_subfiles, cache_data.process_entries, "process"); //3.2) paste the filaments presets = &this->filaments; - configs.clear(); - filament_id_maps.clear(); const auto is_orca_lib = vendor_name == ORCA_FILAMENT_LIBRARY; - for (auto& subfile : filament_subfiles) - { - std::string reason = parse_subfile(substitution_context, substitutions, flags, subfile, configs, filament_id_maps, presets, - presets_loaded, is_orca_lib); - if (!reason.empty()) { - ++m_errors; - //parse error - std::string subfile_path = path + "/" + vendor_name + "/" + subfile.second; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(", got error when parse filament setting from %1%") % subfile_path; - throw ConfigurationError((boost::format("Failed loading configuration file %1%\nSuggest cleaning the directory %2% firstly") % subfile_path % path).str()); - } - } + load_subfiles(filament_subfiles, cache_data.filament_entries, "filament", is_orca_lib); if (is_orca_lib) { m_config_maps = configs; m_filament_id_maps = filament_id_maps; @@ -5316,18 +5419,16 @@ std::pair PresetBundle::load_vendor_configs_ //3.3) paste the printers presets = &this->printers; - configs.clear(); - filament_id_maps.clear(); - for (auto& subfile : machine_subfiles) - { - std::string reason = parse_subfile(substitution_context, substitutions, flags, subfile, configs, filament_id_maps, presets, presets_loaded); - if (!reason.empty()) { - ++m_errors; - //parse error - std::string subfile_path = path + "/" + vendor_name + "/" + subfile.second; - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(", got error when parse printer setting from %1%") % subfile_path; - throw ConfigurationError((boost::format("Failed loading configuration file %1%\nSuggest cleaning the directory %2% firstly") % subfile_path % path).str()); - } + load_subfiles(machine_subfiles, cache_data.machine_entries, "printer"); + + if (will_cache) { + // Clamped: the count is a difference of three tallies, and a stamp that + // wrapped would be added to every future load of this vendor. + cache_data.parse_errors = uint64_t(std::max(0, m_errors - errors_at_entry - install_errors)); + cache_data.vendors = this->vendors; + if (! VendorCacheFile::save((dir_path / (vendor_name + ".opc")).string(), vendor_name, + vendor_profile.config_version.to_string(), cache_data)) + BOOST_LOG_TRIVIAL(warning) << "PresetBundle: failed to save vendor cache for " << vendor_name; } //BBS: add config related logs @@ -5952,4 +6053,94 @@ bool BundleMetadata::save_to_json(const std::string& path) const return false; } } +// ---- Per-vendor preset cache: install into this bundle ------------------- +// The file format itself lives in PresetCacheFormat.cpp (VendorCacheFile). + +bool PresetBundle::load_vendor_cache(const boost::filesystem::path& dir, const std::string& vendor_name, const PresetBundle* base_bundle) +{ + // A vendor is loaded from where it is installed and nowhere else; resources + // reaches the app by being installed into `dir` first. The cache there is + // judged against the profile beside it — or, where the cache is the whole + // of the installation, against nothing, since nothing on disk can then be + // newer than it. That state is Semver::inf(), which no real profile carries. + const boost::filesystem::path profile = dir / (vendor_name + ".json"); + const Semver version = boost::filesystem::exists(profile) ? get_version_from_json(profile.string()) + : Semver::inf(); + return this->load_vendor_cache((dir / (vendor_name + ".opc")).string(), vendor_name, version, base_bundle); +} + +bool PresetBundle::load_vendor_cache(const std::string& cache_path, const std::string& expected_vendor_name, + const Semver& expected_vendor_version, const PresetBundle* base_bundle) +{ + // What this bundle had counted before the cache was tried. The caller + // measures its own parse against this same baseline, so a rejection must + // put it back rather than reset it to zero. + const int errors_at_entry = this->m_errors; + // Read and validated before this bundle is touched: a rejected file leaves + // no state to roll back. + VendorCacheData data; + if (! VendorCacheFile::load(cache_path, expected_vendor_name, expected_vendor_version, data)) + return false; + try { + const std::string& vendor_name = expected_vendor_name; // VendorCacheFile::load checked they match + this->vendors = std::move(data.vendors); + + // What the parse counted before install took over; install recounts its + // own below, so m_errors comes out as a JSON parse would leave it. + m_errors += int(data.parse_errors); + + // Install the entries exactly as load_vendor_configs_from_json installs + // them straight after parsing — same code, same order. The substitution + // context stays empty (the entries were substituted when they were + // parsed), so no substitutions are reported, as before. + ConfigSubstitutionContext substitution_context { ForwardCompatibilitySubstitutionRule::EnableSilent }; + PresetsConfigSubstitutions substitutions; + std::map configs; + std::map filament_id_maps; + const std::string path = boost::filesystem::path(cache_path).parent_path().string(); + size_t count = 0; + auto install_entries = [&](const std::vector& entries, PresetCollection* presets, bool is_from_lib) { + configs.clear(); + filament_id_maps.clear(); + // Only configs of presets that other entries inherit are ever looked + // up again; registering just those skips one full config copy for + // every leaf preset. The library's filaments are all retained — they + // become the m_config_maps other vendors resolve against. + std::set inherited; + for (const CachedPreset& entry : entries) + if (! entry.inherits.empty()) + inherited.insert(entry.inherits); + const std::set* retain_configs = is_from_lib ? nullptr : &inherited; + for (const CachedPreset& entry : entries) { + const std::string reason = load_vendor_preset(entry, path, vendor_name, + base_bundle, LoadConfigBundleAttribute::LoadSystem, substitution_context, substitutions, + configs, filament_id_maps, presets, count, is_from_lib, retain_configs); + if (! reason.empty()) + throw std::runtime_error("entry " + entry.name + " failed to install: " + reason); + } + }; + install_entries(data.process_entries, &this->prints, false); + const bool is_orca_lib = vendor_name == ORCA_FILAMENT_LIBRARY; + install_entries(data.filament_entries, &this->filaments, is_orca_lib); + if (is_orca_lib) { + m_config_maps = configs; + m_filament_id_maps = filament_id_maps; + } + install_entries(data.machine_entries, &this->printers, false); + return true; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "PresetBundle: rejecting vendor cache " << cache_path << ": " << e.what(); + // Restore a clean state so the caller can fall back to the JSON parse. + this->reset(false); + this->vendors.clear(); + this->m_config_maps.clear(); + this->m_filament_id_maps.clear(); + this->m_errors = errors_at_entry; + // A failure partway through installing may have left presets in some + // collections with hold aliases already registered. + this->clear_printer_hold_aliases(); + return false; + } +} + } // namespace Slic3r diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 685687975b..9da8fb4251 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -2,10 +2,12 @@ #define slic3r_PresetBundle_hpp_ #include "Preset.hpp" +#include "PresetCacheFormat.hpp" #include "AppConfig.hpp" #include "enum_bitmask.hpp" #include +#include #include #include #include @@ -170,6 +172,31 @@ struct PresetBundleMetadata class PresetBundle { public: + // ---- Per-vendor preset cache -------------------------------------------- + // One cache file per vendor (plus the Orca filament library), stamped with + // the vendor's own profile version rather than a directory scan. The bytes + // on disk are VendorCacheFile's business (PresetCacheFormat.hpp); what + // lives here is how a cache's contents install into a bundle. + + // The cache is not something a caller loads from: a vendor is loaded with + // load_vendor_configs_from_json, which comes from the cache whenever one covers + // it. What is public here is what the cache's own tests drive directly. + + // Load a per-vendor cache into this bundle by installing its entries, with + // base_bundle's filament library as the inheritance base. Rejects (returns + // false, with this bundle left clean) unless VendorCacheFile::load accepts + // the file — see its contract for the version and identity checks — and + // every entry installs. Options this build no longer defines are dropped, + // not fatal — the payload names its own keys. + bool load_vendor_cache(const std::string& cache_path, const std::string& expected_vendor_name, + const Semver& expected_vendor_version, const PresetBundle* base_bundle = nullptr); + + // Enable writing a per-vendor cache after a JSON parse (off by default). Cache + // content is pure parse output, so the guard is policy, not correctness: only + // the deliberate generators (load_system_presets_from_json, the cache build + // tool) write files, not every incidental load a dialog performs. + void set_generate_vendor_caches(bool enable) { m_generate_vendor_caches = enable; } + static DynamicPrintConfig construct_full_config(Preset &in_printer_preset, Preset &in_print_preset, const DynamicPrintConfig &project_config, @@ -444,8 +471,12 @@ public: /*std::pair load_configbundle( const std::string &path, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule);*/ //Orca: load config bundle from json, pass the base bundle to support cross vendor inheritance + // Orca: `dir` is where the vendor is looked for — its own directory, whether or + // not the profile JSONs are still there. A whole-vendor load comes from the + // vendor's preset cache whenever one covers the profile on disk, and is parsed + // from the JSONs in `dir` only when none does. Nothing here reads resources. std::pair load_vendor_configs_from_json( - const std::string &path, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr); + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr); // Export a config bundle file containing all the presets and the names of the active presets. //void export_configbundle(const std::string &path, bool export_system_settings = false, bool export_physical_printers = false); @@ -517,11 +548,49 @@ public: // Orca: for validation only. bool has_errors(bool check_duplicate_filament_subtypes = false) const; + // Errors the last load recorded. What the cache's error accounting promises — + // a cache-served vendor reports what its parse would — is pinned against this. + int error_count() const { return m_errors; } + // Orca: for validation only. Flag any system preset whose inherits / compatible_printers / // compatible_prints references a deleted (unknown) or renamed (old) preset name. bool check_preset_references() const; + // Merge one vendor's presets with the other vendor's presets, report duplicates. + // Public so per-vendor-cache consumers (e.g. the setup wizard) can assemble a + // bundle out of several per-vendor caches loaded into separate PresetBundle instances. + std::vector merge_presets(PresetBundle &&other); + private: + // Load one vendor from the preset cache installed in `dir`, judged against + // the vendor profile there. False, with this bundle left clean, when there + // is no usable cache and the vendor has to be parsed. This is how + // load_vendor_configs_from_json reads a cache. + bool load_vendor_cache(const boost::filesystem::path& dir, const std::string& vendor_name, const PresetBundle* base_bundle); + + // Load one source-form preset entry into this bundle: resolve `inherits`, + // flatten, validate and register the preset. Returns the reason loading + // failed, empty on success. See the definition for the sharing contract + // between the JSON parse and the cache load. + // retain_configs, when non-null, names the only presets registered into + // config_maps (a full config copy each). The cache load passes the names its + // entries inherit — the only ones ever looked up again; the JSON parse + // retains all, not knowing what later subfiles inherit. + std::string load_vendor_preset(const CachedPreset& entry, + const std::string& path, const std::string& vendor_name, + const PresetBundle* base_bundle, + LoadConfigBundleAttributes flags, + ConfigSubstitutionContext& substitution_context, PresetsConfigSubstitutions& substitutions, + std::map& config_maps, std::map& filament_id_maps, + PresetCollection* presets_collection, size_t& count, bool is_from_lib, + const std::set* retain_configs = nullptr); + + // Clear every collection's m_printer_hold_alias, which reset() leaves alone. + void clear_printer_hold_aliases(); + + // Whether to (re)write a per-vendor cache after a JSON parse. + bool m_generate_vendor_caches { false }; + // Orca: validation only - flag any printer with two or more compatible // filament presets sharing one filament_id (ambiguous AMS subtype match). bool check_duplicate_filament_subtypes() const; @@ -529,8 +598,6 @@ private: //std::pair load_system_presets(ForwardCompatibilitySubstitutionRule compatibility_rule); //BBS: add json related logic std::pair load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule); - // Merge one vendor's presets with the other vendor's presets, report duplicates. - std::vector merge_presets(PresetBundle &&other); // Update the multicolor information for filaments. void update_filament_multi_color(); // Update renamed_from and alias maps of system profiles. diff --git a/src/libslic3r/PresetCacheFormat.cpp b/src/libslic3r/PresetCacheFormat.cpp new file mode 100644 index 0000000000..accebba7b4 --- /dev/null +++ b/src/libslic3r/PresetCacheFormat.cpp @@ -0,0 +1,588 @@ +#include "libslic3r/PresetCacheFormat.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Utils.hpp" + +namespace Slic3r { + +CacheDictionary::CacheDictionary() +{ + // ENUM_UNNAMED is index 0 and always the empty name. + m_enum_values.emplace_back(); +} + +// The ints an enum option holds — one for a coEnum, the whole vector for coEnums. +static std::vector enum_ints(const ConfigOptionDef& def, const ConfigOption* opt) +{ + if (def.type == coEnum) + return { opt->getInt() }; + return static_cast(opt)->values; +} + +// The name this build gives one of those ints, empty where it has none — a +// nullable option's nil, or a definition carrying no enum_keys_map. Enums are +// written by name so a build that reorders an enum's values still reads it right. +static std::string enum_name_of(const ConfigOptionDef& def, int value) +{ + if (def.enum_keys_map != nullptr) + for (const auto& kvp : *def.enum_keys_map) + if (kvp.second == value) + return kvp.first; + return {}; +} + +void CacheDictionary::collect(const DynamicPrintConfig& config) +{ + for (auto it = config.cbegin(); it != config.cend(); ++ it) { + const ConfigOptionDef* def = print_config_def.get(it->first); + if (def == nullptr) + continue; // save_config does not write it either + if (m_key_index.try_emplace(it->first, uint16_t(m_keys.size())).second) { + m_keys.push_back(it->first); + m_types.push_back(uint16_t(def->type)); + } + if (def->type != coEnum && def->type != coEnums) + continue; + for (int value : enum_ints(*def, it->second.get())) { + std::string name = enum_name_of(*def, value); + if (! name.empty() && m_enum_index.try_emplace(name, uint16_t(m_enum_values.size())).second) + m_enum_values.push_back(std::move(name)); + } + } +} + +uint16_t CacheDictionary::key_index(const t_config_option_key& key) const +{ + auto it = m_key_index.find(key); + if (it == m_key_index.end()) + throw std::runtime_error("preset cache: option " + key + " was never collected into the dictionary"); + return it->second; +} + +uint16_t CacheDictionary::enum_index(const std::string& name) const +{ + if (name.empty()) + return ENUM_UNNAMED; + auto it = m_enum_index.find(name); + return it == m_enum_index.end() ? ENUM_UNNAMED : it->second; +} + +void CacheDictionary::save(cereal::BinaryOutputArchive& ar) const +{ + // Checked here rather than left to the caller: an index that wrapped would + // be written silently, and nothing downstream could tell. + if (m_keys.size() > MAX_ENTRIES || m_enum_values.size() > MAX_ENTRIES) + throw std::runtime_error("preset cache: the option dictionary outgrew the uint16 it is indexed with"); + ar(m_keys, m_types, m_enum_values); +} + +void CacheDictionary::load(cereal::BinaryInputArchive& ar) +{ + ar(m_keys, m_types, m_enum_values); + if (m_keys.size() != m_types.size()) + throw std::runtime_error("preset cache: dictionary key and type tables differ in length"); + if (m_keys.size() > MAX_ENTRIES || m_enum_values.size() > MAX_ENTRIES) + throw std::runtime_error("preset cache: dictionary is larger than the uint16 it is indexed with"); + if (m_enum_values.empty() || ! m_enum_values.front().empty()) + throw std::runtime_error("preset cache: dictionary is missing its unnamed-enum slot"); + // Resolved once per file: every option read after this is a vector index. + m_defs.resize(m_keys.size()); + for (size_t i = 0; i < m_keys.size(); ++ i) { + const ConfigOptionDef* def = print_config_def.get(m_keys[i]); + m_defs[i] = (def != nullptr && uint16_t(def->type) == m_types[i]) ? def : nullptr; + } +} + +// ---- one config ----------------------------------------------------------- + +static void save_enum_option(cereal::BinaryOutputArchive& ar, const ConfigOptionDef& def, + const ConfigOption* opt, const CacheDictionary& dict) +{ + const std::vector values = enum_ints(def, opt); + ar(uint32_t(values.size())); + for (int value : values) { + const uint16_t idx = dict.enum_index(enum_name_of(def, value)); + ar(idx); + if (idx == CacheDictionary::ENUM_UNNAMED) + ar(int32_t(value)); + } +} + +// `config` may be null, in which case the option is read and dropped. +static void load_enum_option(cereal::BinaryInputArchive& ar, ConfigOptionType type, + const ConfigOptionDef* def, DynamicPrintConfig* config, + const CacheDictionary& dict) +{ + uint32_t cnt = 0; + ar(cnt); + if (type == coEnum && cnt != 1) + throw std::runtime_error("preset cache: a scalar enum carrying more than one value"); + // Every element is read whatever happens, so the stream stays in sync and + // whatever follows this option still loads. + bool usable = def != nullptr && config != nullptr; + std::vector values; + values.reserve(cnt); + for (uint32_t i = 0; i < cnt; ++ i) { + uint16_t idx = 0; + ar(idx); + if (! dict.valid_enum_index(idx)) + throw std::runtime_error("preset cache: enum value index past the end of the dictionary"); + if (idx == CacheDictionary::ENUM_UNNAMED) { + // An int the writer could not name — a nil, or an option whose + // definition carried no enum_keys_map. It travels verbatim. + int32_t raw = 0; + ar(raw); + values.push_back(int(raw)); + continue; + } + if (! usable) + continue; // the index above was this element's whole payload + if (def->enum_keys_map == nullptr) { + usable = false; // this build no longer maps this option's names + continue; + } + const auto it = def->enum_keys_map->find(dict.enum_name_at(idx)); + if (it == def->enum_keys_map->end()) { + usable = false; // a value this build dropped: the option goes with it + continue; + } + values.push_back(it->second); + } + if (! usable) + return; + if (type == coEnum) { + config->set_key_value(def->opt_key, new ConfigOptionEnumGeneric(def->enum_keys_map, values.front())); + } else { + auto* opt = def->nullable ? static_cast(new ConfigOptionEnumsGenericNullable(def->enum_keys_map)) + : static_cast(new ConfigOptionEnumsGeneric(def->enum_keys_map)); + opt->values = std::move(values); + config->set_key_value(def->opt_key, opt); + } +} + +void save_config(cereal::BinaryOutputArchive& ar, const DynamicPrintConfig& config, const CacheDictionary& dict) +{ + struct Written { uint16_t idx; const ConfigOptionDef* def; const ConfigOption* opt; }; + std::vector written; + written.reserve(config.size()); + for (auto it = config.cbegin(); it != config.cend(); ++ it) + if (const ConfigOptionDef* def = print_config_def.get(it->first)) + written.push_back({ dict.key_index(it->first), def, it->second.get() }); + + ar(uint32_t(written.size())); + for (const Written& w : written) { + ar(w.idx); + if (w.def->type == coEnum || w.def->type == coEnums) + save_enum_option(ar, *w.def, w.opt, dict); + else + w.def->save_option_to_archive(ar, w.opt); + } +} + +// `config` null means: read everything, keep nothing. +static void read_config(cereal::BinaryInputArchive& ar, DynamicPrintConfig* config, const CacheDictionary& dict) +{ + uint32_t cnt = 0; + ar(cnt); + if (config != nullptr) + config->clear(); + // Reused across the loop: constructing a ConfigOptionDef per dropped option + // would allocate its strings and vectors for nothing. + ConfigOptionDef scratch; + for (uint32_t i = 0; i < cnt; ++ i) { + uint16_t idx = 0; + ar(idx); + if (! dict.valid_key_index(idx)) + throw std::runtime_error("preset cache: option index past the end of the dictionary"); + const ConfigOptionType type = dict.type_at(idx); + const ConfigOptionDef* def = dict.def_at(idx); + if (type == coEnum || type == coEnums) { + load_enum_option(ar, type, def, config, dict); + } else if (def != nullptr && config != nullptr) { + config->set_key_value(def->opt_key, def->load_option_from_archive(ar)); + } else { + // Read by the type the writer recorded, then drop: the same outcome + // a JSON profile gets for an option this build no longer has. + scratch.type = type; + std::unique_ptr discard(scratch.load_option_from_archive(ar)); + } + } +} + +void load_config(cereal::BinaryInputArchive& ar, DynamicPrintConfig& config, const CacheDictionary& dict) +{ + read_config(ar, &config, dict); +} + +void skip_config(cereal::BinaryInputArchive& ar, const CacheDictionary& dict) +{ + read_config(ar, nullptr, dict); +} + +// ---- The per-vendor cache file (.opc) ----------------------------- + +namespace { + +#pragma pack(push, 1) +struct CacheFileHeader { + uint32_t magic; + uint32_t version; + uint64_t data_size; + uint32_t crc32; +}; +#pragma pack(pop) +static_assert(sizeof(CacheFileHeader) == 20, "CacheFileHeader must be 20 bytes"); + +constexpr uint32_t CACHE_MAGIC = 0x4F52435A; // "ORCZ" +// Bump when the wire format changes in a way the payload cannot describe +// itself out of: reordering, removing or retyping a field of a hand-written +// serialize() (VendorProfile and its nested types, CachedPreset via +// save_entries below), or a change to the cache's own layout or the +// meaning of its stamps. Option-schema drift is NOT such a change — the +// dictionary handles it, which is why this no longer moves every release. +constexpr uint32_t CACHE_VERSION = 1; + +// A stamp-string read that refuses an absurd length before allocating anything. +// The stamps are read from files named from the outside (peek_version is +// pointed at whatever .opc a directory holds), so the length word may +// be arbitrary bytes — and a resize to a garbage 64-bit length does not fail as +// a catchable bad_alloc here, it takes the app down through the out-of-memory +// handler. A vendor name or profile version is a short token; anything longer +// is not a cache this build wrote. +std::string read_bounded_string(cereal::BinaryInputArchive& ar) +{ + constexpr uint64_t MAX_STAMP_LEN = 1024; + cereal::size_type len = 0; + ar(cereal::make_size_tag(len)); + if (uint64_t(len) > MAX_STAMP_LEN) + throw std::runtime_error("preset cache: string length out of bounds"); + std::string s(size_t(len), '\0'); + ar(cereal::binary_data(s.data(), size_t(len))); + return s; +} + +// The prologue every cache reader starts with: the format version, then the +// vendor's identity. Returns the vendor version stamped on a body this build can +// read, empty on anything else — which is the same answer as "not this vendor". +std::string read_cache_stamps(cereal::BinaryInputArchive& ar, const std::string& expected_vendor_name) +{ + // The version is judged before anything variable-length is read: on a body + // that is not a per-vendor cache of this version, the bytes where a string + // length would sit may be arbitrary framing. + uint32_t cache_version = 0; + ar(cache_version); + if (cache_version != CACHE_VERSION) + return {}; + const std::string vendor_name = read_bounded_string(ar); + const std::string vendor_version = read_bounded_string(ar); + if (vendor_name != expected_vendor_name) + return {}; + return vendor_version; +} + +// A cache stays usable as long as it was built from a vendor profile at least +// as new as the one now on disk. Profiles whose version is invalid cannot be +// judged this way and are never served from cache; where no profile sits +// beside the cache at all, nothing can be newer than it — that state is passed +// as Semver::inf(), which no real profile can carry (an invalid version could +// not say it apart from "profile there but unjudgeable", and zero would +// collide with a genuine "0.0.0"). This is the serve rule; the install rule +// (cache_covers in PresetBundle.cpp) deliberately reads an unjudgeable profile +// the other way, so the two are not one function. +bool cache_covers_version(const std::string& cached, const Semver& on_disk) +{ + if (on_disk == Semver::inf()) + return true; // before parsing `cached`: nothing exists that the stamp must cover + if (! on_disk.valid()) + return false; + const auto cached_ver = Semver::parse(cached); + return cached_ver && *cached_ver >= on_disk; +} + +// CachedPreset on the wire: all fields, declaration order, in one place. +// `config` writes, reads or skips the config sitting in the middle of that +// order — the three things a reader can want to do with it — so save, load and +// the name peek below cannot drift apart. Keep in sync with the struct in +// PresetCacheFormat.hpp and bump CACHE_VERSION on change. Written here rather +// than as a serialize() member because the config needs the file's dictionary, +// which cereal cannot thread through one. +template +void visit_entry(Archive& ar, Entry& e, ConfigFn&& config) +{ + ar(e.name, e.sub_path); + config(); + ar(e.inherits, e.description, e.instantiation, e.setting_id, e.filament_id, e.renamed_from); +} + +// The count comes from a file that has already passed magic and CRC, but a +// reserve is a promise to allocate: cap it and let push_back grow the rest. +constexpr uint32_t MAX_RESERVED_ENTRIES = 4096; + +void save_entries(cereal::BinaryOutputArchive& ar, + const std::vector& entries, + const CacheDictionary& dict) +{ + ar(uint32_t(entries.size())); + for (const CachedPreset& e : entries) + visit_entry(ar, e, [&] { save_config(ar, e.config_src, dict); }); +} + +void load_entries(cereal::BinaryInputArchive& ar, + std::vector& entries, + const CacheDictionary& dict) +{ + uint32_t cnt = 0; + ar(cnt); + entries.clear(); + entries.reserve(std::min(cnt, MAX_RESERVED_ENTRIES)); + for (uint32_t i = 0; i < cnt; ++ i) { + CachedPreset e; + visit_entry(ar, e, [&] { load_config(ar, e.config_src, dict); }); + entries.push_back(std::move(e)); + } +} + +// Read a raw cache body: verify magic, size, CRC. +bool read_cache_blob(const std::string& path, std::string& out_blob) +{ + try { + boost::nowide::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) + return false; + CacheFileHeader fhdr; + if (!ifs.read(reinterpret_cast(&fhdr), sizeof(fhdr))) + return false; + if (fhdr.magic != CACHE_MAGIC) + return false; + // data_size is 8 bytes from a file nothing has authenticated yet, and + // it is about to size an allocation. The body is the whole of the file + // behind the header — anything else is not a cache this build wrote. + ifs.seekg(0, std::ios::end); + const std::streamoff file_size = ifs.tellg(); + if (file_size < std::streamoff(sizeof(fhdr)) || + fhdr.data_size == 0 || + fhdr.data_size != uint64_t(file_size) - sizeof(fhdr)) + return false; + ifs.seekg(sizeof(fhdr), std::ios::beg); + out_blob.assign(fhdr.data_size, '\0'); + if (!ifs.read(&out_blob[0], static_cast(fhdr.data_size))) + return false; + boost::crc_32_type crc; + crc.process_bytes(out_blob.data(), out_blob.size()); + if (crc.checksum() != fhdr.crc32) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: CRC mismatch: " << path; + return false; + } + return true; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: read failed (" << path << "): " << e.what(); + return false; + } +} + +// Write a cache body behind the standard 20-byte file header. False when the +// file could not be opened or written whole. +bool write_cache_blob(const std::string& path, const std::string& blob) +{ + boost::crc_32_type crc; + crc.process_bytes(blob.data(), blob.size()); + // Written beside the target and moved into place, as AppConfig::save does: + // a cache is truncated and rewritten in full, so a write that dies partway + // would otherwise leave a header claiming more body than the file holds. + // The PID suffix also keeps two instances writing the same vendor from + // interleaving. + const std::string tmp_path = path + "." + std::to_string(get_current_pid()) + ".tmp"; + try { + boost::filesystem::create_directories(boost::filesystem::path(path).parent_path()); + { + boost::nowide::ofstream ofs(tmp_path, std::ios::binary | std::ios::trunc); + if (!ofs.is_open()) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: cannot open for writing: " << tmp_path; + return false; + } + CacheFileHeader fhdr; + fhdr.magic = CACHE_MAGIC; + fhdr.version = CACHE_VERSION; + fhdr.data_size = static_cast(blob.size()); + fhdr.crc32 = crc.checksum(); + ofs.write(reinterpret_cast(&fhdr), sizeof(fhdr)); + ofs.write(blob.data(), static_cast(blob.size())); + ofs.close(); // flush; close() raises failbit on error + if (! ofs.good()) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << tmp_path << ")"; + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + return false; + } + } + if (const std::error_code ec = rename_file(tmp_path, path)) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: could not move " << tmp_path << " into place: " << ec.message(); + boost::system::error_code rm; + boost::filesystem::remove(tmp_path, rm); + return false; + } + return true; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: write failed (" << path << "): " << e.what(); + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + return false; + } +} + +} // anonymous namespace + +// static +bool VendorCacheFile::save(const std::string& path, const std::string& vendor_name, + const std::string& vendor_version, const VendorCacheData& data) +{ + try { + // Collected before anything is written: the dictionary sits ahead of the + // entries so a reader resolves it once and then indexes. + CacheDictionary dict; + for (const std::vector* entries : { &data.process_entries, &data.filament_entries, &data.machine_entries }) + for (const CachedPreset& e : *entries) + dict.collect(e.config_src); + + std::ostringstream body(std::ios::binary); + { + cereal::BinaryOutputArchive ar(body); + ar(CACHE_VERSION); + ar(vendor_name, vendor_version); + dict.save(ar); + ar(data.vendors); + save_entries(ar, data.process_entries, dict); + save_entries(ar, data.filament_entries, dict); + save_entries(ar, data.machine_entries, dict); + ar(data.parse_errors); + } + return write_cache_blob(path, body.str()); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: failed to save vendor cache " << path << ": " << e.what(); + return false; + } +} + +// static +bool VendorCacheFile::load(const std::string& path, const std::string& expected_vendor_name, + const Semver& expected_vendor_version, VendorCacheData& data) +{ + std::string blob; + if (! read_cache_blob(path, blob)) + return false; + try { + // Read in place: an istringstream would copy the blob once more just to + // stream over it. + boost::iostreams::stream body(blob.data(), blob.size()); + cereal::BinaryInputArchive ar(body); + const std::string vendor_version = read_cache_stamps(ar, expected_vendor_name); + if (vendor_version.empty() || ! cache_covers_version(vendor_version, expected_vendor_version)) + return false; + CacheDictionary dict; + dict.load(ar); + ar(data.vendors); + load_entries(ar, data.process_entries, dict); + load_entries(ar, data.filament_entries, dict); + load_entries(ar, data.machine_entries, dict); + ar(data.parse_errors); + if (data.vendors.find(expected_vendor_name) == data.vendors.end()) + throw std::runtime_error("vendor cache does not carry its own vendor profile"); + return true; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: rejecting vendor cache " << path << ": " << e.what(); + return false; + } +} + +// static +std::string VendorCacheFile::peek_version(const std::string& path, const std::string& expected_vendor_name) +{ + try { + boost::nowide::ifstream ifs(path, std::ios::binary); + CacheFileHeader fhdr; + if (! ifs.read(reinterpret_cast(&fhdr), sizeof(fhdr)) || fhdr.magic != CACHE_MAGIC) + return {}; + // Only the head of the body is read, and its CRC left unverified: the + // stamps sit at the front, and this answers "what version is this?" + // without paying for tens of megabytes. Callers that need to know the + // file is whole use usable_version instead. + std::string head(static_cast(std::min(fhdr.data_size, 1024)), '\0'); + if (! ifs.read(&head[0], static_cast(head.size()))) + return {}; + std::istringstream body(head, std::ios::binary); + cereal::BinaryInputArchive ar(body); + return read_cache_stamps(ar, expected_vendor_name); + } catch (const std::exception&) { + return {}; + } +} + +// static +Semver VendorCacheFile::usable_version(const std::string& path, const std::string& expected_vendor_name) +{ + std::string blob; + if (! read_cache_blob(path, blob)) + return Semver::invalid(); + try { + boost::iostreams::stream body(blob.data(), blob.size()); + cereal::BinaryInputArchive ar(body); + const auto ver = Semver::parse(read_cache_stamps(ar, expected_vendor_name)); + return ver ? *ver : Semver::invalid(); + } catch (const std::exception&) { + return Semver::invalid(); + } +} + +// static +bool VendorCacheFile::carries_preset(const std::string& path, const std::string& vendor_name, + Preset::Type type, const std::string& preset_name) +{ + std::string blob; + if (! read_cache_blob(path, blob)) + return false; + try { + boost::iostreams::stream body(blob.data(), blob.size()); + cereal::BinaryInputArchive ar(body); + if (read_cache_stamps(ar, vendor_name).empty()) + return false; + CacheDictionary dict; + dict.load(ar); + VendorMap vendors; + ar(vendors); + // Reused: every entry overwrites it, and only its name is ever looked at. + CachedPreset entry; + // Written in this order by save. The list that could carry the preset + // is the last one worth reading. + for (Preset::Type kind : { Preset::TYPE_PRINT, Preset::TYPE_FILAMENT, Preset::TYPE_PRINTER }) { + uint32_t cnt = 0; + ar(cnt); + for (uint32_t i = 0; i < cnt; ++ i) { + visit_entry(ar, entry, [&] { skip_config(ar, dict); }); + if (kind == type && entry.name == preset_name) + return true; + } + if (kind == type) + return false; + } + return false; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "VendorCacheFile: could not read preset names from " << path << ": " << e.what(); + return false; + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/PresetCacheFormat.hpp b/src/libslic3r/PresetCacheFormat.hpp new file mode 100644 index 0000000000..b200ec9911 --- /dev/null +++ b/src/libslic3r/PresetCacheFormat.hpp @@ -0,0 +1,192 @@ +#ifndef slic3r_PresetCacheFormat_hpp_ +#define slic3r_PresetCacheFormat_hpp_ + +#include +#include +#include +#include + +#include +#include +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Semver.hpp" + +namespace Slic3r { + +// How the preset cache writes a DynamicPrintConfig. +// +// Not through the global cereal hooks in PrintConfig.hpp: those key an option by +// its serialization_key_ordinal, which ConfigDef::add assigns by declaration +// order at static-init time. Inserting one option into the middle of +// PrintConfig.cpp shifts every later ordinal, and the lookup on the way back in +// then SUCCEEDS on the wrong option — where the two share a type, and hundreds +// of coFloat/coBool/coInt options do, the bytes deserialize cleanly into the +// wrong key. Silently wrong print settings, no error. Those hooks are also the +// undo/redo wire format, where the process cannot change underneath them, so +// they stay as they are and the cache keys by name instead. +// +// Names are not repeated per preset. Each cache file carries one dictionary of +// the distinct opt_keys it uses, the type each was written as, and the distinct +// enum value names; an option on the wire is then a uint16 index into it plus +// its value. The dictionary is resolved to this build's option definitions once +// per file, after which reading an option is a vector index. +class CacheDictionary +{ +public: + CacheDictionary(); + + // Index reserved in the enum table for an int the writing build could not + // name — a nullable option's nil, or a definition carrying no + // enum_keys_map. The raw int32 follows it on the wire and is loaded + // verbatim, so those values survive too. + static constexpr uint16_t ENUM_UNNAMED = 0; + + // ---- writing ---- + + // Record every key and enum value `config` uses. Call for every config that + // will be written, before writing the dictionary. + void collect(const DynamicPrintConfig& config); + + uint16_t key_index(const t_config_option_key& key) const; + // ENUM_UNNAMED for an empty name or one that was never collected. + uint16_t enum_index(const std::string& name) const; + + // ---- reading ---- + + // The definition an index resolves to in THIS build, or nullptr where the + // key is unknown here or is now defined with a different type. A nullptr + // entry's value is still read — using type_at(idx), the type the writer + // recorded — and then dropped, which is what a JSON profile gets for an + // option this build no longer has. + const ConfigOptionDef* def_at(uint16_t idx) const { return m_defs[idx]; } + ConfigOptionType type_at(uint16_t idx) const { return ConfigOptionType(m_types[idx]); } + const std::string& enum_name_at(uint16_t idx) const { return m_enum_values[idx]; } + // m_defs, not m_keys: only load() sizes it, so this is false for every index + // on a dictionary that was collected rather than read. + bool valid_key_index(uint16_t idx) const { return size_t(idx) < m_defs.size(); } + bool valid_enum_index(uint16_t idx) const { return size_t(idx) < m_enum_values.size(); } + + // The layout these two agree on is covered by CACHE_VERSION (PresetCacheFormat.cpp); + // bump it when they change. + // Throws when either table outgrew the uint16 the wire format indexes it + // with. Both are bounded by the option count (912 at the time of writing), so + // that is a build-time failure in CI, not a runtime one. + void save(cereal::BinaryOutputArchive& ar) const; + // Throws on a dictionary that cannot be indexed as written. + void load(cereal::BinaryInputArchive& ar); + +private: + // Indices are uint16, so a table may hold at most this many entries. + static constexpr size_t MAX_ENTRIES = 0xFFFF; + + std::vector m_keys; + // ConfigOptionType, as written. Sixteen bits, not eight: coVectorType is + // 0x4000, so every vector type — coFloats, coEnums, coStrings — is above + // 255, and a byte would fold each one onto its scalar counterpart. + std::vector m_types; + std::vector m_enum_values; // [ENUM_UNNAMED] is always empty + + // Writing. + std::unordered_map m_key_index; + std::unordered_map m_enum_index; + // Reading, resolved once by load(). + std::vector m_defs; +}; + +// One config, keyed through `dict`. Options print_config_def does not know are +// not written: nothing could give them a type on the way back in. +void save_config(cereal::BinaryOutputArchive& ar, const DynamicPrintConfig& config, const CacheDictionary& dict); +// Throws only on a payload that cannot be indexed; an option this build cannot +// place is dropped, not fatal. +void load_config(cereal::BinaryInputArchive& ar, DynamicPrintConfig& config, const CacheDictionary& dict); +// Consume one config without building it, for a reader that only wants what +// comes after. +void skip_config(cereal::BinaryInputArchive& ar, const CacheDictionary& dict); + +// One preset as its JSON subfile states it: the config diff, the name of the +// preset it inherits, and the parse metadata — everything the parse phase of +// load_vendor_configs_from_json extracts and nothing it derives. Inheritance +// is resolved when the entry is installed, against whatever filament library +// is loaded then, so a cache carries no other vendor's values and no other +// vendor's update can make it stale. +// Written and read by visit_entry in PresetCacheFormat.cpp, which lists every +// field below in this order — once, for the save, the load and the name peek alike. +struct CachedPreset +{ + std::string name; + std::string sub_path; // path under the vendor's directory + DynamicPrintConfig config_src; // the preset's own diff, nothing inherited + std::string inherits; + std::string description; + std::string instantiation; // "true"/"false" as stated; anything else was already counted as a parse error + std::string setting_id; + std::string filament_id; + std::vector renamed_from; +}; + +// What one per-vendor cache file carries besides its stamps: the vendor profile +// map, the presets in source form, and how many errors their parse counted. +struct VendorCacheData +{ + VendorMap vendors; + std::vector process_entries; + std::vector filament_entries; + std::vector machine_entries; + uint64_t parse_errors = 0; +}; + +// A per-vendor preset cache file (.opc): a 20-byte header (magic, format +// version, body size, CRC) framing one cereal body — stamps (format version, +// vendor name, vendor profile version), the option dictionary, then the +// VendorCacheData. Everything about those bytes lives here; when a vendor is +// served from its cache, and how entries install into a bundle, is +// PresetBundle's business. +class VendorCacheFile +{ +public: + // Save one vendor (vendor_name at vendor_version). False when the file + // could not be written whole. + static bool save(const std::string& path, const std::string& vendor_name, + const std::string& vendor_version, const VendorCacheData& data); + + // Read a whole cache into `data`. False — with `data` in an unspecified + // state — unless the file is a cache this build wrote, its CRC holds, it + // names this vendor, it was built from a vendor profile at least as new as + // `expected_vendor_version`, and it carries its own vendor profile. An + // invalid expected version (a profile whose version + // cannot be judged) is never served from cache; Semver::inf() (no profile + // beside the cache at all) accepts whatever is cached. + static bool load(const std::string& path, const std::string& expected_vendor_name, + const Semver& expected_vendor_version, VendorCacheData& data); + + // Read the profile version a cache was stamped with, without deserializing + // its presets. Empty if the file is unreadable, not a cache this build + // understands, or not this vendor's. This is how an installed vendor's + // version is known when only its cache is installed. + static std::string peek_version(const std::string& path, const std::string& expected_vendor_name); + + // The profile version an installed cache can actually be served at, or an + // invalid Semver when the file is not a cache this build can read. Unlike + // peek_version this verifies the body's CRC, at the cost of reading the + // whole file: where the cache is the vendor's whole installation, "a file + // is there" is not enough to call it installed, and a vendor wrongly + // believed installed is never repaired. + static Semver usable_version(const std::string& path, const std::string& expected_vendor_name); + + // Whether a cache carries a preset of `type` under `preset_name`, without + // installing any of them. False when the file is not a cache this build can + // read. The three kinds are written in one stream, so reaching the machines + // means reading past the processes and filaments — their configs are consumed + // and dropped rather than built. This is how a build that ships caches instead + // of preset JSONs answers "which vendor carries this preset?". + static bool carries_preset(const std::string& path, const std::string& vendor_name, + Preset::Type type, const std::string& preset_name); +}; + +} // namespace Slic3r + +#endif // slic3r_PresetCacheFormat_hpp_ diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 26a708b78d..255c8721b9 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -2488,7 +2488,8 @@ namespace cereal { archive(serialization_key_ordinal); assert(serialization_key_ordinal > 0); auto it = Slic3r::print_config_def.by_serialization_key_ordinal.find(serialization_key_ordinal); - assert(it != Slic3r::print_config_def.by_serialization_key_ordinal.end()); + if (it == Slic3r::print_config_def.by_serialization_key_ordinal.end()) + throw std::runtime_error("VendorCache: unknown serialization_key_ordinal " + std::to_string(serialization_key_ordinal) + " - cache is stale"); config.set_key_value(it->second->opt_key, it->second->load_option_from_archive(archive)); } } diff --git a/src/libslic3r/Semver.hpp b/src/libslic3r/Semver.hpp index 4d64b1c7db..d3683b4eb8 100644 --- a/src/libslic3r/Semver.hpp +++ b/src/libslic3r/Semver.hpp @@ -190,6 +190,19 @@ public: os << self.to_string(); return os; } + + // cereal: round-trip through the standard 3-part string (major.minor.patch). + // to_string() uses a BBS 4-part format that semver_parse() cannot read back. + template + std::string save_minimal(const Archive&) const { return to_string_sf(); } + template + void load_minimal(const Archive&, const std::string& s) { + auto v = Semver::parse(s); + if (! v) + throw std::runtime_error("Semver: cannot parse serialized version: " + s); + *this = std::move(*v); + } + private: semver_t ver; diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 62b2eeb78e..55d9b716cf 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include "libslic3r.h" +#include "Semver.hpp" //define CLI errors @@ -722,11 +724,42 @@ void copy_directory_recursively(const boost::filesystem::path& source, std::function filter = nullptr, bool merge_mode = false); -// Install vendor bundles from resources directory to data directory -// bundle_names: vector of vendor bundle names (without .json extension) -// resource_subdir: subdirectory under resources_dir() (default: "profiles") -// data_subdir: subdirectory under data_dir() (default: "system") -// Returns: true if all bundles installed successfully, false otherwise +// ---- Vendor installation on disk ------------------------------------------ +// How a vendor bundle is installed from resources into data_dir()/system: as +// its profile and preset JSONs or, in a build that ships preset caches, as its +// .opc preset cache alone. Loading what is installed is PresetBundle's business; +// the cache file format itself is VendorCacheFile's (PresetCacheFormat.hpp). + +// True if `vendor` is installed in data_dir()/system. A build that ships preset +// caches installs the cache alone, so it — not the profile — marks a vendor +// installed; a cache this build cannot read marks nothing. +bool is_vendor_installed(const std::string& vendor); + +// The version the installed vendor would be loaded at: its cache's stamp while +// that covers the profile beside it, the profile's own version once it does not. +// Invalid Semver if neither form is installed. +Semver installed_vendor_version(const std::string& vendor); + +// Remove every form `vendor` can be installed as from data_dir()/system: its +// profile, its preset cache, and its preset directory. +void remove_installed_vendor(const std::string& vendor); + +// The vendors `dir` holds, sorted: one is named by its profile or, in a build that +// ships preset caches instead of the raw profile JSONs, by its cache alone. +std::set vendor_names_in(const boost::filesystem::path& dir); + +// The version a build ships `vendor` at: whichever of its preset cache and its +// profile is newer, that being the one installing lays down. Invalid Semver if the +// build ships neither. +Semver resource_vendor_version(const std::string& vendor); + +// Install vendors from the resources directory into the data directory, each as +// its preset cache or as its profile and preset JSONs — whichever of the two the +// build ships at the newer version. Anything the previous install of that vendor +// left behind goes, so only the form just installed is there to be loaded. +// bundle_names: vendor names, without extension. +// Every bundle that can be installed is, whatever the others do. Returns false +// if any named bundle could not be installed. bool install_vendor_bundles_from_resources(const std::vector& bundle_names, const std::string& resource_subdir = "profiles", const std::string& data_subdir = "system"); diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 5f429f076a..58ec8318a6 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -17,6 +17,10 @@ #include "Platform.hpp" #include "Time.hpp" #include "libslic3r.h" +// For the vendor-installation helpers: the vendor profile version +// (get_version_from_json) and the preset cache stamp (VendorCacheFile). +#include "Preset.hpp" +#include "PresetCacheFormat.hpp" #ifdef __APPLE__ #include "MacUtils.hpp" @@ -1724,6 +1728,85 @@ void copy_directory_recursively(const boost::filesystem::path& source, return; } +// ---- Vendor installation on disk ------------------------------------------ + +// Whether a cache stamped `cache_ver` still speaks for a vendor whose profile on +// disk claims `profile_ver`: it does unless the profile has moved ahead of it. A +// profile that is missing or carries no judgeable version cannot be ahead of +// anything. The one rule behind both "which form gets installed" and "which form +// is installed"; they must not drift apart. Deliberately NOT the serve rule +// (VendorCacheFile::load), which refuses an unjudgeable profile instead. +static bool cache_covers(const Semver& cache_ver, const Semver& profile_ver) +{ + return cache_ver.valid() && (! profile_ver.valid() || cache_ver >= profile_ver); +} + +bool is_vendor_installed(const std::string& vendor) +{ + const boost::filesystem::path dir = boost::filesystem::path(data_dir()) / PRESET_SYSTEM_DIR; + // A cache is the whole of a cache-only installation, so a file this build + // cannot serve the vendor from is not an installation. Left counted as one, + // the updater would never lay a working copy down. + return boost::filesystem::exists(dir / (vendor + ".json")) + || VendorCacheFile::usable_version((dir / (vendor + ".opc")).string(), vendor).valid(); +} + +Semver installed_vendor_version(const std::string& vendor) +{ + const boost::filesystem::path dir = boost::filesystem::path(data_dir()) / PRESET_SYSTEM_DIR; + const boost::filesystem::path json = dir / (vendor + ".json"); + // Guarded: get_version_from_json logs an error and throws-and-catches its way + // to an invalid version on a file that is not there, and a cache-only vendor + // never has one. + const Semver from_json = boost::filesystem::exists(json) ? get_version_from_json(json.string()) : Semver(); + const Semver from_cache = VendorCacheFile::usable_version((dir / (vendor + ".opc")).string(), vendor); + // Whichever form a load would serve. + return cache_covers(from_cache, from_json) ? from_cache : from_json; +} + +void remove_installed_vendor(const std::string& vendor) +{ + const boost::filesystem::path dir = boost::filesystem::path(data_dir()) / PRESET_SYSTEM_DIR; + boost::filesystem::remove(dir / (vendor + ".json")); + boost::filesystem::remove(dir / (vendor + ".opc")); + if (boost::filesystem::exists(dir / vendor)) + boost::filesystem::remove_all(dir / vendor); +} + +std::set vendor_names_in(const boost::filesystem::path& dir) +{ + std::set names; + for (auto& dir_entry : boost::filesystem::directory_iterator(dir)) { + const auto& path = dir_entry.path(); + if (Slic3r::is_json_file(path.string()) || path.extension() == ".opc") + names.insert(path.stem().string()); + } + return names; +} + +// A vendor's preset cache is the whole of its installation: it carries the presets, +// the vendor profile and the version they were built at, so where one ships nothing +// else needs copying. Unless the profile beside it claims a newer version — a cache +// generated before that profile was bumped is out of date, and a cache that cannot +// be read is no installation at all — and the vendor is installed the way it was +// before caches existed, as its profile and the preset JSONs it points at. Returns +// the version the cache is stamped with, invalid when it is not the form to install. +static Semver installable_cache_version(const boost::filesystem::path& dir, const std::string& vendor) +{ + const auto cache_ver = Semver::parse(VendorCacheFile::peek_version((dir / (vendor + ".opc")).string(), vendor)); + if (! cache_ver) + return Semver::invalid(); + const Semver profile_ver = get_version_from_json((dir / (vendor + ".json")).string()); + return cache_covers(*cache_ver, profile_ver) ? *cache_ver : Semver::invalid(); +} + +Semver resource_vendor_version(const std::string& vendor) +{ + const boost::filesystem::path dir = boost::filesystem::path(resources_dir()) / "profiles"; + const Semver ver = installable_cache_version(dir, vendor); + return ver.valid() ? ver : get_version_from_json((dir / (vendor + ".json")).string()); +} + bool install_vendor_bundles_from_resources( const std::vector& bundle_names, const std::string& resource_subdir, @@ -1736,37 +1819,82 @@ bool install_vendor_bundles_from_resources( BOOST_LOG_TRIVIAL(info) << "Installing " << bundle_names.size() << " bundles from resources..."; + // One vendor that cannot be installed is one vendor missing, not a reason to + // leave the rest uninstalled. The caller is told, and every bundle that can + // be laid down is. + bool all_installed = true; + for (const auto &bundle : bundle_names) { try { + if (bundle.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Refusing to install a bundle with no name"; + all_installed = false; + continue; + } + // Install the JSON file auto path_in_rsrc = (rsrc_path / bundle).replace_extension(".json"); auto path_in_vendors = (vendor_path / bundle).replace_extension(".json"); + auto cache_in_rsrc = (rsrc_path / bundle).replace_extension(".opc"); + auto cache_in_vendors = (vendor_path / bundle).replace_extension(".opc"); - if (!fs::exists(path_in_rsrc)) { + // Either form of the vendor will do: a build may ship it as a cache alone. + if (!fs::exists(path_in_rsrc) && !fs::exists(cache_in_rsrc)) { BOOST_LOG_TRIVIAL(warning) << "Bundle not found in resources: " << bundle; - return false; + all_installed = false; + continue; } // Create target directory if needed if (!fs::exists(vendor_path)) fs::create_directories(vendor_path); - // Copy JSON file std::string error_message; - CopyFileResult cfr = copy_file(path_in_rsrc.string(), path_in_vendors.string(), error_message, false); - if (cfr != CopyFileResult::SUCCESS) { - BOOST_LOG_TRIVIAL(error) << "Failed to copy " << bundle << ".json: " << error_message; - return false; + bool installed_cache = false; + if (installable_cache_version(rsrc_path, bundle).valid()) { + installed_cache = copy_file(cache_in_rsrc.string(), cache_in_vendors.string(), error_message, false) == CopyFileResult::SUCCESS; + if (! installed_cache) { + BOOST_LOG_TRIVIAL(warning) << "Failed to copy " << bundle << ".opc: " << error_message; + } else if (! VendorCacheFile::usable_version(cache_in_vendors.string(), bundle).valid()) { + // The copy is what will be loaded, so it — not the kilobyte + // peek that chose this form — decides whether the profile + // beside it can go. + BOOST_LOG_TRIVIAL(warning) << "Installed cache for " << bundle << " cannot be read; installing its profile instead"; + boost::system::error_code ec; + fs::remove(cache_in_vendors, ec); + installed_cache = false; + } + } + + if (! installed_cache) { + CopyFileResult cfr = copy_file(path_in_rsrc.string(), path_in_vendors.string(), error_message, false); + if (cfr != CopyFileResult::SUCCESS) { + BOOST_LOG_TRIVIAL(error) << "Failed to copy " << bundle << ".json: " << error_message; + all_installed = false; + continue; + } + // Only now: an earlier install's cache would shadow this profile, + // but removing it before the profile lands would leave neither. + boost::system::error_code ec; + fs::remove(cache_in_vendors, ec); + } else { + // Left in place, an earlier install's profile would shadow the cache. + boost::system::error_code ec; + fs::remove(path_in_vendors, ec); + if (ec) + BOOST_LOG_TRIVIAL(warning) << "Could not remove the superseded profile " << path_in_vendors.string() << ": " << ec.message(); } // Copy the vendor directory (if it exists) auto dir_in_rsrc = rsrc_path / bundle; auto dir_in_vendors = vendor_path / bundle; - if (fs::exists(dir_in_rsrc) && fs::is_directory(dir_in_rsrc)) { - // Remove existing directory - if (fs::exists(dir_in_vendors)) - fs::remove_all(dir_in_vendors); + // Whatever is installed came from an earlier version of this vendor and + // would be parsed in place of the one being installed now. + if (fs::exists(dir_in_vendors)) + fs::remove_all(dir_in_vendors); + + if (! installed_cache && fs::exists(dir_in_rsrc) && fs::is_directory(dir_in_rsrc)) { fs::create_directories(dir_in_vendors); // Copy with file filter (same as PresetUpdater::install_bundles_rsrc) @@ -1787,11 +1915,11 @@ bool install_vendor_bundles_from_resources( } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(error) << "Exception installing bundle " << bundle << ": " << e.what(); - return false; + all_installed = false; } } - return true; + return all_installed; } void save_string_file(const boost::filesystem::path& p, const std::string& str) diff --git a/src/slic3r/Config/Snapshot.cpp b/src/slic3r/Config/Snapshot.cpp index 4b071994fc..a7135eac6f 100644 --- a/src/slic3r/Config/Snapshot.cpp +++ b/src/slic3r/Config/Snapshot.cpp @@ -432,14 +432,9 @@ const Snapshot& SnapshotDB::take_snapshot(const AppConfig &app_config, Snapshot: cfg.models_variants_installed.erase(it ++); else ++ it; - // Read the active config bundle, parse the config version. - PresetBundle bundle; - //BBS: change directoties by design - //bundle.load_configbundle((data_dir / PRESET_SYSTEM_DIR / (cfg.name + ".ini")).string(), PresetBundle::LoadConfigBundleAttribute::LoadVendorOnly, ForwardCompatibilitySubstitutionRule::EnableSilent); - bundle.load_vendor_configs_from_json((data_dir/PRESET_SYSTEM_DIR).string(), cfg.name, PresetBundle::LoadConfigBundleAttribute::LoadVendorOnly, ForwardCompatibilitySubstitutionRule::EnableSilent); - for (const auto &vp : bundle.vendors) - if (vp.second.id == cfg.name) - cfg.version.config_version = vp.second.config_version; + // Orca: the version the vendor is installed at, read from its profile or — + // where the cache is the whole installation — from the cache's own stamp. + cfg.version.config_version = installed_vendor_version(cfg.name); snapshot.vendor_configs.emplace_back(std::move(cfg)); } diff --git a/src/slic3r/GUI/ConfigWizard.cpp b/src/slic3r/GUI/ConfigWizard.cpp index 0bbbc15f87..dba8699105 100644 --- a/src/slic3r/GUI/ConfigWizard.cpp +++ b/src/slic3r/GUI/ConfigWizard.cpp @@ -66,41 +66,41 @@ using Config::SnapshotDB; // Configuration data structures extensions needed for the wizard //BBS: set BBL as default -bool Bundle::load(fs::path source_path, bool ais_in_resources, bool ais_bbl_bundle) +bool Bundle::load(fs::path dir, const std::string &vendor_name, bool ais_in_resources, bool ais_bbl_bundle) { this->preset_bundle = std::make_unique(); this->is_in_resources = ais_in_resources; this->is_bbl_bundle = ais_bbl_bundle; - std::string path_string = source_path.string(); - std::string parent_path = source_path.parent_path().string(); //BBS: add json logic for vendor bundles - std::string vendor_name = source_path.filename().string(); - if (Slic3r::is_json_file(path_string)) { - // Remove the .json suffix. - vendor_name.erase(vendor_name.size() - 5); - } - else + // Orca: served from the vendor's preset cache where one covers it — which is + // how a shipped build carries its vendors — and parsed from the JSONs otherwise. + // A vendor that can be neither read nor parsed — a cache the build cannot use + // with the preset JSONs behind it pruned, say — is one the wizard cannot offer. + // Every other vendor still can be, so it is left out rather than thrown over. + size_t presets_loaded = 0; + try { + auto [config_substitutions, loaded] = preset_bundle->load_vendor_configs_from_json( + dir.string(), vendor_name, PresetBundle::LoadConfigBundleAttribute::LoadSystem, ForwardCompatibilitySubstitutionRule::Disable); + UNUSED(config_substitutions); + // No substitutions shall be reported when loading a system config bundle, no substitutions are allowed. + assert(config_substitutions.empty()); + presets_loaded = loaded; + } catch (const std::exception &e) { + BOOST_LOG_TRIVIAL(fatal) << boost::format("Vendor bundle: `%1%`: cannot be loaded, leaving it out: %2%") % vendor_name % e.what(); return false; - - // Throw when parsing invalid configuration. Only valid configuration is supposed to be provided over the air. - //BBS: add json logic for vendor bundles - auto [config_substitutions, presets_loaded] = preset_bundle->load_vendor_configs_from_json( - parent_path, vendor_name, PresetBundle::LoadConfigBundleAttribute::LoadSystem, ForwardCompatibilitySubstitutionRule::Disable); - UNUSED(config_substitutions); - // No substitutions shall be reported when loading a system config bundle, no substitutions are allowed. - assert(config_substitutions.empty()); + } auto first_vendor = preset_bundle->vendors.begin(); if (first_vendor == preset_bundle->vendors.end()) { - BOOST_LOG_TRIVIAL(error) << boost::format("Vendor bundle: `%1%`: No vendor information defined, cannot install.") % path_string; + BOOST_LOG_TRIVIAL(error) << boost::format("Vendor bundle: `%1%`: No vendor information defined, cannot install.") % vendor_name; return false; } if (presets_loaded == 0) { - BOOST_LOG_TRIVIAL(error) << boost::format("Vendor bundle: `%1%`: No profile loaded.") % path_string; + BOOST_LOG_TRIVIAL(error) << boost::format("Vendor bundle: `%1%`: No profile loaded.") % vendor_name; return false; - } + } - BOOST_LOG_TRIVIAL(trace) << boost::format("Vendor bundle: `%1%`: %2% profiles loaded.") % path_string % presets_loaded; + BOOST_LOG_TRIVIAL(trace) << boost::format("Vendor bundle: `%1%`: %2% profiles loaded.") % vendor_name % presets_loaded; this->vendor_profile = &first_vendor->second; return true; } @@ -125,15 +125,10 @@ BundleMap BundleMap::load() //Orca: add custom as default //Orca: add json logic for vendor bundle - auto orca_bundle_path = (vendor_dir / PresetBundle::ORCA_DEFAULT_BUNDLE).replace_extension(".json"); - auto orca_bundle_rsrc = false; - if (!boost::filesystem::exists(orca_bundle_path)) { - orca_bundle_path = (rsrc_vendor_dir / PresetBundle::ORCA_DEFAULT_BUNDLE).replace_extension(".json"); - orca_bundle_rsrc = true; - } { + const bool from_rsrc = ! is_vendor_installed(PresetBundle::ORCA_DEFAULT_BUNDLE); Bundle bbl_bundle; - if (bbl_bundle.load(std::move(orca_bundle_path), orca_bundle_rsrc, true)) + if (bbl_bundle.load(from_rsrc ? rsrc_vendor_dir : vendor_dir, PresetBundle::ORCA_DEFAULT_BUNDLE, from_rsrc, true)) res.emplace(PresetBundle::ORCA_DEFAULT_BUNDLE, std::move(bbl_bundle)); } @@ -141,18 +136,13 @@ BundleMap BundleMap::load() // and then additionally from resources/profiles. bool is_in_resources = false; for (auto dir : { &vendor_dir, &rsrc_vendor_dir }) { - for (const auto &dir_entry : boost::filesystem::directory_iterator(*dir)) { - //BBS: add json logic for vendor bundle - if (Slic3r::is_json_file(dir_entry.path().string())) { - std::string id = dir_entry.path().stem().string(); // stem() = filename() without the trailing ".json" part + for (const std::string &id : vendor_names_in(*dir)) { + // Don't load this bundle if we've already loaded it. + if (res.find(id) != res.end()) { continue; } - // Don't load this bundle if we've already loaded it. - if (res.find(id) != res.end()) { continue; } - - Bundle bundle; - if (bundle.load(dir_entry.path(), is_in_resources)) - res.emplace(std::move(id), std::move(bundle)); - } + Bundle bundle; + if (bundle.load(*dir, id, is_in_resources)) + res.emplace(id, std::move(bundle)); } is_in_resources = true; diff --git a/src/slic3r/GUI/ConfigWizard_private.hpp b/src/slic3r/GUI/ConfigWizard_private.hpp index 364d378b42..7b9674b216 100644 --- a/src/slic3r/GUI/ConfigWizard_private.hpp +++ b/src/slic3r/GUI/ConfigWizard_private.hpp @@ -71,9 +71,11 @@ struct Bundle Bundle() = default; Bundle(Bundle&& other); + // Load the vendor `vendor_name` as it is installed in `dir`, from its preset + // cache or its profile JSONs, whichever is usable. // Returns false if not loaded. Reason for that is logged as boost::log error. //BBS: set BBL as default - bool load(fs::path source_path, bool is_in_resources, bool is_bbl_bundle = false); + bool load(fs::path dir, const std::string &vendor_name, bool is_in_resources, bool is_bbl_bundle = false); const std::string& vendor_id() const { return vendor_profile->id; } }; diff --git a/src/slic3r/GUI/CreatePresetsDialog.cpp b/src/slic3r/GUI/CreatePresetsDialog.cpp index 1bd80d5f00..33f49c2a38 100644 --- a/src/slic3r/GUI/CreatePresetsDialog.cpp +++ b/src/slic3r/GUI/CreatePresetsDialog.cpp @@ -2201,25 +2201,14 @@ bool CreatePrinterPresetDialog::load_system_and_user_presets_with_curr_model(Pre } else { selected_vendor_id = m_printer_preset_vendor_selected.id; - if (boost::filesystem::exists(boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR / selected_vendor_id)) { - preset_path = (boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).string(); - } else if (boost::filesystem::exists(boost::filesystem::path(Slic3r::resources_dir()) / "profiles" / selected_vendor_id)) { - preset_path = (boost::filesystem::path(Slic3r::resources_dir()) / "profiles").string(); - } - - if (preset_path.empty()) { - BOOST_LOG_TRIVIAL(info) << "Preset path was not found"; - MessageDialog dlg(this, _L("Preset path was not found; please reselect vendor."), wxString(SLIC3R_APP_FULL_NAME) + " - " + _L("Info"), - wxYES_NO | wxYES_DEFAULT | wxCENTRE); - dlg.ShowModal(); - return false; - } - try { // Pass the app's preset bundle (which already holds OrcaFilamentLibrary) as the base // bundle so vendor filaments that inherit OFL bases resolve via the existing // cross-vendor inheritance path. - temp_preset_bundle.load_vendor_configs_from_json(preset_path, selected_vendor_id, + // Orca: served from the vendor's preset cache where one covers it — a shipped + // build carries that instead of the raw preset JSONs — and parsed otherwise. + temp_preset_bundle.load_vendor_configs_from_json((boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).string(), + selected_vendor_id, PresetBundle::LoadConfigBundleAttribute::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent, wxGetApp().preset_bundle); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 3a85dc1928..6028ada640 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -6817,6 +6817,12 @@ void GUI_App::add_pending_vendor_preset(const std::pair>(); diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp index 0d2f6c724b..6b58fffead 100644 --- a/src/slic3r/GUI/WebGuideDialog.cpp +++ b/src/slic3r/GUI/WebGuideDialog.cpp @@ -1,7 +1,9 @@ #include "WebGuideDialog.hpp" #include "ConfigWizard.hpp" +#include #include +#include #include #include #include @@ -9,7 +11,9 @@ #include "I18N.hpp" #include "libslic3r/AppConfig.hpp" #include "libslic3r/Config.hpp" +#include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PresetCacheFormat.hpp" #include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "libslic3r_version.h" @@ -41,8 +45,6 @@ using namespace nlohmann; namespace Slic3r { namespace GUI { -json m_ProfileJson; - static wxString update_custom_filaments() { json m_Res = json::object(); @@ -190,12 +192,10 @@ GuideFrame::GuideFrame(GUI_App *pGUI, long style) GuideFrame::~GuideFrame() { - m_destroy = true; - if (m_load_task && m_load_task->joinable()) { + *m_cancel_token = true; // stop the loading thread and any queued CallAfter lambdas before join + if (m_load_task && m_load_task->joinable()) m_load_task->join(); - delete m_load_task; - m_load_task = nullptr; - } + m_load_task.reset(); if (m_browser) { delete m_browser; m_browser = nullptr; @@ -301,15 +301,71 @@ void GuideFrame::OnNavigationRequest(wxWebViewEvent &evt) /** * Callback invoked when a navigation request was accepted */ +// The empty shape every profile-loading path starts from or falls back to. +void GuideFrame::reset_profile_json() +{ + m_ProfileJson["model"] = json::array(); + m_ProfileJson["machine"] = json::object(); + m_ProfileJson["filament"] = json::object(); + m_ProfileJson["process"] = json::array(); +} + +void GuideFrame::init_guide_paths() +{ + m_ProfileJson = json::parse("{}"); + reset_profile_json(); + + vendor_dir = (boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).make_preferred(); + rsrc_vendor_dir = (boost::filesystem::path(resources_dir()) / "profiles").make_preferred(); + orca_bundle_rsrc = true; + + if (boost::filesystem::exists(vendor_dir)) { + for (const auto& entry : boost::filesystem::directory_iterator(vendor_dir)) { + if (!boost::filesystem::is_directory(entry) && + boost::iequals(entry.path().extension().string(), ".json") && + !boost::iequals(entry.path().stem().string(), PresetBundle::ORCA_FILAMENT_LIBRARY)) { + orca_bundle_rsrc = false; + break; + } + } + } + + auto lib_json = boost::filesystem::path(PresetBundle::ORCA_FILAMENT_LIBRARY).replace_extension(".json"); + m_OrcaFilaLibPath = boost::filesystem::exists(vendor_dir / lib_json) + ? (vendor_dir / PresetBundle::ORCA_FILAMENT_LIBRARY).string() + : (rsrc_vendor_dir / PresetBundle::ORCA_FILAMENT_LIBRARY).string(); +} + +void GuideFrame::on_profile_loaded() +{ + // Must be called on the main thread. + SaveProfileData(); + const std::string strAll = m_ProfileJson.dump(-1, ' ', false, json::error_handler_t::ignore); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ", finished, json contents:\n" << strAll; + json res; + res["command"] = "userguide_profile_load_finish"; + res["sequence_id"] = "10001"; + RunScript(wxString::Format("HandleStudio(%s)", res.dump(-1, ' ', true))); +} + void GuideFrame::OnNavigationComplete(wxWebViewEvent &evt) { //wxLogMessage("%s", "Navigation complete; url='" + evt.GetURL() + "'"); if (!bFirstComplete) { - m_load_task = new boost::thread(boost::bind(&GuideFrame::LoadProfileData, this)); - // boost::thread LoadProfileThread(boost::bind(&GuideFrame::LoadProfileData, this)); - //LoadProfileThread.detach(); - bFirstComplete = true; + try { + init_guide_paths(); + if (BuildProfileDataFromPresetBundle()) { + if (!*m_cancel_token) + on_profile_loaded(); + } else { + // Presets not yet in memory — delegate to background thread. + m_load_task = std::make_unique(boost::bind(&GuideFrame::LoadProfileData, this)); + } + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ", init error: " << e.what(); + m_load_task = std::make_unique(boost::bind(&GuideFrame::LoadProfileData, this)); + } } m_browser->Show(); @@ -762,11 +818,9 @@ bool GuideFrame::apply_config(AppConfig *app_config, PresetBundle *preset_bundle bool check_unsaved_preset_changes = false; std::vector install_bundles; std::vector remove_bundles; - const auto vendor_dir = (boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).make_preferred(); for (const auto &it : enabled_vendors) { if (it.second.size() > 0) { - auto vendor_file = vendor_dir/(it.first + ".json"); - if (!fs::exists(vendor_file)) { + if (!is_vendor_installed(it.first)) { install_bundles.emplace_back(it.first); } } @@ -777,8 +831,7 @@ bool GuideFrame::apply_config(AppConfig *app_config, PresetBundle *preset_bundle if (it.second.size() > 0) { if (enabled_vendors.find(it.first) != enabled_vendors.end()) continue; - auto vendor_file = vendor_dir/(it.first + ".json"); - if (fs::exists(vendor_file)) { + if (is_vendor_installed(it.first)) { remove_bundles.emplace_back(it.first); } } @@ -1127,99 +1180,324 @@ int GuideFrame::GetFilamentInfo( std::string VendorDirectory, json & pFilaList, return status; } -int GuideFrame::LoadProfileData() +bool GuideFrame::BuildProfileJson(const PresetBundle& bundle, bool require_all_resource_vendors) { try { - m_ProfileJson = json::parse("{}"); - m_ProfileJson["model"] = json::array(); - m_ProfileJson["machine"] = json::object(); - m_ProfileJson["filament"] = json::object(); - m_ProfileJson["process"] = json::array(); + // Models from vendor profiles + for (const auto& [vendor_id, vp] : bundle.vendors) { + for (const auto& model : vp.models) { + std::string nozzle_str; + for (const auto& v : model.variants) { + if (!nozzle_str.empty()) nozzle_str += ";"; + nozzle_str += v.name; + } + const std::string materials_str = boost::algorithm::join(model.default_materials, ";"); + boost::filesystem::path cover_path = + (boost::filesystem::path(resources_dir()) / "profiles" / vp.id / (model.id + "_cover.png")) + .make_preferred(); + if (!boost::filesystem::exists(cover_path)) + cover_path = + (boost::filesystem::path(resources_dir()) / "web/image/printer" / (model.id + "_cover.png")) + .make_preferred(); - vendor_dir = (boost::filesystem::path(Slic3r::data_dir()) / PRESET_SYSTEM_DIR).make_preferred(); - rsrc_vendor_dir = (boost::filesystem::path(resources_dir()) / "profiles").make_preferred(); - - // Orca: add custom as default - // Orca: add json logic for vendor bundle - orca_bundle_rsrc = true; - - // search if there exists a .json file in vendor_dir folder, if exists, set orca_bundle_rsrc to false - for (const auto& entry : boost::filesystem::directory_iterator(vendor_dir)) { - if (!boost::filesystem::is_directory(entry) && boost::iequals(entry.path().extension().string(), ".json") && !boost::iequals(entry.path().stem().string(), PresetBundle::ORCA_FILAMENT_LIBRARY)) { - orca_bundle_rsrc = false; - break; + json entry; + entry["model"] = model.id; + entry["name"] = model.name; + entry["vendor"] = vp.id; + entry["nozzle_diameter"] = nozzle_str; + entry["materials"] = materials_str; + entry["cover"] = cover_path.string(); + entry["nozzle_selected"] = ""; + entry["sub_path"] = ""; + m_ProfileJson["model"].push_back(entry); } } - // load the default filament library first - std::set loaded_vendors; - auto filament_library_name = boost::filesystem::path(PresetBundle::ORCA_FILAMENT_LIBRARY).replace_extension(".json"); - if (boost::filesystem::exists(vendor_dir / filament_library_name)) { - m_OrcaFilaLibPath = (vendor_dir / PresetBundle::ORCA_FILAMENT_LIBRARY).string(); - LoadProfileFamily(PresetBundle::ORCA_FILAMENT_LIBRARY, (vendor_dir / filament_library_name).string()); - } else { - m_OrcaFilaLibPath = (rsrc_vendor_dir / PresetBundle::ORCA_FILAMENT_LIBRARY).string(); - LoadProfileFamily(PresetBundle::ORCA_FILAMENT_LIBRARY, (rsrc_vendor_dir / filament_library_name).string()); - } - loaded_vendors.insert(PresetBundle::ORCA_FILAMENT_LIBRARY); + // Machine map: preset name -> {model, nozzle variant} + for (const Preset& p : bundle.printers()) { + if (!p.is_system || !p.vendor) continue; + const auto* printer_model = p.config.option("printer_model"); + const auto* printer_variant = p.config.option("printer_variant"); + if (!printer_model || printer_model->value.empty() || !printer_variant) continue; - //load custom bundle from user data path - boost::filesystem::directory_iterator endIter; - for (boost::filesystem::directory_iterator iter(vendor_dir); iter != endIter; iter++) { - if (!boost::filesystem::is_directory(*iter)) { - wxString strVendor = from_u8(iter->path().string()).BeforeLast('.'); - strVendor = strVendor.AfterLast('\\'); - strVendor = strVendor.AfterLast('/'); - - wxString strExtension = from_u8(iter->path().string()).AfterLast('.').Lower(); - if(strExtension.CmpNoCase("json") != 0 || loaded_vendors.find(w2s(strVendor)) != loaded_vendors.end()) - continue; - - LoadProfileFamily(w2s(strVendor), iter->path().string()); - loaded_vendors.insert(w2s(strVendor)); - } - if (m_destroy) - return 0; + json mach; + mach["model"] = printer_model->value; + mach["nozzle"] = printer_variant->value; + m_ProfileJson["machine"][p.name] = mach; } - boost::filesystem::directory_iterator others_endIter; - for (boost::filesystem::directory_iterator iter(rsrc_vendor_dir); iter != others_endIter; iter++) { - if (!boost::filesystem::is_directory(*iter)) { - wxString strVendor = from_u8(iter->path().string()).BeforeLast('.'); - strVendor = strVendor.AfterLast('\\'); - strVendor = strVendor.AfterLast('/'); - wxString strExtension = from_u8(iter->path().string()).AfterLast('.').Lower(); - if (strExtension.CmpNoCase("json") != 0 || loaded_vendors.find(w2s(strVendor)) != loaded_vendors.end()) - continue; + // Filament map from system filament presets (vendor/type already resolved in config) + const json& machines = m_ProfileJson["machine"]; + for (const Preset& p : bundle.filaments()) { + if (!p.is_system || !p.vendor) continue; + const auto* fila_vendor = p.config.option("filament_vendor"); + const auto* fila_type = p.config.option("filament_type"); + const auto* compat_printers = p.config.option("compatible_printers"); - LoadProfileFamily(w2s(strVendor), iter->path().string()); - loaded_vendors.insert(w2s(strVendor)); + std::string vendor = (fila_vendor && !fila_vendor->values.empty()) ? fila_vendor->values[0] : ""; + std::string type = (fila_type && !fila_type->values.empty()) ? fila_type->values[0] : ""; + + std::string model_list; + if (compat_printers) { + for (const std::string& pname : compat_printers->values) { + auto it = machines.find(pname); + if (it != machines.end()) { + const std::string m = (*it)["model"]; + const std::string n = (*it)["nozzle"]; + model_list += "[" + m + "++" + n + "]"; + } + } } - if (m_destroy) - return 0; + + json ff; + ff["name"] = p.name; + ff["sub_path"] = p.file; + ff["vendor"] = vendor; + ff["type"] = type; + ff["models"] = model_list; + ff["selected"] = 0; + m_ProfileJson["filament"][p.name] = ff; } - wxGetApp().CallAfter([this] { - if (!m_destroy) { - //sync to appconfig first to populate current selections - SaveProfileData(); + // Process list from visible system print presets + for (const Preset& p : bundle.prints()) { + if (!p.is_system || !p.vendor || !p.is_visible) continue; + json entry; + entry["name"] = p.name; + entry["sub_path"] = p.file; + m_ProfileJson["process"].push_back(entry); + } - //sync to web after selections are populated - std::string strAll = m_ProfileJson.dump(-1, ' ', false, json::error_handler_t::ignore); + if (require_all_resource_vendors) { + // If rsrc_vendor_dir has vendors (profile JSONs, or the preset caches a + // packaged build ships instead) not covered by the current bundle, the + // bundle is incomplete (e.g. dev env where data_dir/system only has + // OrcaFilamentLibrary+Custom). Fall back so the slow path reads both dirs. + try { + for (const std::string& name : vendor_names_in(rsrc_vendor_dir)) { + if (bundle.vendors.find(name) == bundle.vendors.end()) { + BOOST_LOG_TRIVIAL(info) << "GuideFrame: vendor '" << name + << "' in resources but not in preset_bundle — falling back to JSON loading"; + reset_profile_json(); + return false; + } + } + } catch (const std::exception&) {} + } - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ", finished, json contents: " << std::endl << strAll; - json m_Res = json::object(); - m_Res["command"] = "userguide_profile_load_finish"; - m_Res["sequence_id"] = "10001"; - wxString strJS = wxString::Format("HandleStudio(%s)", m_Res.dump(-1, ' ', true)); + BOOST_LOG_TRIVIAL(info) << "GuideFrame: built profile data (" + << m_ProfileJson["model"].size() << " models, " + << m_ProfileJson["machine"].size() << " machines, " + << m_ProfileJson["filament"].size() << " filaments)"; + return !m_ProfileJson["machine"].empty(); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "GuideFrame::BuildProfileJson failed: " << e.what() + << " — falling back to JSON loading"; + reset_profile_json(); + return false; + } +} - RunScript(strJS); +bool GuideFrame::BuildProfileDataFromPresetBundle() +{ + PresetBundle* pb = wxGetApp().preset_bundle; + if (!pb || pb->vendors.empty()) + return false; + return BuildProfileJson(*pb, /*require_all_resource_vendors=*/true); +} + +bool GuideFrame::BuildProfileDataFromVendors() +{ + try { + // Same vendor set and precedence as the JSON scan in LoadProfileData: a + // vendor in the user's system dir shadows the bundled one of that name. + // vendor_names_in names a vendor by its profile or, where a build ships + // preset caches instead, by its cache alone. + std::map vendor_sources; + for (const boost::filesystem::path& dir : { vendor_dir, rsrc_vendor_dir }) { + boost::system::error_code ec; + if (boost::filesystem::exists(dir, ec)) + for (const std::string& name : vendor_names_in(dir)) + vendor_sources.emplace(name, dir); // first dir wins + } + + // The load order: the filament library first, because the others' + // filaments inherit from it, then every versioned vendor — each loaded + // from the directory it was found in, so a vendor that is not installed + // is served from the shipped profiles. Each is stamped by name and + // version alone: a profile change requires a version bump, so those two + // determine content wherever the vendor's copy sits. + struct VendorSource { std::string name; boost::filesystem::path dir; std::string version; }; + std::vector ordered; + auto add_vendor = [&ordered](const std::string& name, const boost::filesystem::path& dir) { + // The version a load from `dir` would serve: the profile's where one + // exists (a cache is only served while it covers the profile beside + // it), the cache's own stamp where the cache is the whole vendor. + // A profile without a version (blacklist.json) carries no presets + // and is passed over. + const boost::filesystem::path profile = dir / (name + ".json"); + if (boost::filesystem::exists(profile)) { + const Semver v = get_version_from_json(profile.string()); + if (v.valid()) + ordered.push_back({name, dir, v.to_string()}); + } else { + ordered.push_back({name, dir, + VendorCacheFile::peek_version((dir / (name + ".opc")).string(), name)}); } + }; + const std::string filament_library(PresetBundle::ORCA_FILAMENT_LIBRARY); + if (auto it = vendor_sources.find(filament_library); it != vendor_sources.end()) + add_vendor(filament_library, it->second); + for (const auto& [name, dir] : vendor_sources) + if (name != filament_library) + add_vendor(name, dir); + if (ordered.empty()) + return false; + json stamps = json::array(); + for (const VendorSource& v : ordered) + stamps.push_back({v.name, v.version}); + + // What this function derives is a pure function of that stamped set, so + // the derived JSON is cached whole: a fresh cache makes an open one + // file read, with no bundle built and no preset installed. Stale or + // absent, the bundle is rebuilt below and the result written back. + const boost::filesystem::path cache_file = + boost::filesystem::path(Slic3r::data_dir()) / "cache" / "wizard_profile_data.json"; + try { + // Slurped whole and parsed from the buffer — nlohmann's fastest + // input path; a stream adapter costs real time on a multi-MB file. + boost::nowide::ifstream ifs(cache_file.string(), std::ios::binary); + if (ifs.is_open()) { + const std::string text{std::istreambuf_iterator(ifs), std::istreambuf_iterator()}; + json cached = json::parse(text); + if (cached.value("format", 0) == 1 && cached["vendors"] == stamps && + ! cached["profile"]["machine"].empty()) { + for (const char* key : { "model", "machine", "filament", "process" }) + m_ProfileJson[key] = std::move(cached["profile"][key]); + BOOST_LOG_TRIVIAL(info) << "GuideFrame: profile data served from " << cache_file; + return true; + } + } + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(info) << "GuideFrame: rejecting cached profile data: " << e.what(); + } + + // Each vendor comes from its preset cache where one covers it, which is + // what makes this worth doing instead of the scan below; loading into a + // bundle per vendor keeps the install order the startup path has. + PresetBundle bundle; + auto load_vendor = [](PresetBundle& into, const std::string& vendor, + const boost::filesystem::path& dir, const PresetBundle* base) { + into.load_vendor_configs_from_json(dir.string(), vendor, PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent, base); + }; + for (const VendorSource& v : ordered) { + if (*m_cancel_token) + return false; // as in the scan below: a vendor without a cache is parsed, and that takes time + if (v.name == filament_library) { + load_vendor(bundle, v.name, v.dir, nullptr); + } else { + PresetBundle tmp; + load_vendor(tmp, v.name, v.dir, &bundle); + bundle.merge_presets(std::move(tmp)); + } + } + if (bundle.vendors.empty()) + return false; + if (! BuildProfileJson(bundle, /*require_all_resource_vendors=*/false)) + return false; + + // Written through a temp file and moved into place, as the preset caches + // are: half a cache must never be readable, and the PID suffix keeps two + // instances from interleaving on one temp file. + const std::string tmp_path = cache_file.string() + "." + std::to_string(get_current_pid()) + ".tmp"; + try { + json out; + out["format"] = 1; + out["vendors"] = std::move(stamps); + json& profile = out["profile"]; + for (const char* key : { "model", "machine", "filament", "process" }) + profile[key] = m_ProfileJson[key]; + boost::filesystem::create_directories(cache_file.parent_path()); + { + boost::nowide::ofstream ofs(tmp_path, std::ios::binary | std::ios::trunc); + ofs << out.dump(-1, ' ', false, json::error_handler_t::ignore); + ofs.close(); + if (! ofs.good()) + throw std::runtime_error("write failed"); + } + if (const std::error_code ec = rename_file(tmp_path, cache_file.string())) + throw std::runtime_error(ec.message()); + } catch (const std::exception& e) { + boost::system::error_code rm; + boost::filesystem::remove(tmp_path, rm); + BOOST_LOG_TRIVIAL(warning) << "GuideFrame: could not write the profile data cache: " << e.what(); + } + return true; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " failed: " << e.what(); + reset_profile_json(); + return false; + } +} + +int GuideFrame::LoadProfileData() +{ + // Background thread: the fast path in OnNavigationComplete failed (presets not yet loaded). + // Loading order (fastest to slowest): + // 1. Load every vendor, from its preset cache wherever one covers it + // 2. Read all vendor JSONs by hand + try { + if (!BuildProfileDataFromVendors()) { + // Last resort — read all vendor JSONs + std::set loaded_vendors; + auto filament_library_name = boost::filesystem::path(PresetBundle::ORCA_FILAMENT_LIBRARY).replace_extension(".json"); + if (boost::filesystem::exists(vendor_dir / filament_library_name)) + LoadProfileFamily(PresetBundle::ORCA_FILAMENT_LIBRARY, (vendor_dir / filament_library_name).string()); + else + LoadProfileFamily(PresetBundle::ORCA_FILAMENT_LIBRARY, (rsrc_vendor_dir / filament_library_name).string()); + loaded_vendors.insert(PresetBundle::ORCA_FILAMENT_LIBRARY); + + boost::filesystem::directory_iterator endIter; + for (boost::filesystem::directory_iterator iter(vendor_dir); iter != endIter; iter++) { + if (!boost::filesystem::is_directory(*iter)) { + wxString strVendor = from_u8(iter->path().string()).BeforeLast('.'); + strVendor = strVendor.AfterLast('\\'); + strVendor = strVendor.AfterLast('/'); + wxString strExtension = from_u8(iter->path().string()).AfterLast('.').Lower(); + if (strExtension.CmpNoCase("json") != 0 || loaded_vendors.find(w2s(strVendor)) != loaded_vendors.end()) + continue; + LoadProfileFamily(w2s(strVendor), iter->path().string()); + loaded_vendors.insert(w2s(strVendor)); + } + if (*m_cancel_token) return 0; + } + + boost::filesystem::directory_iterator others_endIter; + for (boost::filesystem::directory_iterator iter(rsrc_vendor_dir); iter != others_endIter; iter++) { + if (!boost::filesystem::is_directory(*iter)) { + wxString strVendor = from_u8(iter->path().string()).BeforeLast('.'); + strVendor = strVendor.AfterLast('\\'); + strVendor = strVendor.AfterLast('/'); + wxString strExtension = from_u8(iter->path().string()).AfterLast('.').Lower(); + if (strExtension.CmpNoCase("json") != 0 || loaded_vendors.find(w2s(strVendor)) != loaded_vendors.end()) + continue; + LoadProfileFamily(w2s(strVendor), iter->path().string()); + loaded_vendors.insert(w2s(strVendor)); + } + if (*m_cancel_token) return 0; + } + } + + // Capture the cancel token by value (shared_ptr) so the lambda doesn't + // touch `this` if GuideFrame is destroyed before the event fires. + auto tok = m_cancel_token; + wxGetApp().CallAfter([this, tok] { + if (!*tok) + on_profile_loaded(); }); - } catch (std::exception& e) { - // wxLogMessage("GUIDE: load_profile_error %s ", e.what()); - // wxMessageBox(e.what(), "", MB_OK); - BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ", error: " << e.what() << std::endl; + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ", error: " << e.what(); } filament_info_cache.clear(); diff --git a/src/slic3r/GUI/WebGuideDialog.hpp b/src/slic3r/GUI/WebGuideDialog.hpp index fcdb0841db..b9592d03fe 100644 --- a/src/slic3r/GUI/WebGuideDialog.hpp +++ b/src/slic3r/GUI/WebGuideDialog.hpp @@ -30,10 +30,14 @@ #include "libslic3r/PresetBundle.hpp" #include "slic3r/Utils/PresetUpdater.hpp" +#include +#include #include #include +#include + namespace Slic3r { namespace GUI { class GuideFrame : public DPIDialog @@ -78,6 +82,12 @@ public: int LoadProfileData(); int SaveProfileData(); int LoadProfileFamily(std::string strVendor, std::string strFilePath); + void init_guide_paths(); + void on_profile_loaded(); + bool BuildProfileJson(const PresetBundle& bundle, bool require_all_resource_vendors); + bool BuildProfileDataFromPresetBundle(); + bool BuildProfileDataFromVendors(); + void reset_profile_json(); int SaveProfile(); int GetFilamentInfo( std::string VendorDirectory,json & pFilaList, std::string filepath, std::string &sVendor, std::string &sType); @@ -112,8 +122,11 @@ private: //First Load bool bFirstComplete{false}; - bool m_destroy{false}; - boost::thread* m_load_task{ nullptr }; + // Set once in the destructor. Read through `this` by the loading thread + // (joined before `this` dies) and captured as the shared_ptr by CallAfter + // lambdas so they don't touch `this` after the object is freed. + std::shared_ptr> m_cancel_token{std::make_shared>(false)}; + std::unique_ptr m_load_task; // User Config bool PrivacyUse; @@ -123,6 +136,7 @@ private: bool InstallNetplugin; bool network_plugin_ready {false}; + json m_ProfileJson; json m_OrcaFilaList; std::string m_OrcaFilaLibPath; diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 18a9db4e26..06808e253d 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -1044,46 +1044,42 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const std::set bundles; // Orca: always install filament library bundles.insert(PresetBundle::ORCA_FILAMENT_LIBRARY); - for (auto &dir_entry : boost::filesystem::directory_iterator(rsrc_path)) { - const auto &path = dir_entry.path(); - std::string file_path = path.string(); - if (is_json_file(file_path)) { - const auto path_in_vendor = vendor_path / path.filename(); - std::string vendor_name = path.filename().string(); - // Remove the .json suffix. - vendor_name.erase(vendor_name.size() - 5); - if (bundles.find(vendor_name) != bundles.end())continue; + // A vendor is named by its profile or, where the build ships preset caches + // instead of the raw profile JSONs, by its cache alone. + for (const std::string &vendor_name : vendor_names_in(rsrc_path)) { + if (bundles.find(vendor_name) != bundles.end())continue; - const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE) // always update configs from resource to vendor for ORCA_DEFAULT_BUNDLE - || (enabled_vendors.find(vendor_name) != enabled_vendors.end()); - if (enabled_config_update) { - if ( fs::exists(path_in_vendor)) { - if (is_vendor_enabled) { - Semver resource_ver = get_version_from_json(file_path); - Semver vendor_ver = get_version_from_json(path_in_vendor.string()); + const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE) // always update configs from resource to vendor for ORCA_DEFAULT_BUNDLE + || (enabled_vendors.find(vendor_name) != enabled_vendors.end()); + if (enabled_config_update) { + if (is_vendor_installed(vendor_name)) { + if (is_vendor_enabled) { + // Orca: whichever form of the vendor resources ships at the newer + // version is the one installing lays down, and the one to judge + // what is installed against. + Semver resource_ver = resource_vendor_version(vendor_name); + // Orca: a vendor installed as a preset cache has no profile + // beside it; the version it was installed at is in the cache. + Semver vendor_ver = installed_vendor_version(vendor_name); - if (vendor_ver < resource_ver) { - BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:found vendor " << vendor_name << " newer version " - << resource_ver.to_string() << " from resource, old version " << vendor_ver.to_string(); - bundles.insert(vendor_name); - } - } - else { - //need to be removed because not installed - fs::remove(path_in_vendor); - const auto path_of_vendor = vendor_path / vendor_name; - if (fs::exists(path_of_vendor)) - fs::remove_all(path_of_vendor); + if (vendor_ver < resource_ver) { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:found vendor " << vendor_name << " newer version " + << resource_ver.to_string() << " from resource, old version " << vendor_ver.to_string(); + bundles.insert(vendor_name); } } - else if (is_vendor_enabled) { - bundles.insert(vendor_name); + else { + //need to be removed because not installed + remove_installed_vendor(vendor_name); } } else if (is_vendor_enabled) { bundles.insert(vendor_name); } } + else if (is_vendor_enabled) { + bundles.insert(vendor_name); + } } if (bundles.size() > 0) { @@ -1163,11 +1159,12 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version auto filament_in_cache = (cache_profile_path / vendor_name / PRESET_FILAMENT_NAME); auto machine_in_cache = (cache_profile_path / vendor_name / PRESET_PRINTER_NAME); - if (( fs::exists(path_in_vendor)) + if (is_vendor_installed(vendor_name) || fs::exists(print_in_cache) || fs::exists(filament_in_cache) || fs::exists(machine_in_cache)) { - Semver vendor_ver = get_version_from_json(path_in_vendor.string()); + // Orca: a vendor installed as a preset cache carries its version there. + Semver vendor_ver = installed_vendor_version(vendor_name); std::map key_values; std::vector keys(3); diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index bc10bb4f73..28c39c2d6a 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -17,6 +17,7 @@ add_executable(${_TEST_NAME}_tests test_preset_bundle_loading.cpp test_preset_setting_id.cpp test_preset_diff.cpp + test_vendor_cache.cpp test_elephant_foot_compensation.cpp test_fill_corner_smoothing.cpp test_fill_plane_path.cpp diff --git a/tests/libslic3r/test_geometry.cpp b/tests/libslic3r/test_geometry.cpp index b5f7b7ef98..b4bbe86cc6 100644 --- a/tests/libslic3r/test_geometry.cpp +++ b/tests/libslic3r/test_geometry.cpp @@ -574,11 +574,6 @@ TEST_CASE("Convex polygon intersection on two squares touching one vertex", "[Ge Polygon B = A; B.translate(10 / SCALING_FACTOR, 10 / SCALING_FACTOR); - SVG svg{std::string("one_vertex_touch") + ".svg"}; - svg.draw(A, "blue"); - svg.draw(B, "green"); - svg.Close(); - bool is_inters = Geometry::convex_polygons_intersect(A, B); REQUIRE(is_inters == false); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 844ccb6a8b..037a76a805 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" @@ -132,7 +133,7 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl { PresetBundle bundle; - VendorProfile orca_vendor("ORCA"); + VendorProfile orca_vendor; orca_vendor.id = "ORCA"; VendorProfile::PrinterModel model; model.name = "Orca Test"; orca_vendor.models.emplace_back(model); @@ -143,6 +144,31 @@ TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundl CHECK(bundle.get_current_vendor_type() == VendorType::Unknown); } +TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]") +{ + ScopedTemporaryDir dir; + + // A bare number where the list wants an object. An array element has no key, + // so reporting one as if it did throws nlohmann's invalid_iterator - which is + // not a parse_error, and escapes the catch around the vendor profile parse. + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" + << R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})"; + fs::create_directories(dir.path() / "Acme" / "process"); + std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string()) + << R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + PresetBundle bundle; + size_t loaded = 0; + REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json( + dir.path().string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent).second); + + CHECK(bundle.error_count() > 0); // the malformed element was counted + CHECK(loaded == 1); // the well-formed one beside it still loaded +} + TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]") { PresetBundle bundle; diff --git a/tests/libslic3r/test_vendor_cache.cpp b/tests/libslic3r/test_vendor_cache.cpp new file mode 100644 index 0000000000..85e100f4d4 --- /dev/null +++ b/tests/libslic3r/test_vendor_cache.cpp @@ -0,0 +1,1620 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PresetCacheFormat.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Utils.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; +namespace fs = boost::filesystem; + +namespace { + +struct TempDir { + fs::path path; + TempDir() { + path = fs::temp_directory_path() / fs::unique_path("orca-cache-test-%%%%-%%%%"); + fs::create_directories(path); + } + ~TempDir() { boost::system::error_code ec; fs::remove_all(path, ec); } +}; + +std::string write_vendor_json(const fs::path& dir, const std::string& vendor_id, + const std::string& version = "1.0.0") +{ + const fs::path p = dir / (vendor_id + ".json"); + std::ofstream f(p.string()); + f << R"({"version":")" << version << R"(","name":")" << vendor_id << R"("})"; + return p.string(); +} + +// One vendor profile with a single process preset beside it, as an install or an +// update lays it down: /.json plus //process/standard.json. +void write_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version) +{ + fs::create_directories(dir / vendor / "process"); + std::ofstream((dir / (vendor + ".json")).string()) + << R"({"version":")" << version << R"(","name":")" << vendor + << R"(","process_list":[{"name":"0.20mm Standard @)" << vendor << R"(","sub_path":"process/standard.json"}]})"; + std::ofstream((dir / vendor / "process" / "standard.json").string()) + << R"({"type":"process","name":"0.20mm Standard @)" << vendor + << R"(","from":"system","instantiation":"true","layer_height":"0.2"})"; +} + +// A small but complete vendor: one machine model, one process, a non-instantiated +// base filament with an instantiated child inheriting it, a second standalone +// filament carrying explicit metadata, and one machine preset with a rename — so +// the equivalence test below sees every CachedPreset field populated. +void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version) +{ + fs::create_directories(dir / vendor / "process"); + fs::create_directories(dir / vendor / "filament"); + fs::create_directories(dir / vendor / "machine"); + std::ofstream((dir / (vendor + ".json")).string()) + << R"({"version":")" << version << R"(","name":")" << vendor << R"(",)" + << R"("machine_model_list":[{"name":"Test Model","sub_path":"machine/model.json"}],)" + << R"("process_list":[{"name":"0.20mm Standard @)" << vendor << R"(","sub_path":"process/standard.json"}],)" + << R"("filament_list":[)" + << R"({"name":")" << vendor << R"( Base PLA","sub_path":"filament/base.json"},)" + << R"({"name":")" << vendor << R"( PLA @0.4","sub_path":"filament/pla.json"},)" + << R"({"name":")" << vendor << R"( Silk PLA @0.4","sub_path":"filament/silk.json"}],)" + << R"("machine_list":[{"name":")" << vendor << R"( 0.4 nozzle","sub_path":"machine/printer.json"}]})"; + std::ofstream((dir / vendor / "machine" / "model.json").string()) + << R"({"type":"machine_model","name":"Test Model","nozzle_diameter":"0.4"})"; + std::ofstream((dir / vendor / "process" / "standard.json").string()) + << R"({"type":"process","name":"0.20mm Standard @)" << vendor + << R"(","from":"system","instantiation":"true","layer_height":"0.2"})"; + std::ofstream((dir / vendor / "filament" / "base.json").string()) + << R"({"type":"filament","name":")" << vendor + << R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42"})"; + std::ofstream((dir / vendor / "filament" / "pla.json").string()) + << R"({"type":"filament","name":")" << vendor + << R"( PLA @0.4","from":"system","instantiation":"true","filament_id":"GFA00","filament_cost":"20",)" + << R"("setting_id":"GFSA04","description":"Test PLA description"})"; + std::ofstream((dir / vendor / "filament" / "silk.json").string()) + << R"({"type":"filament","name":")" << vendor + << R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA"})"; + std::ofstream((dir / vendor / "machine" / "printer.json").string()) + << R"({"type":"machine","name":")" << vendor + << R"( 0.4 nozzle","from":"system","instantiation":"true","printer_model":"Test Model","printer_variant":"0.4",)" + << R"("renamed_from":")" << vendor << R"( old 0.4 nozzle"})"; +} + +// The filament library: one non-instantiated base filament other vendors inherit +// from. `cost` lets a test bump the library and watch the change flow through. +void write_lib_tree(const fs::path& dir, const std::string& version, const std::string& cost) +{ + const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY); + fs::create_directories(dir / lib / "filament"); + std::ofstream((dir / (lib + ".json")).string()) + << R"({"version":")" << version << R"(","name":")" << lib << R"(",)" + << R"("filament_list":[{"name":"Generic PLA","sub_path":"filament/generic_pla.json"}]})"; + std::ofstream((dir / lib / "filament" / "generic_pla.json").string()) + << R"({"type":"filament","name":"Generic PLA","from":"system","instantiation":"false",)" + << R"("filament_id":"GFL99","filament_cost":")" << cost << R"("})"; +} + +// A vendor whose one filament inherits the library's base and states nothing of +// its own — everything it shows comes from the library it is resolved against. +void write_vendor_with_lib_filament(const fs::path& dir, const std::string& vendor, const std::string& version) +{ + fs::create_directories(dir / vendor / "filament"); + std::ofstream((dir / (vendor + ".json")).string()) + << R"({"version":")" << version << R"(","name":")" << vendor << R"(",)" + << R"("filament_list":[{"name":")" << vendor << R"( PLA @0.4","sub_path":"filament/pla.json"}]})"; + std::ofstream((dir / vendor / "filament" / "pla.json").string()) + << R"({"type":"filament","name":")" << vendor + << R"( PLA @0.4","from":"system","instantiation":"true","inherits":"Generic PLA"})"; +} + +std::string write_versionless_vendor_json(const fs::path& dir, const std::string& vendor_id) +{ + const fs::path p = dir / (vendor_id + ".json"); + std::ofstream f(p.string()); + f << R"({"name":")" << vendor_id << R"("})"; + return p.string(); +} + +// Whole file as bytes, for the byte-identity comparisons below. +std::string slurp(const fs::path& p) +{ + std::string s; + load_string_file(p, s); + return s; +} + +// Flip one byte of the body. The default lands in the stamps at the front, which +// every reader checks; pass an offset past them to corrupt a file that still +// answers VendorCacheFile::peek_version but cannot survive its CRC. +void corrupt_blob_byte(const std::string& path, std::streamoff at = 30) +{ + std::fstream f(path, std::ios::in | std::ios::out | std::ios::binary); + f.seekp(at); + char b = 0; f.read(&b, 1); + f.seekp(at); + b ^= 0xFF; + f.write(&b, 1); +} + +// Overwrite `n` bytes at `payload_off` into the cache's payload (which starts at +// file offset 20, behind the header) and recompute the header CRC, so the file +// stays authentic and only the deserializer can object to its contents. +void patch_payload_bytes(const std::string& path, size_t payload_off, const void* bytes, size_t n) +{ + constexpr size_t header_size = 20; // magic(4) + version(4) + data_size(8) + crc32(4) + std::ifstream in(path, std::ios::binary); + std::vector data(std::istreambuf_iterator(in), {}); + in.close(); + REQUIRE(data.size() >= header_size + payload_off + n); + std::memcpy(&data[header_size + payload_off], bytes, n); + boost::crc_32_type crc; + crc.process_bytes(&data[header_size], data.size() - header_size); + const uint32_t new_crc = crc.checksum(); + std::memcpy(&data[16], &new_crc, 4); + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(data.data(), static_cast(data.size())); +} + +// Patch cache_version (the payload's first word) so the file passes the CRC +// check but fails the cache_version check in VendorCacheFile::load. +void patch_cache_version(const std::string& path, uint32_t wrong_version) +{ + patch_payload_bytes(path, 0, &wrong_version, sizeof(wrong_version)); +} + +// Truncates the cache's PAYLOAD (everything after the 20-byte header) by +// `truncate_by` bytes and recomputes data_size/crc32 in the header, exactly +// as the cache writer computes them, so the framing's size and CRC checks +// still pass but cereal runs out of bytes partway through deserializing the +// body — exercising VendorCacheFile::load's catch block instead of its early +// (pre-body) rejection paths. +void truncate_payload_and_fix_header(const std::string& path, size_t truncate_by) +{ + constexpr size_t header_size = 20; // magic(4) + version(4) + data_size(8) + crc32(4) + std::ifstream in(path, std::ios::binary); + std::vector data(std::istreambuf_iterator(in), {}); + in.close(); + REQUIRE(data.size() > header_size + truncate_by); + const size_t new_payload_size = data.size() - header_size - truncate_by; + const uint64_t data_size_field = static_cast(new_payload_size); + boost::crc_32_type crc; + crc.process_bytes(&data[header_size], new_payload_size); + const uint32_t crc_field = crc.checksum(); + std::memcpy(&data[8], &data_size_field, sizeof(data_size_field)); // data_size offset + std::memcpy(&data[16], &crc_field, sizeof(crc_field)); // crc32 offset + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(data.data(), static_cast(header_size + new_payload_size)); +} + +// One vendor as a cache's VendorMap. It carries one printer model ("Test Model", +// variant "0.4") so machine entries can pass install's model/variant validation. +VendorMap one_vendor(const std::string& vendor_id, const std::string& name = "", + Semver ver = Semver(1, 0, 0)) +{ + VendorMap vendors; + VendorProfile vp(vendor_id); + vp.name = name.empty() ? vendor_id + " Corp" : name; + vp.config_version = ver; + VendorProfile::PrinterModel model; + model.id = "Test Model"; + model.variants.emplace_back(VendorProfile::PrinterVariant("0.4")); + vp.models.push_back(model); + vendors.emplace(vendor_id, vp); + return vendors; +} + +// Source-form entries as parse_subfile would emit them. The alias is derived by +// install from the '@' in the name, exactly as it is for the JSON parse. +CachedPreset filament_entry(const std::string& name, const std::string& filament_id = "GFA00", + const std::string& inherits = "") +{ + CachedPreset e; + e.name = name; + e.sub_path = "filament/" + name + ".json"; + e.instantiation = "true"; + e.filament_id = filament_id; + e.inherits = inherits; + return e; +} + +CachedPreset printer_entry(const std::string& name) +{ + CachedPreset e; + e.name = name; + e.sub_path = "machine/" + name + ".json"; + e.instantiation = "true"; + e.config_src.set_key_value("printer_model", new ConfigOptionString("Test Model")); + e.config_src.set_key_value("printer_variant", new ConfigOptionString("0.4")); + return e; +} + +static bool save_one_vendor(const std::string& path, const VendorMap& vendors, + const std::string& vendor, const std::string& vendor_version, + const std::vector& filament_entries = {}, + const std::vector& machine_entries = {}, + const std::vector& process_entries = {}) +{ + VendorCacheData data; + data.vendors = vendors; + data.process_entries = process_entries; + data.filament_entries = filament_entries; + data.machine_entries = machine_entries; + return VendorCacheFile::save(path, vendor, vendor_version, data); +} + +// resources_dir()/data_dir() are process-wide, so restore them however the test +// leaves — including through a failed REQUIRE — to stay green under --order rand. +struct ScopedDirs { + std::string prev_data{data_dir()}, prev_rsrc{resources_dir()}; + ScopedDirs(const fs::path& data, const fs::path& rsrc) + { + set_data_dir(data.string()); + set_resources_dir(rsrc.string()); + } + ~ScopedDirs() { set_data_dir(prev_data); set_resources_dir(prev_rsrc); } +}; + +// A data dir and a resources dir, both pointed at by the process-wide accessors, +// with the two directories a vendor is installed into and shipped from already +// created. What every install- and load-order test needs before it starts. +struct InstallDirs { + TempDir data, rsrc; + fs::path system = data.path / PRESET_SYSTEM_DIR; + fs::path profiles = rsrc.path / "profiles"; + ScopedDirs scoped { data.path, rsrc.path }; + + InstallDirs() + { + fs::create_directories(system); + fs::create_directories(profiles); + } +}; + +// Helper: filter a collection by vendor_id. +std::vector presets_for(const PresetCollection& coll, const std::string& vendor_id) +{ + std::vector out; + for (const Preset& p : coll()) + if (p.is_system && p.vendor && p.vendor->id == vendor_id) + out.push_back(&p); + return out; +} + +} // namespace + +namespace Slic3r { +inline bool operator==(const VendorProfile::PrinterVariant& a, const VendorProfile::PrinterVariant& b) { return a.name == b.name; } +inline bool operator==(const VendorProfile::PrinterModel& a, const VendorProfile::PrinterModel& b) +{ + return a.id == b.id && a.name == b.name && a.model_id == b.model_id && a.technology == b.technology + && a.family == b.family && a.variants == b.variants && a.default_materials == b.default_materials + && a.not_support_bed_types == b.not_support_bed_types && a.bed_model == b.bed_model + && a.bed_texture == b.bed_texture && a.image_bed_type == b.image_bed_type + && a.bottom_texture_end_name == b.bottom_texture_end_name + && a.use_double_extruder_default_texture == b.use_double_extruder_default_texture + && a.bottom_texture_rect == b.bottom_texture_rect + && a.bottom_texture_rect_longer == b.bottom_texture_rect_longer + && a.middle_texture_rect == b.middle_texture_rect && a.hotend_model == b.hotend_model; +} +} // namespace Slic3r + +static bool vendor_deep_equal(const VendorProfile& a, const VendorProfile& b) +{ + return a.name == b.name && a.id == b.id && a.config_version == b.config_version + && a.config_update_url == b.config_update_url && a.changelog_url == b.changelog_url + && a.models == b.models && a.default_filaments == b.default_filaments + && a.default_sla_materials == b.default_sla_materials; +} + +static bool preset_deep_equal(const Preset& a, const Preset& b) +{ + return a.type == b.type && a.is_default == b.is_default && a.is_external == b.is_external + && a.is_system == b.is_system && a.is_visible == b.is_visible && a.is_dirty == b.is_dirty + && a.is_compatible == b.is_compatible && a.is_project_embedded == b.is_project_embedded + && a.name == b.name && a.file == b.file && a.loaded == b.loaded + && a.config.equals(b.config) + && a.alias == b.alias && a.renamed_from == b.renamed_from + && a.m_excluded_from == b.m_excluded_from && a.m_from_orca_filament_lib == b.m_from_orca_filament_lib + && a.bundle_id == b.bundle_id && a.version == b.version && a.ini_str == b.ini_str + && a.setting_id == b.setting_id && a.filament_id == b.filament_id && a.user_id == b.user_id + && a.base_id == b.base_id && a.sync_info == b.sync_info && a.description == b.description + && a.updated_time == b.updated_time && a.key_values == b.key_values; +} + +TEST_CASE("a saved cache loads back with names, aliases and filament ids intact", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA @0.4", "GFL_acme_pla")}, + {printer_entry(vid + " Printer 0.4")})); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); + REQUIRE(out.vendors.count(vid) == 1); + + auto fi = presets_for(out.filaments, vid); + auto pr = presets_for(out.printers, vid); + REQUIRE(fi.size() == 1); + CHECK(fi[0]->name == vid + " PLA @0.4"); + CHECK(fi[0]->alias == "Acme PLA"); + CHECK(fi[0]->filament_id == "GFL_acme_pla"); + REQUIRE(pr.size() == 1); + CHECK(pr[0]->name == vid + " Printer 0.4"); +} + +TEST_CASE("loading a missing cache file returns false", "[VendorCache]") +{ + TempDir tmp; + PresetBundle out; + REQUIRE(!out.load_vendor_cache((tmp.path / "nonexistent.opc").string(), "Acme", Semver("1.0.0"))); +} + +TEST_CASE("a cache with a corrupted byte is rejected by the CRC check", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA")})); + corrupt_blob_byte(cache.string()); + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); +} + +TEST_CASE("two vendors produce two independent cache files", "[VendorCache]") +{ + TempDir tmp; + const fs::path cacheA = tmp.path / "vendorA.opc"; + const fs::path cacheB = tmp.path / "vendorB.opc"; + + REQUIRE(save_one_vendor(cacheA.string(), one_vendor("VendorA"), "VendorA", "1.0.0", + {filament_entry("VendorA PLA")})); + REQUIRE(save_one_vendor(cacheB.string(), one_vendor("VendorB"), "VendorB", "1.0.0", + {filament_entry("VendorB PLA")})); + + // Corrupt only vendor B's file; vendor A's must be unaffected. + corrupt_blob_byte(cacheB.string()); + + PresetBundle outA; + REQUIRE(outA.load_vendor_cache(cacheA.string(), "VendorA", Semver("1.0.0"))); + REQUIRE(outA.vendors.count("VendorA") == 1); + REQUIRE(presets_for(outA.filaments, "VendorA").size() == 1); + + PresetBundle outB; + REQUIRE(!outB.load_vendor_cache(cacheB.string(), "VendorB", Semver("1.0.0"))); + REQUIRE(outB.vendors.empty()); +} + +TEST_CASE("vendor profile fields survive a cache round-trip", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + VendorMap vendors; + VendorProfile vp(vid); + vp.name = "Acme Corporation"; + vp.config_version = Semver(2, 5, 1); + VendorProfile::PrinterModel model; + model.id = "AcmePro"; + model.name = "Acme Pro"; + VendorProfile::PrinterVariant v0_4; v0_4.name = "0.4"; + model.variants.push_back(v0_4); + vp.models.push_back(model); + vendors.emplace(vid, vp); + REQUIRE(save_one_vendor(cache.string(), vendors, vid, "2.5.1")); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("2.5.1"))); + REQUIRE(out.vendors.count(vid) == 1); + const VendorProfile& gvp = out.vendors.at(vid); + REQUIRE(vendor_deep_equal(gvp, vendors.at(vid))); + // Spot-check the fields the old test asserted directly, so a + // vendor_deep_equal regression still points at what actually broke. + CHECK(gvp.id == vid); + CHECK(gvp.name == "Acme Corporation"); + REQUIRE(gvp.models.size() == 1); + CHECK(gvp.models[0].id == "AcmePro"); + CHECK(gvp.models[0].name == "Acme Pro"); + REQUIRE(gvp.models[0].variants.size() == 1); + CHECK(gvp.models[0].variants[0].name == "0.4"); +} + +TEST_CASE("config option values survive a cache round-trip", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + auto entry = filament_entry(vid + " PETG @0.4"); + entry.config_src.set_key_value("filament_type", new ConfigOptionStrings({"PETG"})); + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", {entry})); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); + + auto fi = presets_for(out.filaments, vid); + REQUIRE(fi.size() == 1); + const auto* ft = fi[0]->config.option("filament_type"); + REQUIRE(ft != nullptr); + REQUIRE(ft->values.size() >= 1); + CHECK(ft->values[0] == "PETG"); +} + +TEST_CASE("multiple presets in one collection all round-trip", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + const std::vector fi_names = {vid + " PLA", vid + " PETG", vid + " ABS"}; + const std::vector pr_names = {vid + " Printer 0.4", vid + " Printer 0.6"}; + std::vector filament_entries, machine_entries; + for (const auto& n : fi_names) filament_entries.push_back(filament_entry(n)); + for (const auto& n : pr_names) machine_entries.push_back(printer_entry(n)); + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + filament_entries, machine_entries)); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); + + auto fi = presets_for(out.filaments, vid); + auto pr = presets_for(out.printers, vid); + REQUIRE(fi.size() == 3); + REQUIRE(pr.size() == 2); + + std::set fi_got, pr_got; + for (const auto* p : fi) fi_got.insert(p->name); + for (const auto* p : pr) pr_got.insert(p->name); + for (const auto& n : fi_names) CHECK(fi_got.count(n) == 1); + for (const auto& n : pr_names) CHECK(pr_got.count(n) == 1); +} + +TEST_CASE("a truncated cache file is rejected", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "truncated.opc"; + { + std::ofstream f(cache.string(), std::ios::binary); + const char data[] = {0x4F, 0x52, 0x43}; + f.write(data, sizeof(data)); + } + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), "Acme", Semver("1.0.0"))); +} + +TEST_CASE("a cache with the wrong magic number is rejected", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA")})); + + { + std::fstream f(cache.string(), std::ios::in | std::ios::out | std::ios::binary); + const uint32_t bad = 0xDEADBEEFu; + f.write(reinterpret_cast(&bad), sizeof(bad)); + } + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); +} + +TEST_CASE("a vendor with no presets saves and loads cleanly", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid, "Acme Corporation"), vid, "1.0.0")); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); + REQUIRE(out.vendors.count(vid) == 1); + CHECK(out.vendors.at(vid).id == vid); + CHECK(out.vendors.at(vid).name == "Acme Corporation"); + CHECK(presets_for(out.filaments, vid).empty()); + CHECK(presets_for(out.printers, vid).empty()); + CHECK(presets_for(out.prints, vid).empty()); +} + +TEST_CASE("a cache-loaded vendor is indistinguishable from a JSON-loaded one", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + write_full_vendor_tree(user, "Acme", "1.0.0"); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + PresetBundle from_json; + from_json.set_generate_vendor_caches(true); + from_json.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + REQUIRE(fs::exists(user / "Acme.opc")); + + // Take the preset JSONs away: were the cache rejected, the load below would + // have nothing to parse — so its success proves the cache answered. + fs::remove_all(user / "Acme"); + PresetBundle from_cache; + from_cache.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + + // Both paths run the same install code over the same entries, so everything + // observable must come out identical — the vendor profile and every preset, + // field by field. + REQUIRE(from_cache.vendors.count("Acme") == 1); + REQUIRE(vendor_deep_equal(from_cache.vendors.at("Acme"), from_json.vendors.at("Acme"))); + const std::pair colls[] = { + {&from_json.prints, &from_cache.prints}, + {&from_json.filaments, &from_cache.filaments}, + {&from_json.printers, &from_cache.printers}, + }; + for (const auto& [jc, cc] : colls) { + auto a = presets_for(*jc, "Acme"); + auto b = presets_for(*cc, "Acme"); + REQUIRE(a.size() == b.size()); + REQUIRE(!a.empty()); + for (size_t i = 0; i < a.size(); ++i) { + CHECK(a[i]->name == b[i]->name); + CHECK(preset_deep_equal(*a[i], *b[i])); + } + } + + // Pin the explicit metadata against symmetric loss: dropping a field from + // visit_entry (PresetCacheFormat.cpp) keeps the two bundles equal to each + // other, but not to the fixture. + const Preset* pla = from_cache.filaments.find_preset("Acme PLA @0.4", false); + REQUIRE(pla != nullptr); + CHECK(pla->setting_id == "GFSA04"); + CHECK(pla->description == "Test PLA description"); + const Preset* silk = from_cache.filaments.find_preset("Acme Silk PLA @0.4", false); + REQUIRE(silk != nullptr); + CHECK(silk->filament_id == "GFA_base"); // inherited from the non-instantiated base + const auto* cost = silk->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(42., 1e-9)); + const Preset* pr = from_cache.printers.find_preset("Acme 0.4 nozzle", false); + REQUIRE(pr != nullptr); + CHECK(pr->renamed_from == std::vector{"Acme old 0.4 nozzle"}); +} + +TEST_CASE("a cache-served vendor reports the errors its parse counted", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + // One process preset without the required "instantiation" key — a parse-phase + // error the load survives, so it must reach the cache's parse_errors stamp. + fs::create_directories(user / "Acme" / "process"); + std::ofstream((user / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[{"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})"; + std::ofstream((user / "Acme" / "process" / "standard.json").string()) + << R"({"type":"process","name":"0.20mm Standard @Acme","from":"system","layer_height":"0.2"})"; + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + PresetBundle from_json; + from_json.set_generate_vendor_caches(true); + from_json.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + REQUIRE(fs::exists(user / "Acme.opc")); + CHECK(from_json.error_count() > 0); + + fs::remove_all(user / "Acme"); + PresetBundle from_cache; + from_cache.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(from_cache.error_count() == from_json.error_count()); + CHECK(presets_for(from_cache.prints, "Acme").size() == 1); +} + +TEST_CASE("a non-instantiated base in a regular vendor's cache resolves its children and stays out of the library maps", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + + // Entry order is the resolution order: the base must install (into the local + // config maps) before the child that inherits it. + auto base = filament_entry("Acme Base PLA", "GFA_base"); + base.instantiation = "false"; + base.config_src.set_key_value("filament_cost", new ConfigOptionFloats({42.})); + auto child = filament_entry("Acme Silk PLA @0.4", "", "Acme Base PLA"); + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "1.0.0", {base, child})); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), "Acme", Semver("1.0.0"))); + auto fi = presets_for(out.filaments, "Acme"); + REQUIRE(fi.size() == 1); // the base never becomes a preset + CHECK(fi[0]->name == "Acme Silk PLA @0.4"); + CHECK(fi[0]->filament_id == "GFA_base"); + const auto* cost = fi[0]->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(42., 1e-9)); + // Only the filament library's bases persist as the cross-vendor inheritance + // maps; a regular vendor's stay local to its own load. + CHECK(out.m_config_maps.empty()); + CHECK(out.m_filament_id_maps.empty()); +} + +TEST_CASE("a cache with the wrong cache version is rejected", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA")})); + patch_cache_version(cache.string(), 0xFFFFFFFFu); + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); +} + +TEST_CASE("a cache truncated mid-blob is rejected", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + REQUIRE(save_one_vendor(cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA")})); + + { + std::ifstream in(cache.string(), std::ios::binary); + std::vector buf(30); // 20-byte header + 10 bytes of blob + in.read(buf.data(), 30); + in.close(); + std::ofstream out(cache.string(), std::ios::binary | std::ios::trunc); + out.write(buf.data(), 30); + } + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); +} + +TEST_CASE("printer model bed texture fields survive a cache round-trip", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path cache = tmp.path / "vendor.opc"; + + VendorMap vendors = one_vendor(vid); + VendorProfile::PrinterModel model; + model.id = "N1"; + model.name = "Neat One"; + model.bottom_texture_rect_longer = "5,5,50,10"; + vendors.at(vid).models.push_back(model); + REQUIRE(save_one_vendor(cache.string(), vendors, vid, "1.0.0")); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), vid, Semver("1.0.0"))); + REQUIRE(out.vendors.at(vid).models.size() == 2); + REQUIRE(vendor_deep_equal(out.vendors.at(vid), vendors.at(vid))); + CHECK(out.vendors.at(vid).models[1].bottom_texture_rect_longer == "5,5,50,10"); +} + +TEST_CASE("a cache older than the vendor profile on disk is rejected", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "1.0.0")); + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), "Acme", Semver("1.0.1"))); +} + +TEST_CASE("a cache newer than the vendor profile on disk is used", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "1.2.0", + {filament_entry("Acme PLA")})); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), "Acme", Semver("1.0.0"))); + CHECK(presets_for(out.filaments, "Acme").size() == 1); +} + +TEST_CASE("a vendor cache outlives a filament library update and resolves against the new library", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY); + write_lib_tree(user, "1.0.0", "20"); + write_vendor_with_lib_filament(user, "Acme", "1.0.0"); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + // First launch: the library parses first, then the vendor against it, and + // both caches are written. + PresetBundle base1; + base1.set_generate_vendor_caches(true); + base1.load_vendor_configs_from_json(user.string(), lib, PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + PresetBundle acme1; + acme1.set_generate_vendor_caches(true); + acme1.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent, &base1); + REQUIRE(fs::exists(user / "Acme.opc")); + { + auto fi = presets_for(acme1.filaments, "Acme"); + REQUIRE(fi.size() == 1); + const auto* cost = fi[0]->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(20., 1e-9)); + } + + // An update delivers a new library only; the vendor stays as it was. + write_lib_tree(user, "2.0.0", "30"); + PresetBundle base2; + base2.load_vendor_configs_from_json(user.string(), lib, PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + + // Take the vendor's preset JSONs away: were its cache rejected, the load + // below would have nothing to parse — so its success proves the cache + // survived the library bump. + fs::remove_all(user / "Acme"); + PresetBundle acme2; + auto [substitutions, presets_loaded] = acme2.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent, &base2); + CHECK(presets_loaded == 1); + auto fi = presets_for(acme2.filaments, "Acme"); + REQUIRE(fi.size() == 1); + // The cache holds only the vendor's own diff; the library values come from + // the library loaded now, not the one in effect when the cache was written. + const auto* cost = fi[0]->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(30., 1e-9)); + CHECK(fi[0]->filament_id == "GFL99"); +} + +TEST_CASE("a vendor installed as its cache alone still loads after a library update", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY); + write_lib_tree(user, "1.0.0", "20"); + write_vendor_with_lib_filament(user, "Acme", "1.0.0"); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + // Generate the vendor's cache, then strip the vendor to the cache alone — + // the shape of a packaged install, which ships each vendor as its .opc and + // nothing else. + PresetBundle base1; + base1.set_generate_vendor_caches(true); + base1.load_vendor_configs_from_json(user.string(), lib, PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + PresetBundle acme1; + acme1.set_generate_vendor_caches(true); + acme1.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent, &base1); + fs::remove(user / "Acme.json"); + fs::remove_all(user / "Acme"); + + // An OTA update then delivers a new library only. With no JSONs anywhere to + // fall back on, the vendor must keep loading from its cache. + write_lib_tree(user, "2.0.0", "30"); + PresetBundle base2; + base2.load_vendor_configs_from_json(user.string(), lib, PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + PresetBundle acme2; + auto [substitutions, presets_loaded] = acme2.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent, &base2); + CHECK(presets_loaded == 1); + REQUIRE(acme2.vendors.count("Acme") == 1); + auto fi = presets_for(acme2.filaments, "Acme"); + REQUIRE(fi.size() == 1); + const auto* cost = fi[0]->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(30., 1e-9)); +} + +TEST_CASE("a cache entry whose parent is missing falls back to the vendor's JSONs", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + write_vendor_tree(user, "Acme", "1.0.0"); + + // A cache claiming the installed version, but whose entry inherits a preset + // no loaded library provides. + REQUIRE(save_one_vendor((user / "Acme.opc").string(), one_vendor("Acme", "Cached Acme"), "Acme", "1.0.0", + {filament_entry("Acme PLA @0.4", "GFA00", "No Such Base")})); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + // Directly: the load fails and leaves the bundle clean. + PresetBundle direct; + REQUIRE(!direct.load_vendor_cache((user / "Acme.opc").string(), "Acme", Semver("1.0.0"))); + CHECK(direct.vendors.empty()); + + // Through the vendor load: the JSONs answer instead, as if no cache existed. + PresetBundle out; + auto [substitutions, presets_loaded] = out.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(presets_loaded == 1); + CHECK(out.vendors.at("Acme").name == "Acme"); // the profile's name, not the cache's +} + +TEST_CASE("a profile with no usable version is never served from cache", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "1.0.0")); + + PresetBundle out; + // An unversioned vendor profile has no version to compare against. + REQUIRE(!out.load_vendor_cache(cache.string(), "Acme", Semver::invalid())); + // And a cache carrying no version of its own cannot cover a profile that has one. + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "")); + REQUIRE(!out.load_vendor_cache(cache.string(), "Acme", Semver("1.0.0"))); + REQUIRE(out.vendors.empty()); +} + +TEST_CASE("a versionless profile beside a cache keeps the cache from being served", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + + REQUIRE(save_one_vendor((user / "Acme.opc").string(), one_vendor("Acme", "Cached Acme"), "Acme", "1.0.0", + {filament_entry("Acme PLA @0.4")})); + // The profile beside the cache parses to no usable version, which can no + // more judge the cache's staleness than it could be cached itself. + write_versionless_vendor_json(user, "Acme"); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + PresetBundle out; + auto [substitutions, presets_loaded] = out.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent); + // Nothing came from the cache: the versionless profile was parsed instead, + // and it carries no presets. + CHECK(presets_loaded == 0); +} + +TEST_CASE("a vendor's cache is its whole installation", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources"; + const fs::path data = tmp.path / "data"; + fs::create_directories(rsrc / "profiles" / "Acme" / "machine"); + write_vendor_json(rsrc / "profiles", "Acme"); + std::ofstream((rsrc / "profiles" / "Acme" / "machine" / "printer.json").string()) << "{}"; + + REQUIRE(save_one_vendor((rsrc / "profiles" / "Acme.opc").string(), one_vendor("Acme"), "Acme", "1.0.0")); + + ScopedDirs dirs(data, rsrc); + REQUIRE(install_vendor_bundles_from_resources({"Acme"})); + // The cache carries the presets, the vendor profile and the version they were + // built at, so it is installed on its own. + CHECK(fs::exists(data / "system" / "Acme.opc")); + CHECK(!fs::exists(data / "system" / "Acme.json")); + CHECK(!fs::exists(data / "system" / "Acme")); + CHECK(is_vendor_installed("Acme")); + CHECK(installed_vendor_version("Acme") == Semver(1, 0, 0)); + + // A vendor with no cache is installed as its profile and preset JSONs instead, + // parsing them being the only way left to load it — and the cache the previous + // install left behind has to go, or it would shadow the profile just installed. + fs::remove(rsrc / "profiles" / "Acme.opc"); + REQUIRE(install_vendor_bundles_from_resources({"Acme"})); + CHECK(!fs::exists(data / "system" / "Acme.opc")); + CHECK(fs::exists(data / "system" / "Acme" / "machine" / "printer.json")); + CHECK(installed_vendor_version("Acme") == Semver(1, 0, 0)); + + // Installing the cache again takes the profile and its preset JSONs back out. + REQUIRE(save_one_vendor((rsrc / "profiles" / "Acme.opc").string(), one_vendor("Acme"), "Acme", "1.0.0")); + REQUIRE(install_vendor_bundles_from_resources({"Acme"})); + CHECK(fs::exists(data / "system" / "Acme.opc")); + CHECK(!fs::exists(data / "system" / "Acme.json")); + CHECK(!fs::exists(data / "system" / "Acme")); +} + +TEST_CASE("a vendor shipped as a cache alone is installed and loaded from it", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + + // A packaged build: every vendor is its cache, with no profile of any kind + // beside it — not even the filament library's. + const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY); + REQUIRE(save_one_vendor((rsrc / (lib + ".opc")).string(), one_vendor(lib, "Shipped Library"), lib, "1.0.0")); + REQUIRE(save_one_vendor((rsrc / "Acme.opc").string(), one_vendor("Acme", "Shipped Acme"), "Acme", "1.0.0")); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + // The version the build ships the vendor at comes from the cache, there being + // no profile to read it from. + CHECK(resource_vendor_version("Acme") == Semver(1, 0, 0)); + + // Resources reaches the app by being installed, never by being loaded from. + REQUIRE(install_vendor_bundles_from_resources({lib, "Acme"})); + CHECK(fs::exists(user / "Acme.opc")); + CHECK(!fs::exists(user / "Acme.json")); + CHECK(installed_vendor_version("Acme") == Semver(1, 0, 0)); + + PresetBundle after; + after.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(after.vendors.at("Acme").name == "Shipped Acme"); +} + +TEST_CASE("a vendor with a profile in the data dir is parsed there and cached there, whatever resources ships", "[VendorCache]") +{ + // The reported regression: a valid resources/profiles/.opc answered + // first, so the JSON in system/ was never parsed and system/.opc was + // never written. Main reads system/ and nothing else. + InstallDirs dirs; + + write_vendor_tree(dirs.system, "Shadow", "1.0.0"); + // A cache in resources at the very same version — under the old two-tier + // lookup this was accepted and the parse skipped. + REQUIRE(save_one_vendor((dirs.profiles / "Shadow.opc").string(), one_vendor("Shadow"), "Shadow", "1.0.0")); + + PresetBundle bundle; + bundle.set_generate_vendor_caches(true); + REQUIRE(bundle.load_vendor_configs_from_json(dirs.system.string(), "Shadow", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent).second > 0); + + // Parsed from system/, and its cache written back beside the profile. + CHECK(fs::exists(dirs.system / "Shadow.opc")); + CHECK(presets_for(bundle.prints, "Shadow").size() == 1); +} + +TEST_CASE("a vendor with nothing installed is not loaded from resources", "[VendorCache]") +{ + // Resources reaches the app by being installed into system/ first. A vendor + // that is not installed is not loaded, however completely resources ships it. + InstallDirs dirs; + + write_vendor_tree(dirs.profiles, "Absent", "1.0.0"); + REQUIRE(save_one_vendor((dirs.profiles / "Absent.opc").string(), one_vendor("Absent"), "Absent", "1.0.0")); + + PresetBundle bundle; + REQUIRE_THROWS(bundle.load_vendor_configs_from_json(dirs.system.string(), "Absent", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent)); + CHECK(presets_for(bundle.prints, "Absent").empty()); +} + +TEST_CASE("a cache installed with no profile beside it is used whatever its version", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + write_vendor_json(rsrc, "Acme"); + + // Installed at an older version than the one now shipped in resources. Nothing + // sits beside it claiming to be newer, so the cache is what the vendor is. + REQUIRE(save_one_vendor((user / "Acme.opc").string(), one_vendor("Acme", "Installed Acme"), "Acme", "0.9.0")); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + CHECK(VendorCacheFile::peek_version((user / "Acme.opc").string(), "Acme") == "0.9.0"); + CHECK(VendorCacheFile::peek_version((user / "Acme.opc").string(), "Other").empty()); + CHECK(installed_vendor_version("Acme") == Semver(0, 9, 0)); + + // Loading the vendor takes the installed cache, not the newer shipped profile. + PresetBundle out; + out.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(out.vendors.at("Acme").name == "Installed Acme"); +} + +TEST_CASE("a vendor whose cache covers it is loaded without parsing any JSON", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + + REQUIRE(save_one_vendor((user / "Acme.opc").string(), one_vendor("Acme", "Cached Acme"), "Acme", "1.0.0", + {filament_entry("Acme PLA @0.4")}, + {printer_entry("Acme Printer 0.4")})); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + // The cache is the whole installation — no profile, no preset JSONs — and the + // caller asks for the vendor exactly as it would for a JSON install. + PresetBundle out; + auto [substitutions, presets_loaded] = out.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::Disable); + CHECK(substitutions.empty()); + CHECK(presets_loaded == 2); + CHECK(out.vendors.at("Acme").name == "Cached Acme"); + + // Nothing was written back: the presets never came from a parse. + CHECK(!fs::exists(user / "Acme.json")); +} + +TEST_CASE("a vendor whose cache is stale falls back to parsing its JSONs", "[VendorCache]") +{ + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + + // An update installed the vendor at 2.0.0; the cache next to it was built from + // the profile before that, so it no longer covers what is on disk. + write_vendor_tree(user, "Acme", "2.0.0"); + REQUIRE(save_one_vendor((user / "Acme.opc").string(), one_vendor("Acme", "Cached Acme"), "Acme", "1.0.0")); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + PresetBundle out; + auto [substitutions, presets_loaded] = out.load_vendor_configs_from_json( + user.string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(presets_loaded == 1); + CHECK(out.vendors.at("Acme").config_version == Semver(2, 0, 0)); + + // A one-off parse like this one leaves the stale cache alone: only a bundle + // told its parses are complete writes one. + CHECK(VendorCacheFile::peek_version((user / "Acme.opc").string(), "Acme") == "1.0.0"); + + PresetBundle caching; + caching.set_generate_vendor_caches(true); + caching.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + CHECK(VendorCacheFile::peek_version((user / "Acme.opc").string(), "Acme") + == get_version_from_json((user / "Acme.json").string()).to_string()); +} + +TEST_CASE("a cache with a mismatched vendor name is rejected", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + REQUIRE(save_one_vendor(cache.string(), one_vendor("VendorA"), "VendorA", "1.0.0")); + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(cache.string(), "VendorB", Semver("1.0.0"))); +} + +TEST_CASE("a cache is rejected against an unparsable version", "[VendorCache]") +{ + TempDir tmp; + const fs::path cache = tmp.path / "vendor.opc"; + REQUIRE(save_one_vendor(cache.string(), one_vendor("Acme"), "Acme", "1.0.0")); + PresetBundle out; + // A profile version that does not parse comes out of get_version_from_json + // as zero, which cannot be judged any more than Semver::invalid() can. + REQUIRE(!out.load_vendor_cache(cache.string(), "Acme", Semver())); + REQUIRE(out.vendors.empty()); // rejection happens before the body is touched +} + +TEST_CASE("the filament library's inheritance maps are rebuilt on cache load", "[VendorCache]") +{ + // m_config_maps/m_filament_id_maps are the inheritance base other vendors + // resolve against. The cache no longer stores them: they are rebuilt by + // installing the library's entries — including the non-instantiated bases, + // which exist for exactly this and never become presets. + TempDir tmp; + const fs::path cache = tmp.path / "lib.opc"; + const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY); + + auto base = filament_entry("Generic PLA", "GFL99"); + base.instantiation = "false"; + base.config_src.set_key_value("filament_cost", new ConfigOptionFloats({20.})); + REQUIRE(save_one_vendor(cache.string(), one_vendor(lib), lib, "1.0.0", {base})); + + PresetBundle out; + REQUIRE(out.load_vendor_cache(cache.string(), lib, Semver("1.0.0"))); + REQUIRE(out.m_config_maps.count("Generic PLA") == 1); + const auto* cost = out.m_config_maps.at("Generic PLA").option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(20., 1e-9)); + CHECK(out.m_filament_id_maps.at("Generic PLA") == "GFL99"); + CHECK(presets_for(out.filaments, lib).empty()); // not instantiated, not a preset +} + +TEST_CASE("the same fixture parsed twice serializes byte-identically", "[VendorCache]") +{ + // Shipped caches must be reproducible: the same profiles must produce the + // same bytes on every machine that generates them. + TempDir tmp; + const fs::path rsrc = tmp.path / "resources" / "profiles"; + const fs::path user = tmp.path / "data" / PRESET_SYSTEM_DIR; + fs::create_directories(rsrc); + fs::create_directories(user); + write_full_vendor_tree(user, "Acme", "1.0.0"); + + ScopedDirs dirs(tmp.path / "data", tmp.path / "resources"); + + PresetBundle first; + first.set_generate_vendor_caches(true); + first.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + REQUIRE(fs::exists(user / "Acme.opc")); + const std::string bytes1 = slurp(user / "Acme.opc"); + fs::remove(user / "Acme.opc"); + + PresetBundle second; + second.set_generate_vendor_caches(true); + second.load_vendor_configs_from_json(user.string(), "Acme", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + REQUIRE(slurp(user / "Acme.opc") == bytes1); +} + +TEST_CASE("a cache that fails mid-body deserialization is rejected and leaves the bundle clean", "[VendorCache]") +{ + TempDir tmp; + const std::string vid = "Acme"; + const fs::path valid_cache = tmp.path / "valid.opc"; + const fs::path corrupt_cache = tmp.path / "corrupt.opc"; + + REQUIRE(save_one_vendor(valid_cache.string(), one_vendor(vid), vid, "1.0.0", + {filament_entry(vid + " PLA @0.4")}, + {printer_entry(vid + " Printer 0.4")})); + // Truncate the tail (machine entries + parse_errors, per VendorCacheFile::save's + // field order) so the header's size/CRC still validate but cereal runs out of + // bytes partway through the body. Grow the cut if a given size ever stops + // throwing (e.g. after an unrelated field-order change to the cache format). + size_t truncate_by = 40; + bool throws = false; + for (; truncate_by <= 200; truncate_by += 8) { + fs::copy_file(valid_cache, corrupt_cache, fs::copy_option::overwrite_if_exists); + truncate_payload_and_fix_header(corrupt_cache.string(), truncate_by); + PresetBundle probe_bundle; + if (!probe_bundle.load_vendor_cache(corrupt_cache.string(), vid, Semver("1.0.0"))) { + throws = true; + break; + } + } + REQUIRE(throws); + + PresetBundle out; + REQUIRE(!out.load_vendor_cache(corrupt_cache.string(), vid, Semver("1.0.0"))); + // The catch block put the bundle back the way a failed parse would leave it. + CHECK(out.vendors.empty()); + CHECK(out.m_config_maps.empty()); + CHECK(presets_for(out.filaments, vid).empty()); + + // The recovery must leave a bundle a caller can still load a good cache into. + REQUIRE(out.load_vendor_cache(valid_cache.string(), vid, Semver("1.0.0"))); + CHECK(out.vendors.count(vid) == 1); + CHECK(presets_for(out.filaments, vid).size() == 1); +} + +TEST_CASE("a cache rejected mid-body leaves the error count where it found it", "[VendorCache]") +{ + InstallDirs dirs; + + // A vendor whose root profile counts a parse error, so the bundle carries a + // non-zero tally into the load below. Without one there is nothing for a + // rejected cache to zero, and nothing to underflow. + std::ofstream((dirs.system / "Noisy.json").string()) + << R"({"version":"1.0.0","name":"Noisy","process_list":"not a list"})"; + + write_vendor_tree(dirs.system, "Counted", "1.0.0"); + // A cache that passes every stamp and then dies in the entries. + REQUIRE(save_one_vendor((dirs.system / "Counted.opc").string(), one_vendor("Counted"), "Counted", "1.0.0", + {filament_entry("Counted PLA @0.4")})); + truncate_payload_and_fix_header((dirs.system / "Counted.opc").string(), 8); + + PresetBundle bundle; + bundle.set_generate_vendor_caches(true); + bundle.load_vendor_configs_from_json(dirs.system.string(), "Noisy", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent); + REQUIRE(bundle.error_count() > 0); + + // The same bundle: the cache is tried, fails mid-body, and the parse that + // follows must be measured against the tally the cache found rather than + // against zero. + REQUIRE(bundle.load_vendor_configs_from_json(dirs.system.string(), "Counted", PresetBundle::LoadSystem, + ForwardCompatibilitySubstitutionRule::EnableSilent).second > 0); + + // The rewritten cache must carry the parse's own error count, not an + // underflowed one. Reload it and check the bundle does not inherit a + // nonsensical tally. + PresetBundle reloaded; + REQUIRE(reloaded.load_vendor_cache((dirs.system / "Counted.opc").string(), "Counted", Semver(1, 0, 0))); + CHECK(reloaded.error_count() == 0); +} + +TEST_CASE("a preset is traced to its vendor in a build that ships caches alone", "[VendorCache]") +{ + InstallDirs dirs; + + std::vector filaments { filament_entry("Cached PLA @0.4") }; + std::vector printers { printer_entry("Cached 0.4 nozzle") }; + REQUIRE(save_one_vendor((dirs.profiles / "Cached.opc").string(), one_vendor("Cached"), "Cached", "1.0.0", + filaments, printers)); + + CHECK(PresetBundle::find_preset_vendor("Cached PLA @0.4", Preset::TYPE_FILAMENT) == "Cached"); + CHECK(PresetBundle::find_preset_vendor("Cached 0.4 nozzle", Preset::TYPE_PRINTER) == "Cached"); + CHECK(PresetBundle::find_preset_vendor("Nobody's PLA", Preset::TYPE_FILAMENT).empty()); +} + +TEST_CASE("a bundle that cannot be installed does not drop the others", "[VendorCache]") +{ + InstallDirs dirs; + + write_vendor_tree(dirs.profiles, "Good", "1.0.0"); + + // An empty name sorts first out of a std::map, and a name resources does not + // carry can appear anywhere. Neither may cost the batch the vendors it can + // install. + CHECK_FALSE(install_vendor_bundles_from_resources({"", "Absent", "Good"})); + CHECK(fs::exists(dirs.system / "Good.json")); +} + +TEST_CASE("a cache that arrives unusable leaves the profile fallback in place", "[VendorCache]") +{ + InstallDirs dirs; + + write_vendor_tree(dirs.profiles, "Torn", "1.0.0"); + const std::string cache = (dirs.profiles / "Torn.opc").string(); + REQUIRE(save_one_vendor(cache, one_vendor("Torn"), "Torn", "1.0.0")); + // Past the stamps at the front, so the 1 KB peek that chooses the cache form + // still succeeds — only the CRC, which decides whether it can be served, + // catches this. + corrupt_blob_byte(cache, std::streamoff(fs::file_size(cache)) - 4); + REQUIRE(VendorCacheFile::peek_version(cache, "Torn") == "1.0.0"); + + CHECK(install_vendor_bundles_from_resources({"Torn"})); + CHECK(fs::exists(dirs.system / "Torn.json")); + CHECK_FALSE(fs::exists(dirs.system / "Torn.opc")); +} + +TEST_CASE("a vendor installed as an unreadable cache alone counts as not installed", "[VendorCache]") +{ + InstallDirs dirs; + + const std::string cache = (dirs.system / "Broken.opc").string(); + REQUIRE(save_one_vendor(cache, one_vendor("Broken"), "Broken", "1.0.0")); + REQUIRE(is_vendor_installed("Broken")); + + // A cache this build cannot serve is not an installation: there is no + // profile beside it and, since the single-tier load, nowhere else to load + // the vendor from. + corrupt_blob_byte(cache); + CHECK_FALSE(is_vendor_installed("Broken")); + CHECK_FALSE(installed_vendor_version("Broken").valid()); +} + +TEST_CASE("a stale profile beside a newer cache does not hide the cache's version", "[VendorCache]") +{ + InstallDirs dirs; + + write_vendor_json(dirs.system, "Both", "1.0.0"); + REQUIRE(save_one_vendor((dirs.system / "Both.opc").string(), one_vendor("Both"), "Both", "2.0.0")); + + // The cache covers the profile, so the cache is what a load serves — and + // 2.0.0 is the version installed, not the 1.0.0 the profile still claims. + CHECK(installed_vendor_version("Both") == Semver(2, 0, 0)); +} + +TEST_CASE("a profile newer than the cache beside it is the installed version", "[VendorCache]") +{ + InstallDirs dirs; + + write_vendor_json(dirs.system, "Both", "3.0.0"); + REQUIRE(save_one_vendor((dirs.system / "Both.opc").string(), one_vendor("Both"), "Both", "2.0.0")); + + // The cache no longer covers the profile, so the profile is parsed — and + // its version is the one in force. + CHECK(installed_vendor_version("Both") == Semver(3, 0, 0)); +} + +TEST_CASE("a header claiming more body than the file holds is rejected", "[VendorCache]") +{ + TempDir tmp; + const std::string cache = (tmp.path / "Bounded.opc").string(); + REQUIRE(save_one_vendor(cache, one_vendor("Bounded"), "Bounded", "1.0.0")); + + // Claim a body far larger than the file. Nothing may be allocated on the + // strength of that number. + { + std::fstream f(cache, std::ios::in | std::ios::out | std::ios::binary); + const uint64_t huge = 400ull * 1024ull * 1024ull; + f.seekp(8); + f.write(reinterpret_cast(&huge), sizeof(huge)); + } + + PresetBundle bundle; + REQUIRE_FALSE(bundle.load_vendor_cache(cache, "Bounded", Semver(1, 0, 0))); +} + +TEST_CASE("a failed write leaves the previous cache in place", "[VendorCache]") +{ + TempDir tmp; + const std::string cache = (tmp.path / "Durable.opc").string(); + REQUIRE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "1.0.0")); + const std::string before = slurp(cache); + + // A directory where the temp file wants to go: the write cannot complete, + // and must not have destroyed what was already there to find that out. + const fs::path blocker = fs::path(cache + "." + std::to_string(get_current_pid()) + ".tmp"); + fs::create_directories(blocker); + + REQUIRE_FALSE(save_one_vendor(cache, one_vendor("Durable"), "Durable", "2.0.0")); + CHECK(slurp(cache) == before); + + fs::remove_all(blocker); +} + +TEST_CASE("a cache written by another build's option ordering still loads", "[VendorCache]") +{ + // The regression the fingerprint used to prevent by refusing the file + // outright: nothing in the payload depends on serialization_key_ordinal, so + // a build that inserted an option ahead of these reads them back correctly. + TempDir tmp; + const std::string cache = (tmp.path / "Ordinal.opc").string(); + + auto e = filament_entry("Ordinal PLA @0.4"); + e.config_src.set_key_value("filament_cost", new ConfigOptionFloats({42.})); + e.config_src.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); + REQUIRE(save_one_vendor(cache, one_vendor("Ordinal"), "Ordinal", "1.0.0", {e})); + + PresetBundle bundle; + REQUIRE(bundle.load_vendor_cache(cache, "Ordinal", Semver(1, 0, 0))); + const auto filaments = presets_for(bundle.filaments, "Ordinal"); + REQUIRE(filaments.size() == 1); + const auto* cost = filaments.front()->config.option("filament_cost"); + REQUIRE(cost != nullptr); + CHECK_THAT(cost->values.front(), WithinAbs(42., 1e-9)); + CHECK(filaments.front()->config.option("filament_type")->values.front() == "PLA"); +} + +// ---- CacheDictionary and the name-keyed config payload ------------------- + +namespace { + +// Round-trip one config through the dictionary payload, optionally mutating the +// dictionary between write and read to stand in for another build's schema. +DynamicPrintConfig roundtrip_config(const DynamicPrintConfig& in, + const std::function& mutate_blob = {}) +{ + CacheDictionary wdict; + wdict.collect(in); + std::ostringstream os(std::ios::binary); + { + cereal::BinaryOutputArchive ar(os); + wdict.save(ar); + save_config(ar, in, wdict); + } + std::string blob = os.str(); + if (mutate_blob) + mutate_blob(blob); + std::istringstream is(blob, std::ios::binary); + cereal::BinaryInputArchive ar(is); + CacheDictionary rdict; + rdict.load(ar); + DynamicPrintConfig out; + load_config(ar, out, rdict); + return out; +} + +} // namespace + +TEST_CASE("a config round-trips through the cache dictionary", "[VendorCache]") +{ + DynamicPrintConfig in; + in.set_key_value("layer_height", new ConfigOptionFloat(0.28)); + in.set_key_value("printer_model", new ConfigOptionString("Test Model")); + in.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.6})); + in.set_key_value("spiral_mode", new ConfigOptionBool(true)); + + const DynamicPrintConfig out = roundtrip_config(in); + + CHECK_THAT(out.opt_float("layer_height"), WithinAbs(0.28, 1e-9)); + CHECK(out.opt_string("printer_model") == "Test Model"); + REQUIRE(out.option("nozzle_diameter") != nullptr); + CHECK(out.option("nozzle_diameter")->values.size() == 2); + CHECK(out.opt_bool("spiral_mode") == true); +} + +TEST_CASE("an enum option round-trips by name, not by index", "[VendorCache]") +{ + // top_surface_pattern is a coEnum; its stored int is an index into an enum + // whose order is not a wire contract. Assert on the NAME, so a reordering + // of the enum in PrintConfig.cpp cannot make this test pass by accident. + const ConfigOptionDef* def = print_config_def.get("top_surface_pattern"); + REQUIRE(def != nullptr); + REQUIRE(def->type == coEnum); + REQUIRE(def->enum_keys_map != nullptr); + const int monotonic = def->enum_keys_map->at("monotonic"); + + DynamicPrintConfig in; + in.set_key_value("top_surface_pattern", new ConfigOptionEnumGeneric(def->enum_keys_map, monotonic)); + + const DynamicPrintConfig out = roundtrip_config(in); + REQUIRE(out.option("top_surface_pattern") != nullptr); + CHECK(out.opt_enum("top_surface_pattern") == InfillPattern(monotonic)); + CHECK(out.option("top_surface_pattern")->serialize() == "monotonic"); +} + +TEST_CASE("a nullable vector enum round-trips by name, nil included", "[VendorCache]") +{ + // coEnums carries a vector of ints and, unlike coEnum, its ConfigOptionType + // does not fit in a byte - a truncated type in the dictionary would make a + // reader take this for a scalar enum and run off the end of the stream. + // nozzle_type is also nullable, and nil is an int no enum_keys_map names, + // so this covers the dictionary's unnamed-value escape hatch too. + const ConfigOptionDef* def = print_config_def.get("nozzle_type"); + REQUIRE(def != nullptr); + REQUIRE(def->type == coEnums); + REQUIRE(def->nullable); + REQUIRE(def->enum_keys_map != nullptr); + const int brass = def->enum_keys_map->at("brass"); + const int nil = ConfigOptionInts::nil_value(); + + DynamicPrintConfig in; + auto* opt = new ConfigOptionEnumsGenericNullable(def->enum_keys_map); + opt->values = { brass, nil }; + in.set_key_value("nozzle_type", opt); + in.set_key_value("printer_model", new ConfigOptionString("Test Model")); + + const DynamicPrintConfig out = roundtrip_config(in); + const auto* got = out.option("nozzle_type"); + REQUIRE(got != nullptr); + CHECK(got->values == std::vector{brass, nil}); + CHECK(out.opt_string("printer_model") == "Test Model"); +} + +TEST_CASE("an option the build no longer knows is dropped, and the rest still load", "[VendorCache]") +{ + DynamicPrintConfig in; + in.set_key_value("layer_height", new ConfigOptionFloat(0.28)); + in.set_key_value("printer_model", new ConfigOptionString("Test Model")); + + // Rename the key in the dictionary the reader sees: "layer_height" becomes + // "layer_heighX", a key no build defines. Same length, so the blob's + // offsets are untouched - this is exactly what a removed or renamed option + // looks like to a reader. + const DynamicPrintConfig out = roundtrip_config(in, [](std::string& blob) { + const size_t at = blob.find("layer_height"); + REQUIRE(at != std::string::npos); + blob[at + 11] = 'X'; + }); + + CHECK(out.option("layer_height") == nullptr); + CHECK(out.opt_string("printer_model") == "Test Model"); +} + +TEST_CASE("an option whose type changed is dropped, and the rest still load", "[VendorCache]") +{ + // A payload from a build where layer_height was a coString. This one has it + // as a coFloat, so nothing can be done with the value - but the dictionary + // says how it was written, so its bytes are still consumed and printer_model + // behind it still lands. Hand-written rather than round-tripped: only a + // dictionary this build did not produce can disagree with it. + std::ostringstream os(std::ios::binary); + { + cereal::BinaryOutputArchive ar(os); + const std::vector keys { "layer_height", "printer_model" }; + const std::vector types { uint16_t(coString), uint16_t(coString) }; + const std::vector enums { std::string() }; // the ENUM_UNNAMED slot + ar(keys, types, enums); + ar(uint32_t(2)); + ar(uint16_t(0)); ar(ConfigOptionString("0.28")); + ar(uint16_t(1)); ar(ConfigOptionString("Test Model")); + } + + std::istringstream is(os.str(), std::ios::binary); + cereal::BinaryInputArchive ar(is); + CacheDictionary rdict; + rdict.load(ar); + DynamicPrintConfig out; + REQUIRE_NOTHROW(load_config(ar, out, rdict)); + CHECK(out.option("layer_height") == nullptr); + CHECK(out.opt_string("printer_model") == "Test Model"); +} + +TEST_CASE("skip_config consumes a config without building one", "[VendorCache]") +{ + DynamicPrintConfig in; + in.set_key_value("layer_height", new ConfigOptionFloat(0.28)); + in.set_key_value("printer_model", new ConfigOptionString("Test Model")); + + CacheDictionary wdict; + wdict.collect(in); + std::ostringstream os(std::ios::binary); + { + cereal::BinaryOutputArchive ar(os); + wdict.save(ar); + save_config(ar, in, wdict); + ar(std::string("sentinel")); // must still be reachable after the skip + } + + std::istringstream is(os.str(), std::ios::binary); + cereal::BinaryInputArchive ar(is); + CacheDictionary rdict; + rdict.load(ar); + skip_config(ar, rdict); + std::string sentinel; + ar(sentinel); + CHECK(sentinel == "sentinel"); +} + +TEST_CASE("a dictionary index past the end of the table is refused", "[VendorCache]") +{ + DynamicPrintConfig in; + in.set_key_value("layer_height", new ConfigOptionFloat(0.28)); + + CacheDictionary wdict; + wdict.collect(in); + std::ostringstream os(std::ios::binary); + { + cereal::BinaryOutputArchive ar(os); + wdict.save(ar); + save_config(ar, in, wdict); + } + std::string blob = os.str(); + // The payload's tail is the option count (uint32), the key index (uint16) + // and the double. Point the key index somewhere the table does not go. + const uint16_t bad = 0xFFFE; + std::memcpy(&blob[blob.size() - sizeof(double) - sizeof(uint16_t)], &bad, sizeof(bad)); + + std::istringstream is(blob, std::ios::binary); + cereal::BinaryInputArchive ar(is); + CacheDictionary rdict; + rdict.load(ar); + DynamicPrintConfig out; + REQUIRE_THROWS(load_config(ar, out, rdict)); +} + +TEST_CASE("a stamp string with an absurd length is rejected, not allocated", "[VendorCache]") +{ + // The stamps are read from whatever .opc a directory holds, and a + // string resize to a garbage 64-bit length does not fail as a catchable + // bad_alloc — it takes the app down through the out-of-memory handler. A + // CRC-valid body opening with the right cache version but foreign framing + // where the name's length word sits must be refused before anything is + // allocated. + TempDir tmp; + const std::string cache = (tmp.path / "Evil.opc").string(); + REQUIRE(save_one_vendor(cache, one_vendor("Evil"), "Evil", "1.0.0")); + + // The vendor name's length word sits right behind the payload's version + // word; make it claim a ~9-exabyte name. + const uint64_t huge = 0x7FFFFFFFFFFFFFFFull; + patch_payload_bytes(cache, sizeof(uint32_t), &huge, sizeof(huge)); + + PresetBundle out; + REQUIRE(! out.load_vendor_cache(cache, "Evil", Semver::inf())); + CHECK(out.vendors.empty()); + CHECK(VendorCacheFile::peek_version(cache, "Evil").empty()); +} + From f05444dc94bc325a4eef1ec1dafc33e1331caec9 Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:50:58 +0200 Subject: [PATCH 071/138] Fix unstable contours from triangulated planar faces (redone) (#15316) --- src/libslic3r/TriangleMeshSlicer.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index 2c1c0da23f..c403a6bd92 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -1499,6 +1499,13 @@ static std::vector make_loops( Polygons &polygons = layers[line_idx]; polygons = make_loops(lines[line_idx]); + // Orca: A planar quad represented by two triangles contributes a point where the + // slicing plane crosses the shared diagonal. After rounding to coord_t this + // point may be very slightly off the otherwise straight contour edge. Apart + // from being redundant, such points make the subsequent contour + // simplification depend on the slice height (and may move seam candidates). + remove_collinear(polygons); + auto this_mode = line_idx < params.slicing_mode_normal_below_layer ? params.mode_below : params.mode; if (! polygons.empty()) { if (this_mode == MeshSlicingParams::SlicingMode::Positive) { From 90f76fa28c1c05dce79271dcf0be608119879b62 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Fri, 21 Aug 2026 13:59:16 -0500 Subject: [PATCH 072/138] fix: restore compile parallelism for clang-cl builds with the Visual Studio generator (#15324) --- CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fc688b35df..fe3ae1d26d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -343,7 +343,10 @@ else () endif () if (MSVC) - if (SLIC3R_MSVC_COMPILE_PARALLEL AND NOT IS_CLANG_CL) + # /MP only matters for the VS generators, where CMake turns it into the + # MultiProcessorCompilation property. Ninja parallelises on its own, and + # clang-cl warns "argument unused" if the flag reaches it. + if (SLIC3R_MSVC_COMPILE_PARALLEL AND CMAKE_GENERATOR MATCHES "Visual Studio") add_compile_options(/MP) endif () # /bigobj (Increase Number of Sections in .Obj file) From 4b397fc2cc1c3f552d74f98e7ed3b6fd7436be6d Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Fri, 21 Aug 2026 20:24:39 -0500 Subject: [PATCH 073/138] fix: slice the same model to the same lightning infill every time (#15311) --- src/libslic3r/Fill/Lightning/TreeNode.cpp | 14 ++++++---- src/libslic3r/Fill/Lightning/TreeNode.hpp | 4 ++- tests/fff_print/test_fill.cpp | 33 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Fill/Lightning/TreeNode.cpp b/src/libslic3r/Fill/Lightning/TreeNode.cpp index 982d47b10e..3d57ebae4a 100644 --- a/src/libslic3r/Fill/Lightning/TreeNode.cpp +++ b/src/libslic3r/Fill/Lightning/TreeNode.cpp @@ -351,19 +351,23 @@ void Node::convertToPolylines(Polylines &output, const coord_t line_overlap) con { Polylines result; result.emplace_back(); - convertToPolylines(0, result); + // Orca: the layers are filled in parallel, so they would consume a shared generator in a + // different order every run, and a model would not slice the same way twice. Each tree seeds + // its own from where it is rooted; one constant seed would start them all on the same pick. + std::mt19937_64 rng { uint64_t(PointHash{}(m_p)) }; + convertToPolylines(0, result, rng); removeJunctionOverlap(result, line_overlap); append(output, std::move(result)); } -void Node::convertToPolylines(size_t long_line_idx, Polylines &output) const +void Node::convertToPolylines(size_t long_line_idx, Polylines &output, std::mt19937_64 &rng) const { if (m_children.empty()) { output[long_line_idx].points.push_back(m_p); return; } - size_t first_child_idx = rand() % m_children.size(); - m_children[first_child_idx]->convertToPolylines(long_line_idx, output); + const size_t first_child_idx = rng() % m_children.size(); + m_children[first_child_idx]->convertToPolylines(long_line_idx, output, rng); output[long_line_idx].points.push_back(m_p); for (size_t idx_offset = 1; idx_offset < m_children.size(); idx_offset++) { @@ -371,7 +375,7 @@ void Node::convertToPolylines(size_t long_line_idx, Polylines &output) const const Node& child = *m_children[child_idx]; output.emplace_back(); size_t child_line_idx = output.size() - 1; - child.convertToPolylines(child_line_idx, output); + child.convertToPolylines(child_line_idx, output, rng); output[child_line_idx].points.emplace_back(m_p); } } diff --git a/src/libslic3r/Fill/Lightning/TreeNode.hpp b/src/libslic3r/Fill/Lightning/TreeNode.hpp index 14aa5e4888..95559524ba 100644 --- a/src/libslic3r/Fill/Lightning/TreeNode.hpp +++ b/src/libslic3r/Fill/Lightning/TreeNode.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "../../EdgeGrid.hpp" @@ -259,8 +260,9 @@ protected: * * \param long_line a reference to a polyline in \p output which to continue building on in the recursion * \param output all branches in this tree connected into polylines + * \param rng the generator the junctions draw from, carried through the recursion */ - void convertToPolylines(size_t long_line_idx, Polylines &output) const; + void convertToPolylines(size_t long_line_idx, Polylines &output, std::mt19937_64 &rng) const; void removeJunctionOverlap(Polylines &polylines, coord_t line_overlap) const; diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 21a5000401..d26639c659 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -755,6 +755,9 @@ struct SparseInfillShape { size_t sharp_turns { 0 }; size_t path_count { 0 }; double length { 0. }; + // Digest of every point in the order it is printed. The counts above all survive the same + // extrusions being joined into different polylines, so only this tells two such fills apart. + uint64_t sequence { 14695981039346656037ull }; }; static SparseInfillShape sparse_infill_shape(const Print &print) @@ -767,6 +770,9 @@ static SparseInfillShape sparse_infill_shape(const Print &print) const Points3 &pts = path.polyline.points; ++shape.path_count; shape.point_count += pts.size(); + for (const auto &pt : pts) + for (const coord_t coordinate : {pt.x(), pt.y(), pt.z()}) + shape.sequence = (shape.sequence ^ uint64_t(coordinate)) * 1099511628211ull; for (size_t i = 1; i < pts.size(); ++i) shape.length += (pts[i] - pts[i - 1]).head<2>().cast().norm(); for (size_t i = 1; i + 1 < pts.size(); ++i) { @@ -793,6 +799,33 @@ static SparseInfillShape sparse_infill_shape(const Print &print) return shape; } +TEST_CASE("Lightning infill slices the same model the same way twice", "[Fill][Regression]") +{ + // Slicing twice in one process catches a generator that carries state from one slice to the + // next, or whose result depends on how the parallel layer fill interleaves. + auto shape = [] { + Print print; + Slic3r::Test::init_and_process_print({Slic3r::Test::cube(20)}, print, + {{"sparse_infill_pattern", "lightning"}, + {"sparse_infill_density", "50%"}, + {"layer_height", 0.2}}); + return sparse_infill_shape(print); + }; + + const SparseInfillShape first = shape(); + const SparseInfillShape second = shape(); + + REQUIRE(first.path_count > 0); + REQUIRE(second.path_count == first.path_count); + REQUIRE(second.point_count == first.point_count); + REQUIRE(second.sharp_turns == first.sharp_turns); + // No tolerance: the same extrusions in the same order add up to the very same number. + REQUIRE_THAT(second.length, Catch::Matchers::WithinAbs(first.length, 0.)); + // All of the above agree when the same branches are joined into different polylines, so the + // point sequence is what actually decides whether the two slices produced the same infill. + REQUIRE(second.sequence == first.sequence); +} + TEST_CASE("Lightning infill rounds the turns of its branches with the smooth factor", "[Fill]") { auto shape_for = [](const std::string &smooth_factor) { From 3b4e65d8a9f50617f10b40046cc98c1c5cced5f0 Mon Sep 17 00:00:00 2001 From: Noisyfox Date: Sun, 23 Aug 2026 05:46:01 +0800 Subject: [PATCH 074/138] Fix thin wall fuzzy (#14309) Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> --- src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp | 19 ++++++++++++++----- src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp | 2 +- src/libslic3r/PerimeterGenerator.cpp | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp index 11e2d081d2..97f8f743fb 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp @@ -682,7 +682,7 @@ Polygon apply_fuzzy_skin(const Polygon& polygon, const PerimeterGenerator& perim return fuzzified; } -void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, const bool is_contour) +void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, const bool is_contour, const bool closed) { const auto slice_z = perimeter_generator.slice_z; const auto& regions = perimeter_generator.regions_by_fuzzify; @@ -690,7 +690,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato const auto& config = regions.begin()->first; const bool fuzzify = should_fuzzify(config, perimeter_generator.layer_id, extrusion->inset_idx, is_contour); if (fuzzify) - fuzzy_extrusion_line(extrusion->junctions, slice_z, config); + fuzzy_extrusion_line(extrusion->junctions, slice_z, config, closed); } else { // Merge regions that produce identical fuzzy effects (differ only in type). // When the style (e.g. External) and a painted region (All) both fuzzify this loop @@ -701,10 +701,19 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fast path: single merged region — apply directly without splitting if (merged_regions.size() == 1 && merged_regions.front().expolygons.empty()) { - fuzzy_extrusion_line(extrusion->junctions, slice_z, *merged_regions.front().config); + fuzzy_extrusion_line(extrusion->junctions, slice_z, *merged_regions.front().config, closed); return; } + // Open path means this is a thin wall that collapsed into a single thick line, in this case the path will go exactly + // between the middle two sides of the object. And since the paint segmentation never goes beyond the middle line because + // it uses voronoi diagram, we need to expand the segmentation a little bit to make sure it covers the path. + if (!closed) { + for (auto& r : merged_regions) { + r.expolygons = offset_ex(r.expolygons, perimeter_generator.ext_perimeter_flow.scaled_width() / 10); + } + } + #ifdef DEBUG_FUZZY { int i = 0; @@ -752,7 +761,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fuzzy splitted extrusion if (std::all_of(splitted.begin(), splitted.end(), [](const Algorithm::SplitLineJunction& j) { return j.clipped; })) { // The entire polygon is fuzzified - fuzzy_extrusion_line(extrusion->junctions, slice_z, *r.config); + fuzzy_extrusion_line(extrusion->junctions, slice_z, *r.config, closed); continue; } else { const auto current_ext = extrusion->junctions; @@ -803,7 +812,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato } //Orca: ensure the loop is closed after fuzzy - if (!extrusion->junctions.empty() && extrusion->junctions.front().p != extrusion->junctions.back().p) { + if (closed && !extrusion->junctions.empty() && extrusion->junctions.front().p != extrusion->junctions.back().p) { extrusion->junctions.back().p = extrusion->junctions.front().p; extrusion->junctions.back().w = extrusion->junctions.front().w; } diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp index e099139c90..51d503a3c9 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp @@ -16,7 +16,7 @@ void group_region_by_fuzzify(PerimeterGenerator& g); bool should_fuzzify(const FuzzySkinConfig& config, int layer_id, size_t loop_idx, bool is_contour); Polygon apply_fuzzy_skin(const Polygon& polygon, const PerimeterGenerator& perimeter_generator, size_t loop_idx, bool is_contour); -void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, bool is_contour); +void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, bool is_contour, bool closed = true); } // namespace Slic3r::Feature::FuzzySkin diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 2d6c993d78..659a3a7038 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -229,6 +229,22 @@ static ExtrusionEntityCollection traverse_loops(const PerimeterGenerator &perime // Append thin walls to the nearest-neighbor search (only for first iteration) if (! thin_walls.empty()) { + // Orca: apply fuzzy skin to thin walls as well + for (auto& thin_wall : thin_walls) { + // First, we convert the ThickPolyline into Arachne::ExtrusionLine so we could reuse our existing fuzzy code + Arachne::ExtrusionLine el(0, true); + el.junctions.reserve(thin_wall.points.size()); + for (int i = 0; i < thin_wall.points.size(); i++) { + el.junctions.emplace_back(thin_wall.points[i], thin_wall.width[i], 0); + } + + // Then we fuzzy it + apply_fuzzy_skin(&el, perimeter_generator, true, thin_wall.is_closed()); + + // Then convert the result back to ThickPolyline + thin_wall = Arachne::to_thick_polyline(el); + } + variable_width(thin_walls, erExternalPerimeter, perimeter_generator.ext_perimeter_flow, coll.entities); thin_walls.clear(); } From 550e234a37a408c7f2b8ca6dd3fe6c42f912a0eb Mon Sep 17 00:00:00 2001 From: Anthony Cox Date: Sat, 22 Aug 2026 16:23:59 -0600 Subject: [PATCH 075/138] Newer GCCs are bitching about in-class initialisation. Lets fix that! (#15292) --- src/libslic3r/GCode/ThumbnailData.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/GCode/ThumbnailData.hpp b/src/libslic3r/GCode/ThumbnailData.hpp index 1a41c7486e..82563d64f2 100644 --- a/src/libslic3r/GCode/ThumbnailData.hpp +++ b/src/libslic3r/GCode/ThumbnailData.hpp @@ -32,7 +32,7 @@ using ThumbnailsList = std::vector; struct ThumbnailsParams { - const Vec2ds sizes; + const Vec2ds sizes{}; bool printable_only; bool parts_only; bool show_bed; From 8fea099d99931a475a5baffc28a2c19949fcfa93 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 17:14:07 -0300 Subject: [PATCH 076/138] BBL Port Color Mix Base --- .../standard_color_recipes.json | 14705 ++++++++++++++++ src/libslic3r/CMakeLists.txt | 14 +- src/libslic3r/ColorDecomposeRecipe.cpp | 361 + src/libslic3r/ColorDecomposeRecipe.hpp | 64 + src/libslic3r/FilamentMixer.cpp | 554 + src/libslic3r/FilamentMixer.hpp | 144 + src/libslic3r/FilamentMixerModel.hpp | 819 + src/libslic3r/Format/OBJ.cpp | 150 +- src/libslic3r/Format/OBJ.hpp | 16 +- src/libslic3r/Format/ResourcePathUtils.hpp | 240 + src/libslic3r/Format/objparser.cpp | 1 + src/libslic3r/Format/objparser.hpp | 3 + src/libslic3r/GCode.cpp | 325 +- src/libslic3r/GCode.hpp | 5 + src/libslic3r/GCode/ToolOrdering.cpp | 691 + src/libslic3r/GCode/ToolOrdering.hpp | 82 + src/libslic3r/Layer.cpp | 6 + src/libslic3r/Model.cpp | 42 +- src/libslic3r/Model.hpp | 12 +- src/libslic3r/Preset.cpp | 1 + src/libslic3r/PresetBundle.cpp | 154 +- src/libslic3r/PresetBundle.hpp | 3 + src/libslic3r/Print.cpp | 21 + src/libslic3r/Print.hpp | 17 +- src/libslic3r/PrintApply.cpp | 131 +- src/libslic3r/PrintConfig.cpp | 64 + src/libslic3r/PrintConfig.hpp | 9 + src/libslic3r/TexturePainting.cpp | 663 + src/libslic3r/TexturePainting.hpp | 115 + src/libslic3r/TextureToColor/Callbacks.hpp | 15 + src/libslic3r/TextureToColor/CgalUtils.hpp | 173 + src/libslic3r/TextureToColor/ColorUtils.cpp | 1643 ++ src/libslic3r/TextureToColor/ColorUtils.hpp | 207 + src/libslic3r/TextureToColor/Repair.hpp | 252 + .../TextureToColor/TextureToColor.cpp | 789 + .../TextureToColor/TextureToColor.hpp | 65 + src/libslic3r/TextureToColor/TriMesh.hpp | 28 + src/libslic3r/TriangleSelector.cpp | 14 + src/libslic3r/TriangleSelector.hpp | 3 + src/slic3r/CMakeLists.txt | 10 + src/slic3r/GUI/ColorDecomposeDialog.cpp | 943 + src/slic3r/GUI/ColorDecomposeDialog.hpp | 152 + src/slic3r/GUI/ColorDecomposeSupport.cpp | 386 + src/slic3r/GUI/ColorDecomposeSupport.hpp | 104 + src/slic3r/GUI/ConfigManipulation.cpp | 54 +- src/slic3r/GUI/GLCanvas3D.cpp | 12 + src/slic3r/GUI/GLCanvas3D.hpp | 1 + .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 7 + src/slic3r/GUI/GradientCurveEditor.cpp | 644 + src/slic3r/GUI/GradientCurveEditor.hpp | 115 + src/slic3r/GUI/MainFrame.cpp | 5 + src/slic3r/GUI/MixedFilamentDialog.cpp | 1983 +++ src/slic3r/GUI/MixedFilamentDialog.hpp | 176 + src/slic3r/GUI/NotificationManager.hpp | 2 + src/slic3r/GUI/PartPlate.cpp | 106 + src/slic3r/GUI/PartPlate.hpp | 3 + src/slic3r/GUI/Plater.cpp | 1772 +- src/slic3r/GUI/Plater.hpp | 25 +- src/slic3r/GUI/Tab.cpp | 16 + src/slic3r/GUI/Tab.hpp | 2 + src/slic3r/GUI/TextureImportDialog.cpp | 4333 +++++ src/slic3r/GUI/TextureImportDialog.hpp | 398 + src/slic3r/GUI/Widgets/ComboBox.cpp | 28 +- src/slic3r/GUI/Widgets/ComboBox.hpp | 6 + src/slic3r/GUI/Widgets/DropDown.cpp | 5 +- src/slic3r/GUI/Widgets/DropDown.hpp | 1 + src/slic3r/GUI/Widgets/SpinInput.cpp | 16 + src/slic3r/GUI/Widgets/SpinInput.hpp | 5 + src/slic3r/GUI/Widgets/TextInput.cpp | 9 + src/slic3r/GUI/Widgets/TextInput.hpp | 1 + src/slic3r/GUI/WipeTowerDialog.cpp | 74 +- src/slic3r/GUI/WipeTowerDialog.hpp | 4 + tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_filament_mixer.cpp | 182 + .../libslic3r/test_preset_bundle_loading.cpp | 43 + 75 files changed, 34174 insertions(+), 51 deletions(-) create mode 100644 resources/filament_mixing/standard_color_recipes.json create mode 100644 src/libslic3r/ColorDecomposeRecipe.cpp create mode 100644 src/libslic3r/ColorDecomposeRecipe.hpp create mode 100644 src/libslic3r/FilamentMixer.cpp create mode 100644 src/libslic3r/FilamentMixer.hpp create mode 100644 src/libslic3r/FilamentMixerModel.hpp create mode 100644 src/libslic3r/Format/ResourcePathUtils.hpp create mode 100644 src/libslic3r/TexturePainting.cpp create mode 100644 src/libslic3r/TexturePainting.hpp create mode 100644 src/libslic3r/TextureToColor/Callbacks.hpp create mode 100644 src/libslic3r/TextureToColor/CgalUtils.hpp create mode 100644 src/libslic3r/TextureToColor/ColorUtils.cpp create mode 100644 src/libslic3r/TextureToColor/ColorUtils.hpp create mode 100644 src/libslic3r/TextureToColor/Repair.hpp create mode 100644 src/libslic3r/TextureToColor/TextureToColor.cpp create mode 100644 src/libslic3r/TextureToColor/TextureToColor.hpp create mode 100644 src/libslic3r/TextureToColor/TriMesh.hpp create mode 100644 src/slic3r/GUI/ColorDecomposeDialog.cpp create mode 100644 src/slic3r/GUI/ColorDecomposeDialog.hpp create mode 100644 src/slic3r/GUI/ColorDecomposeSupport.cpp create mode 100644 src/slic3r/GUI/ColorDecomposeSupport.hpp create mode 100644 src/slic3r/GUI/GradientCurveEditor.cpp create mode 100644 src/slic3r/GUI/GradientCurveEditor.hpp create mode 100644 src/slic3r/GUI/MixedFilamentDialog.cpp create mode 100644 src/slic3r/GUI/MixedFilamentDialog.hpp create mode 100644 src/slic3r/GUI/TextureImportDialog.cpp create mode 100644 src/slic3r/GUI/TextureImportDialog.hpp create mode 100644 tests/libslic3r/test_filament_mixer.cpp diff --git a/resources/filament_mixing/standard_color_recipes.json b/resources/filament_mixing/standard_color_recipes.json new file mode 100644 index 0000000000..280be054b2 --- /dev/null +++ b/resources/filament_mixing/standard_color_recipes.json @@ -0,0 +1,14705 @@ +{ + "_comment": "Simulated values (source=filament_mixer) are generated by FilamentMixer, a degree-4 polynomial regression trained to approximate Mixbox behavior (Mean Delta-E ~2.07). This file does not use Mixbox source code, binaries, or data files. See src/libslic3r/FilamentMixerModel.hpp.", + "entries": [ + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 48.12, + 36.72, + -25.69 + ], + "measured_rgb": "#9A5B9E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 48.038, + 34.252, + -26.762 + ], + "measured_rgb": "#955DA0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 47.957, + 31.783, + -27.833 + ], + "measured_rgb": "#905FA1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 47.875, + 29.315, + -28.905 + ], + "measured_rgb": "#8B61A3", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 47.793, + 26.847, + -29.977 + ], + "measured_rgb": "#8563A4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 47.712, + 24.378, + -31.048 + ], + "measured_rgb": "#8065A6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 47.63, + 21.91, + -32.12 + ], + "measured_rgb": "#7967A7", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 48.247, + 19.212, + -32.842 + ], + "measured_rgb": "#756AAA", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 48.863, + 16.513, + -33.563 + ], + "measured_rgb": "#706DAD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 49.48, + 13.815, + -34.285 + ], + "measured_rgb": "#6B71B0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 50.097, + 11.117, + -35.007 + ], + "measured_rgb": "#6574B3", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 50.713, + 8.418, + -35.728 + ], + "measured_rgb": "#5F77B6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 51.33, + 5.72, + -36.45 + ], + "measured_rgb": "#587AB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 72.05, + -32.59, + 59.71 + ], + "measured_rgb": "#96BE39", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 70.497, + -34.185, + 54.833 + ], + "measured_rgb": "#8CBB41", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 68.943, + -35.78, + 49.957 + ], + "measured_rgb": "#82B748", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 67.39, + -37.375, + 45.08 + ], + "measured_rgb": "#78B44E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 65.837, + -38.97, + 40.203 + ], + "measured_rgb": "#6DB054", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 64.283, + -40.565, + 35.327 + ], + "measured_rgb": "#61AD5A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 62.73, + -42.16, + 30.45 + ], + "measured_rgb": "#53AA5F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 62.037, + -42.202, + 26.398 + ], + "measured_rgb": "#4DA865", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 61.343, + -42.243, + 22.347 + ], + "measured_rgb": "#45A66B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 60.65, + -42.285, + 18.295 + ], + "measured_rgb": "#3CA471", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 59.957, + -42.327, + 14.243 + ], + "measured_rgb": "#32A376", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 59.263, + -42.368, + 10.192 + ], + "measured_rgb": "#23A17B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 58.57, + -42.41, + 6.14 + ], + "measured_rgb": "#089F81", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 76.96, + -13.8, + -20.22 + ], + "measured_rgb": "#86C7E3", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 75.9, + -13.61, + -21.202 + ], + "measured_rgb": "#81C4E1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 74.84, + -13.42, + -22.183 + ], + "measured_rgb": "#7DC1E0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 73.78, + -13.23, + -23.165 + ], + "measured_rgb": "#79BEDF", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 72.72, + -13.04, + -24.147 + ], + "measured_rgb": "#75BBDE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 71.66, + -12.85, + -25.128 + ], + "measured_rgb": "#70B8DD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 70.6, + -12.66, + -26.11 + ], + "measured_rgb": "#6CB6DB", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 67.377, + -9.625, + -27.845 + ], + "measured_rgb": "#69ABD6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 64.153, + -6.59, + -29.58 + ], + "measured_rgb": "#65A1D0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 60.93, + -3.555, + -31.315 + ], + "measured_rgb": "#6298CA", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 57.707, + -0.52, + -33.05 + ], + "measured_rgb": "#5F8EC4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 54.483, + 2.515, + -34.785 + ], + "measured_rgb": "#5B84BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 51.26, + 5.55, + -36.52 + ], + "measured_rgb": "#577AB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 68.59, + 15.52, + 56.99 + ], + "measured_rgb": "#DB9B3C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 66.767, + 18.537, + 52.082 + ], + "measured_rgb": "#D99443", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 64.943, + 21.553, + 47.173 + ], + "measured_rgb": "#D78D48", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 63.12, + 24.57, + 42.265 + ], + "measured_rgb": "#D4864E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 61.297, + 27.587, + 37.357 + ], + "measured_rgb": "#D28053", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 59.473, + 30.603, + 32.448 + ], + "measured_rgb": "#CF7958", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 57.65, + 33.62, + 27.54 + ], + "measured_rgb": "#CC725C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 57.01, + 35.452, + 24.22 + ], + "measured_rgb": "#CC6E61", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 56.37, + 37.283, + 20.9 + ], + "measured_rgb": "#CB6B65", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 55.73, + 39.115, + 17.58 + ], + "measured_rgb": "#CB6869", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 55.09, + 40.947, + 14.26 + ], + "measured_rgb": "#CA656D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 54.45, + 42.778, + 10.94 + ], + "measured_rgb": "#CA6271", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 53.81, + 44.61, + 7.62 + ], + "measured_rgb": "#C95E75", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 72.63, + 29.26, + -12.43 + ], + "measured_rgb": "#DDA0CA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 71.607, + 29.98, + -12.407 + ], + "measured_rgb": "#DB9CC7", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 70.583, + 30.7, + -12.383 + ], + "measured_rgb": "#DA99C4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 69.56, + 31.42, + -12.36 + ], + "measured_rgb": "#D896C1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 68.537, + 32.14, + -12.337 + ], + "measured_rgb": "#D692BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 67.513, + 32.86, + -12.313 + ], + "measured_rgb": "#D48FBB", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 66.49, + 33.58, + -12.29 + ], + "measured_rgb": "#D38CB9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 64.777, + 36.348, + -12.727 + ], + "measured_rgb": "#D285B5", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 63.063, + 39.117, + -13.163 + ], + "measured_rgb": "#D17EB1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 61.35, + 41.885, + -13.6 + ], + "measured_rgb": "#D077AD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 59.637, + 44.653, + -14.037 + ], + "measured_rgb": "#CF70A9", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 57.923, + 47.422, + -14.473 + ], + "measured_rgb": "#CE69A6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 56.21, + 50.19, + -14.91 + ], + "measured_rgb": "#CD61A2", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 89.77, + -11.57, + 41.5 + ], + "measured_rgb": "#E8E691", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 89.41, + -11.307, + 43.647 + ], + "measured_rgb": "#E8E58C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 89.05, + -11.043, + 45.793 + ], + "measured_rgb": "#E9E487", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 88.69, + -10.78, + 47.94 + ], + "measured_rgb": "#E9E281", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 88.33, + -10.517, + 50.087 + ], + "measured_rgb": "#EAE17C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 87.97, + -10.253, + 52.233 + ], + "measured_rgb": "#EAE077", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 87.61, + -9.99, + 54.38 + ], + "measured_rgb": "#EADF71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 87.398, + -9.86, + 57.192 + ], + "measured_rgb": "#EBDE6B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 87.187, + -9.73, + 60.003 + ], + "measured_rgb": "#ECDD64", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 86.975, + -9.6, + 62.815 + ], + "measured_rgb": "#ECDC5D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 86.763, + -9.47, + 65.627 + ], + "measured_rgb": "#EDDB56", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 86.552, + -9.34, + 68.438 + ], + "measured_rgb": "#EDDB4E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 86.34, + -9.21, + 71.25 + ], + "measured_rgb": "#EDDA46", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 58.92, + -7.07, + 33.53 + ], + "measured_rgb": "#969052", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 56.835, + -4.933, + 27.242 + ], + "measured_rgb": "#918A59", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 55.45, + -1.15, + 24.56 + ], + "measured_rgb": "#92845A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 54.072, + 1.273, + 19.464 + ], + "measured_rgb": "#907F60", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 53.38, + 4.68, + 18.05 + ], + "measured_rgb": "#937C61", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 52.131, + 7.12, + 11.6 + ], + "measured_rgb": "#907869", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 51.41, + 10.52, + 8.37 + ], + "measured_rgb": "#92746D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 50.768, + 12.107, + 5.298 + ], + "measured_rgb": "#917170", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 49.97, + 15.33, + 2.21 + ], + "measured_rgb": "#926E74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 57.345, + -9.33, + 27.302 + ], + "measured_rgb": "#8B8D59", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 56.135, + -6.58, + 23.635 + ], + "measured_rgb": "#8A895D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 54.463, + -2.851, + 18.678 + ], + "measured_rgb": "#8A8362", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 53.385, + 0.29, + 15.782 + ], + "measured_rgb": "#8A7E65", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 52.613, + 3.31, + 13.936 + ], + "measured_rgb": "#8C7B66", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 51.603, + 6.16, + 8.38 + ], + "measured_rgb": "#8B776D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 51.038, + 8.243, + 5.013 + ], + "measured_rgb": "#8B7571", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 50.674, + 10.886, + 3.9 + ], + "measured_rgb": "#8E7272", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 56.98, + -14.34, + 24.74 + ], + "measured_rgb": "#7F8F5D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 55.123, + -8.998, + 18.633 + ], + "measured_rgb": "#818863", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 53.19, + -3.76, + 11.71 + ], + "measured_rgb": "#81806B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 52.698, + -0.693, + 12.101 + ], + "measured_rgb": "#857D69", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 51.52, + 1.39, + 8.81 + ], + "measured_rgb": "#83796C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 51.074, + 5.2, + 5.16 + ], + "measured_rgb": "#867671", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 50.1, + 8.05, + -1.71 + ], + "measured_rgb": "#84737A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 55.514, + -13.663, + 18.858 + ], + "measured_rgb": "#798B64", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 54.392, + -10.32, + 15.045 + ], + "measured_rgb": "#7A8768", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 52.967, + -5.5, + 10.567 + ], + "measured_rgb": "#7C816C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 51.84, + -2.31, + 6.725 + ], + "measured_rgb": "#7C7C70", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 51.083, + 0.928, + 4.271 + ], + "measured_rgb": "#7E7972", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 50.849, + 3.236, + 3.172 + ], + "measured_rgb": "#817774", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 55.17, + -16.33, + 16.79 + ], + "measured_rgb": "#728B66", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 53.931, + -11.167, + 12.925 + ], + "measured_rgb": "#76866A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 52.23, + -6.85, + 6.94 + ], + "measured_rgb": "#758071", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 51.497, + -3.06, + 4.368 + ], + "measured_rgb": "#797C73", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 50.42, + -0.02, + -0.56 + ], + "measured_rgb": "#777879", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 54.193, + -15.753, + 11.043 + ], + "measured_rgb": "#6C896E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 53.26, + -12.21, + 7.178 + ], + "measured_rgb": "#6E8573", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 52.327, + -8.667, + 3.312 + ], + "measured_rgb": "#6F8177", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 51.389, + -4.229, + 1.238 + ], + "measured_rgb": "#747D78", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 54.15, + -18.72, + 9.16 + ], + "measured_rgb": "#648A71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 52.967, + -12.623, + 4.053 + ], + "measured_rgb": "#698577", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 51.49, + -6.94, + -4.18 + ], + "measured_rgb": "#697F82", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 53.675, + -16.828, + 2.661 + ], + "measured_rgb": "#61887B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 53.17, + -14.343, + 1.343 + ], + "measured_rgb": "#63867C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 53.47, + -16.97, + -4.04 + ], + "measured_rgb": "#578886", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 60.62, + 16.41, + -27.19 + ], + "measured_rgb": "#978BC2", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 58.416, + 18.423, + -27.936 + ], + "measured_rgb": "#9484BD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 56.19, + 22.63, + -28.47 + ], + "measured_rgb": "#967BB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 55.817, + 22.511, + -27.942 + ], + "measured_rgb": "#957AB6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 56.48, + 23.36, + -26.2 + ], + "measured_rgb": "#9A7BB5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 54.683, + 24.228, + -27.087 + ], + "measured_rgb": "#9676B2", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 53.86, + 25.93, + -26.83 + ], + "measured_rgb": "#9773AF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 52.924, + 27.03, + -27.166 + ], + "measured_rgb": "#966FAD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 51.71, + 29.47, + -27.22 + ], + "measured_rgb": "#976AAA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 60.566, + 13.416, + -27.029 + ], + "measured_rgb": "#918CC2", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 58.437, + 16.227, + -28.148 + ], + "measured_rgb": "#9085BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 55.09, + 20.811, + -29.603 + ], + "measured_rgb": "#8E7AB7", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 54.78, + 21.542, + -29.157 + ], + "measured_rgb": "#8F78B6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 54.433, + 22.913, + -28.35 + ], + "measured_rgb": "#9276B3", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 53.707, + 23.395, + -28.23 + ], + "measured_rgb": "#9174B1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 53.303, + 23.898, + -28.057 + ], + "measured_rgb": "#9173B0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 52.606, + 25.673, + -27.911 + ], + "measured_rgb": "#9270AE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 62.64, + 7.61, + -25.75 + ], + "measured_rgb": "#8C95C5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 57.776, + 12.715, + -29.283 + ], + "measured_rgb": "#8586BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 54.3, + 18.26, + -31.18 + ], + "measured_rgb": "#857AB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 53.449, + 19.947, + -30.78 + ], + "measured_rgb": "#8776B5", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 52.15, + 21.92, + -30.78 + ], + "measured_rgb": "#8772B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 52.733, + 22.562, + -29.373 + ], + "measured_rgb": "#8B72B0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 52.34, + 22.37, + -29.11 + ], + "measured_rgb": "#8A72AF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 58.326, + 9.338, + -30.016 + ], + "measured_rgb": "#7E89C1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 56.387, + 12.275, + -30.918 + ], + "measured_rgb": "#7F83BD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.575, + 16.404, + -32.238 + ], + "measured_rgb": "#7E79B8", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 52.46, + 18.41, + -32.043 + ], + "measured_rgb": "#7F75B4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 51.78, + 19.563, + -31.891 + ], + "measured_rgb": "#8072B2", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 52.036, + 21.038, + -30.619 + ], + "measured_rgb": "#8572B1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 55.95, + 8.13, + -33.38 + ], + "measured_rgb": "#7084C0", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 54.999, + 11.835, + -32.553 + ], + "measured_rgb": "#7880BC", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 52.66, + 15.1, + -33.36 + ], + "measured_rgb": "#7778B7", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 51.95, + 17.29, + -32.751 + ], + "measured_rgb": "#7B74B4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 50.73, + 18.36, + -32.85 + ], + "measured_rgb": "#7A71B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 56.176, + 7.293, + -32.738 + ], + "measured_rgb": "#7085BF", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 54.497, + 10.53, + -33.105 + ], + "measured_rgb": "#727FBB", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 52.207, + 15.028, + -33.565 + ], + "measured_rgb": "#7677B6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 51.837, + 15.861, + -33.386 + ], + "measured_rgb": "#7775B5", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 58.08, + 3.22, + -31.73 + ], + "measured_rgb": "#6C8CC3", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 54.626, + 9.807, + -32.928 + ], + "measured_rgb": "#7180BB", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 51.3, + 15.67, + -33.95 + ], + "measured_rgb": "#7474B4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 56.067, + 5.637, + -32.746 + ], + "measured_rgb": "#6B86BF", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 54.872, + 8.214, + -33.064 + ], + "measured_rgb": "#6E82BC", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 55.02, + 5.77, + -33.45 + ], + "measured_rgb": "#6883BD", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 72.25, + -37.06, + 24.67 + ], + "measured_rgb": "#76C283", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 71.136, + -38.009, + 27.818 + ], + "measured_rgb": "#73BF7A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 71.21, + -38.72, + 34.12 + ], + "measured_rgb": "#77BF6E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 70.097, + -39.222, + 34.128 + ], + "measured_rgb": "#73BD6B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 69.96, + -39.61, + 37.44 + ], + "measured_rgb": "#74BC64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 69.533, + -39.725, + 38.622 + ], + "measured_rgb": "#74BB61", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 70.07, + -39.32, + 41.87 + ], + "measured_rgb": "#78BC5C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 69.807, + -38.943, + 42.092 + ], + "measured_rgb": "#79BB5A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 70.1, + -37.86, + 43.47 + ], + "measured_rgb": "#7DBC58", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 70.176, + -37.819, + 21.824 + ], + "measured_rgb": "#6BBD82", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 69.947, + -38.248, + 24.663 + ], + "measured_rgb": "#6CBC7D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 69.443, + -39.038, + 29.554 + ], + "measured_rgb": "#6DBB72", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 69.12, + -39.335, + 30.823 + ], + "measured_rgb": "#6DBA6F", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 68.702, + -39.682, + 32.737 + ], + "measured_rgb": "#6DB96A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 68.57, + -40.245, + 36.555 + ], + "measured_rgb": "#6EB962", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 68.759, + -40.381, + 40.397 + ], + "measured_rgb": "#71B95B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 69.094, + -39.751, + 41.165 + ], + "measured_rgb": "#74BA5B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 68.33, + -38.15, + 16.14 + ], + "measured_rgb": "#5EB888", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 68.196, + -38.822, + 20.824 + ], + "measured_rgb": "#61B87F", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 68.0, + -39.06, + 23.72 + ], + "measured_rgb": "#64B779", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 67.702, + -39.731, + 26.834 + ], + "measured_rgb": "#64B673", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 67.31, + -39.95, + 28.01 + ], + "measured_rgb": "#64B570", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 67.165, + -41.047, + 33.805 + ], + "measured_rgb": "#66B564", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 66.94, + -42.1, + 38.9 + ], + "measured_rgb": "#67B559", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 66.828, + -39.823, + 17.494 + ], + "measured_rgb": "#56B482", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 66.665, + -40.218, + 19.873 + ], + "measured_rgb": "#58B47D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 66.196, + -41.017, + 23.343 + ], + "measured_rgb": "#58B375", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 66.203, + -41.143, + 26.033 + ], + "measured_rgb": "#5BB370", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 66.233, + -41.326, + 29.073 + ], + "measured_rgb": "#5EB36B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 66.539, + -41.536, + 32.664 + ], + "measured_rgb": "#62B465", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 65.49, + -41.1, + 16.47 + ], + "measured_rgb": "#4CB180", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 65.258, + -41.626, + 19.645 + ], + "measured_rgb": "#4FB17A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 64.84, + -42.56, + 23.16 + ], + "measured_rgb": "#4FB072", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 64.848, + -42.5, + 25.195 + ], + "measured_rgb": "#52B06E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 64.66, + -43.0, + 29.24 + ], + "measured_rgb": "#55AF66", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 64.347, + -41.829, + 15.957 + ], + "measured_rgb": "#45AE7E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 64.113, + -42.238, + 17.53 + ], + "measured_rgb": "#46AE7B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 63.979, + -42.717, + 20.384 + ], + "measured_rgb": "#48AE75", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 64.149, + -42.788, + 22.598 + ], + "measured_rgb": "#4CAE71", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 63.44, + -42.15, + 13.87 + ], + "measured_rgb": "#3DAC80", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 63.204, + -42.248, + 14.638 + ], + "measured_rgb": "#3DAC7E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 62.68, + -43.14, + 16.62 + ], + "measured_rgb": "#3CAA79", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 63.005, + -41.008, + 11.148 + ], + "measured_rgb": "#3BAB83", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 62.993, + -41.544, + 12.664 + ], + "measured_rgb": "#3CAB81", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 62.36, + -39.43, + 6.74 + ], + "measured_rgb": "#36A98A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 70.73, + 16.88, + 29.74 + ], + "measured_rgb": "#DBA178", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 69.381, + 17.858, + 34.612 + ], + "measured_rgb": "#DB9C6B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 69.48, + 16.27, + 40.49 + ], + "measured_rgb": "#DB9D60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 68.666, + 17.873, + 45.073 + ], + "measured_rgb": "#DC9A56", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 69.27, + 16.88, + 51.38 + ], + "measured_rgb": "#DE9C4A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 68.885, + 17.481, + 51.935 + ], + "measured_rgb": "#DE9A48", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 69.9, + 15.64, + 54.97 + ], + "measured_rgb": "#DF9E44", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 68.967, + 17.607, + 54.28 + ], + "measured_rgb": "#DF9A43", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 68.51, + 18.92, + 54.92 + ], + "measured_rgb": "#DF9841", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 68.098, + 21.004, + 29.415 + ], + "measured_rgb": "#DA9772", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 67.933, + 20.422, + 33.605 + ], + "measured_rgb": "#DA966A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 68.071, + 19.105, + 40.002 + ], + "measured_rgb": "#DA975E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 67.248, + 20.468, + 43.348 + ], + "measured_rgb": "#DB9456", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 66.701, + 21.497, + 46.382 + ], + "measured_rgb": "#DC924E", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 67.485, + 19.923, + 49.455 + ], + "measured_rgb": "#DD954A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 68.282, + 18.367, + 52.279 + ], + "measured_rgb": "#DD9846", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 68.339, + 18.505, + 52.939 + ], + "measured_rgb": "#DE9845", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 65.63, + 25.71, + 24.9 + ], + "measured_rgb": "#D88D74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 65.949, + 23.927, + 32.271 + ], + "measured_rgb": "#D98F68", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 65.89, + 22.83, + 39.29 + ], + "measured_rgb": "#D98F5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 65.294, + 24.002, + 41.295 + ], + "measured_rgb": "#DA8D55", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 64.35, + 25.89, + 42.23 + ], + "measured_rgb": "#DA8951", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 66.085, + 22.364, + 46.975 + ], + "measured_rgb": "#DB904C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 66.42, + 21.28, + 49.24 + ], + "measured_rgb": "#DB9148", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 62.967, + 30.239, + 26.0 + ], + "measured_rgb": "#D7826C", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 63.57, + 28.218, + 30.77 + ], + "measured_rgb": "#D78565", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 63.934, + 26.355, + 36.534 + ], + "measured_rgb": "#D8875B", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 64.015, + 25.97, + 38.727 + ], + "measured_rgb": "#D88857", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 63.753, + 26.364, + 40.092 + ], + "measured_rgb": "#D88754", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 64.808, + 24.427, + 43.305 + ], + "measured_rgb": "#D98B50", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 59.7, + 36.79, + 22.33 + ], + "measured_rgb": "#D5746B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 61.215, + 32.341, + 28.641 + ], + "measured_rgb": "#D67C63", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 63.06, + 27.54, + 36.56 + ], + "measured_rgb": "#D78459", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 62.983, + 27.508, + 36.188 + ], + "measured_rgb": "#D68459", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 62.76, + 27.62, + 36.83 + ], + "measured_rgb": "#D68358", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 60.188, + 34.818, + 23.527 + ], + "measured_rgb": "#D4776A", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 60.885, + 32.692, + 27.032 + ], + "measured_rgb": "#D57B65", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 61.837, + 29.803, + 31.746 + ], + "measured_rgb": "#D57F5F", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 62.068, + 29.258, + 33.017 + ], + "measured_rgb": "#D5805D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 59.98, + 34.97, + 21.22 + ], + "measured_rgb": "#D3776D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 60.398, + 33.292, + 25.022 + ], + "measured_rgb": "#D37967", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 60.8, + 31.47, + 28.02 + ], + "measured_rgb": "#D37C63", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 59.099, + 36.827, + 20.252 + ], + "measured_rgb": "#D3736D", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 59.751, + 34.909, + 23.141 + ], + "measured_rgb": "#D37669", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 57.81, + 39.76, + 17.5 + ], + "measured_rgb": "#D26D6E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 62.49, + 27.88, + 52.76 + ], + "measured_rgb": "#D98237", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 60.433, + 30.793, + 50.433 + ], + "measured_rgb": "#D67A38", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 58.377, + 33.707, + 48.107 + ], + "measured_rgb": "#D47238", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 56.32, + 36.62, + 45.78 + ], + "measured_rgb": "#D16B38", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.263, + 39.533, + 43.453 + ], + "measured_rgb": "#CE6338", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 52.207, + 42.447, + 41.127 + ], + "measured_rgb": "#CB5A38", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 50.15, + 45.36, + 38.8 + ], + "measured_rgb": "#C85237", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 49.213, + 46.43, + 38.045 + ], + "measured_rgb": "#C64E37", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 48.277, + 47.5, + 37.29 + ], + "measured_rgb": "#C44B36", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 47.34, + 48.57, + 36.535 + ], + "measured_rgb": "#C34735", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 46.403, + 49.64, + 35.78 + ], + "measured_rgb": "#C14335", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 45.467, + 50.71, + 35.025 + ], + "measured_rgb": "#BF3F34", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 44.53, + 51.78, + 34.27 + ], + "measured_rgb": "#BD3B33", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 29.46, + 3.51, + -19.32 + ], + "measured_rgb": "#384563", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 29.34, + 4.79, + -16.368 + ], + "measured_rgb": "#3E445E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 29.22, + 6.07, + -13.417 + ], + "measured_rgb": "#44435A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 29.1, + 7.35, + -10.465 + ], + "measured_rgb": "#484155", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 28.98, + 8.63, + -7.513 + ], + "measured_rgb": "#4D4050", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 28.86, + 9.91, + -4.562 + ], + "measured_rgb": "#503F4B", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 28.74, + 11.19, + -1.61 + ], + "measured_rgb": "#543E47", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 29.12, + 13.203, + 0.292 + ], + "measured_rgb": "#583D45", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 29.5, + 15.217, + 2.193 + ], + "measured_rgb": "#5D3D43", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 29.88, + 17.23, + 4.095 + ], + "measured_rgb": "#623C41", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 30.26, + 19.243, + 5.997 + ], + "measured_rgb": "#663B3F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 30.64, + 21.257, + 7.898 + ], + "measured_rgb": "#6A3B3D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 31.02, + 23.27, + 9.8 + ], + "measured_rgb": "#6E3A3B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 60.85, + 40.5, + 16.46 + ], + "measured_rgb": "#DC7478", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 59.283, + 41.47, + 17.302 + ], + "measured_rgb": "#D96F72", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 57.717, + 42.44, + 18.143 + ], + "measured_rgb": "#D66A6D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 56.15, + 43.41, + 18.985 + ], + "measured_rgb": "#D26568", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.583, + 44.38, + 19.827 + ], + "measured_rgb": "#CF6063", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 53.017, + 45.35, + 20.668 + ], + "measured_rgb": "#CC5B5E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 51.45, + 46.32, + 21.51 + ], + "measured_rgb": "#C95558", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 50.295, + 46.967, + 23.548 + ], + "measured_rgb": "#C75152", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 49.14, + 47.613, + 25.587 + ], + "measured_rgb": "#C54D4C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 47.985, + 48.26, + 27.625 + ], + "measured_rgb": "#C24946", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 46.83, + 48.907, + 29.663 + ], + "measured_rgb": "#C04540", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 45.675, + 49.553, + 31.702 + ], + "measured_rgb": "#BE413A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 44.52, + 50.2, + 33.74 + ], + "measured_rgb": "#BB3D34", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 36.34, + -18.37, + -11.18 + ], + "measured_rgb": "#155E67", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 37.912, + -19.985, + -6.787 + ], + "measured_rgb": "#206264", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 39.483, + -21.6, + -2.393 + ], + "measured_rgb": "#286760", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 41.055, + -23.215, + 2.0 + ], + "measured_rgb": "#2F6B5D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 42.627, + -24.83, + 6.393 + ], + "measured_rgb": "#356F59", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 44.198, + -26.445, + 10.787 + ], + "measured_rgb": "#3A7456", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 45.77, + -28.06, + 15.18 + ], + "measured_rgb": "#3F7852", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 48.035, + -28.535, + 19.455 + ], + "measured_rgb": "#477E50", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 50.3, + -29.01, + 23.73 + ], + "measured_rgb": "#50844E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 52.565, + -29.485, + 28.005 + ], + "measured_rgb": "#588A4C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 54.83, + -29.96, + 32.28 + ], + "measured_rgb": "#5F9049", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 57.095, + -30.435, + 36.555 + ], + "measured_rgb": "#679646", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 59.36, + -30.91, + 40.83 + ], + "measured_rgb": "#6E9C43", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 88.94, + -14.1, + 49.31 + ], + "measured_rgb": "#E5E57F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 88.612, + -13.948, + 52.973 + ], + "measured_rgb": "#E6E477", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 88.283, + -13.797, + 56.637 + ], + "measured_rgb": "#E6E26E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 87.955, + -13.645, + 60.3 + ], + "measured_rgb": "#E7E165", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 87.627, + -13.493, + 63.963 + ], + "measured_rgb": "#E8E05C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 87.298, + -13.342, + 67.627 + ], + "measured_rgb": "#E8DF52", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 86.97, + -13.19, + 71.29 + ], + "measured_rgb": "#E9DE47", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 86.717, + -12.727, + 71.763 + ], + "measured_rgb": "#E9DD45", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 86.463, + -12.263, + 72.237 + ], + "measured_rgb": "#E9DC43", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 86.21, + -11.8, + 72.71 + ], + "measured_rgb": "#E9DB41", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 85.957, + -11.337, + 73.183 + ], + "measured_rgb": "#E9DA3F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 85.703, + -10.873, + 73.657 + ], + "measured_rgb": "#E9D93D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 85.45, + -10.41, + 74.13 + ], + "measured_rgb": "#EAD83B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 64.3, + -4.33, + -32.03 + ], + "measured_rgb": "#68A1D5", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 61.86, + -3.247, + -33.64 + ], + "measured_rgb": "#619AD1", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 59.42, + -2.163, + -35.25 + ], + "measured_rgb": "#5993CD", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 56.98, + -1.08, + -36.86 + ], + "measured_rgb": "#528DC9", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.54, + 0.003, + -38.47 + ], + "measured_rgb": "#4A86C5", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 52.1, + 1.087, + -40.08 + ], + "measured_rgb": "#427FC1", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 49.66, + 2.17, + -41.69 + ], + "measured_rgb": "#3979BD", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 47.967, + 3.17, + -42.525 + ], + "measured_rgb": "#3474BA", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 46.273, + 4.17, + -43.36 + ], + "measured_rgb": "#2F6FB6", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 44.58, + 5.17, + -44.195 + ], + "measured_rgb": "#2A6BB3", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 42.887, + 6.17, + -45.03 + ], + "measured_rgb": "#2566B0", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 41.193, + 7.17, + -45.865 + ], + "measured_rgb": "#1F62AD", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 39.5, + 8.17, + -46.7 + ], + "measured_rgb": "#175DAA", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 31.91, + 0.15, + -2.61 + ], + "measured_rgb": "#494B4F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 33.44, + 0.855, + 2.003 + ], + "measured_rgb": "#514E4C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 35.38, + -0.41, + 5.94 + ], + "measured_rgb": "#57534A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 35.64, + 1.093, + 7.858 + ], + "measured_rgb": "#5B5347", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 36.29, + 0.41, + 9.25 + ], + "measured_rgb": "#5C5547", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 37.468, + 2.187, + 12.217 + ], + "measured_rgb": "#635645", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 38.64, + 1.69, + 14.18 + ], + "measured_rgb": "#665944", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 40.133, + 3.466, + 17.156 + ], + "measured_rgb": "#6E5C43", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 42.17, + 4.75, + 20.85 + ], + "measured_rgb": "#776041", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 31.95, + 3.178, + 0.199 + ], + "measured_rgb": "#504A4B", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 33.03, + 2.825, + 2.678 + ], + "measured_rgb": "#544C4A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 34.485, + 2.602, + 6.069 + ], + "measured_rgb": "#594F48", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 35.25, + 3.28, + 8.383 + ], + "measured_rgb": "#5D5146", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 36.043, + 3.661, + 10.318 + ], + "measured_rgb": "#615244", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 37.473, + 4.46, + 13.22 + ], + "measured_rgb": "#675543", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 39.044, + 4.952, + 16.087 + ], + "measured_rgb": "#6D5842", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 39.826, + 4.901, + 17.277 + ], + "measured_rgb": "#6F5A42", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 30.91, + 6.56, + 0.53 + ], + "measured_rgb": "#534548", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 32.258, + 5.695, + 3.2 + ], + "measured_rgb": "#574947", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 33.92, + 5.0, + 6.85 + ], + "measured_rgb": "#5C4D45", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 34.678, + 6.296, + 8.956 + ], + "measured_rgb": "#614E44", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 35.41, + 8.12, + 11.49 + ], + "measured_rgb": "#664E41", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 37.46, + 7.938, + 14.712 + ], + "measured_rgb": "#6D5341", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 39.55, + 7.62, + 17.96 + ], + "measured_rgb": "#735840", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 31.482, + 8.343, + 3.323 + ], + "measured_rgb": "#594545", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 32.495, + 8.078, + 5.33 + ], + "measured_rgb": "#5C4844", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 33.939, + 8.505, + 8.552 + ], + "measured_rgb": "#624B43", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 34.88, + 9.587, + 10.82 + ], + "measured_rgb": "#674C41", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 35.457, + 10.859, + 12.473 + ], + "measured_rgb": "#6B4D40", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 37.115, + 9.671, + 14.811 + ], + "measured_rgb": "#6E5140", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 31.04, + 10.39, + 4.11 + ], + "measured_rgb": "#5B4343", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 32.583, + 10.243, + 6.938 + ], + "measured_rgb": "#604742", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 34.11, + 10.36, + 9.83 + ], + "measured_rgb": "#654A41", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 35.023, + 11.606, + 11.92 + ], + "measured_rgb": "#6A4B40", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 36.08, + 14.87, + 15.11 + ], + "measured_rgb": "#734B3D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 31.752, + 12.508, + 6.458 + ], + "measured_rgb": "#614341", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 32.888, + 12.963, + 8.565 + ], + "measured_rgb": "#654640", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 34.023, + 13.418, + 10.672 + ], + "measured_rgb": "#6A4840", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 34.834, + 13.824, + 12.307 + ], + "measured_rgb": "#6D493F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 31.33, + 14.17, + 6.7 + ], + "measured_rgb": "#624140", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 33.096, + 14.688, + 9.628 + ], + "measured_rgb": "#69453F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 35.07, + 16.93, + 13.62 + ], + "measured_rgb": "#72483E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 32.354, + 16.62, + 9.312 + ], + "measured_rgb": "#69423E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 32.996, + 16.524, + 10.244 + ], + "measured_rgb": "#6B433E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 32.98, + 19.7, + 11.64 + ], + "measured_rgb": "#70413C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 62.23, + 35.29, + 25.26 + ], + "measured_rgb": "#DC7C6C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 61.534, + 35.748, + 26.888 + ], + "measured_rgb": "#DB7A67", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 62.26, + 34.42, + 28.24 + ], + "measured_rgb": "#DB7D66", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 61.158, + 35.561, + 30.821 + ], + "measured_rgb": "#DA795F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 61.75, + 34.7, + 32.79 + ], + "measured_rgb": "#DC7B5D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 60.502, + 35.282, + 34.869 + ], + "measured_rgb": "#D97756", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 60.7, + 34.27, + 37.47 + ], + "measured_rgb": "#D97852", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 60.206, + 34.426, + 39.029 + ], + "measured_rgb": "#D8774E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 60.1, + 33.81, + 42.27 + ], + "measured_rgb": "#D87747", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 60.271, + 37.504, + 25.308 + ], + "measured_rgb": "#D97567", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 60.112, + 37.532, + 27.162 + ], + "measured_rgb": "#D97463", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 59.954, + 37.561, + 29.018 + ], + "measured_rgb": "#D97460", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 59.465, + 37.562, + 31.432 + ], + "measured_rgb": "#D8735A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 59.055, + 37.325, + 33.396 + ], + "measured_rgb": "#D77256", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 59.055, + 36.875, + 34.347 + ], + "measured_rgb": "#D77254", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 59.367, + 35.876, + 36.068 + ], + "measured_rgb": "#D77451", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 59.55, + 35.359, + 37.618 + ], + "measured_rgb": "#D7744F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 58.47, + 39.69, + 23.5 + ], + "measured_rgb": "#D66E66", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 57.918, + 40.28, + 27.642 + ], + "measured_rgb": "#D66C5D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 57.49, + 40.73, + 31.65 + ], + "measured_rgb": "#D76A55", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 56.315, + 41.212, + 32.738 + ], + "measured_rgb": "#D46750", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 56.36, + 40.4, + 33.05 + ], + "measured_rgb": "#D36850", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 56.883, + 39.276, + 34.064 + ], + "measured_rgb": "#D36A4F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 57.41, + 38.13, + 34.08 + ], + "measured_rgb": "#D46D50", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 55.689, + 42.794, + 28.026 + ], + "measured_rgb": "#D36457", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 55.607, + 42.722, + 29.887 + ], + "measured_rgb": "#D36454", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 55.526, + 42.651, + 31.749 + ], + "measured_rgb": "#D36350", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 55.095, + 42.505, + 33.515 + ], + "measured_rgb": "#D2624C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 54.828, + 42.215, + 34.274 + ], + "measured_rgb": "#D1624A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 55.474, + 41.194, + 34.226 + ], + "measured_rgb": "#D2654C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 52.99, + 45.97, + 30.69 + ], + "measured_rgb": "#CF594C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 54.026, + 44.398, + 31.429 + ], + "measured_rgb": "#D15E4D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 53.48, + 44.5, + 33.71 + ], + "measured_rgb": "#D05C48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 53.875, + 43.798, + 34.292 + ], + "measured_rgb": "#D05E48", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 53.05, + 44.39, + 35.65 + ], + "measured_rgb": "#CF5B44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 52.053, + 47.001, + 31.923 + ], + "measured_rgb": "#CE5548", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 51.938, + 46.902, + 33.508 + ], + "measured_rgb": "#CE5545", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 51.974, + 46.493, + 35.433 + ], + "measured_rgb": "#CE5642", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 52.243, + 45.967, + 35.487 + ], + "measured_rgb": "#CE5742", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 51.23, + 48.13, + 31.57 + ], + "measured_rgb": "#CD5247", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 51.072, + 48.014, + 34.379 + ], + "measured_rgb": "#CD5242", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 50.05, + 49.01, + 38.06 + ], + "measured_rgb": "#CC4D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 50.014, + 48.998, + 33.907 + ], + "measured_rgb": "#CB4E40", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 50.106, + 48.917, + 34.856 + ], + "measured_rgb": "#CB4E3F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 48.46, + 50.2, + 35.77 + ], + "measured_rgb": "#C84839", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 46.44, + 9.95, + -5.84 + ], + "measured_rgb": "#7B6978", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 43.093, + 9.297, + -6.861 + ], + "measured_rgb": "#706171", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 40.15, + 7.97, + -8.83 + ], + "measured_rgb": "#655B6D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 39.33, + 7.278, + -9.51 + ], + "measured_rgb": "#61596C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 39.65, + 5.81, + -11.22 + ], + "measured_rgb": "#5E5B70", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 36.105, + 6.125, + -11.934 + ], + "measured_rgb": "#555368", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 33.79, + 5.58, + -14.05 + ], + "measured_rgb": "#4D4E66", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 32.584, + 5.717, + -14.126 + ], + "measured_rgb": "#4A4B63", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 30.83, + 5.37, + -15.57 + ], + "measured_rgb": "#444761", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 44.996, + 10.637, + -4.941 + ], + "measured_rgb": "#796573", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 42.687, + 9.97, + -5.912 + ], + "measured_rgb": "#71606F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 39.862, + 8.788, + -7.563 + ], + "measured_rgb": "#675A6A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 38.19, + 8.055, + -8.48 + ], + "measured_rgb": "#615668", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 37.5, + 7.445, + -9.22 + ], + "measured_rgb": "#5E5567", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 34.875, + 6.985, + -10.533 + ], + "measured_rgb": "#554F63", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 33.355, + 6.882, + -11.161 + ], + "measured_rgb": "#514C60", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 32.484, + 6.31, + -12.739 + ], + "measured_rgb": "#4C4A60", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 45.86, + 11.99, + -3.07 + ], + "measured_rgb": "#7E6672", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 42.708, + 11.107, + -4.343 + ], + "measured_rgb": "#745F6C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 38.3, + 9.97, + -5.91 + ], + "measured_rgb": "#665564", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 37.05, + 8.832, + -7.45 + ], + "measured_rgb": "#605363", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 34.66, + 8.47, + -7.96 + ], + "measured_rgb": "#594D5E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 33.568, + 8.272, + -8.298 + ], + "measured_rgb": "#564B5C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 31.4, + 8.08, + -8.9 + ], + "measured_rgb": "#504658", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 44.09, + 12.48, + -2.568 + ], + "measured_rgb": "#7B616D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 41.16, + 11.92, + -3.262 + ], + "measured_rgb": "#725B67", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 38.23, + 11.36, + -3.958 + ], + "measured_rgb": "#6A5461", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 35.265, + 10.333, + -5.157 + ], + "measured_rgb": "#604E5B", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 33.946, + 9.492, + -6.243 + ], + "measured_rgb": "#5B4B5A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 33.394, + 8.879, + -7.239 + ], + "measured_rgb": "#584A5A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 45.25, + 13.53, + -1.37 + ], + "measured_rgb": "#80636E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 40.543, + 13.197, + -1.734 + ], + "measured_rgb": "#745863", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 35.23, + 12.19, + -2.7 + ], + "measured_rgb": "#644C57", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 34.455, + 11.074, + -3.972 + ], + "measured_rgb": "#604B58", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 32.87, + 10.7, + -4.06 + ], + "measured_rgb": "#5B4854", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 40.503, + 15.308, + 0.332 + ], + "measured_rgb": "#78565F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 37.998, + 14.625, + -0.243 + ], + "measured_rgb": "#70515A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 35.493, + 13.942, + -0.818 + ], + "measured_rgb": "#694C55", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 34.5, + 12.689, + -2.045 + ], + "measured_rgb": "#644A55", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 38.26, + 17.77, + 2.61 + ], + "measured_rgb": "#774F56", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 36.514, + 16.453, + 1.608 + ], + "measured_rgb": "#704C54", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 33.25, + 15.01, + 0.49 + ], + "measured_rgb": "#65464E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 36.912, + 18.292, + 3.339 + ], + "measured_rgb": "#754C52", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 36.228, + 17.339, + 2.496 + ], + "measured_rgb": "#714B52", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 35.37, + 20.0, + 5.16 + ], + "measured_rgb": "#74474C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 61.06, + -24.52, + 4.52 + ], + "measured_rgb": "#629F8B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 57.848, + -25.117, + 4.243 + ], + "measured_rgb": "#589783", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 54.77, + -24.86, + 1.44 + ], + "measured_rgb": "#4D8F80", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 52.842, + -24.709, + 1.617 + ], + "measured_rgb": "#498A7B", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 51.03, + -23.9, + 0.04 + ], + "measured_rgb": "#448579", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 50.331, + -22.636, + -1.173 + ], + "measured_rgb": "#448279", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 50.33, + -19.96, + -4.63 + ], + "measured_rgb": "#46827F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 47.473, + -21.21, + -4.144 + ], + "measured_rgb": "#3C7B77", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 44.4, + -21.12, + -5.79 + ], + "measured_rgb": "#317372", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 59.761, + -26.071, + 8.356 + ], + "measured_rgb": "#609C80", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 57.712, + -25.973, + 6.767 + ], + "measured_rgb": "#59977E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 54.838, + -25.724, + 4.421 + ], + "measured_rgb": "#4F8F7B", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 52.725, + -25.367, + 3.373 + ], + "measured_rgb": "#498A77", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 51.078, + -24.654, + 2.192 + ], + "measured_rgb": "#458575", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 49.633, + -24.048, + 1.07 + ], + "measured_rgb": "#418173", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 48.295, + -23.241, + -0.276 + ], + "measured_rgb": "#3E7D72", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 47.321, + -22.711, + -1.654 + ], + "measured_rgb": "#3B7B72", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 60.51, + -27.72, + 13.78 + ], + "measured_rgb": "#649F79", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 56.832, + -27.292, + 9.998 + ], + "measured_rgb": "#579576", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 54.51, + -26.79, + 7.33 + ], + "measured_rgb": "#4F8F75", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 52.699, + -26.542, + 6.207 + ], + "measured_rgb": "#498A72", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 50.59, + -25.92, + 4.68 + ], + "measured_rgb": "#448470", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 48.934, + -25.459, + 3.313 + ], + "measured_rgb": "#3F806E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 46.58, + -26.41, + 4.19 + ], + "measured_rgb": "#377A67", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 59.54, + -27.398, + 13.802 + ], + "measured_rgb": "#629C76", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 57.05, + -27.815, + 12.345 + ], + "measured_rgb": "#599573", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.733, + -28.082, + 10.13 + ], + "measured_rgb": "#4D8D6E", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 51.412, + -28.053, + 9.243 + ], + "measured_rgb": "#46876A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 50.144, + -27.794, + 8.631 + ], + "measured_rgb": "#438468", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 48.907, + -27.218, + 6.971 + ], + "measured_rgb": "#3F8068", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 61.06, + -26.66, + 15.28 + ], + "measured_rgb": "#699F77", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 56.013, + -28.529, + 13.977 + ], + "measured_rgb": "#56936D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 52.12, + -30.09, + 12.99 + ], + "measured_rgb": "#478965", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 50.654, + -29.184, + 11.401 + ], + "measured_rgb": "#438564", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 48.43, + -29.41, + 11.97 + ], + "measured_rgb": "#3E805E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 59.968, + -26.563, + 15.444 + ], + "measured_rgb": "#679D74", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 56.752, + -28.317, + 15.672 + ], + "measured_rgb": "#5A956C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 53.538, + -30.073, + 15.901 + ], + "measured_rgb": "#4E8D64", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 50.997, + -30.209, + 14.208 + ], + "measured_rgb": "#458660", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 62.09, + -24.71, + 15.38 + ], + "measured_rgb": "#70A17A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 56.579, + -28.785, + 18.097 + ], + "measured_rgb": "#5B9467", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 51.74, + -31.81, + 19.04 + ], + "measured_rgb": "#48895A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 59.237, + -28.888, + 22.914 + ], + "measured_rgb": "#669B65", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 56.854, + -29.771, + 21.59 + ], + "measured_rgb": "#5D9562", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 57.68, + -32.73, + 32.07 + ], + "measured_rgb": "#609850", + "source": "measured" + } + ] +} diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 333f43a68c..b8e537c5aa 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -179,6 +179,17 @@ set(lisbslic3r_sources Fill/Lightning/Layer.hpp Fill/Lightning/TreeNode.cpp Fill/Lightning/TreeNode.hpp + FilamentMixer.cpp + FilamentMixer.hpp + FilamentMixerModel.hpp + ColorDecomposeRecipe.cpp + ColorDecomposeRecipe.hpp + TexturePainting.hpp + TexturePainting.cpp + TextureToColor/TextureToColor.hpp + TextureToColor/TextureToColor.cpp + TextureToColor/ColorUtils.hpp + TextureToColor/ColorUtils.cpp Flow.cpp Flow.hpp FlushVolCalc.cpp @@ -194,6 +205,7 @@ set(lisbslic3r_sources format.hpp Format/OBJ.cpp Format/OBJ.hpp + Format/ResourcePathUtils.hpp Format/objparser.cpp Format/objparser.hpp Format/SL1.cpp @@ -549,7 +561,7 @@ target_compile_definitions(libslic3r PUBLIC -DUSE_TBB -DTBB_USE_CAPTURED_EXCEPTI if (USE_SLIC3R_CONSOLE_LOG) target_compile_definitions(libslic3r PRIVATE $<$:SLIC3R_CONSOLE_LOG>) endif() -target_include_directories(libslic3r PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) +target_include_directories(libslic3r PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/TextureToColor PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(libslic3r SYSTEM PUBLIC ${EXPAT_INCLUDE_DIRS}) # Find the OCCT and related libraries diff --git a/src/libslic3r/ColorDecomposeRecipe.cpp b/src/libslic3r/ColorDecomposeRecipe.cpp new file mode 100644 index 0000000000..8e5de9c06f --- /dev/null +++ b/src/libslic3r/ColorDecomposeRecipe.cpp @@ -0,0 +1,361 @@ +#include "ColorDecomposeRecipe.hpp" + +#include "FilamentMixer.hpp" +#include "Utils.hpp" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace { + +struct LabColor { + double l{0.0}; + double a{0.0}; + double b{0.0}; +}; + +struct StandardRecipeEntry { + ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::CMYW}; + std::string material; + std::string source; + std::vector component_keys; + std::vector component_hexes; + std::vector ratios; + std::string measured_hex; + LabColor measured_lab; +}; + +static double srgb_to_linear(double v) +{ + v /= 255.0; + return v <= 0.04045 ? v / 12.92 : std::pow((v + 0.055) / 1.055, 2.4); +} + +static double xyz_to_lab_component(double v) +{ + constexpr double eps = 216.0 / 24389.0; + constexpr double kappa = 24389.0 / 27.0; + return v > eps ? std::cbrt(v) : (kappa * v + 16.0) / 116.0; +} + +static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb) +{ + const double r = srgb_to_linear(rgb.r); + const double g = srgb_to_linear(rgb.g); + const double b = srgb_to_linear(rgb.b); + + const double x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const double y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); + const double z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + + const double fx = xyz_to_lab_component(x); + const double fy = xyz_to_lab_component(y); + const double fz = xyz_to_lab_component(z); + + return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)}; +} + +static double delta_e76(const LabColor& a, const LabColor& b) +{ + return std::sqrt(std::pow(a.l - b.l, 2.0) + std::pow(a.a - b.a, 2.0) + std::pow(a.b - b.b, 2.0)); +} + +static bool material_matches(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return false; + return a == b || a == b + " Basic" || b == a + " Basic"; +} + +static std::vector> ratio_grid(size_t n) +{ + std::vector> out; + if (n == 2) { + for (int a = 20; a <= 80; a += 5) + out.push_back({a, 100 - a}); + } else if (n == 3) { + for (int a = 20; a <= 60; a += 5) + for (int b = 20; b <= 80 - a; b += 5) { + const int c = 100 - a - b; + if (c >= 20) + out.push_back({a, b, c}); + } + } + return out; +} + +static ColorDecomposeRecipeMode parse_mode(const std::string& s) +{ + if (s == "RYBW" || s == "RGBY") + return ColorDecomposeRecipeMode::RYBW; + return ColorDecomposeRecipeMode::CMYW; +} + +static std::vector load_standard_entries() +{ + std::vector entries; + const std::string path = resources_dir() + "/filament_mixing/standard_color_recipes.json"; + std::ifstream ifs(path); + if (!ifs) + return entries; + + nlohmann::json root = nlohmann::json::parse(ifs, nullptr, false); + if (root.is_discarded() || !root.contains("entries") || !root["entries"].is_array()) + return entries; + + for (const auto& item : root["entries"]) { + if (!item.is_object()) + continue; + StandardRecipeEntry entry; + entry.mode = parse_mode(item.value("mode", "CMYW")); + entry.material = item.value("material", ""); + entry.source = item.value("source", ""); + entry.measured_hex = item.value("measured_rgb", ""); + + if (item.contains("components") && item["components"].is_array()) { + for (const auto& comp : item["components"]) { + if (comp.is_object()) { + entry.component_keys.push_back(comp.value("key", "")); + entry.component_hexes.push_back(comp.value("rgb", "")); + } + } + } + if (item.contains("ratios") && item["ratios"].is_array()) { + for (const auto& ratio : item["ratios"]) { + if (ratio.is_number_integer()) + entry.ratios.push_back(ratio.get()); + } + } + if (item.contains("measured_lab") && item["measured_lab"].is_array() && item["measured_lab"].size() >= 3) { + entry.measured_lab = { + item["measured_lab"][0].get(), + item["measured_lab"][1].get(), + item["measured_lab"][2].get() + }; + } else { + ColorDecomposeRgb measured_rgb; + if (!color_decompose_hex_to_rgb(entry.measured_hex, measured_rgb)) + continue; + entry.measured_lab = rgb_to_lab(measured_rgb); + } + + if (entry.component_hexes.size() >= 2 && entry.component_hexes.size() == entry.ratios.size() && + !entry.measured_hex.empty()) + entries.push_back(std::move(entry)); + } + return entries; +} + +static const std::vector& standard_entries() +{ + static const std::vector entries = load_standard_entries(); + return entries; +} + +static void evaluate_candidate(const ColorDecomposeRgb& target, + const std::vector& hexes, + const std::vector& ratios, + const std::vector& indices, + ColorDecomposeRecipeMode mode, + double& best_score, + ColorDecomposeRecipeResult& best) +{ + const std::string mixed = blend_color_multi(hexes, ratios); + ColorDecomposeRgb mixed_rgb; + if (!color_decompose_hex_to_rgb(mixed, mixed_rgb)) + return; + + const double score = delta_e76(rgb_to_lab(target), rgb_to_lab(mixed_rgb)); + if (score >= best_score) + return; + + best_score = score; + best.valid = true; + best.mode = mode; + best.matched_color_hex = mixed; + best.components.clear(); + for (size_t i = 0; i < hexes.size(); ++i) { + ColorDecomposeRecipeComponent comp; + comp.color_hex = hexes[i]; + comp.ratio = ratios[i]; + comp.filament_index = i < indices.size() ? indices[i] : 0; + best.components.push_back(comp); + } +} + +} // namespace + +std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb) +{ + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); + return std::string(buf); +} + +bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out) +{ + if (hex.size() < 7 || hex[0] != '#') + return false; + unsigned r = 0, g = 0, b = 0; + if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &r, &g, &b) != 3) + return false; + out = {static_cast(r), static_cast(g), static_cast(b)}; + return true; +} + +ColorDecomposeRecipeResult recommend_from_physical_filaments( + const ColorDecomposeRgb& target, + const std::vector& physical_filaments, + const std::string& preferred_material_type) +{ + std::vector candidates; + for (const auto& filament : physical_filaments) { + if (filament.is_mixed) + continue; + ColorDecomposeRgb ignored; + if (!color_decompose_hex_to_rgb(filament.color_hex, ignored)) + continue; + if (preferred_material_type.empty() || material_matches(filament.type, preferred_material_type)) + candidates.push_back(filament); + } + if (candidates.size() < 2) + candidates = physical_filaments; + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [](const auto& filament) { + if (filament.is_mixed) + return true; + ColorDecomposeRgb ignored; + return !color_decompose_hex_to_rgb(filament.color_hex, ignored); + }), candidates.end()); + + constexpr size_t kMaxCandidates = 8; + if (candidates.size() > kMaxCandidates) { + const LabColor target_lab = rgb_to_lab(target); + std::sort(candidates.begin(), candidates.end(), + [&target_lab](const ColorDecomposePhysicalFilament& a, const ColorDecomposePhysicalFilament& b) { + ColorDecomposeRgb rgb_a, rgb_b; + color_decompose_hex_to_rgb(a.color_hex, rgb_a); + color_decompose_hex_to_rgb(b.color_hex, rgb_b); + return delta_e76(target_lab, rgb_to_lab(rgb_a)) + < delta_e76(target_lab, rgb_to_lab(rgb_b)); + }); + candidates.resize(kMaxCandidates); + } + + ColorDecomposeRecipeResult best; + double best_score = std::numeric_limits::max(); + + for (size_t i = 0; i < candidates.size(); ++i) { + for (size_t j = i + 1; j < candidates.size(); ++j) { + const std::vector hexes = {candidates[i].color_hex, candidates[j].color_hex}; + const std::vector indices = {candidates[i].filament_index, candidates[j].filament_index}; + for (const auto& ratios : ratio_grid(2)) + evaluate_candidate(target, hexes, ratios, indices, ColorDecomposeRecipeMode::MaterialList, best_score, best); + + for (size_t k = j + 1; k < candidates.size(); ++k) { + const std::vector hexes3 = {candidates[i].color_hex, candidates[j].color_hex, candidates[k].color_hex}; + const std::vector indices3 = {candidates[i].filament_index, candidates[j].filament_index, candidates[k].filament_index}; + for (const auto& ratios : ratio_grid(3)) + evaluate_candidate(target, hexes3, ratios, indices3, ColorDecomposeRecipeMode::MaterialList, best_score, best); + } + } + } + + return best; +} + +ColorDecomposeRecipeResult lookup_standard_recipe( + const ColorDecomposeRgb& target, + ColorDecomposeRecipeMode mode, + const std::string& preferred_material_type) +{ + const LabColor target_lab = rgb_to_lab(target); + ColorDecomposeRecipeResult best; + double best_score = std::numeric_limits::max(); + + auto consider = [&](bool require_material_match) { + for (const StandardRecipeEntry& entry : standard_entries()) { + if (entry.mode != mode) + continue; + if (require_material_match && !material_matches(entry.material, preferred_material_type)) + continue; + if (!require_material_match && !preferred_material_type.empty() && material_matches(entry.material, preferred_material_type)) + continue; + + const double score = delta_e76(target_lab, entry.measured_lab); + if (score >= best_score) + continue; + + best_score = score; + best.valid = true; + best.mode = mode; + best.matched_color_hex = entry.measured_hex; + best.components.clear(); + for (size_t i = 0; i < entry.component_hexes.size(); ++i) { + ColorDecomposeRecipeComponent comp; + comp.color_hex = entry.component_hexes[i]; + comp.base_color = i < entry.component_keys.size() ? entry.component_keys[i] : ""; + comp.ratio = entry.ratios[i]; + comp.filament_index = 0; + best.components.push_back(comp); + } + } + }; + + consider(true); + if (!best.valid) + consider(false); + return best; +} + +std::string lookup_measured_blend_color(const std::vector& component_hexes, + const std::vector& ratios) +{ + if (component_hexes.size() < 2 || component_hexes.size() != ratios.size()) + return {}; + + auto normalize_hex = [](const std::string& hex) -> std::string { + ColorDecomposeRgb rgb; + if (!color_decompose_hex_to_rgb(hex, rgb)) + return {}; + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); + return std::string(buf); + }; + + std::vector norm_hexes; + norm_hexes.reserve(component_hexes.size()); + for (const auto& h : component_hexes) { + std::string n = normalize_hex(h); + if (n.empty()) + return {}; + norm_hexes.push_back(std::move(n)); + } + + for (const StandardRecipeEntry& entry : standard_entries()) { + if (entry.source != "measured" && entry.source != "interpolated") + continue; + if (entry.component_hexes.size() != norm_hexes.size()) + continue; + if (entry.ratios != ratios) + continue; + + bool match = true; + for (size_t i = 0; i < norm_hexes.size(); ++i) { + if (normalize_hex(entry.component_hexes[i]) != norm_hexes[i]) { + match = false; + break; + } + } + if (match) + return entry.measured_hex; + } + return {}; +} + +} // namespace Slic3r diff --git a/src/libslic3r/ColorDecomposeRecipe.hpp b/src/libslic3r/ColorDecomposeRecipe.hpp new file mode 100644 index 0000000000..146bf322a2 --- /dev/null +++ b/src/libslic3r/ColorDecomposeRecipe.hpp @@ -0,0 +1,64 @@ +#ifndef SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP +#define SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP + +#include +#include + +namespace Slic3r { + +enum class ColorDecomposeRecipeMode { + MaterialList, + CMYW, + RYBW +}; + +struct ColorDecomposeRgb { + unsigned char r{0}; + unsigned char g{0}; + unsigned char b{0}; +}; + +struct ColorDecomposePhysicalFilament { + std::string color_hex; + std::string name; + std::string type; + bool is_mixed{false}; + unsigned int filament_index{0}; // 1-based physical filament index +}; + +struct ColorDecomposeRecipeComponent { + std::string color_hex; + std::string base_color; + int ratio{0}; + unsigned int filament_index{0}; // 1-based for physical filaments, 0 for standard base colors +}; + +struct ColorDecomposeRecipeResult { + bool valid{false}; + ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::MaterialList}; + std::string matched_color_hex; + std::vector components; +}; + +std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb); +bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out); + +ColorDecomposeRecipeResult recommend_from_physical_filaments( + const ColorDecomposeRgb& target, + const std::vector& physical_filaments, + const std::string& preferred_material_type); + +ColorDecomposeRecipeResult lookup_standard_recipe( + const ColorDecomposeRgb& target, + ColorDecomposeRecipeMode mode, + const std::string& preferred_material_type); + +// Look up the measured blend color for an exact (component_hexes, ratios) match +// in the standard color recipe table. Returns the measured hex color if found +// with reliable source data ("measured" or "interpolated"), empty string otherwise. +std::string lookup_measured_blend_color(const std::vector& component_hexes, + const std::vector& ratios); + +} // namespace Slic3r + +#endif // SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP diff --git a/src/libslic3r/FilamentMixer.cpp b/src/libslic3r/FilamentMixer.cpp new file mode 100644 index 0000000000..6514e6d3d2 --- /dev/null +++ b/src/libslic3r/FilamentMixer.cpp @@ -0,0 +1,554 @@ +#include "FilamentMixer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ColorDecomposeRecipe.hpp" +#include "FilamentMixerModel.hpp" +#include "LocalesUtils.hpp" + +namespace Slic3r { +namespace { + +inline float clamp01(float x) +{ + return std::max(0.0f, std::min(1.0f, x)); +} + +inline float srgb_to_linear(float x) +{ + return (x >= 0.04045f) ? std::pow((x + 0.055f) / 1.055f, 2.4f) : x / 12.92f; +} + +inline float linear_to_srgb(float x) +{ + return (x >= 0.0031308f) ? (1.055f * std::pow(x, 1.0f / 2.4f) - 0.055f) : (12.92f * x); +} + +inline unsigned char to_u8(float x) +{ + const float clamped = clamp01(x); + return static_cast(clamped * 255.0f + 0.5f); +} + +inline float to_f01(unsigned char x) +{ + return static_cast(x) / 255.0f; +} + +} // namespace + +void filament_mixer_lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b) +{ + ::filament_mixer::lerp(r1, g1, b1, r2, g2, b2, t, out_r, out_g, out_b); +} + +void filament_mixer_lerp_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b) +{ + unsigned char ur = 0, ug = 0, ub = 0; + filament_mixer_lerp(to_u8(r1), to_u8(g1), to_u8(b1), + to_u8(r2), to_u8(g2), to_u8(b2), + t, &ur, &ug, &ub); + *out_r = to_f01(ur); + *out_g = to_f01(ug); + *out_b = to_f01(ub); +} + +void filament_mixer_lerp_linear_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b) +{ + const float sr1 = linear_to_srgb(clamp01(r1)); + const float sg1 = linear_to_srgb(clamp01(g1)); + const float sb1 = linear_to_srgb(clamp01(b1)); + const float sr2 = linear_to_srgb(clamp01(r2)); + const float sg2 = linear_to_srgb(clamp01(g2)); + const float sb2 = linear_to_srgb(clamp01(b2)); + + float out_sr = 0.0f, out_sg = 0.0f, out_sb = 0.0f; + filament_mixer_lerp_float(sr1, sg1, sb1, sr2, sg2, sb2, t, &out_sr, &out_sg, &out_sb); + + *out_r = srgb_to_linear(clamp01(out_sr)); + *out_g = srgb_to_linear(clamp01(out_sg)); + *out_b = srgb_to_linear(clamp01(out_sb)); +} + +static bool parse_hex(const std::string &hex, unsigned char &r, unsigned char &g, unsigned char &b) +{ + if (hex.size() < 7 || hex[0] != '#') return false; + unsigned rv = 0, gv = 0, bv = 0; + if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &rv, &gv, &bv) != 3) return false; + r = (unsigned char)rv; g = (unsigned char)gv; b = (unsigned char)bv; + return true; +} + +std::string blend_color(const std::string& hex_a, const std::string& hex_b, float ratio_b) +{ + unsigned char r1 = 128, g1 = 128, b1 = 128; + unsigned char r2 = 128, g2 = 128, b2 = 128; + parse_hex(hex_a, r1, g1, b1); + parse_hex(hex_b, r2, g2, b2); + + unsigned char mr = 0, mg = 0, mb = 0; + filament_mixer_lerp(r1, g1, b1, r2, g2, b2, ratio_b, &mr, &mg, &mb); + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", mr, mg, mb); + return std::string(buf); +} + +std::string blend_color_multi(const std::vector &hex_colors, + const std::vector &weights) +{ + if (hex_colors.size() >= 2 && hex_colors.size() == weights.size()) { + std::string measured = lookup_measured_blend_color(hex_colors, weights); + if (!measured.empty()) + return measured; + } + + if (hex_colors.empty()) + return "#000000"; + if (hex_colors.size() == 1) { + unsigned char cr = 128, cg = 128, cb = 128; + parse_hex(hex_colors.front(), cr, cg, cb); + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", cr, cg, cb); + return std::string(buf); + } + + assert(hex_colors.size() == weights.size()); + + unsigned char r = 128, g = 128, b = 128; + int accumulated = 0; + + for (size_t i = 0; i < hex_colors.size() && i < weights.size(); ++i) { + if (weights[i] <= 0) + continue; + unsigned char cr = 128, cg = 128, cb = 128; + parse_hex(hex_colors[i], cr, cg, cb); + if (accumulated == 0) { + r = cr; g = cg; b = cb; + accumulated = weights[i]; + } else { + const int new_total = accumulated + weights[i]; + const float t = static_cast(weights[i]) / static_cast(new_total); + filament_mixer_lerp(r, g, b, cr, cg, cb, t, &r, &g, &b); + accumulated = new_total; + } + } + + if (accumulated == 0) + return "#000000"; + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b); + return std::string(buf); +} + +std::vector parse_mixed_components(const std::string &str) +{ + std::vector components; + if (str.empty()) + return components; + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + try { + int val = std::stoi(token); + if (val >= 0) + components.push_back(static_cast(val)); + } catch (...) {} + } + return components; +} + +namespace { + +// Parse a token that may represent a finite double or "use default" (empty / "nan"). +// Returns NaN on either explicit sentinel or any parse error. +inline double parse_tangent_token(const std::string& tok) +{ + if (tok.empty()) return std::numeric_limits::quiet_NaN(); + std::string lower(tok.size(), '\0'); + std::transform(tok.begin(), tok.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower == "nan") return std::numeric_limits::quiet_NaN(); + try { + const double v = std::stod(tok); + if (!std::isfinite(v)) return std::numeric_limits::quiet_NaN(); + return v; + } catch (...) { + return std::numeric_limits::quiet_NaN(); + } +} + +// Split a "a,b,c,d" segment on commas, preserving empty tokens (so "0.5,0.4,," yields +// {"0.5","0.4","",""}). Used by the gradient-curve parser to distinguish NaN tangents +// from a malformed segment. +inline std::vector split_commas(const std::string& seg) +{ + std::vector out; + size_t start = 0; + while (true) { + const size_t comma = seg.find(',', start); + if (comma == std::string::npos) { + out.emplace_back(seg.substr(start)); + return out; + } + out.emplace_back(seg.substr(start, comma - start)); + start = comma + 1; + } +} + +} // namespace + +// Default Fritsch-Carlson PCHIP tangents for a sorted-by-x anchor list. m has size n +// matching the anchor count; for n == 1 the tangent is 0; for n == 2 both endpoint +// tangents equal the single secant (degenerates to linear). +std::vector compute_pchip_default_tangents(const std::vector& pts) +{ + const size_t n = pts.size(); + std::vector m(n, 0.0); + if (n < 2) return m; + + std::vector d(n - 1); + for (size_t i = 0; i + 1 < n; ++i) { + const double h = std::max(1e-12, pts[i + 1].x - pts[i].x); + d[i] = (pts[i + 1].y - pts[i].y) / h; + } + + m[0] = d[0]; + m[n - 1] = d[n - 2]; + for (size_t i = 1; i + 1 < n; ++i) + m[i] = 0.5 * (d[i - 1] + d[i]); + + // Fritsch-Carlson monotonic guard: kill flats then rescale steep tangents so the + // resulting cubic never overshoots [min, max] of the surrounding anchors. + for (size_t i = 0; i + 1 < n; ++i) { + if (d[i] == 0.0) { + m[i] = 0.0; + m[i + 1] = 0.0; + continue; + } + const double a = m[i] / d[i]; + const double b = m[i + 1] / d[i]; + const double s = a * a + b * b; + if (s > 9.0) { + const double tau = 3.0 / std::sqrt(s); + m[i] = tau * a * d[i]; + m[i + 1] = tau * b * d[i]; + } + } + return m; +} + +GradientCurve parse_gradient_curve(const std::string& s) +{ + GradientCurve curve; + if (s.empty()) + return curve; + + CNumericLocalesSetter c_locale_setter; + std::istringstream ss(s); + std::string segment; + while (std::getline(ss, segment, '|')) { + if (segment.empty()) + continue; + const auto fields = split_commas(segment); + // 2-field legacy form -> (x, y), tangents stay NaN. + // 4-field form -> (x, y, m_in, m_out), empty / "nan" tokens preserved as NaN. + if (fields.size() != 2 && fields.size() != 4) { + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: ignoring malformed segment \"" + << segment << "\" (expected 2 or 4 comma-separated fields, got " + << fields.size() << ")"; + continue; + } + try { + double x = std::stod(fields[0]); + double y = std::stod(fields[1]); + x = std::max(0.0, std::min(1.0, x)); + y = std::max(kGradientMinRatio, std::min(kGradientMaxRatio, y)); + GradientAnchor a; + a.x = x; + a.y = y; + if (fields.size() == 4) { + a.m_in = parse_tangent_token(fields[2]); + a.m_out = parse_tangent_token(fields[3]); + } + curve.points.push_back(a); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: ignoring unparseable segment \"" + << segment << "\": " << e.what(); + } + } + + if (curve.points.size() < 2) { + if (!curve.points.empty()) + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: only " + << curve.points.size() << " valid point(s), need at least 2; discarding"; + curve.points.clear(); + return curve; + } + + std::sort(curve.points.begin(), curve.points.end(), + [](const GradientAnchor& a, const GradientAnchor& b) { + return a.x < b.x; + }); + return curve; +} + +std::string serialize_gradient_curve(const GradientCurve& c) +{ + if (c.points.empty()) + return std::string{}; + + CNumericLocalesSetter c_locale_setter; + std::string out; + char buf[128]; + for (size_t i = 0; i < c.points.size(); ++i) { + if (i > 0) out += '|'; + const auto& a = c.points[i]; + const bool has_in = std::isfinite(a.m_in); + const bool has_out = std::isfinite(a.m_out); + if (has_in || has_out) { + // Emit empty tokens for NaN slots so the legacy parser would still split + // four fields; the new parser interprets empty tokens as "use PCHIP default". + char in_buf[32] = {0}; + char out_buf[32] = {0}; + if (has_in) std::snprintf(in_buf, sizeof(in_buf), "%.4f", a.m_in); + if (has_out) std::snprintf(out_buf, sizeof(out_buf), "%.4f", a.m_out); + std::snprintf(buf, sizeof(buf), "%.4f,%.4f,%s,%s", + a.x, a.y, in_buf, out_buf); + } else { + // 4-field form is only emitted when at least one tangent is finite; the + // 2-field form is emitted otherwise so the JSON payload stays minimal + // and remains readable by older clients that only know (x, y) pairs. + std::snprintf(buf, sizeof(buf), "%.4f,%.4f", a.x, a.y); + } + out += buf; + } + return out; +} + +double sample_gradient_curve(const GradientCurve& c, double t) +{ + const auto& pts = c.points; + if (pts.size() < 2) + return 0.5; + if (t <= pts.front().x) + return pts.front().y; + if (t >= pts.back().x) + return pts.back().y; + + // PCHIP defaults are computed for every call; control point counts are typically + // tiny (< 16) so the allocation cost is negligible compared to any actual rendering + // or G-code work that drives the sampler. + const std::vector m_def = compute_pchip_default_tangents(pts); + const size_t n = pts.size(); + + // Linear scan to locate the interval [pts[i].x, pts[i+1].x] containing t. Cheap + // and avoids the upper_bound boilerplate; n is small. + for (size_t i = 1; i < n; ++i) { + const double x0 = pts[i - 1].x; + const double x1 = pts[i].x; + if (t > x1) continue; + + const double y0 = pts[i - 1].y; + const double y1 = pts[i].y; + const double h = std::max(1e-12, x1 - x0); + const double m_left = std::isfinite(pts[i - 1].m_out) ? pts[i - 1].m_out : m_def[i - 1]; + const double m_right = std::isfinite(pts[i].m_in) ? pts[i].m_in : m_def[i]; + + const double u = (t - x0) / h; + const double u2 = u * u; + const double u3 = u2 * u; + const double h00 = 2.0 * u3 - 3.0 * u2 + 1.0; + const double h10 = u3 - 2.0 * u2 + u; + const double h01 = -2.0 * u3 + 3.0 * u2; + const double h11 = u3 - u2; + double y = h00 * y0 + h10 * h * m_left + + h01 * y1 + h11 * h * m_right; + // Defensive clamp in case tangent overrides on legacy curves push the + // single-segment Hermite slightly outside the anchor band. + if (y < kGradientMinRatio) y = kGradientMinRatio; + if (y > kGradientMaxRatio) y = kGradientMaxRatio; + return y; + } + return pts.back().y; +} + +std::vector parse_mixed_ratios(const std::string &str, size_t n_components) +{ + CNumericLocalesSetter c_locale_setter; + std::vector ratios; + if (!str.empty()) { + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + try { + double val = std::stod(token); + if (val > 0.0) + ratios.push_back(val); + } catch (...) {} + } + } + + if (ratios.size() != n_components || n_components == 0) { + ratios.assign(n_components, n_components > 0 ? 1.0 / n_components : 0.0); + return ratios; + } + + double sum = std::accumulate(ratios.begin(), ratios.end(), 0.0); + if (sum > 0.0 && std::abs(sum - 1.0) > 1e-6) { + for (double &r : ratios) + r /= sum; + } + return ratios; +} + +bool has_any_mixed_filament(const std::vector &is_mixed) +{ + for (unsigned char v : is_mixed) + if (v) return true; + return false; +} + +std::vector check_mixed_filament_integrity( + const std::vector &is_mixed, + const std::vector &comp_strs, + size_t num_physical) +{ + std::vector broken; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) { + broken.push_back(i); + continue; + } + auto comps = parse_mixed_components(comp_strs[i]); + if (comps.size() < 2) { + broken.push_back(i); + continue; + } + for (unsigned int c : comps) { + if (c < 1 || c > num_physical) { + broken.push_back(i); + break; + } + } + } + return broken; +} + +std::vector expand_mixed_filaments( + const std::vector &extruders_0based, + const std::vector &is_mixed, + const std::vector &comp_strs) +{ + std::vector result; + for (unsigned int ext : extruders_0based) { + if (ext < is_mixed.size() && is_mixed[ext] && ext < comp_strs.size()) { + auto comps = parse_mixed_components(comp_strs[ext]); + for (unsigned int c : comps) + if (c >= 1) result.push_back(c - 1); + } else { + result.push_back(ext); + } + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +void remap_mixed_components_on_delete( + const std::vector &is_mixed, + std::vector &comp_strs, + unsigned int del_1based) +{ + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) continue; + + auto comps = parse_mixed_components(comp_strs[i]); + std::ostringstream ss; + for (size_t j = 0; j < comps.size(); ++j) { + if (j > 0) ss << ','; + if (comps[j] == del_1based) + ss << 0; + else if (comps[j] > del_1based) + ss << (comps[j] - 1); + else + ss << comps[j]; + } + comp_strs[i] = ss.str(); + } +} + +std::vector check_mixed_filament_type_consistency( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &filament_types) +{ + std::vector result; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) continue; + auto comps = parse_mixed_components(comp_strs[i]); + if (comps.size() < 2) continue; + + std::string ref_type; + bool mismatch = false; + for (unsigned int c : comps) { + if (c == 0) continue; // sentinel for deleted component + size_t idx = static_cast(c) - 1; // 1-based -> 0-based + if (idx >= filament_types.size()) continue; + if (ref_type.empty()) + ref_type = filament_types[idx]; + else if (filament_types[idx] != ref_type) { + mismatch = true; + break; + } + } + if (mismatch) + result.push_back(i); + } + return result; +} + +void expand_mixed_slots_in_unprintables( + std::vector> &unprintables, + const std::vector &is_mixed, + const std::vector &comp_strs) +{ + for (auto &unprintable_set : unprintables) { + std::set expanded; + for (int fid : unprintable_set) { + if (fid >= 0 && (size_t)fid < is_mixed.size() && is_mixed[fid] + && (size_t)fid < comp_strs.size()) { + auto comps = parse_mixed_components(comp_strs[fid]); + for (unsigned int c : comps) + if (c >= 1) expanded.insert((int)(c - 1)); + } else { + expanded.insert(fid); + } + } + unprintable_set = std::move(expanded); + } +} + +} // namespace Slic3r diff --git a/src/libslic3r/FilamentMixer.hpp b/src/libslic3r/FilamentMixer.hpp new file mode 100644 index 0000000000..2ca4182066 --- /dev/null +++ b/src/libslic3r/FilamentMixer.hpp @@ -0,0 +1,144 @@ +#ifndef SLIC3R_FILAMENT_MIXER_HPP +#define SLIC3R_FILAMENT_MIXER_HPP + +#include +#include +#include +#include +#include + +namespace Slic3r { + +// Photoshop-style gradient curve control point in [0,1] x [0,1]. +// (x, y) is the anchor position; (m_in, m_out) are optional cubic Hermite tangent +// overrides. NaN means "use the PCHIP-computed default", which is the case for plain +// anchors loaded from old 2-field 3MF projects or freshly added via a quick click. +// A press-and-drag on a curve segment populates m_out of its left anchor and m_in of +// its right anchor so the segment bends without inserting a new anchor. +struct GradientAnchor { + double x = 0.0; + double y = 0.0; + double m_in = std::numeric_limits::quiet_NaN(); + double m_out = std::numeric_limits::quiet_NaN(); +}; + +// Sorted list of GradientAnchor; x in [0,1], y in [kGradientMinRatio, kGradientMaxRatio]. +// Empty means "no custom curve" (callers should fall back to the linear range). +struct GradientCurve { + std::vector points; + bool empty() const { return points.empty(); } +}; + +// Reserved blend ratio range. Anchor y values (= component 0's ratio) are constrained +// to this band so the mixed filament never reaches pure 0% / 100% of either physical +// component, which keeps both extruders flowing and avoids degenerate transitions. +// Both the editor and the sampler enforce this clamp. +constexpr double kGradientMinRatio = 0.1; +constexpr double kGradientMaxRatio = 0.9; + +// Parse "x0,y0[,m_in0,m_out0]|x1,y1[,m_in1,m_out1]|..." into a GradientCurve. +// (Anchors are pipe-separated; the fields within an anchor are comma-separated.) +// Accepts both the legacy 2-field form (tangents -> NaN) and the new 4-field form +// (empty token or "nan" preserved as NaN). Returns an empty curve when the input is +// empty or unparsable. Points are clamped to [0,1] for (x, y) and re-sorted by x. +GradientCurve parse_gradient_curve(const std::string& s); + +// Serialize a GradientCurve back to a string. Emits 4 fields per anchor when any +// tangent override is finite; emits 2 fields when both tangents are NaN so unchanged +// projects stay byte-identical with the legacy format. Returns "" when empty. +std::string serialize_gradient_curve(const GradientCurve& c); + +// Sample the curve at t in [0,1] using cubic Hermite with Fritsch-Carlson PCHIP +// default tangents, optionally overridden per anchor via m_in / m_out. Returns the +// clamped end values when t is outside the control point range. Returns 0.5 when the +// curve has fewer than 2 points (a safety fallback; callers should check empty()). +double sample_gradient_curve(const GradientCurve& c, double t); + +// Compute Fritsch-Carlson PCHIP default tangents for a sorted-by-x anchor list. +// Result size == pts.size(). Useful for callers that need to know what tangent the +// sampler would synthesize when m_in / m_out are NaN (e.g. the GUI's segment-bend +// interaction that inserts a virtual anchor and reads back the surrounding tangents). +std::vector compute_pchip_default_tangents(const std::vector& pts); + +void filament_mixer_lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b); + +void filament_mixer_lerp_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b); + +void filament_mixer_lerp_linear_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b); + +// Blend two hex colors ("#RRGGBB") by ratio (0.0 ~ 1.0 for color_b). +// Returns "#RRGGBB" string. +std::string blend_color(const std::string& hex_a, const std::string& hex_b, float ratio_b); + +// Blend N hex colors by integer weights using polynomial pigment mixing. +// Pairwise accumulation via filament_mixer_lerp. Returns "#RRGGBB". +std::string blend_color_multi(const std::vector &hex_colors, + const std::vector &weights); + +// Parse comma-separated 1-based component IDs, e.g. "1,3" → {1, 3}. +std::vector parse_mixed_components(const std::string &str); + +// Parse comma-separated ratio values, e.g. "0.7,0.3" → {0.7, 0.3}. +// Returns equal ratios (1/n each) when str is empty or invalid. +// Normalizes so the sum equals 1.0. +std::vector parse_mixed_ratios(const std::string &str, size_t n_components); + +// Returns true if any element in is_mixed is true. +// ConfigOptionBools stores values as std::vector. +bool has_any_mixed_filament(const std::vector &is_mixed); + +// Check which mixed filament slots have broken component references. +// Returns 0-based indices of mixed slots whose components reference +// filaments beyond num_physical (i.e., deleted filaments). +std::vector check_mixed_filament_integrity( + const std::vector &is_mixed, + const std::vector &comp_strs, + size_t num_physical); + +// Expand mixed filament slots in an extruder list to their physical components. +// Input/output are 0-based indices. Non-mixed slots pass through unchanged. +// Result is sorted and deduplicated. +std::vector expand_mixed_filaments( + const std::vector &extruders_0based, + const std::vector &is_mixed, + const std::vector &comp_strs); + +// Remap mixed filament component references after a physical filament is deleted. +// del_1based: the 1-based index of the deleted physical filament. +// For each mixed slot: +// - if component == del_1based -> replace with 0 (sentinel for deleted/unselected) +// - if component > del_1based -> decrement by 1 +void remap_mixed_components_on_delete( + const std::vector &is_mixed, + std::vector &comp_strs, + unsigned int del_1based); + +// Check which mixed filament slots have type-mismatched components. +// filament_types: type strings for physical filaments (0-based, size == num_physical). +// Component IDs in comp_strs are 1-based; the function converts to 0-based to look up types. +// Returns 0-based config indices of mixed slots with mismatched component types. +std::vector check_mixed_filament_type_consistency( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &filament_types); + +// Expand mixed-slot IDs in geometric unprintable sets to their physical component IDs. +// Each set entry that corresponds to a mixed slot is replaced by the slot's component +// IDs (0-based). Non-mixed entries pass through unchanged. +void expand_mixed_slots_in_unprintables( + std::vector> &unprintables, + const std::vector &is_mixed, + const std::vector &comp_strs); + +} // namespace Slic3r + +#endif // SLIC3R_FILAMENT_MIXER_HPP diff --git a/src/libslic3r/FilamentMixerModel.hpp b/src/libslic3r/FilamentMixerModel.hpp new file mode 100644 index 0000000000..89b299471b --- /dev/null +++ b/src/libslic3r/FilamentMixerModel.hpp @@ -0,0 +1,819 @@ +/* + * FilamentMixer — Header-only C++ pigment color mixer + * + * Filament mixer implementation using a degree-4 polynomial regression + * trained to approximate Mixbox behavior (Mean Delta-E ~2.07). + * This library does not include Mixbox source code, binaries, or data files. + * + * Usage: + * #include "FilamentMixerModel.hpp" + * + * unsigned char r, g, b; + * filament_mixer::lerp(0, 33, 133, 252, 211, 0, 0.5f, &r, &g, &b); + * // r=47, g=141, b=56 (blue + yellow → green) + * + * No dependencies beyond the C++ standard library. + * + * MIT License + * + * Copyright (c) 2026 Justin Hayes + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +#ifndef FILAMENT_MIXER_MODEL_HPP +#define FILAMENT_MIXER_MODEL_HPP + +#include +#include +#include + +namespace filament_mixer { +namespace detail { + +// BEGIN AUTO-GENERATED COEFFICIENTS +// Auto-generated by scripts/export_poly_coefficients.py +// Do not edit manually. +// Degree-4 polynomial, 330 features, 7 inputs + +static const int POLY_DEGREE = 4; +static const int N_FEATURES = 330; +static const int N_INPUTS = 7; + +static const int POWERS[330][7] = { + {0, 0, 0, 0, 0, 0, 0}, + {1, 0, 0, 0, 0, 0, 0}, + {0, 1, 0, 0, 0, 0, 0}, + {0, 0, 1, 0, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 0, 1, 0, 0}, + {0, 0, 0, 0, 0, 1, 0}, + {0, 0, 0, 0, 0, 0, 1}, + {2, 0, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 0, 1, 0, 0, 0, 0}, + {1, 0, 0, 1, 0, 0, 0}, + {1, 0, 0, 0, 1, 0, 0}, + {1, 0, 0, 0, 0, 1, 0}, + {1, 0, 0, 0, 0, 0, 1}, + {0, 2, 0, 0, 0, 0, 0}, + {0, 1, 1, 0, 0, 0, 0}, + {0, 1, 0, 1, 0, 0, 0}, + {0, 1, 0, 0, 1, 0, 0}, + {0, 1, 0, 0, 0, 1, 0}, + {0, 1, 0, 0, 0, 0, 1}, + {0, 0, 2, 0, 0, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + {0, 0, 1, 0, 1, 0, 0}, + {0, 0, 1, 0, 0, 1, 0}, + {0, 0, 1, 0, 0, 0, 1}, + {0, 0, 0, 2, 0, 0, 0}, + {0, 0, 0, 1, 1, 0, 0}, + {0, 0, 0, 1, 0, 1, 0}, + {0, 0, 0, 1, 0, 0, 1}, + {0, 0, 0, 0, 2, 0, 0}, + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 0, 1, 0, 1}, + {0, 0, 0, 0, 0, 2, 0}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 0, 2}, + {3, 0, 0, 0, 0, 0, 0}, + {2, 1, 0, 0, 0, 0, 0}, + {2, 0, 1, 0, 0, 0, 0}, + {2, 0, 0, 1, 0, 0, 0}, + {2, 0, 0, 0, 1, 0, 0}, + {2, 0, 0, 0, 0, 1, 0}, + {2, 0, 0, 0, 0, 0, 1}, + {1, 2, 0, 0, 0, 0, 0}, + {1, 1, 1, 0, 0, 0, 0}, + {1, 1, 0, 1, 0, 0, 0}, + {1, 1, 0, 0, 1, 0, 0}, + {1, 1, 0, 0, 0, 1, 0}, + {1, 1, 0, 0, 0, 0, 1}, + {1, 0, 2, 0, 0, 0, 0}, + {1, 0, 1, 1, 0, 0, 0}, + {1, 0, 1, 0, 1, 0, 0}, + {1, 0, 1, 0, 0, 1, 0}, + {1, 0, 1, 0, 0, 0, 1}, + {1, 0, 0, 2, 0, 0, 0}, + {1, 0, 0, 1, 1, 0, 0}, + {1, 0, 0, 1, 0, 1, 0}, + {1, 0, 0, 1, 0, 0, 1}, + {1, 0, 0, 0, 2, 0, 0}, + {1, 0, 0, 0, 1, 1, 0}, + {1, 0, 0, 0, 1, 0, 1}, + {1, 0, 0, 0, 0, 2, 0}, + {1, 0, 0, 0, 0, 1, 1}, + {1, 0, 0, 0, 0, 0, 2}, + {0, 3, 0, 0, 0, 0, 0}, + {0, 2, 1, 0, 0, 0, 0}, + {0, 2, 0, 1, 0, 0, 0}, + {0, 2, 0, 0, 1, 0, 0}, + {0, 2, 0, 0, 0, 1, 0}, + {0, 2, 0, 0, 0, 0, 1}, + {0, 1, 2, 0, 0, 0, 0}, + {0, 1, 1, 1, 0, 0, 0}, + {0, 1, 1, 0, 1, 0, 0}, + {0, 1, 1, 0, 0, 1, 0}, + {0, 1, 1, 0, 0, 0, 1}, + {0, 1, 0, 2, 0, 0, 0}, + {0, 1, 0, 1, 1, 0, 0}, + {0, 1, 0, 1, 0, 1, 0}, + {0, 1, 0, 1, 0, 0, 1}, + {0, 1, 0, 0, 2, 0, 0}, + {0, 1, 0, 0, 1, 1, 0}, + {0, 1, 0, 0, 1, 0, 1}, + {0, 1, 0, 0, 0, 2, 0}, + {0, 1, 0, 0, 0, 1, 1}, + {0, 1, 0, 0, 0, 0, 2}, + {0, 0, 3, 0, 0, 0, 0}, + {0, 0, 2, 1, 0, 0, 0}, + {0, 0, 2, 0, 1, 0, 0}, + {0, 0, 2, 0, 0, 1, 0}, + {0, 0, 2, 0, 0, 0, 1}, + {0, 0, 1, 2, 0, 0, 0}, + {0, 0, 1, 1, 1, 0, 0}, + {0, 0, 1, 1, 0, 1, 0}, + {0, 0, 1, 1, 0, 0, 1}, + {0, 0, 1, 0, 2, 0, 0}, + {0, 0, 1, 0, 1, 1, 0}, + {0, 0, 1, 0, 1, 0, 1}, + {0, 0, 1, 0, 0, 2, 0}, + {0, 0, 1, 0, 0, 1, 1}, + {0, 0, 1, 0, 0, 0, 2}, + {0, 0, 0, 3, 0, 0, 0}, + {0, 0, 0, 2, 1, 0, 0}, + {0, 0, 0, 2, 0, 1, 0}, + {0, 0, 0, 2, 0, 0, 1}, + {0, 0, 0, 1, 2, 0, 0}, + {0, 0, 0, 1, 1, 1, 0}, + {0, 0, 0, 1, 1, 0, 1}, + {0, 0, 0, 1, 0, 2, 0}, + {0, 0, 0, 1, 0, 1, 1}, + {0, 0, 0, 1, 0, 0, 2}, + {0, 0, 0, 0, 3, 0, 0}, + {0, 0, 0, 0, 2, 1, 0}, + {0, 0, 0, 0, 2, 0, 1}, + {0, 0, 0, 0, 1, 2, 0}, + {0, 0, 0, 0, 1, 1, 1}, + {0, 0, 0, 0, 1, 0, 2}, + {0, 0, 0, 0, 0, 3, 0}, + {0, 0, 0, 0, 0, 2, 1}, + {0, 0, 0, 0, 0, 1, 2}, + {0, 0, 0, 0, 0, 0, 3}, + {4, 0, 0, 0, 0, 0, 0}, + {3, 1, 0, 0, 0, 0, 0}, + {3, 0, 1, 0, 0, 0, 0}, + {3, 0, 0, 1, 0, 0, 0}, + {3, 0, 0, 0, 1, 0, 0}, + {3, 0, 0, 0, 0, 1, 0}, + {3, 0, 0, 0, 0, 0, 1}, + {2, 2, 0, 0, 0, 0, 0}, + {2, 1, 1, 0, 0, 0, 0}, + {2, 1, 0, 1, 0, 0, 0}, + {2, 1, 0, 0, 1, 0, 0}, + {2, 1, 0, 0, 0, 1, 0}, + {2, 1, 0, 0, 0, 0, 1}, + {2, 0, 2, 0, 0, 0, 0}, + {2, 0, 1, 1, 0, 0, 0}, + {2, 0, 1, 0, 1, 0, 0}, + {2, 0, 1, 0, 0, 1, 0}, + {2, 0, 1, 0, 0, 0, 1}, + {2, 0, 0, 2, 0, 0, 0}, + {2, 0, 0, 1, 1, 0, 0}, + {2, 0, 0, 1, 0, 1, 0}, + {2, 0, 0, 1, 0, 0, 1}, + {2, 0, 0, 0, 2, 0, 0}, + {2, 0, 0, 0, 1, 1, 0}, + {2, 0, 0, 0, 1, 0, 1}, + {2, 0, 0, 0, 0, 2, 0}, + {2, 0, 0, 0, 0, 1, 1}, + {2, 0, 0, 0, 0, 0, 2}, + {1, 3, 0, 0, 0, 0, 0}, + {1, 2, 1, 0, 0, 0, 0}, + {1, 2, 0, 1, 0, 0, 0}, + {1, 2, 0, 0, 1, 0, 0}, + {1, 2, 0, 0, 0, 1, 0}, + {1, 2, 0, 0, 0, 0, 1}, + {1, 1, 2, 0, 0, 0, 0}, + {1, 1, 1, 1, 0, 0, 0}, + {1, 1, 1, 0, 1, 0, 0}, + {1, 1, 1, 0, 0, 1, 0}, + {1, 1, 1, 0, 0, 0, 1}, + {1, 1, 0, 2, 0, 0, 0}, + {1, 1, 0, 1, 1, 0, 0}, + {1, 1, 0, 1, 0, 1, 0}, + {1, 1, 0, 1, 0, 0, 1}, + {1, 1, 0, 0, 2, 0, 0}, + {1, 1, 0, 0, 1, 1, 0}, + {1, 1, 0, 0, 1, 0, 1}, + {1, 1, 0, 0, 0, 2, 0}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 0, 2}, + {1, 0, 3, 0, 0, 0, 0}, + {1, 0, 2, 1, 0, 0, 0}, + {1, 0, 2, 0, 1, 0, 0}, + {1, 0, 2, 0, 0, 1, 0}, + {1, 0, 2, 0, 0, 0, 1}, + {1, 0, 1, 2, 0, 0, 0}, + {1, 0, 1, 1, 1, 0, 0}, + {1, 0, 1, 1, 0, 1, 0}, + {1, 0, 1, 1, 0, 0, 1}, + {1, 0, 1, 0, 2, 0, 0}, + {1, 0, 1, 0, 1, 1, 0}, + {1, 0, 1, 0, 1, 0, 1}, + {1, 0, 1, 0, 0, 2, 0}, + {1, 0, 1, 0, 0, 1, 1}, + {1, 0, 1, 0, 0, 0, 2}, + {1, 0, 0, 3, 0, 0, 0}, + {1, 0, 0, 2, 1, 0, 0}, + {1, 0, 0, 2, 0, 1, 0}, + {1, 0, 0, 2, 0, 0, 1}, + {1, 0, 0, 1, 2, 0, 0}, + {1, 0, 0, 1, 1, 1, 0}, + {1, 0, 0, 1, 1, 0, 1}, + {1, 0, 0, 1, 0, 2, 0}, + {1, 0, 0, 1, 0, 1, 1}, + {1, 0, 0, 1, 0, 0, 2}, + {1, 0, 0, 0, 3, 0, 0}, + {1, 0, 0, 0, 2, 1, 0}, + {1, 0, 0, 0, 2, 0, 1}, + {1, 0, 0, 0, 1, 2, 0}, + {1, 0, 0, 0, 1, 1, 1}, + {1, 0, 0, 0, 1, 0, 2}, + {1, 0, 0, 0, 0, 3, 0}, + {1, 0, 0, 0, 0, 2, 1}, + {1, 0, 0, 0, 0, 1, 2}, + {1, 0, 0, 0, 0, 0, 3}, + {0, 4, 0, 0, 0, 0, 0}, + {0, 3, 1, 0, 0, 0, 0}, + {0, 3, 0, 1, 0, 0, 0}, + {0, 3, 0, 0, 1, 0, 0}, + {0, 3, 0, 0, 0, 1, 0}, + {0, 3, 0, 0, 0, 0, 1}, + {0, 2, 2, 0, 0, 0, 0}, + {0, 2, 1, 1, 0, 0, 0}, + {0, 2, 1, 0, 1, 0, 0}, + {0, 2, 1, 0, 0, 1, 0}, + {0, 2, 1, 0, 0, 0, 1}, + {0, 2, 0, 2, 0, 0, 0}, + {0, 2, 0, 1, 1, 0, 0}, + {0, 2, 0, 1, 0, 1, 0}, + {0, 2, 0, 1, 0, 0, 1}, + {0, 2, 0, 0, 2, 0, 0}, + {0, 2, 0, 0, 1, 1, 0}, + {0, 2, 0, 0, 1, 0, 1}, + {0, 2, 0, 0, 0, 2, 0}, + {0, 2, 0, 0, 0, 1, 1}, + {0, 2, 0, 0, 0, 0, 2}, + {0, 1, 3, 0, 0, 0, 0}, + {0, 1, 2, 1, 0, 0, 0}, + {0, 1, 2, 0, 1, 0, 0}, + {0, 1, 2, 0, 0, 1, 0}, + {0, 1, 2, 0, 0, 0, 1}, + {0, 1, 1, 2, 0, 0, 0}, + {0, 1, 1, 1, 1, 0, 0}, + {0, 1, 1, 1, 0, 1, 0}, + {0, 1, 1, 1, 0, 0, 1}, + {0, 1, 1, 0, 2, 0, 0}, + {0, 1, 1, 0, 1, 1, 0}, + {0, 1, 1, 0, 1, 0, 1}, + {0, 1, 1, 0, 0, 2, 0}, + {0, 1, 1, 0, 0, 1, 1}, + {0, 1, 1, 0, 0, 0, 2}, + {0, 1, 0, 3, 0, 0, 0}, + {0, 1, 0, 2, 1, 0, 0}, + {0, 1, 0, 2, 0, 1, 0}, + {0, 1, 0, 2, 0, 0, 1}, + {0, 1, 0, 1, 2, 0, 0}, + {0, 1, 0, 1, 1, 1, 0}, + {0, 1, 0, 1, 1, 0, 1}, + {0, 1, 0, 1, 0, 2, 0}, + {0, 1, 0, 1, 0, 1, 1}, + {0, 1, 0, 1, 0, 0, 2}, + {0, 1, 0, 0, 3, 0, 0}, + {0, 1, 0, 0, 2, 1, 0}, + {0, 1, 0, 0, 2, 0, 1}, + {0, 1, 0, 0, 1, 2, 0}, + {0, 1, 0, 0, 1, 1, 1}, + {0, 1, 0, 0, 1, 0, 2}, + {0, 1, 0, 0, 0, 3, 0}, + {0, 1, 0, 0, 0, 2, 1}, + {0, 1, 0, 0, 0, 1, 2}, + {0, 1, 0, 0, 0, 0, 3}, + {0, 0, 4, 0, 0, 0, 0}, + {0, 0, 3, 1, 0, 0, 0}, + {0, 0, 3, 0, 1, 0, 0}, + {0, 0, 3, 0, 0, 1, 0}, + {0, 0, 3, 0, 0, 0, 1}, + {0, 0, 2, 2, 0, 0, 0}, + {0, 0, 2, 1, 1, 0, 0}, + {0, 0, 2, 1, 0, 1, 0}, + {0, 0, 2, 1, 0, 0, 1}, + {0, 0, 2, 0, 2, 0, 0}, + {0, 0, 2, 0, 1, 1, 0}, + {0, 0, 2, 0, 1, 0, 1}, + {0, 0, 2, 0, 0, 2, 0}, + {0, 0, 2, 0, 0, 1, 1}, + {0, 0, 2, 0, 0, 0, 2}, + {0, 0, 1, 3, 0, 0, 0}, + {0, 0, 1, 2, 1, 0, 0}, + {0, 0, 1, 2, 0, 1, 0}, + {0, 0, 1, 2, 0, 0, 1}, + {0, 0, 1, 1, 2, 0, 0}, + {0, 0, 1, 1, 1, 1, 0}, + {0, 0, 1, 1, 1, 0, 1}, + {0, 0, 1, 1, 0, 2, 0}, + {0, 0, 1, 1, 0, 1, 1}, + {0, 0, 1, 1, 0, 0, 2}, + {0, 0, 1, 0, 3, 0, 0}, + {0, 0, 1, 0, 2, 1, 0}, + {0, 0, 1, 0, 2, 0, 1}, + {0, 0, 1, 0, 1, 2, 0}, + {0, 0, 1, 0, 1, 1, 1}, + {0, 0, 1, 0, 1, 0, 2}, + {0, 0, 1, 0, 0, 3, 0}, + {0, 0, 1, 0, 0, 2, 1}, + {0, 0, 1, 0, 0, 1, 2}, + {0, 0, 1, 0, 0, 0, 3}, + {0, 0, 0, 4, 0, 0, 0}, + {0, 0, 0, 3, 1, 0, 0}, + {0, 0, 0, 3, 0, 1, 0}, + {0, 0, 0, 3, 0, 0, 1}, + {0, 0, 0, 2, 2, 0, 0}, + {0, 0, 0, 2, 1, 1, 0}, + {0, 0, 0, 2, 1, 0, 1}, + {0, 0, 0, 2, 0, 2, 0}, + {0, 0, 0, 2, 0, 1, 1}, + {0, 0, 0, 2, 0, 0, 2}, + {0, 0, 0, 1, 3, 0, 0}, + {0, 0, 0, 1, 2, 1, 0}, + {0, 0, 0, 1, 2, 0, 1}, + {0, 0, 0, 1, 1, 2, 0}, + {0, 0, 0, 1, 1, 1, 1}, + {0, 0, 0, 1, 1, 0, 2}, + {0, 0, 0, 1, 0, 3, 0}, + {0, 0, 0, 1, 0, 2, 1}, + {0, 0, 0, 1, 0, 1, 2}, + {0, 0, 0, 1, 0, 0, 3}, + {0, 0, 0, 0, 4, 0, 0}, + {0, 0, 0, 0, 3, 1, 0}, + {0, 0, 0, 0, 3, 0, 1}, + {0, 0, 0, 0, 2, 2, 0}, + {0, 0, 0, 0, 2, 1, 1}, + {0, 0, 0, 0, 2, 0, 2}, + {0, 0, 0, 0, 1, 3, 0}, + {0, 0, 0, 0, 1, 2, 1}, + {0, 0, 0, 0, 1, 1, 2}, + {0, 0, 0, 0, 1, 0, 3}, + {0, 0, 0, 0, 0, 4, 0}, + {0, 0, 0, 0, 0, 3, 1}, + {0, 0, 0, 0, 0, 2, 2}, + {0, 0, 0, 0, 0, 1, 3}, + {0, 0, 0, 0, 0, 0, 4} +}; + +static const double COEF[330][3] = { + {8.70954844857314666e-12, 1.27926950848359881e-09, -2.06865474316332923e-09}, + {1.05783308354771544e+00, -8.02119209663359686e-03, -7.88705651445470723e-02}, + {1.35905954452774837e-02, 8.71267975138422468e-01, 1.04898760410704936e-01}, + {-4.16452026099768252e-02, 1.75465381596434100e-02, 1.00224594702931546e+00}, + {4.50321316661211821e-02, -7.11409155427628892e-02, 3.91232300778902690e-03}, + {1.76675507851922452e-02, -1.32709276116036640e-01, 6.36935270589509828e-02}, + {-5.23434830565911030e-02, 3.77681739012521722e-02, -2.08691145087504179e-02}, + {-2.33722556520224792e-03, -1.57542611462692145e-03, -3.05158628452478807e-03}, + {-8.87678609044812990e-04, 3.83194388837734693e-04, 1.37779212442523083e-03}, + {-2.11519042076831979e-03, 5.82337362515735358e-04, 2.24055108941204821e-04}, + {4.61545125563611917e-04, 7.72869451707915893e-04, -1.10800630143346882e-03}, + {1.05937484157345879e-03, -3.14448681732842211e-04, -1.75129182446198098e-03}, + {1.49045689016363055e-03, -2.09220860101674106e-04, 5.93100338908187697e-04}, + {-3.51246656293852696e-04, -8.20743017485394289e-04, 5.71854064480802862e-04}, + {-9.18204643629581319e-01, -2.27788122702773155e-01, 6.39980793022790623e-02}, + {9.24243491377523679e-05, 7.32841332381495400e-04, -1.55219718415109450e-03}, + {7.13695056804217989e-04, -8.46467621879685712e-05, 6.50202947442505750e-04}, + {1.66640864747485983e-03, -1.24492362771216523e-04, 2.68236502346156410e-04}, + {-7.20253644860527516e-04, 7.81434220384157334e-04, 1.12661089007361367e-03}, + {-6.83033334365238206e-05, 7.27742627159490762e-04, -1.78048843835204584e-03}, + {-3.13431571993316588e-02, -8.57604034845650287e-01, -2.57225920656276863e-01}, + {-6.47867200595898341e-05, -1.16688982572457655e-03, 1.14174511750260031e-03}, + {-5.00713925613324338e-04, -6.87598082111323477e-04, 6.20598069880440176e-04}, + {-8.56716727659588957e-05, 9.74478786593559361e-04, -1.65892838405139512e-03}, + {6.53468478750158263e-04, 7.51662000672516676e-04, -6.73196326298856570e-04}, + {-4.42539011000103941e-02, -2.01965359697350230e-02, -9.94663493761314355e-01}, + {-7.39107395392403087e-04, 5.28870828612476996e-04, 1.00947183860234540e-03}, + {-2.06577300933763214e-03, 9.60215813758718011e-04, -3.27993888180819421e-04}, + {3.47783280638377555e-04, 8.41824316850705743e-04, -8.87458944147930993e-04}, + {1.20960551709587905e+00, -7.07660818059813873e-02, -8.56332806008946491e-03}, + {2.11116509318935269e-04, 7.68490846994171776e-04, -1.63228995491542417e-03}, + {6.47698075356516103e-04, -4.20589129268072884e-04, 1.18354001300614896e-03}, + {-2.78795945253848716e-02, 1.22199201000304547e+00, -2.07383075858847743e-01}, + {-5.32457386680677347e-05, -9.58027320315790677e-04, 9.89667309649038679e-04}, + {-9.03932426306289782e-02, -4.00969232187064692e-02, 1.26285611182120072e+00}, + {-2.19453630740322871e-03, -1.21893190049422620e-03, -1.92293368093085417e-03}, + {1.72950845415964505e-06, -8.93952511560151819e-09, -6.14874900641340649e-06}, + {8.02644554976326974e-06, -6.42543741723487294e-06, -6.07103419227907060e-06}, + {3.20307552755319525e-06, -4.83533743093466500e-06, 9.13563764113473065e-07}, + {-2.18105804067510178e-06, 6.19595552598436322e-07, 5.21392855381760945e-06}, + {-2.43310123604345563e-06, 2.17201813434465818e-06, 1.94098874242362718e-07}, + {-1.56293672065252465e-06, 3.95256011818110372e-06, 1.68792962079201969e-06}, + {-1.37567295252127852e-03, 3.59746071987262106e-04, 7.38927139000157259e-05}, + {4.27822004137219658e-06, -8.80187479967658548e-07, 2.29453131891411977e-06}, + {7.68758937964332534e-06, 2.40909410585557829e-07, 4.69351234070854509e-06}, + {-2.87166709944317033e-06, 7.60223902901142716e-07, 4.57864913314467992e-06}, + {-4.01295140267654560e-06, 2.65929275888376483e-06, -2.36575067819565221e-06}, + {2.32693030513910805e-07, 2.28814396769890308e-06, 1.83526107699893970e-07}, + {-2.18213927011287265e-03, 1.65013083920367864e-03, 2.31992998847323087e-04}, + {-7.70829764693697905e-06, 4.23888841240673345e-07, 7.30018322002944087e-06}, + {-1.23111329452911533e-06, 1.50076529718910084e-06, -1.91139744928209288e-06}, + {-1.68872756433485760e-06, 1.03254236824697979e-06, -1.72081108163607555e-06}, + {1.64276928199709460e-06, -4.96350219553231067e-07, -1.46349385185670297e-06}, + {1.12731767057843682e-03, 5.03104281148445223e-04, 1.36398977654308994e-03}, + {-1.05449609518089293e-06, -4.06952115309007489e-07, 3.53062441379482783e-06}, + {-1.98745923822574166e-06, 4.98021943693208180e-07, 3.92645061370218429e-06}, + {-1.55569377977005097e-07, -4.00262856484093037e-07, -2.49609122397048688e-06}, + {2.18005022830924673e-03, -4.10275057064835439e-05, -2.59776311836759947e-04}, + {5.41337439827552225e-07, -1.88603932528607146e-06, -2.06428606152470051e-06}, + {-6.03243799807140491e-06, -3.75067864464502022e-06, -3.05702776851046742e-06}, + {2.30038011634901016e-03, -1.32581161861259635e-03, -1.07680096899188406e-03}, + {4.46773877910556887e-06, 1.85008408528524772e-08, -2.72851357570281713e-06}, + {-1.49177636513049289e-03, -1.91426739654176659e-04, -1.71206384332753194e-03}, + {2.31661325589237743e-02, 2.26540538563063554e-01, 5.42330337046266139e-02}, + {-1.40563059963100256e-06, -4.50551806294901061e-06, 8.87542894832671347e-06}, + {-1.66780916452391459e-06, 4.12065434881171526e-06, -3.55865035776836702e-06}, + {2.71536622051954390e-07, -3.08564858926584692e-06, -1.52164363662402047e-06}, + {2.66659632027280158e-06, -1.19436686895073481e-06, -3.25738306279285683e-06}, + {-1.43666282346327501e-06, -2.51923473623639690e-06, 5.21205120344175876e-06}, + {2.82954522469612199e-04, -1.59147454710008968e-03, 1.27685773978167098e-03}, + {-3.99471240294241303e-06, 9.97323772325767188e-08, -5.28196823261495307e-06}, + {-6.39858432699424995e-06, -4.59897864440506933e-06, -2.39736149785715891e-06}, + {2.89457420106498109e-06, -3.10427512149489757e-06, 9.75553221437691631e-07}, + {-8.96518259720091581e-07, -5.53996694461914366e-06, 1.03733964032237669e-05}, + {8.82130497168875905e-04, -2.33618402105562365e-03, 1.35100410641244379e-03}, + {-2.14088521029685841e-06, 2.59005410360388117e-06, -9.78713171504927426e-08}, + {-4.50668337071552516e-06, 3.58808570076458002e-06, -1.56159349007541082e-06}, + {-1.52345101244247272e-06, 2.21066768791959578e-06, -2.19555898547246775e-06}, + {2.07334042074768356e-03, -1.56333498489329517e-03, -5.53762940364141767e-04}, + {2.22151748134440108e-06, -4.74729938900429749e-07, -3.46744150304684889e-06}, + {2.95389009221172505e-06, -2.96312023445686329e-06, -9.00385068308695580e-07}, + {-6.47780848348620771e-04, 2.38772263398574292e-03, -8.93908589731968019e-04}, + {9.69501567645025819e-07, 2.41432205872957328e-06, 5.56908291093893837e-07}, + {-6.33392066185247586e-04, 2.38613844267241120e-03, -1.05383725637261472e-03}, + {6.76250135616376785e-02, -5.57799579151454852e-02, 1.83393652374666566e-01}, + {3.53986894266120067e-06, 5.92996717102502093e-06, -7.32378536156402804e-06}, + {5.69667193362453916e-06, 1.20219201908705218e-06, -4.56663805956276925e-06}, + {7.11494218295222192e-07, 2.93069858359131137e-06, 1.23210839732268429e-07}, + {-3.41917893741799928e-06, -1.47435291776966751e-06, 1.07397354370819542e-06}, + {7.30931882734254710e-04, 1.15433149094644884e-03, -2.40026982569019722e-03}, + {-1.22780859907432871e-06, 2.29287908084027789e-06, 1.84270754640877832e-06}, + {7.71579140080615178e-07, 2.92378122615943208e-06, -1.91800935486416413e-07}, + {-3.76107279903559188e-07, -1.83159743461489867e-06, 8.17089655984204466e-07}, + {-1.10830882430058061e-03, -5.10908079549339251e-04, -1.77835176235151705e-03}, + {-1.26839781743699406e-06, -2.86942252006448415e-06, 4.47464983859263005e-06}, + {-1.44518716284694482e-06, -7.03360635528004451e-06, 1.04898109513258675e-05}, + {-4.98687888007460470e-04, 1.86990180752567262e-03, -1.24341018156770089e-03}, + {-2.90479801332704790e-06, -9.24272269110706229e-07, 7.56354222045119151e-07}, + {-1.16451534008294149e-03, -2.34216801827852273e-03, 4.91479264672447288e-03}, + {-7.70970926241258958e-02, 9.35855573900774423e-02, 1.50623807158846906e-01}, + {1.14039905307547484e-06, -1.80664235182388840e-07, -5.15527441317074897e-06}, + {7.50559587697416375e-06, -6.23982034686780714e-06, -5.01245198064126721e-06}, + {2.37840954889385892e-06, -4.15663063190341991e-06, 1.93118829429697603e-06}, + {-1.54903048110950777e-03, 2.65832194444263125e-04, 5.34401520444913940e-04}, + {4.00040634507183718e-06, -2.43965474694277443e-06, 2.88683251413283937e-06}, + {7.72301916160400559e-06, -9.54300275625495457e-07, 5.50777546561020959e-06}, + {-2.28103126593574368e-03, 1.02658341009706066e-03, 1.22010567464172614e-03}, + {-6.32818026002207601e-06, 9.83088209200334157e-07, 5.24316808343458507e-06}, + {1.37175660779395581e-03, 4.01188715721313943e-04, 7.59370199245276625e-04}, + {-3.33184694847917573e-01, 7.82846225823195241e-02, -9.94270054263078074e-02}, + {-1.70108770909324636e-06, -5.10749831734438279e-06, 9.80267482880020635e-06}, + {-1.79301365419055891e-06, 4.44839673308561508e-06, -3.83837422072638712e-06}, + {1.71911692904483371e-04, -1.56077480341044431e-03, 1.30725115579017584e-03}, + {-3.55763938679129477e-06, 1.20558966207589408e-06, -5.94340114624253291e-06}, + {1.02325453537648178e-03, -1.52640960762801372e-03, 3.10973117856692537e-04}, + {3.81842873295820109e-03, -3.02114884453467680e-01, 2.78264587142456665e-01}, + {3.46123498726202961e-06, 5.05929187103208375e-06, -6.85764673719752027e-06}, + {4.47228353489932293e-04, 9.60672217798415784e-04, -2.19382758010531077e-03}, + {2.22711833124298791e-01, -4.14141995162802465e-02, -4.27998216564745015e-01}, + {-1.78271151817048783e-03, -9.81039111371464307e-04, -1.37513011841553174e-03}, + {3.35305394560947434e-10, -1.26710751613412498e-09, 3.54248685940916630e-09}, + {-9.26917423371698135e-09, 6.21190912597491263e-09, 1.86942252233812667e-08}, + {-1.56687696151180944e-09, -5.44315731376698864e-09, 1.93822974337010123e-09}, + {7.52897716393974292e-10, -3.48923168136394679e-10, -5.94217786087369859e-10}, + {2.52116855170569920e-10, -2.48216903975251313e-09, 1.01699001303634518e-09}, + {3.72215577457146729e-09, 4.51910314724912610e-10, -6.15361639422218332e-09}, + {-2.62088816666700142e-07, 3.23631086683010168e-07, 8.85302852722882894e-07}, + {-1.30537319842360944e-08, 1.46808588619151692e-08, 2.67574040702101001e-09}, + {-1.23991327621864045e-08, 2.61298349069072344e-08, -4.58919307373337193e-09}, + {5.03079244928983371e-09, -6.73783119575777079e-10, -1.13935871848269699e-08}, + {9.09065785148488459e-09, -1.04304054004966673e-08, -3.23123813816827976e-09}, + {9.55627910137479830e-10, -1.41129563591135820e-08, -1.75594400131373618e-09}, + {-1.05549669436946769e-07, 8.47284096194811896e-08, 6.70761880091491625e-07}, + {-5.92079330008488114e-10, 6.31702118392141188e-09, -4.51534448719925763e-09}, + {-1.04033970327321867e-09, 4.67775485013532943e-09, 2.79348504744758586e-09}, + {5.38758108958869997e-09, -9.55380699552144108e-09, 6.16488249338686956e-11}, + {1.12057409185073453e-09, -3.00645183748393663e-09, -2.14940637510707688e-09}, + {-6.27004681934967278e-07, 8.59159786402940127e-07, 2.73192537668387470e-07}, + {7.36784189214745311e-10, -8.12761968838060511e-10, -2.43226564583531868e-09}, + {1.25546123497244366e-09, -6.98609614602219153e-10, -5.29894812750786315e-09}, + {-8.88351475714088679e-10, 1.37132565025677167e-09, 1.92497813869541012e-09}, + {6.10992637326349119e-07, -6.13496367368217277e-07, -2.19901889726877020e-06}, + {-8.59090437677068053e-11, 2.72772732179404898e-09, 1.54554039011323141e-09}, + {-4.58798915525804318e-10, 4.54384851966693759e-09, 3.63189350816028877e-09}, + {9.93115786933340683e-08, 1.63700862245048928e-07, -1.71397937400244449e-07}, + {-1.62985361318312982e-09, -3.10762126448649312e-09, 1.76193495557419588e-09}, + {6.27207737564569601e-07, -1.49343052365004934e-06, 8.16168870109573730e-08}, + {1.42518738380244172e-03, -3.47531891583186285e-04, -2.98661838800559913e-04}, + {8.98157254125564464e-09, -8.24242643235328920e-09, -5.34769730234363472e-09}, + {-2.17776999489327494e-08, -4.47141107473569832e-09, -1.10218517090920898e-08}, + {3.19614509858290319e-09, -3.32861183754973311e-09, 9.92016746526047655e-11}, + {-2.91660393059167689e-09, 5.59829099744391101e-09, 1.70080685646389895e-09}, + {1.22479524179014421e-09, 9.20737683318684219e-09, -1.10618757209746121e-10}, + {7.70594587548882257e-09, -1.33267446898667659e-06, 4.52812675308736368e-07}, + {9.46080642993951670e-09, -1.95483249032513129e-08, -1.23592694620255905e-08}, + {-2.02330094345448686e-09, 1.18198534293512125e-10, 2.34746776184291406e-09}, + {4.00839940406516604e-09, -4.80716730311137042e-09, 5.25802457129742606e-09}, + {-2.53115202408782380e-09, 2.05563177591017165e-10, 5.46003270374129102e-09}, + {3.24841319972028232e-08, -1.24284705839720552e-06, 4.97326549863015555e-07}, + {1.37729661009444726e-09, -1.67903983772088594e-09, -5.62083748989472554e-09}, + {-3.53256937590806785e-10, 4.49320892992322030e-09, -4.02300486673778934e-09}, + {2.48976475547557641e-09, -6.97256366533061112e-09, 1.43185084622299286e-09}, + {-4.38617299338556199e-09, 9.45081248826811111e-08, -2.91197460585562728e-07}, + {3.24429103026879773e-09, -1.71647943601749287e-09, 2.71076100455402980e-09}, + {3.86933235105302309e-09, -2.82628156988984358e-09, 8.24455756442965537e-09}, + {-7.46614068323353530e-07, 1.27696340529665289e-06, 6.88413034833322557e-07}, + {-5.78118683480788320e-09, 1.34319005917760137e-09, -1.15898873831454807e-09}, + {4.42686972671260670e-07, 6.41810588767341775e-07, -1.16058405342719939e-08}, + {2.24399192788231686e-03, -1.35129336477888174e-03, -7.39944244498236844e-04}, + {7.47869199901884940e-09, -2.68762612165573955e-09, -7.41584788022109365e-09}, + {1.80867308283150230e-09, -2.21500551234043996e-09, 1.86995768869380186e-09}, + {-5.05514829302056157e-09, 4.74048706539109688e-09, 2.52998993977016085e-09}, + {1.32441967115592973e-09, 5.70339246663831290e-09, 7.13448300437846683e-10}, + {1.19767475292940212e-06, 6.72445227582811568e-07, -1.97500319605841551e-06}, + {-1.70612399208458498e-09, 1.07145120553653328e-09, 1.73225882249550267e-09}, + {1.15369127445807962e-09, -5.80362996549510513e-09, 9.33515653667171819e-10}, + {3.38692740520230018e-09, 3.72531013675958533e-09, -3.18062756687886861e-09}, + {1.14787653780236421e-06, -1.84917201319622368e-06, -2.44834286920736499e-07}, + {1.45558928799083276e-09, 1.12720083267348059e-09, 9.00940544390493869e-10}, + {2.09654001104286891e-09, 4.92913422578400429e-09, 3.04938074791039071e-10}, + {3.54033623213741155e-07, 1.07259516691213860e-06, -6.03027205987524684e-07}, + {-2.72038239157446071e-09, -1.60070143945256760e-09, 6.03853855807301443e-10}, + {-2.03235662485238069e-06, -1.03151962834260348e-06, 1.99637918628457062e-06}, + {-1.26261175077493210e-03, -4.98503988506484859e-04, -1.03875859619143593e-03}, + {6.43182729298530376e-10, 8.01776645076301975e-10, -1.83589794755523172e-09}, + {4.01805119037978997e-09, -5.63673552278487477e-10, -1.09102650663883693e-08}, + {-1.48648961195707585e-09, 5.01067861508053269e-09, 2.99132781045319263e-09}, + {-8.91404754824534629e-07, 7.49163968581634775e-07, 2.12542215183124383e-06}, + {2.38642574451608525e-09, -3.47605810802065207e-09, 3.86935566920598717e-10}, + {-2.80031986488182838e-09, -4.25160427697246490e-11, 2.24182921879090280e-09}, + {-1.26991357818351247e-07, -1.45348284568834647e-07, 5.68792533226815389e-07}, + {1.39227229745131353e-09, -1.84849578699353145e-09, 2.24967258190267305e-09}, + {-1.15462500328497586e-06, 1.84347590761761086e-06, 3.64918716654494962e-07}, + {-2.09357112083411985e-03, 1.60820400301404873e-05, 2.27418117008655948e-04}, + {-1.04484803378768198e-08, 4.86043558178828050e-09, 2.00996588123336650e-09}, + {1.44040971927772432e-08, 1.42223015309195233e-09, 1.99778974613318283e-09}, + {-1.62414574166394599e-07, -1.31976785339561840e-06, 4.43918084507000099e-07}, + {3.73061943836905385e-09, 1.00036822436866402e-08, -1.05450977117005351e-09}, + {-2.06551932971539565e-07, -9.72167971235462190e-07, 4.28861904300768815e-07}, + {-2.16051814014425313e-03, 1.48780488507118812e-03, 7.79940397419977911e-04}, + {-4.80544204428667854e-09, -1.09870773590259319e-09, 6.58876991984844174e-09}, + {1.31575045692056136e-06, 4.32430764481131318e-07, -1.55255090541518703e-06}, + {1.28823975640215602e-03, 4.04521283440268135e-04, 1.76186984141882253e-03}, + {-1.09767251093991436e-01, -4.94112205838347640e-02, -5.43102978164306804e-02}, + {7.93691223854864347e-10, 1.54639511196208446e-08, -1.71518303448969789e-08}, + {2.56523843833456056e-09, -2.31047392329486456e-09, -4.29758133398648601e-09}, + {-9.87725901069325118e-09, 4.28127375218245732e-09, 2.02888056355376989e-09}, + {3.21762172461603768e-10, -5.82937505211322815e-09, 3.88293127512318037e-09}, + {1.63250610252241302e-09, -7.02161705168347083e-09, 3.46592492032893329e-09}, + {-1.44272117683086343e-07, -4.40408510988914148e-07, 5.92746408872857344e-07}, + {2.71961467235293242e-09, -1.47466668633244868e-08, 2.89637452632884873e-08}, + {1.47637712476396399e-08, 1.16406781783262581e-09, 2.04904540557215853e-09}, + {-5.53709807865621073e-09, 7.05512286092169205e-09, 1.56159114805820565e-09}, + {5.29268649740455288e-09, 2.10616986628942016e-08, -3.03219004488264332e-08}, + {1.79978890693655025e-07, 7.95085399132693105e-07, -4.78366567607801940e-07}, + {-4.03847393894152251e-10, 2.90357085597214848e-09, 1.12992165623992946e-09}, + {2.99031871486832301e-09, -1.37951879780606745e-09, 2.41048263988075107e-09}, + {1.26882357398550027e-09, 1.30631467101793852e-09, 7.99574240151201820e-10}, + {-1.41169562567489137e-08, 1.27148955713198356e-06, -2.89386439707162157e-07}, + {-2.68794415198003733e-09, 8.73673404455654889e-10, 2.89557382238125882e-09}, + {-4.90264437380538709e-09, 1.89207244316591527e-09, 2.25393465003165261e-09}, + {-3.58274654665979853e-08, 2.91386646529383231e-07, -4.98477764412919022e-08}, + {1.65722165851311942e-09, -1.11673743863338615e-09, -4.14131162695952071e-09}, + {-1.47751280626939874e-07, -2.41471865000848773e-07, -8.53552350049691100e-07}, + {-2.24352957583577790e-04, 1.60900273524284708e-03, -1.32260753549593617e-03}, + {2.05497643901431104e-09, 1.38702982710459111e-08, -3.09887516689033582e-09}, + {3.39770491949997755e-09, 9.41613393506957053e-09, -7.09844738544518350e-10}, + {7.86209687630989862e-10, 1.93556837224662104e-10, -6.58630930350234678e-09}, + {-6.86841181152253455e-10, -5.57194149153339424e-09, 1.41214109156129197e-09}, + {2.59516074158083754e-07, 1.30703181255419770e-06, -4.02454784192984860e-07}, + {-5.79425202262839889e-10, 4.05071760856134944e-09, 3.02384985106929349e-09}, + {4.00677924866643664e-09, -2.25614611715219127e-09, 7.52819043214891792e-09}, + {2.34003759425061020e-09, 5.27462258592681366e-09, -2.05723854618256041e-10}, + {2.29340174767722615e-07, 1.05507868574435809e-06, -4.45904844964539748e-07}, + {-3.91634245866523401e-09, 1.07849931763048801e-09, 1.85542686770290288e-09}, + {-6.62166513287765213e-09, 3.86355018811013196e-09, -1.87861701195224384e-09}, + {1.32112240848469842e-07, 4.39339645861430705e-08, -1.59384598983486336e-06}, + {2.02488462108796341e-09, -1.48427112267590644e-09, -4.32055485832805175e-09}, + {-4.27701540045566375e-07, -1.46229443391283215e-06, -2.38186369433401879e-07}, + {-9.86744509368740232e-04, 1.91104095070606826e-03, -8.17774843405986713e-04}, + {2.06891823117949514e-10, -2.64060942556376688e-09, 1.86419366055012858e-09}, + {8.33785634979378187e-09, -1.00697171434571686e-08, -2.84106664583116952e-09}, + {5.07057938692323518e-09, -9.56246298811080919e-09, -6.33399999117045809e-11}, + {-6.78808357162941078e-08, -2.21612941845184680e-07, 9.42031624998063144e-08}, + {-3.04300065007145903e-09, 5.64120231083542478e-09, 1.65718606892628628e-09}, + {3.76240642807612602e-09, -4.58941407446844529e-09, 5.06162500801821125e-09}, + {7.25149885354159363e-07, -1.18149759075966698e-06, -6.82406347277120240e-07}, + {-4.84358128605144600e-09, 4.56893046833772853e-09, 2.67044331092591847e-09}, + {-2.54939737986958903e-07, -1.06106228658746360e-06, 5.04013386790069795e-07}, + {-2.17097468872509735e-03, 1.41624400187313607e-03, 8.11305605779899562e-04}, + {2.24635331169675823e-10, -6.02144184513875302e-09, 4.15827878380570226e-09}, + {-4.55408258326350790e-09, 6.20319154376325343e-09, 2.08760821823750220e-09}, + {2.10871853867367065e-07, -4.29346688506603014e-07, 1.15683623843482186e-07}, + {1.00732072683129559e-09, 3.88267751283422058e-11, -6.73798626615873530e-09}, + {5.34506627847264326e-09, -8.01262819982717645e-08, 1.60888846226225901e-06}, + {5.83419066552946048e-04, -2.36474094848551555e-03, 8.79373865688287898e-04}, + {-4.85158746510450101e-10, -6.78789624508624456e-09, 4.95385649168511577e-09}, + {3.47485142271342085e-07, 5.60944792101468470e-07, -4.35887910682497548e-07}, + {5.75824910919892421e-04, -2.18618554413632388e-03, 1.22736498224538170e-03}, + {-2.51838883195707221e-02, -8.23487774284355212e-02, 3.33658831723806573e-02}, + {-8.70167529698484543e-09, -1.37080219501928280e-08, 1.80728228771354082e-08}, + {-4.67111571644807100e-09, -2.72041008123058425e-09, 7.06648883852523113e-09}, + {7.26183221906172727e-10, -6.77816339167414128e-09, 4.52883232651690726e-09}, + {5.28852302228433047e-09, 6.47161005340457507e-09, -8.67298467766008940e-09}, + {-2.25465519365641853e-07, -6.46057585221293529e-07, 3.48151143400587948e-07}, + {-1.30051025504229756e-09, -3.25062288891730944e-09, 2.01775679498084060e-09}, + {-5.12724809831333062e-09, 9.33902577666956280e-10, -6.96327353416625883e-10}, + {-3.10810940873373909e-09, -7.49756534634826721e-10, 6.87357185058523612e-10}, + {-1.52109221995821997e-06, -4.22908767925417317e-07, 1.38629667568307413e-06}, + {1.42955317028459206e-09, -7.02968461219199980e-10, -3.81617160094549490e-09}, + {2.53707400921232562e-09, -1.60727622877665510e-09, -4.18765366827500429e-09}, + {-2.14750738948554787e-07, -6.40554276953864132e-07, 3.76128531993924486e-07}, + {3.83073214815787821e-09, 4.50296289838947317e-10, 2.29523194894554194e-09}, + {4.76340728555735282e-07, 6.83235613037347367e-07, -4.72205395646296822e-07}, + {-6.10651996176347607e-04, -1.06790499934057291e-03, 2.29083496655867842e-03}, + {3.95497823379997726e-09, 1.38236928154400474e-09, -6.26218820548585242e-09}, + {1.11904936705986557e-09, -1.37869946362223494e-08, -9.34049783699042457e-10}, + {1.25499246411697740e-09, -2.73635453185150368e-09, -2.91506864740637139e-09}, + {-3.59882924006599270e-07, 1.32511373732895413e-06, -1.55110207063907657e-07}, + {1.07068498511608823e-09, 8.92087770321126072e-09, 2.62826524433101838e-10}, + {-2.69316546841480431e-09, 9.61138280075601870e-10, 5.19946977139973399e-09}, + {-5.92563579700916554e-07, -1.05071339539294234e-06, 1.56249964602256375e-07}, + {1.32198180180509439e-09, 5.16087961255351502e-09, 8.46339526239248130e-10}, + {2.07323220008381881e-06, 1.02309267446332522e-06, -2.07661522726165781e-06}, + {1.31402366846389393e-03, 3.78229792813366064e-04, 1.77496793932758741e-03}, + {8.59301428624004160e-10, -6.83071707530125138e-09, 3.36249680876754553e-09}, + {5.27310424491833629e-09, 2.09999085065692981e-08, -3.10459945807028959e-08}, + {-8.88666080375855039e-08, 4.60897593930476024e-07, 7.41576575386676540e-07}, + {-4.85540663230921155e-10, -5.58243438975036810e-09, 7.40450811775872353e-10}, + {4.03141117225058743e-07, 1.52035531639227450e-06, 9.06206514897367477e-08}, + {5.61075629915620496e-04, -2.05847905628765053e-03, 1.12849817492909434e-03}, + {5.11216541321246609e-09, 7.26292920250060092e-09, -8.97145741030058730e-09}, + {-4.26211688914213127e-07, -7.03366608210270750e-07, 6.27995585866791828e-07}, + {1.15309052943982646e-03, 2.34474318844151959e-03, -4.91856748507475423e-03}, + {1.01104427799588961e-01, -4.22361682938472982e-02, -1.88750007538552200e-01}, + {3.94738332298860684e-10, -7.81372397340440727e-10, 4.06815717224340290e-09}, + {-8.61483928638051566e-09, 5.37427180535843263e-09, 1.81738104426676372e-08}, + {-8.48011268844706123e-10, -5.33803143354383280e-09, 2.99703953494934172e-10}, + {3.89154099408092063e-07, -2.44166311268514957e-07, -8.03240371135063858e-07}, + {-1.20249536439409610e-08, 1.48908931921210019e-08, 1.88292573199966284e-09}, + {-1.16401289163015065e-08, 2.57866422936903206e-08, -5.27022399332555125e-09}, + {1.37065399911928676e-07, 2.16494406102361175e-08, -7.63924557662179482e-07}, + {-6.94754161319199870e-10, 6.65038621394664631e-09, -4.31779645371221932e-09}, + {4.72542155592614588e-07, -7.58546986886782931e-07, -2.35913417925837088e-07}, + {1.46133817312113241e-03, -3.25193103208258009e-04, -3.06625181254991741e-04}, + {9.35794082672593210e-09, -7.92923574022275091e-09, -5.41426242728348939e-09}, + {-2.15279239157428748e-08, -4.16754339024882903e-09, -1.12896482995505920e-08}, + {2.60645369870582400e-10, 1.44616071127263122e-06, -3.63334053799999057e-07}, + {9.17105741349288905e-09, -2.02295233654725681e-08, -1.20002956877085509e-08}, + {-1.27759226226098477e-07, 1.28193771791124470e-06, -5.83097827522305323e-07}, + {2.26880791869919426e-03, -1.34042850080092401e-03, -7.65092051285704835e-04}, + {7.03374036792325796e-09, -2.53508958270032281e-09, -7.66132998708535240e-09}, + {-9.71978722189015265e-07, -5.57836512454779054e-07, 1.96329328074063003e-06}, + {-1.26115140811304343e-03, -4.81792074617704632e-04, -1.06803272537897391e-03}, + {1.19419564863885497e-01, 5.07766738901840875e-02, 4.87642090320925953e-02}, + {1.14090414893297520e-09, 1.56073433760228752e-08, -1.78054684078429726e-08}, + {3.03285130343056153e-09, -1.58615337531031741e-09, -4.94928394101368241e-09}, + {2.64483280249840080e-07, 2.97155396291660413e-07, -5.41608085095034164e-07}, + {2.68757552324139226e-09, -1.41400907649469332e-08, 2.93255796729452456e-08}, + {-2.11094617584561828e-07, -6.56355695552793272e-07, 3.72180321686621518e-07}, + {-2.55073452371079590e-04, 1.57943859317488818e-03, -1.29154484940938240e-03}, + {1.40049266628139435e-09, 1.40747080656922208e-08, -2.58792021839981956e-09}, + {-2.12330362681090179e-07, -1.30522733223815968e-06, 5.84417623253341567e-07}, + {-9.33144849909676392e-04, 1.90305575962152547e-03, -8.35564417983726418e-04}, + {1.81624805201406961e-02, 6.84911174969819458e-02, -2.28291882522520390e-02}, + {-8.25231299961259879e-09, -1.40227519596081152e-08, 1.78809529925716415e-08}, + {1.90689491530449118e-07, 7.01057736002264065e-07, -4.26430629252294580e-07}, + {-5.85146839837499930e-04, -1.07311215649546045e-03, 2.31986890222730339e-03}, + {-1.05962397073886522e-01, 5.51532131360410807e-02, 1.87542648909451215e-01}, + {-1.37499370823599516e-03, -8.49619409242363438e-04, -1.18180356709159952e-03} +}; + +static const double INTERCEPT[3] = { + -1.29208772400146188e+00, + 6.62251952866635918e+00, + -1.35908984683965173e-01 +}; +// END AUTO-GENERATED COEFFICIENTS + +inline void compute_poly_features(const double x[7], double out[330]) { + for (int i = 0; i < N_FEATURES; ++i) { + double val = 1.0; + for (int j = 0; j < N_INPUTS; ++j) { + if (POWERS[i][j] != 0) { + double base = x[j]; + int exp = POWERS[i][j]; + // Fast integer exponentiation (max exp = 4) + double p = 1.0; + for (int e = 0; e < exp; ++e) + p *= base; + val *= p; + } + } + out[i] = val; + } +} + +} // namespace detail + +struct RGB { + unsigned char r, g, b; +}; + +/** + * Mix two RGB colors using polynomial pigment mixing. + * + * This performs polynomial pigment-style RGB interpolation. + * + * @param r1,g1,b1 First color (0-255) + * @param r2,g2,b2 Second color (0-255) + * @param t Mixing ratio: 0.0 = all color1, 1.0 = all color2 + * @param out_r,out_g,out_b Output color (0-255) + */ +inline void lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b) { + // Clamp t + if (t <= 0.0f) { + *out_r = r1; *out_g = g1; *out_b = b1; + return; + } + if (t >= 1.0f) { + *out_r = r2; *out_g = g2; *out_b = b2; + return; + } + + double x[7] = { + static_cast(r1), static_cast(g1), static_cast(b1), + static_cast(r2), static_cast(g2), static_cast(b2), + static_cast(t) + }; + + double features[330]; + detail::compute_poly_features(x, features); + + // Dot product: features @ COEF + INTERCEPT + for (int c = 0; c < 3; ++c) { + double sum = detail::INTERCEPT[c]; + for (int i = 0; i < detail::N_FEATURES; ++i) { + sum += features[i] * detail::COEF[i][c]; + } + // Clamp to [0, 255] and truncate (matches numpy astype(int) behavior) + int val = static_cast(sum); + if (val < 0) val = 0; + if (val > 255) val = 255; + + if (c == 0) *out_r = static_cast(val); + else if (c == 1) *out_g = static_cast(val); + else *out_b = static_cast(val); + } +} + +/** + * Convenience overload returning an RGB struct. + */ +inline RGB lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t) { + RGB result; + lerp(r1, g1, b1, r2, g2, b2, t, &result.r, &result.g, &result.b); + return result; +} + +} // namespace filament_mixer + +#endif // FILAMENT_MIXER_MODEL_HPP diff --git a/src/libslic3r/Format/OBJ.cpp b/src/libslic3r/Format/OBJ.cpp index 71f7d1e7e2..50826924f4 100644 --- a/src/libslic3r/Format/OBJ.cpp +++ b/src/libslic3r/Format/OBJ.cpp @@ -1,6 +1,8 @@ #include "../libslic3r.h" #include "../Model.hpp" #include "../TriangleMesh.hpp" +#include "../TexturePainting.hpp" +#include "ResourcePathUtils.hpp" #include "OBJ.hpp" #include "objparser.hpp" @@ -21,7 +23,7 @@ namespace Slic3r { -bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::string &message) +bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::string &message, ObjParser::MtlData *out_mtl) { if (meshptr == nullptr) return false; @@ -98,6 +100,7 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s its.indices.reserve(num_faces + num_quads); if (exist_mtl) { obj_info.is_single_mtl = data.usemtls.size() == 1 && mtl_data.new_mtl_unmap.size() == 1; + obj_info.usemtls = data.usemtls; obj_info.face_colors.reserve(num_faces + num_quads); } bool has_color = data.has_vertex_color; @@ -210,14 +213,17 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s } if (meshptr->volume() < 0) meshptr->flip_triangles(); + // Hand the parsed material table back so callers can build a TexturedMesh from it. + if (out_mtl) + *out_mtl = mtl_data; return true; } -bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &message, const char *object_name_in) +bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &message, const char *object_name_in, ObjParser::MtlData *out_mtl) { TriangleMesh mesh; - bool ret = load_obj(path, &mesh, obj_info, message); + bool ret = load_obj(path, &mesh, obj_info, message, out_mtl); if (ret) { std::string object_name; @@ -232,6 +238,144 @@ bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &me return ret; } +bool obj_to_textured_mesh( + const ObjInfo& obj_info, + const indexed_triangle_set& its, + const ObjParser::MtlData& mtl_data, + const std::string& obj_directory, + TexturedMesh& out) +{ + if (its.vertices.empty() || its.indices.empty() || !obj_info.has_uv_png) + return false; + + const size_t nv = its.vertices.size(); + const size_t nf = its.indices.size(); + + // 1. Copy vertices + out.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) + out.vertices[i] = {its.vertices[i].x(), its.vertices[i].y(), its.vertices[i].z()}; + + // 2. Copy face indices + out.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) + out.indices[i] = {its.indices[i][0], its.indices[i][1], its.indices[i][2]}; + + // 3. Build per-face UV (uv_coords + uv_indices) + // OBJ UV convention: V=0 at bottom (OpenGL); texture sampling expects V=0 at top (like glTF/OpenCV). + // Flip V here so downstream code works uniformly. + if (!obj_info.uvs.empty()) { + const size_t uv_face_count = obj_info.uvs.size(); + out.uv_coords.resize(uv_face_count * 3); + out.uv_indices.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + if (fi < uv_face_count) { + int base = static_cast(fi * 3); + out.uv_coords[base + 0] = {obj_info.uvs[fi][0].x(), 1.f - obj_info.uvs[fi][0].y()}; + out.uv_coords[base + 1] = {obj_info.uvs[fi][1].x(), 1.f - obj_info.uvs[fi][1].y()}; + out.uv_coords[base + 2] = {obj_info.uvs[fi][2].x(), 1.f - obj_info.uvs[fi][2].y()}; + out.uv_indices[fi] = {base, base + 1, base + 2}; + } else { + out.uv_indices[fi] = {0, 0, 0}; + } + } + } + + // 4. Build material list and load textures from disk + // Map: material name -> material index + std::map mtl_name_to_idx; + for (size_t i = 0; i < mtl_data.mtl_orders.size(); ++i) + mtl_name_to_idx[mtl_data.mtl_orders[i]] = static_cast(i); + + const int num_materials = static_cast(mtl_data.mtl_orders.size()); + out.material_colors.resize(num_materials, {1.f, 1.f, 1.f, 1.f}); + out.material_texture_map.resize(num_materials, -1); + + // Map: texture filename -> index in out.textures + std::map png_to_tex_idx; + + for (int mi = 0; mi < num_materials; ++mi) { + const std::string& name = mtl_data.mtl_orders[mi]; + auto it = mtl_data.new_mtl_unmap.find(name); + if (it == mtl_data.new_mtl_unmap.end()) + continue; + const auto& mtl = *(it->second); + + // Material color from Kd + out.material_colors[mi] = {mtl.Kd[0], mtl.Kd[1], mtl.Kd[2], mtl.Tr}; + + // Texture from map_Kd + if (mtl.map_Kd.empty()) + continue; + + auto tex_it = png_to_tex_idx.find(mtl.map_Kd); + if (tex_it != png_to_tex_idx.end()) { + out.material_texture_map[mi] = tex_it->second; + continue; + } + + // Resolve texture file path. + const boost::filesystem::path requested_tex_path(mtl.map_Kd); + const boost::filesystem::path tex_path = requested_tex_path.is_absolute() ? + resource_path::resolve_existing_path_case_insensitive(requested_tex_path, "obj_to_textured_mesh: map_Kd") : + resource_path::resolve_existing_relative_path_case_insensitive( + boost::filesystem::path(obj_directory), requested_tex_path, "obj_to_textured_mesh: map_Kd"); + + if (tex_path.empty()) { + BOOST_LOG_TRIVIAL(warning) << "obj_to_textured_mesh: texture not found: " << requested_tex_path; + continue; + } + + // Read raw file bytes + boost::nowide::ifstream file(tex_path.string(), std::ios::binary | std::ios::ate); + if (!file.is_open()) + continue; + auto file_size = file.tellg(); + if (file_size <= 0) + continue; + file.seekg(0, std::ios::beg); + + TextureImage ti; + ti.data.resize(static_cast(file_size)); + file.read(reinterpret_cast(ti.data.data()), file_size); + ti.width = -1; + ti.height = -1; + ti.channels = 0; + + int new_idx = static_cast(out.textures.size()); + out.textures.push_back(std::move(ti)); + png_to_tex_idx[mtl.map_Kd] = new_idx; + out.material_texture_map[mi] = new_idx; + } + + // 5. Build per-face material_ids from usemtls ranges + out.material_ids.resize(nf, -1); + if (!obj_info.usemtls.empty()) { + for (size_t fi = 0; fi < nf; ++fi) { + int face_idx = static_cast(fi); + for (size_t k = 0; k < obj_info.usemtls.size(); ++k) { + const auto& um = obj_info.usemtls[k]; + if (face_idx >= um.face_start && face_idx <= um.face_end) { + auto name_it = mtl_name_to_idx.find(um.name); + if (name_it != mtl_name_to_idx.end()) + out.material_ids[fi] = name_it->second; + break; + } + } + } + } + + if (out.textures.empty()) { + BOOST_LOG_TRIVIAL(warning) << "obj_to_textured_mesh: no textures loaded"; + return false; + } + + BOOST_LOG_TRIVIAL(info) << "obj_to_textured_mesh: " << nf << " faces, " + << out.textures.size() << " textures, " + << num_materials << " materials"; + return true; +} + bool store_obj(const char *path, TriangleMesh *mesh) { //FIXME returning false even if write failed. diff --git a/src/libslic3r/Format/OBJ.hpp b/src/libslic3r/Format/OBJ.hpp index 2d4370c99a..7338fe0813 100644 --- a/src/libslic3r/Format/OBJ.hpp +++ b/src/libslic3r/Format/OBJ.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_Format_OBJ_hpp_ #define slic3r_Format_OBJ_hpp_ #include "libslic3r/Color.hpp" +#include "objparser.hpp" #include namespace Slic3r { @@ -18,6 +19,7 @@ struct ObjInfo { std::map pngs; std::unordered_map uv_map_pngs; bool has_uv_png{false}; + std::vector usemtls; // material spans, for texture import }; struct ObjDialogInOut @@ -32,8 +34,18 @@ struct ObjDialogInOut std::string lost_material_name{""}; }; typedef std::function ObjImportColorFn; -extern bool load_obj(const char *path, TriangleMesh *mesh, ObjInfo &vertex_colors, std::string &message); -extern bool load_obj(const char *path, Model *model, ObjInfo &vertex_colors, std::string &message, const char *object_name = nullptr); +extern bool load_obj(const char *path, TriangleMesh *mesh, ObjInfo &vertex_colors, std::string &message, ObjParser::MtlData *out_mtl = nullptr); +extern bool load_obj(const char *path, Model *model, ObjInfo &vertex_colors, std::string &message, const char *object_name = nullptr, ObjParser::MtlData *out_mtl = nullptr); + +struct TexturedMesh; +// Build a TexturedMesh (vertices + per-face UVs + decoded texture images) from a parsed OBJ +// plus its material table, so the texture-to-color importer can sample face colours. +extern bool obj_to_textured_mesh( + const ObjInfo& obj_info, + const indexed_triangle_set& its, + const ObjParser::MtlData& mtl_data, + const std::string& obj_directory, + TexturedMesh& out); extern bool store_obj(const char *path, TriangleMesh *mesh); extern bool store_obj(const char *path, ModelObject *model); diff --git a/src/libslic3r/Format/ResourcePathUtils.hpp b/src/libslic3r/Format/ResourcePathUtils.hpp new file mode 100644 index 0000000000..d82b92bd45 --- /dev/null +++ b/src/libslic3r/Format/ResourcePathUtils.hpp @@ -0,0 +1,240 @@ +#ifndef slic3r_Format_ResourcePathUtils_hpp_ +#define slic3r_Format_ResourcePathUtils_hpp_ + +#include +#include +#include +#include +#include + +#include +#include + +namespace Slic3r { +namespace resource_path { + +inline std::string ascii_lower_copy(const std::string& value) +{ + std::string lowered; + lowered.reserve(value.size()); + for (unsigned char ch : value) + lowered.push_back(static_cast(std::tolower(ch))); + return lowered; +} + +inline boost::filesystem::path portable_path_copy(const boost::filesystem::path& value) +{ + std::string portable = value.string(); + std::replace(portable.begin(), portable.end(), '\\', '/'); + return boost::filesystem::path(portable); +} + +inline int hex_digit_value(char ch) +{ + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +// Byte-level percent decoding. Per RFC 3986 the %XX byte stream is expected to be +// UTF-8 when produced from URIs / Assimp aiString; this function performs no +// transcoding, so callers must treat both input and output as raw UTF-8 bytes. +inline std::string percent_decode_copy(const std::string& value) +{ + std::string decoded; + decoded.reserve(value.size()); + for (std::size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + const int hi = hex_digit_value(value[i + 1]); + const int lo = hex_digit_value(value[i + 2]); + if (hi >= 0 && lo >= 0) { + decoded.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + decoded.push_back(value[i]); + } + return decoded; +} + +inline std::string strip_file_uri_prefix_copy(const std::string& value) +{ + const std::string lower = ascii_lower_copy(value); + if (lower.rfind("file://", 0) != 0) + return value; + + std::string path = value.substr(7); + if (ascii_lower_copy(path).rfind("localhost/", 0) == 0) + path.erase(0, std::string("localhost").size()); + else if (!path.empty() && path.front() != '/') + path = "//" + path; + + // file:///C:/... should become C:/..., while file:///tmp/... keeps /tmp/... + if (path.size() >= 3 && path[0] == '/' && std::isalpha(static_cast(path[1])) && path[2] == ':') + path.erase(path.begin()); + return path; +} + +inline bool file_uri_has_remote_authority(const std::string& value) +{ + const std::string lower = ascii_lower_copy(value); + if (lower.rfind("file://", 0) != 0) + return false; + + const std::string path = value.substr(7); + if (path.empty() || path.front() == '/') + return false; + + const std::size_t slash = path.find('/'); + const std::string authority = path.substr(0, slash); + return ascii_lower_copy(authority) != "localhost"; +} + +inline bool looks_like_windows_absolute_path(const boost::filesystem::path& path) +{ + const std::string portable = portable_path_copy(path).string(); + return portable.size() >= 3 + && std::isalpha(static_cast(portable[0])) + && portable[1] == ':' + && portable[2] == '/'; +} + +inline boost::filesystem::path filename_from_portable_path(const boost::filesystem::path& value) +{ + const boost::filesystem::path portable = portable_path_copy(value); + return portable.filename(); +} + +inline boost::filesystem::path find_child_case_insensitive( + const boost::filesystem::path& directory, + const boost::filesystem::path& requested_name, + const char* context) +{ + if (!boost::filesystem::exists(directory) || !boost::filesystem::is_directory(directory)) + return {}; + + const std::string requested_lower = ascii_lower_copy(requested_name.filename().string()); + std::vector matches; + + boost::system::error_code ec; + for (boost::filesystem::directory_iterator it(directory, ec), end; !ec && it != end; it.increment(ec)) { + if (ascii_lower_copy(it->path().filename().string()) == requested_lower) + matches.push_back(it->path()); + } + + if (matches.size() == 1) + return matches.front(); + + if (matches.size() > 1) { + BOOST_LOG_TRIVIAL(warning) << context << ": ambiguous case-insensitive resource match for " + << requested_name << " in " << directory; + } + + return {}; +} + +inline boost::filesystem::path resolve_existing_path_case_insensitive( + const boost::filesystem::path& requested_path, + const char* context = "resource_path") +{ + const boost::filesystem::path normalized_path = portable_path_copy(requested_path); + + if (normalized_path.empty()) + return {}; + + if (boost::filesystem::exists(normalized_path)) + return normalized_path; + + boost::filesystem::path current; + bool initialized = false; + + for (const boost::filesystem::path& part : normalized_path) { + if (part == normalized_path.root_name() || part == normalized_path.root_directory()) { + current /= part; + initialized = true; + continue; + } + + if (!initialized) { + current = boost::filesystem::current_path(); + initialized = true; + } + + boost::filesystem::path exact = current / part; + if (boost::filesystem::exists(exact)) { + current = exact; + continue; + } + + boost::filesystem::path matched = find_child_case_insensitive(current, part, context); + if (matched.empty()) + return {}; + + BOOST_LOG_TRIVIAL(info) << context << ": resolved resource path case-insensitively from " + << exact << " to " << matched; + current = matched; + } + + return boost::filesystem::exists(current) ? current : boost::filesystem::path(); +} + +inline boost::filesystem::path resolve_existing_relative_path_case_insensitive( + const boost::filesystem::path& base_dir, + const boost::filesystem::path& resource_path, + const char* context = "resource_path") +{ + const boost::filesystem::path requested = resource_path.is_absolute() ? resource_path : base_dir / resource_path; + return resolve_existing_path_case_insensitive(requested, context); +} + +// Resolve a resource path that originated outside our own code (e.g. a glTF/FBX +// material texture reference or a file:// URI inside a 3MF descriptor). +// +// `raw_path` is expected to be UTF-8 regardless of host platform: file URIs are +// UTF-8 by spec, and Assimp aiString uses UTF-8 internally. Cross-platform +// correctness on Windows additionally relies on the process having called +// boost::nowide::nowide_filesystem() during startup (see src/BambuStudio.cpp), +// which imbues boost::filesystem::path with a UTF-8 codecvt so that +// `path(std::string)` constructs from UTF-8 byte sequences. Callers that bypass +// the main entry point (standalone CLI tools, unit tests) must reproduce that +// setup themselves before invoking this helper. +inline boost::filesystem::path resolve_external_resource_path( + const boost::filesystem::path& base_dir, + const std::string& raw_path, + const char* context = "resource_path", + bool allow_basename_fallback = true) +{ + if (raw_path.empty()) + return {}; + + const bool remote_file_uri = file_uri_has_remote_authority(raw_path); + const std::string decoded_path = percent_decode_copy(strip_file_uri_prefix_copy(raw_path)); + const boost::filesystem::path requested = portable_path_copy(boost::filesystem::path(decoded_path)); + + boost::filesystem::path resolved = (requested.is_absolute() || looks_like_windows_absolute_path(requested)) ? + resolve_existing_path_case_insensitive(requested, context) : + resolve_existing_relative_path_case_insensitive(base_dir, requested, context); + if (!resolved.empty()) + return resolved; + + if (!allow_basename_fallback || remote_file_uri) + return {}; + + const boost::filesystem::path basename = filename_from_portable_path(requested); + if (basename.empty()) + return {}; + + resolved = resolve_existing_relative_path_case_insensitive(base_dir, basename, context); + if (!resolved.empty()) { + BOOST_LOG_TRIVIAL(info) << context << ": resolved resource by basename from " + << requested << " to " << resolved; + } + return resolved; +} + +} // namespace resource_path +} // namespace Slic3r + +#endif /* slic3r_Format_ResourcePathUtils_hpp_ */ diff --git a/src/libslic3r/Format/objparser.cpp b/src/libslic3r/Format/objparser.cpp index 82bf2b4963..6ee117adc9 100644 --- a/src/libslic3r/Format/objparser.cpp +++ b/src/libslic3r/Format/objparser.cpp @@ -394,6 +394,7 @@ static bool mtl_parseline(const char *line, MtlData &data) ObjNewMtl new_mtl; cur_mtl_name = line; data.new_mtl_unmap[cur_mtl_name] = std::make_shared(); + data.mtl_orders.emplace_back(cur_mtl_name); break; } case 'm': { diff --git a/src/libslic3r/Format/objparser.hpp b/src/libslic3r/Format/objparser.hpp index 48493de3de..58afd015a8 100644 --- a/src/libslic3r/Format/objparser.hpp +++ b/src/libslic3r/Format/objparser.hpp @@ -122,6 +122,9 @@ struct MtlData // Version of the data structure for load / store in the private binary format. int version; std::unordered_map> new_mtl_unmap; + // Material names in declaration order. new_mtl_unmap is unordered, but OBJ material + // indices are positional, so texture import needs the original order. + std::vector mtl_orders; }; extern bool objparse(const char *path, ObjData &data); extern bool mtlparse(const char *path, MtlData &data); diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 18d805936e..ba26f7f0da 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6557,6 +6557,318 @@ LayerResult GCode::process_layer( } } } + + // Mixed-color sublayer extrusion: if this extruder is a component of a mixed sublayer + // group, extrude the mixed slot's geometry at the appropriate sub-Z with scaled flow. + // Ported from BambuStudio's 混色耗材 feature; adapted to Orca's InstanceVisit-based + // instance loop and its finer-grained per-role region filament options. + for (const auto &grp : layer_tools.mixed_sub_layer_groups) { + int sub_idx = -1; + for (size_t k = 0; k < grp.components_0based.size(); ++k) { + if (grp.components_0based[k] == extruder_id) { + sub_idx = static_cast(k); + break; + } + } + if (sub_idx < 0) + continue; + + auto mixed_instances_it = filament_to_print_instances.find(grp.mixed_slot_0based); + if (mixed_instances_it == filament_to_print_instances.end() || mixed_instances_it->second.first.empty()) + continue; + + double lh = grp.layer_height > 0. ? grp.layer_height : static_cast(height); + double cumulative_h = 0.0; + for (int i = 0; i < sub_idx; ++i) + cumulative_h += grp.sub_heights[i]; + double default_sub_h = grp.sub_heights[sub_idx]; + double default_sub_z = print_z - lh + cumulative_h + default_sub_h; + + m_sub_layer_flow_ratio = default_sub_h / lh; + m_sub_layer_height = default_sub_h; + m_nominal_z = default_sub_z; + + gcode += this->set_extruder(extruder_id, default_sub_z); + + for (InstanceToPrint &instance_to_print : mixed_instances_it->second.first) { + const bool use_per_volume = grp.is_gradient + && !grp.per_volume_gradient.empty() + && std::any_of(grp.per_volume_gradient.begin(), grp.per_volume_gradient.end(), + [&](const auto &kv) { return kv.first.obj == &instance_to_print.print_object; }); + + // --- Shared instance preamble (mirrors Orca's main instance loop) --- + const LayerToPrint &layer_to_print = layers[instance_to_print.layer_id]; + const auto &inst = instance_to_print.print_object.instances()[instance_to_print.instance_id]; + + bool object_layer_over_raft = layer_to_print.object_layer && layer_to_print.object_layer->id() > 0 && + instance_to_print.print_object.slicing_parameters().raft_layers() == layer_to_print.object_layer->id(); + m_config.apply(print.default_region_config()); + m_config.apply(instance_to_print.print_object.config(), true); + m_layer = layer_to_print.layer(); + m_object_layer_over_raft = object_layer_over_raft; + if (m_config.reduce_crossing_wall) + m_avoid_crossing_perimeters.init_layer(*m_layer); + + if (this->config().gcode_label_objects) { + gcode += std::string("; printing object ") + instance_to_print.print_object.model_object()->name + + " id:" + std::to_string(instance_to_print.print_object.get_id()) + " copy " + + std::to_string(inst.id) + "\n"; + } + if (m_enable_exclude_object) { + if (is_BBL_Printer()) { + m_writer.set_object_start_str( + std::string("; start printing object, unique label id: ") + + std::to_string(instance_to_print.label_object_id) + "\n" + "M624 " + + _encode_label_ids_to_base64({instance_to_print.label_object_id}) + "\n"); + } else { + const auto gflavor = print.config().gcode_flavor.value; + if (gflavor == gcfKlipper) { + m_writer.set_object_start_str(std::string("EXCLUDE_OBJECT_START NAME=") + + get_instance_name(&instance_to_print.print_object, inst.id) + "\n"); + } else if (gflavor == gcfMarlinLegacy || gflavor == gcfMarlinFirmware || gflavor == gcfRepRapFirmware) { + m_writer.set_object_start_str(std::string("M486 S") + std::to_string(inst.unique_id) + "\n"); + } + } + } + + m_extrusion_quality_estimator.set_current_object(&instance_to_print.print_object); + + const Point &offset = inst.shift; + std::pair this_object_copy(&instance_to_print.print_object, offset); + if (m_last_obj_copy != this_object_copy) + m_avoid_crossing_perimeters.use_external_mp_once(); + m_last_obj_copy = this_object_copy; + this->set_origin(unscale(offset)); + + // --- Build emission plan --- + // Each entry represents one travel_to_z + extrude pass. Per-object mode produces + // exactly 1 entry (all regions, single sub_z); per-volume mode produces N entries + // for tagged volumes plus an optional entry for untagged residue. + struct SubLayerEmitEntry { + double sub_h; + double sub_z; + std::function region_filter; + bool skip = false; + }; + std::vector emit_plan; + + auto compute_sub_zh = [&](double r1, double r2, double &out_sub_h, double &out_sub_z) { + std::vector sub_heights_local(grp.components_0based.size()); + for (size_t ci = 0; ci < grp.components_0based.size(); ++ci) + sub_heights_local[ci] = (static_cast(ci) == grp.gradient_first_sorted_idx) ? r1 * lh : r2 * lh; + double cum = 0.0; + for (int ci = 0; ci < sub_idx; ++ci) + cum += sub_heights_local[ci]; + out_sub_h = sub_heights_local[sub_idx]; + out_sub_z = print_z - lh + cum + out_sub_h; + }; + + auto gradient_ratios = [](const auto &g) -> std::pair { + double t = (g.total_layers > 0) ? (2.0 * g.current_idx + 1.0) / (2.0 * g.total_layers) : 0.5; + // Custom curve wins over linear range when present; OFF path stays bit-identical. + double r1 = g.curve.empty() + ? (g.gradient_start + (g.gradient_end - g.gradient_start) * t) + : sample_gradient_curve(g.curve, t); + return {r1, 1.0 - r1}; + }; + + // Orca splits BBS's three role filaments into five; a region belongs to the slot + // when any of its roles is assigned to it. + auto region_uses_slot = [](const PrintRegionConfig &rcfg, unsigned int slot_1b) { + return (unsigned int)rcfg.outer_wall_filament_id.value == slot_1b + || (unsigned int)rcfg.inner_wall_filament_id.value == slot_1b + || (unsigned int)rcfg.sparse_infill_filament_id.value == slot_1b + || (unsigned int)rcfg.internal_solid_filament_id.value == slot_1b + || (unsigned int)rcfg.top_surface_filament_id.value == slot_1b + || (unsigned int)rcfg.bottom_surface_filament_id.value == slot_1b; + }; + + double obj_sub_z = default_sub_z; + + if (use_per_volume) { + const PrintObject *po = &instance_to_print.print_object; + const unsigned int slot_1b = grp.mixed_slot_0based + 1; + + // Discover tagged volumes and untagged presence for this instance. + std::set tagged_volumes_present; + bool has_untagged_for_slot = false; + for (ObjectByExtruder::Island &island : instance_to_print.object_by_extruder.islands) { + for (size_t r = 0; r < island.by_region.size(); ++r) { + const auto ®ion = island.by_region[r]; + if (region.perimeters.empty() && region.infills.empty()) + continue; + const PrintRegion &pr = print.get_print_region(r); + if (!region_uses_slot(pr.config(), slot_1b)) + continue; + ObjectID vid = pr.gradient_volume_id(); + if (vid.valid()) + tagged_volumes_present.insert(vid); + else + has_untagged_for_slot = true; + } + } + + // One entry per tagged volume. + for (const ObjectID &target_vid : tagged_volumes_present) { + auto vg_it = grp.per_volume_gradient.find({po, target_vid}); + if (vg_it == grp.per_volume_gradient.end()) + continue; + const auto &vg = vg_it->second; + auto [r1, r2] = gradient_ratios(vg); + + bool vol_no_split = false; + bool skip_entry = false; + const size_t n = grp.components_0based.size(); + if (n == 2 && vg.current_idx + 1 == vg.total_layers) { + const size_t dom_idx = (r1 >= r2) ? 0 : 1; + const unsigned int first_sorted_comp = grp.components_0based[grp.gradient_first_sorted_idx]; + const unsigned int other_comp = grp.components_0based[1 - grp.gradient_first_sorted_idx]; + const unsigned int dom_0b = (dom_idx == 0) ? first_sorted_comp : other_comp; + const unsigned int oth_0b = (dom_idx == 0) ? other_comp : first_sorted_comp; + if (dom_0b < oth_0b) { + vol_no_split = true; + if (extruder_id != dom_0b) + skip_entry = true; + } + } + + double vol_sub_h = default_sub_h; + double vol_sub_z = default_sub_z; + if (vol_no_split) { + vol_sub_h = lh; + vol_sub_z = print_z; + } else { + compute_sub_zh(r1, r2, vol_sub_h, vol_sub_z); + } + + emit_plan.push_back({vol_sub_h, vol_sub_z, + [target_vid, &print](size_t r) { + return print.get_print_region(r).gradient_volume_id() == target_vid; + }, + skip_entry}); + } + + // Optional entry for untagged regions (modifier / painted / fuzzy_skin). + if (has_untagged_for_slot) { + double obj_sub_h = default_sub_h; + auto og_it = grp.per_object_gradient.find(po); + if (og_it != grp.per_object_gradient.end()) { + auto [r1, r2] = gradient_ratios(og_it->second); + compute_sub_zh(r1, r2, obj_sub_h, obj_sub_z); + } + emit_plan.push_back({obj_sub_h, obj_sub_z, + [&print](size_t r) { + return !print.get_print_region(r).gradient_volume_id().valid(); + }, + false}); + } + } else { + // Legacy per-object path: single entry, no region filter. + double legacy_sub_h = default_sub_h; + obj_sub_z = default_sub_z; + if (grp.is_gradient) { + auto og_it = grp.per_object_gradient.find(&instance_to_print.print_object); + if (og_it != grp.per_object_gradient.end()) { + auto [r1, r2] = gradient_ratios(og_it->second); + compute_sub_zh(r1, r2, legacy_sub_h, obj_sub_z); + } + } + emit_plan.push_back({legacy_sub_h, obj_sub_z, nullptr, false}); + } + + // --- Unified emission loop --- + auto plan_has_infill = [](const std::vector &by_region) { + for (const auto &r : by_region) + if (!r.infills.empty()) + return true; + return false; + }; + + for (auto &entry : emit_plan) { + if (entry.skip) + continue; + m_sub_layer_flow_ratio = entry.sub_h / lh; + m_sub_layer_height = entry.sub_h; + m_nominal_z = entry.sub_z; + // Use the same lazy-Z mechanism as change_layer(): set the flag so travel_to + // fires even when m_last_pos coincides with the first extrusion point, + // ensuring Z reaches sub_z via the combined XY+Z move. + m_need_change_layer_lift_z = true; + + for (ObjectByExtruder::Island &island : instance_to_print.object_by_extruder.islands) { + const auto &src = island.by_region; + std::vector subset_storage; + if (entry.region_filter) { + subset_storage.resize(src.size()); + for (size_t r = 0; r < src.size(); ++r) + if (entry.region_filter(r)) + subset_storage[r] = src[r]; + } + const auto &by_region_specific = entry.region_filter ? subset_storage : src; + + // Orca resolves infill-first per region inside extrude_perimeters() + // (unlike BBS, which branches on a single global flag), so mirror the + // main instance loop's ordering exactly. + gcode += this->extrude_perimeters(print, by_region_specific, first_layer, false); + if (!has_wipe_tower && need_insert_timelapse_gcode_for_traditional + && printer_structure == PrinterStructure::psI3 + && !has_insert_timelapse_gcode && plan_has_infill(by_region_specific)) { + gcode += this->retract(false, false, auto_lift_type, true); + gcode += insert_timelapse_gcode(); + has_insert_timelapse_gcode = true; + } + gcode += this->extrude_infill(print, by_region_specific, false); + gcode += this->extrude_perimeters(print, by_region_specific, first_layer, true); + // ironing + gcode += this->extrude_infill(print, by_region_specific, true); + } + } + + // --- Shared support --- + if (instance_to_print.object_by_extruder.support && !instance_to_print.object_by_extruder.support->empty()) { + if (use_per_volume) { + m_nominal_z = obj_sub_z; + gcode += m_writer.travel_to_z(obj_sub_z, "restore Z for support"); + } + ExtrusionRole support_role = instance_to_print.object_by_extruder.support_extrusion_role; + gcode += this->extrude_support(*instance_to_print.object_by_extruder.support, support_role); + // Make sure ironing is the last (Orca names this role erIroning, not erSupportIroning). + if (support_role == erMixed || support_role == erSupportMaterialInterface) + gcode += this->extrude_support(*instance_to_print.object_by_extruder.support, erIroning); + } + + // --- Shared instance footer (mirrors Orca's main instance loop) --- + if (!m_writer.is_object_start_str_empty()) { + m_writer.set_object_start_str(""); + } else if (m_enable_exclude_object) { + if (is_BBL_Printer()) { + m_writer.set_object_end_str(std::string("; stop printing object, unique label id: ") + + std::to_string(instance_to_print.label_object_id) + "\n" + + "M625\n"); + } else { + const auto gflavor = print.config().gcode_flavor.value; + if (gflavor == gcfKlipper) { + m_writer.set_object_end_str(std::string("EXCLUDE_OBJECT_END NAME=") + + get_instance_name(&instance_to_print.print_object, inst.id) + "\n"); + } else if (gflavor == gcfMarlinLegacy || gflavor == gcfMarlinFirmware || gflavor == gcfRepRapFirmware) { + m_writer.set_object_end_str(std::string("M486 S-1\n")); + } + } + } + } + + m_sub_layer_flow_ratio = 0.0; + m_sub_layer_height = 0.0; + } + // Flush any pending object end label before leaving the sublayer block, otherwise the + // wipe tower's add_object_end_labels may consume it into a local temp string and the + // M625 would be lost for BBL printers. + if (!layer_tools.mixed_sub_layer_groups.empty()) { + m_writer.add_object_end_labels(gcode); + m_nominal_z = print_z; + gcode += m_writer.travel_to_z(print_z, "restore Z after sublayers"); + } + } if (first_layer) { for (auto iter = by_extruder.begin(); iter != by_extruder.end(); ++iter) { @@ -7634,6 +7946,15 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } } + // Mixed-color sublayer: this path belongs to one sub-layer of a split layer, so scale the + // flow down to that sub-layer's share of the nominal layer height and report the sub-height + // as the effective extrusion height. Inert (ratio == 0) outside the sublayer emission block. + float effective_height = path.height; + if (m_sub_layer_flow_ratio > 0.0) { + _mm3_per_mm *= m_sub_layer_flow_ratio; + effective_height = static_cast(m_sub_layer_height); + } + // Effective extrusion length per distance unit = (filament_flow_ratio/cross_section) * mm3_per_mm / print flow ratio // m_writer.extruder()->e_per_mm3() below is (filament flow ratio / cross-sectional area) double e_per_mm = m_writer.filament()->e_per_mm3() * _mm3_per_mm; @@ -7933,8 +8254,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, gcode += buf; } - if (last_was_wipe_tower || std::abs(m_last_height - path.height) > EPSILON) { - m_last_height = path.height; + if (last_was_wipe_tower || std::abs(m_last_height - effective_height) > EPSILON) { + m_last_height = effective_height; sprintf(buf, ";%s%g\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Height).c_str(), m_last_height); gcode += buf; } diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 6bdb04a8a9..990bf0fee7 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -747,6 +747,11 @@ private: Print* m_curr_print = nullptr; unsigned int m_toolchange_count; coordf_t m_nominal_z; + // Mixed-color sublayer state. Non-zero only while emitting a mixed slot's sub-layer: + // scales extrusion flow to the sub-layer's share of the nominal layer height, and + // reports that sub-height as the effective extrusion height. Reset to 0 afterwards. + double m_sub_layer_flow_ratio = 0.0; + double m_sub_layer_height = 0.0; bool m_need_change_layer_lift_z = false; int m_start_gcode_filament = -1; std::string m_filament_instances_code; diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index f37025d4e7..19f8fddb93 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -7,6 +7,8 @@ #include "GCode/ToolOrderUtils.hpp" #include "FilamentGroupUtils.hpp" #include "MultiNozzleUtils.hpp" +#include "FilamentMixer.hpp" +#include "LocalesUtils.hpp" #include "Utils.hpp" #include "I18N.hpp" @@ -22,8 +24,13 @@ #endif #include +#include #include #include +#include +#include +#include +#include #include #include @@ -402,7 +409,9 @@ void ToolOrdering::sort_and_build_data(const Print& print, unsigned int first_ex // if first extruder is -1, we can decide the first layer tool order before doing reorder function // so we shouldn't reorder first layer in reorder function bool reorder_first_layer = (first_extruder != (unsigned int)(-1)); + this->resolve_mixed_filaments(print.config()); reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + this->enforce_mixed_component_order(); m_sorted = true; double max_layer_height = 0.; @@ -422,6 +431,9 @@ void ToolOrdering::sort_and_build_data(const Print& print, unsigned int first_ex this->fill_wipe_tower_partitions(print.config(), object_bottom_z, max_layer_height); if (this->insert_wipe_tower_extruder()) { reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + // Orca reorders a second time here (BBS has no such path); re-enforce so the + // mixed sub-layer component order survives the extra pass. + this->enforce_mixed_component_order(); this->fill_wipe_tower_partitions(print.config(), object_bottom_z, max_layer_height); } @@ -433,7 +445,9 @@ void ToolOrdering::sort_and_build_data(const PrintObject& object , unsigned int // if first extruder is -1, we can decide the first layer tool order before doing reorder function // so we shouldn't reorder first layer in reorder function bool reorder_first_layer = (first_extruder != (unsigned int)(-1)); + this->resolve_mixed_filaments(object.print()->config()); reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + this->enforce_mixed_component_order(); m_sorted = true; double max_layer_height = calc_max_layer_height(object.print()->config(), object.config().layer_height); @@ -441,6 +455,9 @@ void ToolOrdering::sort_and_build_data(const PrintObject& object , unsigned int this->fill_wipe_tower_partitions(object.print()->config(), object.layers().front()->print_z - object.layers().front()->height, max_layer_height); if (this->insert_wipe_tower_extruder()) { reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + // Orca reorders a second time here (BBS has no such path); re-enforce so the + // mixed sub-layer component order survives the extra pass. + this->enforce_mixed_component_order(); this->fill_wipe_tower_partitions(object.print()->config(), object.layers().front()->print_z - object.layers().front()->height, max_layer_height); } @@ -723,6 +740,38 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto it_per_layer_extruder_override = per_layer_extruder_switches.begin(); unsigned int extruder_override = 0; + // Pre-compute 1-based IDs of mixed filament slots for per-object tracking. + // mixed_slots_1based covers ALL mixed slots (needed by calc_slot_lh for + // accurate layer height when a slot skips layers). gradient_slots_1based + // and per_part_slots_1based are subsets for gradient-specific logic. + std::set mixed_slots_1based; + std::set gradient_slots_1based; + std::set per_part_slots_1based; + { + const PrintConfig &cfg = object.print()->config(); + const auto &is_mixed = cfg.filament_is_mixed.values; + const auto &grad_flags = cfg.filament_mixed_gradient.values; + const auto &per_part_flags = cfg.filament_mixed_gradient_per_part.values; + const auto &comp_strs = cfg.filament_mixed_components.values; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + auto comps = parse_mixed_components(i < comp_strs.size() ? comp_strs[i] : ""); + if (comps.size() < 2) + continue; + mixed_slots_1based.insert(static_cast(i + 1)); + // Gradient/per-part are only defined for 2-component slots; keep their + // tracking limited to them (mirrors the is_gradient guard at resolve time). + if (comps.size() != 2) + continue; + if (i >= grad_flags.size() || !grad_flags[i]) + continue; + gradient_slots_1based.insert(static_cast(i + 1)); + if (i < per_part_flags.size() && per_part_flags[i]) + per_part_slots_1based.insert(static_cast(i + 1)); + } + } + // BBS: collect first layer extruders of an object's wall, which will be used by brim generator int layerCount = 0; std::vector firstLayerExtruders; @@ -732,6 +781,9 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto for (auto layer : object.layers()) { LayerTools &layer_tools = this->tools_for_layer(layer->print_z); + m_object_all_layer_indices[&object].push_back( + static_cast(&layer_tools - m_layer_tools.data())); + // Override extruder with the next for (; it_per_layer_extruder_override != per_layer_extruder_switches.end() && it_per_layer_extruder_override->first < layer->print_z + EPSILON; ++ it_per_layer_extruder_override) extruder_override = (int)it_per_layer_extruder_override->second; @@ -739,6 +791,9 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto // Store the current extruder override (set to zero if no overriden), so that layer_tools.wiping_extrusions().is_overridable_and_mark() will use it. layer_tools.extruder_override = extruder_override; + // Snapshot extruders before this object's regions to track new additions. + const size_t ext_snapshot = layer_tools.extruders.size(); + // What extruders are required to print this object layer? for (const LayerRegion *layerm : layer->regions()) { const PrintRegion ®ion = layerm->region(); @@ -805,6 +860,54 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto if (has_internal_solid || has_top_solid_surface || has_bottom_surface || has_infill) layer_tools.has_object = true; } + + // Record mixed slot usage for this object at this layer. + // All mixed slots are tracked (not just gradient) so that calc_slot_lh + // can compute accurate layer heights even when a slot skips layers. + if (!mixed_slots_1based.empty()) { + size_t layer_idx = static_cast(&layer_tools - m_layer_tools.data()); + std::set seen; + for (size_t ei = ext_snapshot; ei < layer_tools.extruders.size(); ++ei) { + unsigned int ext_1based = layer_tools.extruders[ei]; + if (mixed_slots_1based.count(ext_1based) && seen.insert(ext_1based).second) + m_mixed_object_layers[ext_1based - 1][&object].push_back(layer_idx); + } + } + + // Per-part gradient: walk LayerRegions and record which (slot, ModelVolume) pairs + // contributed to this layer. Only regions tagged by PrintApply.cpp's get_create_region + // (i.e. gradient_volume_id().valid()) are considered, so this loop is a strict no-op + // unless per_part_gradient is enabled for at least one slot AND the corresponding + // ModelObject has >=2 model-part volumes using that slot. The per-object pass above is + // unaffected — both run the same layer's data through orthogonal containers. + if (!per_part_slots_1based.empty()) { + size_t layer_idx = static_cast(&layer_tools - m_layer_tools.data()); + std::set> vol_seen; + for (const LayerRegion *layerm : layer->regions()) { + if (layerm->slices.empty()) + continue; + const PrintRegion ®ion = layerm->region(); + ObjectID vol_id = region.gradient_volume_id(); + if (! vol_id.valid()) + continue; + const PrintRegionConfig &rcfg = region.config(); + // Orca splits BBS's three role slots into five; cover them all so a mixed + // slot used by any role is tracked. + const unsigned int role_slots[5] = { + static_cast(rcfg.outer_wall_filament_id.value), + static_cast(rcfg.inner_wall_filament_id.value), + static_cast(rcfg.sparse_infill_filament_id.value), + static_cast(rcfg.top_surface_filament_id.value), + static_cast(rcfg.bottom_surface_filament_id.value), + }; + for (unsigned int ext_1based : role_slots) { + if (ext_1based >= 1 + && per_part_slots_1based.count(ext_1based) + && vol_seen.insert({ext_1based, vol_id}).second) + m_gradient_volume_layers[ext_1based - 1][{&object, vol_id}].push_back(layer_idx); + } + } + } layerCount++; } @@ -1945,6 +2048,594 @@ MultiNozzleUtils::LayeredNozzleGroupResult ToolOrdering::build_sequential_group_ return result ? *result : MultiNozzleUtils::LayeredNozzleGroupResult(); } +static double snap_to_simple_fraction(double r, int max_denom = 10) +{ + double best_r = r; + double best_err = 1.0; + for (int q = 1; q <= max_denom; ++q) { + int p = (int)std::round(r * q); + if (p < 0) p = 0; + if (p > q) p = q; + double candidate = (double)p / q; + double err = std::abs(candidate - r); + if (err < best_err) { + best_err = err; + best_r = candidate; + } + } + return best_r; +} + +void ToolOrdering::resolve_mixed_filaments(const PrintConfig &config) +{ + const auto &is_mixed = config.filament_is_mixed.values; + const auto &comp_strs = config.filament_mixed_components.values; + const auto &ratio_strs = config.filament_mixed_sublayer_ratios.values; + + if (!has_any_mixed_filament(is_mixed)) + return; + + const bool sublayer_enabled = config.enable_mixed_color_sublayer.value; + + struct SlotInfo { + std::vector components; // 1-based + std::vector ratios; + std::vector accum; // deficit accumulator (integer, unit: 1e-6 mm) + }; + std::vector slots(is_mixed.size()); + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + slots[i].components = parse_mixed_components(i < comp_strs.size() ? comp_strs[i] : ""); + if (slots[i].components.size() < 2) { + slots[i].components.clear(); + continue; + } + for (unsigned int cid : slots[i].components) { + unsigned int idx0 = cid - 1; + if (idx0 >= is_mixed.size() || (idx0 < is_mixed.size() && is_mixed[idx0])) { + slots[i].components.clear(); + break; + } + } + if (slots[i].components.empty()) + continue; + slots[i].ratios = parse_mixed_ratios( + i < ratio_strs.size() ? ratio_strs[i] : "", slots[i].components.size()); + if (!sublayer_enabled) { + for (double &r : slots[i].ratios) + r = snap_to_simple_fraction(r); + double sum = 0; + for (double r : slots[i].ratios) sum += r; + if (sum > 0) + for (double &r : slots[i].ratios) r /= sum; + } + slots[i].accum.assign(slots[i].components.size(), 0LL); + } + + // Parse gradient settings per slot + const auto &gradient_flags = config.filament_mixed_gradient.values; + const auto &gradient_range_strs = config.filament_mixed_gradient_range.values; + const auto &gradient_curve_strs = config.filament_mixed_gradient_curve.values; + struct GradientInfo { + double start = 0.10; + double end_val = 0.90; + GradientCurve curve; // empty -> use linear (start, end_val); non-empty wins + }; + std::vector is_gradient(is_mixed.size(), false); + std::vector gradient_info(is_mixed.size()); + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i] || slots[i].components.size() != 2) + continue; + if (i >= gradient_flags.size() || !gradient_flags[i]) + continue; + is_gradient[i] = true; + if (i < gradient_range_strs.size() && !gradient_range_strs[i].empty()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(gradient_range_strs[i].c_str(), "%f,%f", &v0, &v1) == 2 && + v0 > 0 && v0 < 1.0 && v1 > 0 && v1 < 1.0) { + gradient_info[i].start = v0; + gradient_info[i].end_val = v1; + } + } + if (i < gradient_curve_strs.size() && !gradient_curve_strs[i].empty()) + gradient_info[i].curve = parse_gradient_curve(gradient_curve_strs[i]); + } + + // Pass 1: identify continuous runs for each gradient slot (Per-Run). + // A "run" is a maximal sequence of consecutive layers where the slot appears. + struct GradientRunInfo { + std::vector run_lengths; + int current_run = -1; + size_t current_idx = 0; + bool prev_appeared = false; + bool last_absent_was_relevant = false; + }; + std::map gradient_runs; + for (size_t i = 0; i < is_mixed.size(); ++i) + if (is_gradient[i]) gradient_runs[static_cast(i)] = {}; + + // Build per-slot sets of all layer indices where any slot-owning object has a + // layer. Used by gradient run detection (a gap is real only if the slot is + // absent at a layer belonging to one of its own objects) and by calc_slot_lh + // to keep prev_relevant_z_for_slot current even when a slot skips many layers. + std::map> slot_relevant_layers; + for (auto &[slot_idx, obj_map] : m_mixed_object_layers) { + for (auto &[obj, _] : obj_map) { + auto it = m_object_all_layer_indices.find(obj); + if (it != m_object_all_layer_indices.end()) + slot_relevant_layers[slot_idx].insert(it->second.begin(), it->second.end()); + } + } + + if (!gradient_runs.empty()) { + for (size_t li = 0; li < m_layer_tools.size(); ++li) { + if (li == 0) continue; + const auto < = m_layer_tools[li]; + for (auto &[slot, run] : gradient_runs) { + bool here = std::find(lt.extruders.begin(), lt.extruders.end(), slot) != lt.extruders.end(); + if (here) { + bool real_gap = false; + if (!run.prev_appeared && !run.run_lengths.empty()) { + real_gap = run.last_absent_was_relevant; + } + if (run.run_lengths.empty() || real_gap) + run.run_lengths.push_back(0); + run.run_lengths.back()++; + run.last_absent_was_relevant = false; + } else if (!run.run_lengths.empty()) { + auto rel_it = slot_relevant_layers.find(slot); + if (rel_it != slot_relevant_layers.end() && rel_it->second.count(li)) + run.last_absent_was_relevant = true; + } + run.prev_appeared = here; + } + } + for (auto &[slot, run] : gradient_runs) { + run.current_run = -1; + run.current_idx = 0; + run.prev_appeared = false; + run.last_absent_was_relevant = false; + } + } + + // Per-object gradient: pre-compute per-object runs (respecting Z gaps within each object). + struct PerObjRunState { + std::vector run_start_offsets; // index into layer_indices where each run starts + std::vector run_lengths; + int current_run = -1; + size_t current_idx = 0; + }; + + // Detect whether a gap between two consecutive gradient-slot appearances is a + // real run break. A gap is real only if the object has its own layer inside the + // gap that does NOT use the gradient slot (i.e. the slot was genuinely absent). + // Uses lower_bound to skip global indices that don't belong to the object. + auto has_real_gap = [](size_t prev_idx, size_t cur_idx, + const std::set& obj_set, + const std::set& slot_set) -> bool { + for (auto it = obj_set.lower_bound(prev_idx + 1); + it != obj_set.end() && *it < cur_idx; ++it) { + if (!slot_set.count(*it)) + return true; + } + return false; + }; + + // Segment a sorted list of layer indices into runs, using has_real_gap to decide + // where to break. Shared by the per-object and per-volume paths below. + auto segment_runs = [&](const std::vector& layer_indices, + const std::set& obj_set, + const std::set& slot_set) -> PerObjRunState { + PerObjRunState st; + for (size_t i = 0; i < layer_indices.size(); ++i) { + bool new_run = (i == 0) || + has_real_gap(layer_indices[i - 1], layer_indices[i], obj_set, slot_set); + if (new_run) { + st.run_start_offsets.push_back(i); + st.run_lengths.push_back(0); + } + st.run_lengths.back()++; + } + return st; + }; + + std::map> per_obj_runs; + for (auto &[slot, obj_map] : m_mixed_object_layers) { + if (slot >= is_gradient.size() || !is_gradient[slot]) + continue; + for (auto &[obj, layer_indices] : obj_map) { + sort_remove_duplicates(layer_indices); + // Erase layer 0 — this mutation is also relied upon by the Pass 2 binary_search below. + if (!layer_indices.empty() && layer_indices.front() == 0) + layer_indices.erase(layer_indices.begin()); + + const auto &all_obj_layers = m_object_all_layer_indices[obj]; + std::set all_obj_set(all_obj_layers.begin(), all_obj_layers.end()); + std::set grad_set(layer_indices.begin(), layer_indices.end()); + + per_obj_runs[slot][obj] = segment_runs(layer_indices, all_obj_set, grad_set); + } + } + + // Per-volume gradient: mirror the per-object run-segmentation logic above for + // m_gradient_volume_layers. When per_part_gradient is off (or no qualifying volume exists), + // m_gradient_volume_layers is empty and per_vol_runs ends up empty too — so all subsequent + // checks of `per_vol_runs.find(slot) != end()` will fail and the legacy per-object path + // remains the only path taken. + using VolumeKey = LayerTools::MixedSubLayerGroup::VolumeKey; + std::map> per_vol_runs; + for (auto &[slot, vol_map] : m_gradient_volume_layers) { + if (slot >= is_gradient.size() || !is_gradient[slot]) + continue; + for (auto &[vkey, layer_indices] : vol_map) { + sort_remove_duplicates(layer_indices); + if (!layer_indices.empty() && layer_indices.front() == 0) + layer_indices.erase(layer_indices.begin()); + + const auto &all_obj_layers = m_object_all_layer_indices[vkey.obj]; + std::set all_obj_set(all_obj_layers.begin(), all_obj_layers.end()); + std::set vol_grad_set(layer_indices.begin(), layer_indices.end()); + + per_vol_runs[slot][vkey] = segment_runs(layer_indices, all_obj_set, vol_grad_set); + } + } + // Pass 2: resolve per layer + coordf_t prev_print_z = 0.; + // Track last print_z per mixed slot so that layer height is computed from the + // slot's own previous appearance, not from a global Z that may include layers + // belonging only to other objects with different layer heights. + std::map prev_print_z_for_slot; + // Track the last Z where a slot-owning object had ANY layer (regardless of + // whether the slot was present). Used to detect genuine gaps: if the slot was + // absent but its owner objects had layers, prev_relevant_z advances while + // prev_print_z_for_slot stays stale. Taking the max of both gives correct lh. + std::map prev_relevant_z_for_slot; + + // Compute the effective layer height for a mixed slot by choosing the best + // reference Z among: (1) the slot's own last Z, (2) the last Z where the + // slot's owning object had any layer, (3) the global previous Z as fallback + // when the slot appears for the first time. + auto calc_slot_lh = [&](unsigned int ext, coordf_t print_z) -> double { + auto slot_pz_it = prev_print_z_for_slot.find(ext); + auto rel_pz_it = prev_relevant_z_for_slot.find(ext); + coordf_t base_z = prev_print_z; + if (slot_pz_it != prev_print_z_for_slot.end()) { + base_z = slot_pz_it->second; + if (rel_pz_it != prev_relevant_z_for_slot.end()) + base_z = std::max(base_z, rel_pz_it->second); + } + double lh = print_z - base_z; + return (lh > 0.) ? lh : 0.2; // 0.2mm safety fallback; should not trigger in normal operation + }; + + for (LayerTools < : m_layer_tools) { + size_t layer_idx = static_cast(< - m_layer_tools.data()); + + // Update gradient run state (skip first layer to match counting). + if (layer_idx > 0) { + for (auto &[slot, run] : gradient_runs) { + bool here = std::find(lt.extruders.begin(), lt.extruders.end(), slot) != lt.extruders.end(); + if (here) { + if (!run.prev_appeared) { + if (run.last_absent_was_relevant || run.current_run < 0) { + run.current_run++; + run.current_idx = 0; + } + } + run.last_absent_was_relevant = false; + } else { + auto rel_it = slot_relevant_layers.find(slot); + if (rel_it != slot_relevant_layers.end() && rel_it->second.count(layer_idx)) + run.last_absent_was_relevant = true; + } + run.prev_appeared = here; + } + } + + std::vector new_extruders; + for (unsigned int ext : lt.extruders) { + if (ext >= slots.size() || slots[ext].components.empty()) { + new_extruders.push_back(ext); + continue; + } + auto &s = slots[ext]; + + // Skip sublayer splitting for the first layer to preserve bed adhesion. + if (sublayer_enabled && layer_idx > 0) { + double lh = calc_slot_lh(ext, lt.print_z); + size_t n = s.components.size(); + + std::vector sub_heights; + bool gradient_last_no_split = false; + unsigned int gradient_last_dominant_0b = 0; + if (is_gradient[ext] && n == 2) { + auto gr_it = gradient_runs.find(ext); + if (gr_it != gradient_runs.end() && gr_it->second.current_run >= 0 && + static_cast(gr_it->second.current_run) < gr_it->second.run_lengths.size()) { + auto &run = gr_it->second; + size_t N = run.run_lengths[run.current_run]; + size_t idx = run.current_idx++; + double t = (N > 0) ? (2.0 * idx + 1.0) / (2.0 * N) : 0.5; + // Custom curve wins over linear range when present; OFF path stays bit-identical. + double r1 = gradient_info[ext].curve.empty() + ? (gradient_info[ext].start + (gradient_info[ext].end_val - gradient_info[ext].start) * t) + : sample_gradient_curve(gradient_info[ext].curve, t); + double r2 = 1.0 - r1; + sub_heights.push_back(r1 * lh); + sub_heights.push_back(r2 * lh); + // The sublayer split path sorts components by physical ID ascending; + // the higher-ID component ends up on top (visible surface). If the + // gradient's dominant component has the lower physical ID, splitting + // would put the non-dominant color on the visible top surface. In + // that case, skip the split and print this final run-layer as pure + // dominant color to preserve the gradient appearance. + if (idx == N - 1) { + // When r1 == r2 (exactly 50/50), component[0] is treated as dominant. + size_t dominant = (r1 >= r2) ? 0 : 1; + unsigned int dom_0b = s.components[dominant] - 1; + unsigned int oth_0b = s.components[1 - dominant] - 1; + if (dom_0b < oth_0b) { + gradient_last_no_split = true; + gradient_last_dominant_0b = dom_0b; + } + } + } else { + for (double r : s.ratios) + sub_heights.push_back(r * lh); + } + } else { + for (double r : s.ratios) + sub_heights.push_back(r * lh); + } + + // Per-part gradient: when this slot has any qualifying volume, the global + // no-split short-circuit must NOT bypass MixedSubLayerGroup creation — each + // volume needs its own no-split decision in GCode.cpp (a per-volume "last + // run-layer" can occur on a different layer index than the per-object one). We + // still keep the per-object short-circuit when per_vol_runs[ext] is empty, which + // covers the legacy path bit-identically. + bool per_vol_active_for_slot = per_vol_runs.find(ext) != per_vol_runs.end() + && !per_vol_runs[ext].empty(); + + if (gradient_last_no_split && !per_vol_active_for_slot) { + lt.mixed_filament_resolution[ext] = gradient_last_dominant_0b; + new_extruders.push_back(gradient_last_dominant_0b); + prev_print_z_for_slot[ext] = lt.print_z; + continue; + } + + LayerTools::MixedSubLayerGroup grp; + grp.mixed_slot_0based = ext; + grp.layer_height = lh; + grp.is_gradient = is_gradient[ext]; + for (size_t k = 0; k < s.components.size(); ++k) { + unsigned int comp_0based = s.components[k] - 1; + grp.components_0based.push_back(comp_0based); + } + grp.sub_heights = sub_heights; + + // Write gradient metadata (run-aware). Both per_object_gradient and + // per_volume_gradient are populated independently from their own run-state + // machines; the GCode emitter chooses per-region: + // - tagged region (gradient_volume_id valid) -> per_volume_gradient[{obj, vol}] + // - untagged region (modifier / painted / etc.) -> per_object_gradient[obj] + // Populating both keeps the per-object run state correct even when per-volume + // takes over for the same (slot, obj), and lets untagged geometry (which is + // explicitly NOT split per-volume in v1 per the design doc) keep its legacy + // per-object gradient ratios. + if (grp.is_gradient) { + auto vol_runs_slot_it = per_vol_runs.find(ext); + if (vol_runs_slot_it != per_vol_runs.end()) { + auto vol_slot_it = m_gradient_volume_layers.find(ext); + for (auto &[vkey, st] : vol_runs_slot_it->second) { + auto &layer_indices = vol_slot_it->second[vkey]; + if (!std::binary_search(layer_indices.begin(), layer_indices.end(), layer_idx)) + continue; + if (st.current_run < 0 || + st.current_idx >= st.run_lengths[st.current_run]) { + st.current_run++; + st.current_idx = 0; + } + size_t run_N = st.run_lengths[st.current_run]; + size_t run_idx = st.current_idx++; + grp.per_volume_gradient[vkey] = { + run_N, + run_idx, + gradient_info[ext].start, + gradient_info[ext].end_val, + gradient_info[ext].curve, + }; + } + } + + auto runs_slot_it = per_obj_runs.find(ext); + if (runs_slot_it != per_obj_runs.end()) { + auto slot_it = m_mixed_object_layers.find(ext); + for (auto &[obj, st] : runs_slot_it->second) { + auto &layer_indices = slot_it->second[obj]; + if (!std::binary_search(layer_indices.begin(), layer_indices.end(), layer_idx)) + continue; + if (st.current_run < 0 || + st.current_idx >= st.run_lengths[st.current_run]) { + st.current_run++; + st.current_idx = 0; + } + size_t run_N = st.run_lengths[st.current_run]; + size_t run_idx = st.current_idx++; + grp.per_object_gradient[obj] = { + run_N, + run_idx, + gradient_info[ext].start, + gradient_info[ext].end_val, + gradient_info[ext].curve, + }; + } + } + } + + if (grp.components_0based.size() > 1) { + unsigned int first_comp_0based = s.components[0] - 1; + std::vector idx(grp.components_0based.size()); + std::iota(idx.begin(), idx.end(), 0); + std::sort(idx.begin(), idx.end(), [&](size_t a, size_t b) { + return grp.components_0based[a] < grp.components_0based[b]; + }); + std::vector sorted_comps; + std::vector sorted_heights; + for (size_t i : idx) { + sorted_comps.push_back(grp.components_0based[i]); + sorted_heights.push_back(grp.sub_heights[i]); + } + grp.components_0based = std::move(sorted_comps); + grp.sub_heights = std::move(sorted_heights); + if (grp.is_gradient) { + for (size_t i = 0; i < grp.components_0based.size(); ++i) { + if (grp.components_0based[i] == first_comp_0based) { + grp.gradient_first_sorted_idx = static_cast(i); + break; + } + } + } + } + + for (unsigned int comp : grp.components_0based) + new_extruders.push_back(comp); + lt.mixed_sub_layer_groups.push_back(std::move(grp)); + prev_print_z_for_slot[ext] = lt.print_z; + } else { + // Deficit Round-Robin: pick one component per layer. + // Weight by layer height so volume ratios stay accurate + // even with adaptive layer heights. + double lh = calc_slot_lh(ext, lt.print_z); + long long lh_i = std::llround(lh * 1e6); + + // For 2-component gradient on the first layer, use the gradient's + // starting ratio instead of the configured mixing ratio so the + // selected filament matches the gradient's "from" end. + // Only affects the first layer; when sublayer splitting is enabled + // (required for gradient), layers 1+ take the sublayer path and + // do not touch the DRR accumulator. + if (layer_idx == 0 && is_gradient[ext] && s.components.size() == 2) { + double r0 = gradient_info[ext].start; + s.accum[0] += std::llround(r0 * lh_i); + s.accum[1] += std::llround((1.0 - r0) * lh_i); + } else { + for (size_t k = 0; k < s.ratios.size(); ++k) + s.accum[k] += std::llround(s.ratios[k] * lh_i); + } + size_t sel = 0; + for (size_t k = 1; k < s.accum.size(); ++k) + if (s.accum[k] > s.accum[sel]) + sel = k; + s.accum[sel] -= lh_i; + unsigned int resolved = s.components[sel] - 1; + lt.mixed_filament_resolution[ext] = resolved; + new_extruders.push_back(resolved); + prev_print_z_for_slot[ext] = lt.print_z; + } + } + lt.extruders = new_extruders; + sort_remove_duplicates(lt.extruders); + + // Update prev_relevant_z: for each slot that has relevant-layer tracking, + // advance if the current layer belongs to a slot-owning object. + for (auto &[slot, rel_set] : slot_relevant_layers) { + if (rel_set.count(layer_idx)) + prev_relevant_z_for_slot[slot] = lt.print_z; + } + + prev_print_z = lt.print_z; + } +} + +void ToolOrdering::enforce_mixed_component_order() +{ + for (LayerTools < : m_layer_tools) { + if (lt.mixed_sub_layer_groups.empty()) + continue; + + // Build a set of extruders present in lt.extruders for fast lookup. + std::set ext_set(lt.extruders.begin(), lt.extruders.end()); + + // 1. Build DAG from mixed group constraints. + // For each group [c0, c1, c2, ...], add edges c0->c1, c1->c2, ... + // Only between components that are both present in lt.extruders. + // Use an edge set to avoid duplicate edges inflating in-degree. + std::map> adj; + std::map in_degree; + std::set> edge_set; + + for (unsigned int ext : lt.extruders) + in_degree[ext] = 0; + + for (const auto &grp : lt.mixed_sub_layer_groups) { + for (size_t i = 0; i + 1 < grp.components_0based.size(); ++i) { + unsigned int a = grp.components_0based[i]; + unsigned int b = grp.components_0based[i + 1]; + if (!ext_set.count(a) || !ext_set.count(b)) + continue; + if (edge_set.insert({a, b}).second) { + adj[a].push_back(b); + in_degree[b] += 1; + } + } + } + + // 2. Record original position (from flush optimizer) as priority. + std::map orig_pos; + for (size_t i = 0; i < lt.extruders.size(); ++i) + orig_pos[lt.extruders[i]] = i; + + // 3. Kahn's topological sort with priority queue (prefer original position). + auto cmp = [&orig_pos](unsigned int lhs, unsigned int rhs) { + return orig_pos[lhs] > orig_pos[rhs]; // min-heap by orig_pos + }; + std::priority_queue, decltype(cmp)> pq(cmp); + + for (unsigned int ext : lt.extruders) { + if (in_degree[ext] == 0) + pq.push(ext); + } + + std::vector ordered; + ordered.reserve(lt.extruders.size()); + while (!pq.empty()) { + unsigned int ext = pq.top(); + pq.pop(); + ordered.push_back(ext); + if (auto it = adj.find(ext); it != adj.end()) { + for (unsigned int next : it->second) { + if (--in_degree[next] == 0) + pq.push(next); + } + } + } + + // Safety: if topological sort didn't produce all elements, keep original order. + if (ordered.size() != lt.extruders.size()) + ordered = lt.extruders; + + // 4. Verify: every mixed group's component order is preserved as subsequence. + for (const auto &grp : lt.mixed_sub_layer_groups) { + size_t prev_pos = 0; + bool valid = true; + for (unsigned int c : grp.components_0based) { + if (!ext_set.count(c)) + continue; + auto it = std::find(ordered.begin() + prev_pos, ordered.end(), c); + if (it == ordered.end()) { valid = false; break; } + prev_pos = (it - ordered.begin()) + 1; + } + assert(valid && "enforce_mixed_component_order: mixed group subsequence violated"); + (void)valid; + } + + lt.extruders = ordered; + } +} + void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer) { const PrintConfig* print_config = m_print_config_ptr; diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index c77b152fe9..699afa7091 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -5,12 +5,16 @@ #include "../libslic3r.h" +#include +#include #include #include #include "../FilamentGroup.hpp" +#include "../FilamentMixer.hpp" #include "../MultiNozzleUtils.hpp" #include "../ExtrusionEntity.hpp" +#include "../ObjectID.hpp" #include "../PrintConfig.hpp" namespace Slic3r { @@ -172,6 +176,65 @@ public: // Custom G-code (color change, extruder switch, pause) to be performed before this layer starts to print. const CustomGCode::Item *custom_gcode = nullptr; + // 0-based mixed filament slot → 0-based resolved physical filament for this layer. + // Populated by ToolOrdering::resolve_mixed_filaments(). Empty when no mixed filaments. + std::map mixed_filament_resolution; + + unsigned int resolve_mixed(unsigned int filament_0based) const { + auto it = mixed_filament_resolution.find(filament_0based); + return (it != mixed_filament_resolution.end()) ? it->second : filament_0based; + } + + struct MixedSubLayerGroup { + unsigned int mixed_slot_0based; + std::vector components_0based; + std::vector sub_heights; // per-component, sum ≈ layer_height + double layer_height = 0.; // the actual lh used to compute sub_heights + bool is_gradient = false; + int gradient_first_sorted_idx = 0; // index of "first" config component after sorting + + struct ObjectGradient { + size_t total_layers; + size_t current_idx; + double gradient_start; + double gradient_end; + GradientCurve curve; // empty -> linear fallback (start, end); non-empty wins + }; + std::map per_object_gradient; + + // Per-volume gradient: same metadata layout as ObjectGradient but keyed by + // (PrintObject*, ModelVolume id). Populated only when filament_mixed_gradient_per_part is + // enabled for this slot AND the corresponding ModelObject contains >=2 model-part volumes + // using this slot. When non-empty for a given (PrintObject*), GCode emission takes the + // per-volume path for tagged regions; untagged regions (modifier/painted/fuzzy_skin) still + // use per_object_gradient. Both maps are populated in parallel to keep run states correct. + struct VolumeKey { + const PrintObject* obj; + ObjectID volume_id; + bool operator<(const VolumeKey &o) const { + if (obj != o.obj) return std::less{}(obj, o.obj); + return volume_id < o.volume_id; + } + bool operator==(const VolumeKey &o) const { + return obj == o.obj && volume_id == o.volume_id; + } + }; + using VolumeGradient = ObjectGradient; + std::map per_volume_gradient; + }; + std::vector mixed_sub_layer_groups; + + const MixedSubLayerGroup* mixed_group_by_slot(unsigned int slot_id) const { + for (const auto &g : mixed_sub_layer_groups) + if (g.mixed_slot_0based == slot_id) + return &g; + return nullptr; + } + + bool is_mixed_slot(unsigned int slot_id) const { + return mixed_group_by_slot(slot_id) != nullptr; + } + WipingExtrusions& wiping_extrusions() { m_wiping_extrusions.set_layer_tools_ptr(this); return m_wiping_extrusions; @@ -299,6 +362,8 @@ private: void mark_skirt_layers(const PrintConfig &config, coordf_t max_layer_height); void collect_extruder_statistics(bool prime_multi_material); void reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer); + void resolve_mixed_filaments(const PrintConfig &config); + void enforce_mixed_component_order(); // BBS std::vector generate_first_layer_tool_order(const Print& print); @@ -313,6 +378,23 @@ private: std::vector m_all_printing_extruders; const DynamicPrintConfig* m_print_full_config = nullptr; const PrintConfig* m_print_config_ptr = nullptr; + + // Per-object gradient tracking: slot(0-based) -> PrintObject* -> list of layer indices + // where that object uses the slot. Populated by collect_extruders, consumed by resolve_mixed_filaments. + std::map>> m_mixed_object_layers; + + // All layer indices (in m_layer_tools) where each object has any layer. + // Used by gradient run detection to distinguish real gaps (object has a layer + // that doesn't use the slot) from spurious gaps (another object's layer). + std::map> m_object_all_layer_indices; + + // Per-volume gradient tracking: slot(0-based) -> (PrintObject*, ModelVolume id) -> list of + // layer indices where the given volume contributes to the slot. Populated by collect_extruders + // alongside m_mixed_object_layers when per_part gradient is enabled for the slot AND the + // ModelObject has >=2 model-part volumes using the slot. Empty for all other configurations, + // which keeps every legacy per-object code path bit-identical (loops over an empty map are + // no-ops; downstream emission falls through to the per-object branch). + std::map>> m_gradient_volume_layers; const PrintObject* m_print_object_ptr = nullptr; Print* m_print; bool m_sorted = false; diff --git a/src/libslic3r/Layer.cpp b/src/libslic3r/Layer.cpp index b3a145bed0..87ad11bcf8 100644 --- a/src/libslic3r/Layer.cpp +++ b/src/libslic3r/Layer.cpp @@ -210,6 +210,12 @@ void Layer::make_perimeters() if (! (*it)->slices.empty()) { LayerRegion* other_layerm = *it; const PrintRegion &other_region = other_layerm->region(); + // Per-part gradient tags a region with its owning ModelVolume; merging two + // differently-tagged regions would collapse volumes that need independent + // gradient runs. Both tags are invalid unless per-part gradient is on, so + // this is a no-op for every other configuration. + if (this_region.gradient_volume_id() != other_region.gradient_volume_id()) + continue; if (is_perimeter_compatible(*m_object->print(), this_region, other_region)) { other_layerm->perimeters.clear(); diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index c689c7ce78..3617e85991 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -1,6 +1,7 @@ #include "Model.hpp" #include "libslic3r.h" #include "BuildVolume.hpp" +#include "TexturePainting.hpp" #include "ClipperUtils.hpp" #include "Exception.hpp" #include "Model.hpp" @@ -104,6 +105,7 @@ Model& Model::assign_copy(const Model &rhs) this->mk_version = rhs.mk_version; this->md_name = rhs.md_name; this->md_value = rhs.md_value; + this->texture_mesh = rhs.texture_mesh; return *this; } @@ -139,6 +141,7 @@ Model& Model::assign_copy(Model &&rhs) this->mk_version = rhs.mk_version; this->md_name = rhs.md_name; this->md_value = rhs.md_value; + this->texture_mesh = std::move(rhs.texture_mesh); this->backup_path = std::move(rhs.backup_path); this->object_backup_id_map = std::move(rhs.object_backup_id_map); this->next_object_backup_id = rhs.next_object_backup_id; @@ -281,8 +284,21 @@ Model Model::read_from_file(const std::string& result = load_stl(input_file.c_str(), &model, nullptr, stlFn,256); else if (boost::algorithm::iends_with(input_file, ".obj")) { ObjInfo obj_info; - result = load_obj(input_file.c_str(), &model, obj_info, message); - if (result){ + ObjParser::MtlData mtl_data; + result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data); + if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) { + // Textured OBJ: hand the mesh + materials to the texture-to-color importer instead + // of the flat per-face colour dialog. Replaces Orca's previous "not implemented" + // placeholder for this branch. + auto tex_mesh = std::make_shared(); + std::string obj_dir = boost::filesystem::path(input_file).parent_path().string(); + if (obj_to_textured_mesh(obj_info, + model.objects.back()->volumes[0]->mesh().its, + mtl_data, obj_dir, *tex_mesh)) { + model.texture_mesh = tex_mesh; + } + } + else if (result){ ObjDialogInOut in_out; in_out.model = &model; in_out.lost_material_name = obj_info.lost_material_name; @@ -578,6 +594,7 @@ void Model::clear_objects() this->objects.clear(); object_backup_id_map.clear(); next_object_backup_id = 1; + texture_mesh.reset(); } // BBS: backup, reuse objects @@ -2576,7 +2593,8 @@ void ModelVolume::update_extruder_count(size_t extruder_count) } } -void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id) +void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id, + const std::vector &filament_is_mixed) { std::vector used_extruders = get_extruders(); for (int extruder_id : used_extruders) { @@ -2587,8 +2605,13 @@ void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_cou } // Same stale-assignment cleanup as update_extruder_count, for the filament-delete path. // Ported from BambuStudio (STUDIO-15763). - if (extruder_id() > extruder_count) { - this->config.erase("extruder"); + size_t eid = extruder_id(); + if (eid > extruder_count) { + // A mixed-color slot is virtual and legitimately sits past the physical filament count, + // so an assignment to one is not stale and must survive the delete. + bool is_mixed = !filament_is_mixed.empty() && eid >= 1 && (eid - 1) < filament_is_mixed.size() && filament_is_mixed[eid - 1]; + if (!is_mixed) + this->config.erase("extruder"); } } @@ -3495,6 +3518,15 @@ void FacetsAnnotation::get_facets(const ModelVolume& mv, std::vectorset(selector); +} + void FacetsAnnotation::set_enforcer_block_type_limit(const ModelVolume &mv, EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament, diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 2d46bc4cdf..6834c7a59b 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -47,6 +47,8 @@ namespace cereal { } namespace Slic3r { + +struct TexturedMesh; enum class ConversionType; class BuildVolume; @@ -740,6 +742,9 @@ public: EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament = EnforcerBlockerType::NONE, EnforcerBlockerType replace_filament = EnforcerBlockerType::NONE); + // Shift painted filament indices >= threshold by delta. Used when a physical filament is + // inserted ahead of existing slots (mixed-color slots are kept at the end of the list). + void shift_states_above(const ModelVolume &mv, EnforcerBlockerType threshold, int delta); indexed_triangle_set get_facets_strict(const ModelVolume& mv, EnforcerBlockerType type) const; bool has_facets(const ModelVolume& mv, EnforcerBlockerType type) const; bool empty() const { return m_data.triangles_to_split.empty(); } @@ -932,7 +937,8 @@ public: // BBS std::vector get_extruders() const; void update_extruder_count(size_t extruder_count); - void update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id = -1); + void update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id = -1, + const std::vector &filament_is_mixed = {}); // Split this volume, append the result to the object owning this volume. // Return the number of volumes created from this one. @@ -1549,6 +1555,10 @@ public: std::shared_ptr model_info = nullptr; std::shared_ptr profile_info = nullptr; + // Textured mesh data for texture-to-painting import. Populated by the loader when a mesh + // arrives with usable UVs and a texture map; consumed (and reset) by the import dialog. + std::shared_ptr texture_mesh; + //makerlab information std::string mk_name; std::string mk_version; diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 1334bd4e7a..0a6491078d 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1183,6 +1183,7 @@ static std::vector s_Preset_print_options{ "flush_into_infill", "flush_into_objects", "flush_into_support", + "enable_mixed_color_sublayer", "tree_support_branch_angle", "tree_support_angle_slow", "tree_support_wall_count", diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 6fcc6e05c1..961ed59d2b 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -7,6 +7,7 @@ #include "PresetCacheFormat.hpp" #include "PrintConfig.hpp" +#include "FilamentMixer.hpp" #include "libslic3r.h" #include "I18N.hpp" #include "Utils.hpp" @@ -71,7 +72,17 @@ static std::vector s_project_options { // whether dynamic per-nozzle filament mapping is active. Persisted with the project and // restored from a saved 3mf; reset to false on load and set true only by live device sync. "has_filament_switcher", - "enable_filament_dynamic_map" + "enable_filament_dynamic_map", + // Mixed-color filament slots. Project-level parallel arrays indexed like filament_colour: + // which slots are virtual mixes, their component filaments, blend ratios and the optional + // Z-gradient description. Kept with the project so a saved 3mf round-trips the mix setup. + "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" }; //Orca: add custom as default @@ -2704,6 +2715,40 @@ 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) +{ + 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(",")); + 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("|")); + 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"); +} + void PresetBundle::update_selections(AppConfig &config) { std::string initial_printer_profile_name = printers.get_selected_preset_name(); @@ -2784,6 +2829,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()); // 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, @@ -2934,6 +2980,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()); // 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, @@ -3068,6 +3115,31 @@ 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. + auto join_bools = [](const std::vector &vals) { + std::string s; + for (size_t i = 0; i < vals.size(); ++i) { + if (i > 0) s += ","; + s += (vals[i] ? "1" : "0"); + } + return s; + }; + if (auto *opt = project_config.option("filament_is_mixed")) + config.set_printer_setting(printer_name, "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, "|")); + 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, "|")); + if (auto *opt = project_config.option("filament_mixed_gradient")) + config.set_printer_setting(printer_name, "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, "|")); + 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, "|")); + 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)); + // BBS //config.set("presets", "sla_print", sla_prints.get_selected_preset_name()); //config.set("presets", "sla_material", sla_materials.get_selected_preset_name()); @@ -3103,6 +3175,24 @@ void PresetBundle::set_num_filaments(unsigned int n, std::vector ne filament_volume_map->values.resize(n, static_cast(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. Missing this leaves the + // arrays short and every lookup of a newly created slot reads past the end. + if (auto* opt = project_config.option("filament_is_mixed")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_components")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_sublayer_ratios")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_gradient_range")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient_curve")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("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()) { @@ -3143,6 +3233,24 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) filament_volume_map->values.resize(n, static_cast(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. Missing this leaves the + // arrays short and every lookup of a newly created slot reads past the end. + if (auto* opt = project_config.option("filament_is_mixed")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_components")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_sublayer_ratios")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_gradient_range")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient_curve")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("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_color.empty()) { @@ -3215,9 +3323,53 @@ void PresetBundle::update_num_filaments(unsigned int to_del_flament_id) erase_or_resize(filament_color_type->values); erase_or_resize(ams_multi_color_filment); + // Mixed-color metadata. Component IDs reference other slots by 1-based index, so a deleted + // *physical* filament must be remapped out of every mix before the arrays themselves shrink. + // Deleting a mixed slot needs no remap (nothing references a mixed slot as a component). + { + auto *is_mixed_opt = project_config.option("filament_is_mixed"); + auto *comp_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_opt) { + bool del_is_physical = (to_del_flament_id >= is_mixed_opt->values.size() + || !is_mixed_opt->values[to_del_flament_id]); + if (del_is_physical) + remap_mixed_components_on_delete(is_mixed_opt->values, comp_opt->values, + to_del_flament_id + 1); + } + if (is_mixed_opt) + erase_or_resize(is_mixed_opt->values); + if (comp_opt) + erase_or_resize(comp_opt->values); + } + if (auto *opt = project_config.option("filament_mixed_sublayer_ratios")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_range")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_curve")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_per_part")) + erase_or_resize(opt->values); + update_multi_material_filament_presets(to_del_flament_id); } +bool PresetBundle::is_mixed_filament(size_t idx) const +{ + auto *opt = project_config.option("filament_is_mixed"); + return opt && idx < opt->values.size() && opt->values[idx]; +} + +std::vector PresetBundle::physical_filament_config_indices() const +{ + std::vector indices; + for (size_t i = 0; i < filament_presets.size(); ++i) + if (!is_mixed_filament(i)) + indices.push_back(i); + return indices; +} + void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info) { diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 9da8fb4251..7640d1ff8d 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -497,6 +497,9 @@ public: // Read out the number of extruders from an active printer preset, // update size and content of filament_presets. void update_multi_material_filament_presets(size_t to_delete_filament_id = size_t(-1)); + // 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; void on_extruders_count_changed(int extruder_count); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 509744abe2..33389d27c6 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2719,6 +2719,19 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } auto objectExtruderMap = getObjectExtruderMap(*this); + // Resolve mixed filament virtual slots to physical components so brim + // extruder matching works correctly (mixed slot IDs are not present + // in printExtruders after ToolOrdering::resolve_mixed_filaments). + if (m_config.print_sequence != PrintSequence::ByObject && !tool_ordering.layer_tools().empty()) { + const LayerTools &first_lt = tool_ordering.layer_tools().front(); + for (auto &[obj_id, ext_1based] : objectExtruderMap) { + if (ext_1based == 0) + continue; + auto it = first_lt.mixed_filament_resolution.find(ext_1based - 1); + if (it != first_lt.mixed_filament_resolution.end()) + ext_1based = it->second + 1; + } + } std::vector> objPrintVec; for (const PrintInstance* instance : print_object_instances_ordering) { const ObjectID& print_object_ID = instance->print_object->id(); @@ -3776,6 +3789,14 @@ bool Print::is_dynamic_group_reorder() const const bool enabled = opt && opt->value; if (!enabled || m_config.filament_map_mode != FilamentMapMode::fmmAutoForFlush || m_config.nozzle_diameter.size() <= 1) return false; + + // Dynamic regrouping and mixed-color slots are incompatible: a mixed slot is resolved to + // different physical components per layer, so a group assignment made up-front would be wrong. + const auto &is_mixed = m_config.filament_is_mixed.values; + for (unsigned int filament_id : extruders()) { + if (filament_id < is_mixed.size() && is_mixed[filament_id]) + return false; + } return true; } diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index b38a0ca058..efee489c57 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -117,9 +117,9 @@ class PrintRegion public: PrintRegion() = default; PrintRegion(const PrintRegionConfig &config); - PrintRegion(const PrintRegionConfig &config, const size_t config_hash, int print_object_region_id = -1) : m_config(config), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id) {} + PrintRegion(const PrintRegionConfig &config, const size_t config_hash, int print_object_region_id = -1, ObjectID gradient_volume_id = ObjectID()) : m_config(config), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id), m_gradient_volume_id(gradient_volume_id) {} PrintRegion(PrintRegionConfig &&config); - PrintRegion(PrintRegionConfig &&config, const size_t config_hash, int print_object_region_id = -1) : m_config(std::move(config)), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id) {} + PrintRegion(PrintRegionConfig &&config, const size_t config_hash, int print_object_region_id = -1, ObjectID gradient_volume_id = ObjectID()) : m_config(std::move(config)), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id), m_gradient_volume_id(gradient_volume_id) {} ~PrintRegion() = default; // Methods NOT modifying the PrintRegion's state: @@ -129,6 +129,10 @@ public: // Identifier of this PrintRegion in the list of Print::m_print_regions. int print_region_id() const throw() { return m_print_region_id; } int print_object_region_id() const throw() { return m_print_object_region_id; } + // Volume identity used to differentiate same-config regions when per-part gradient is enabled. + // Default-constructed (invalid) means this region is not tied to a specific volume — preserves + // existing behavior for all paths not using per_part_gradient. + ObjectID gradient_volume_id() const throw() { return m_gradient_volume_id; } // 1-based extruder identifier for this region and role. unsigned int extruder(FlowRole role) const; Flow flow(const PrintObject &object, FlowRole role, double layer_height, bool first_layer = false) const; @@ -158,6 +162,10 @@ private: int m_print_region_id { -1 }; int m_print_object_region_id { -1 }; int m_ref_cnt { 0 }; + // Per-part gradient: when non-invalid, this region belongs exclusively to one ModelVolume, + // letting same-color volumes within a combined ModelObject be tracked separately for gradient + // emission. Default invalid -> region keying behaves exactly as before. + ObjectID m_gradient_volume_id; }; inline bool operator==(const PrintRegion &lhs, const PrintRegion &rhs) { return lhs.config_hash() == rhs.config_hash() && lhs.config() == rhs.config(); } @@ -306,6 +314,11 @@ public: Transform3d trafo_bboxes; std::vector cached_volume_ids; + // Per-part gradient: the slot_per_part_enabled bit vector that produced these regions. + // Print::apply compares it against the current one to detect a change that PrintRegionConfig + // alone would not reveal, and regenerates the regions when it differs. + std::vector last_slot_per_part_enabled; + void ref_cnt_inc() { ++ m_ref_cnt; } void ref_cnt_dec() { if (-- m_ref_cnt == 0) delete this; } void clear() { diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index e2e9bc737d..bb9da850ca 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1,6 +1,7 @@ #include "ClipperUtils.hpp" #include "Model.hpp" #include "Print.hpp" +#include "FilamentMixer.hpp" #include #include @@ -886,7 +887,12 @@ bool verify_update_print_object_regions( size_t hash = regions[i]->config_hash(); size_t j = i; for (++ j; j < regions.size() && regions[j]->config_hash() == hash; ++ j) - if (regions[i]->config() == regions[j]->config()) { + // Same config but different gradient_volume_id is intentional (per-part gradient + // splitting) and must NOT be flagged as a merge. When per-part is off all regions + // carry an invalid (default) gradient_volume_id, so the AND condition is always + // true and behavior matches the legacy check. + if (regions[i]->config() == regions[j]->config() + && regions[i]->gradient_volume_id() == regions[j]->gradient_volume_id()) { // Regions were merged. We need to reslice. return false; } @@ -978,7 +984,10 @@ static PrintObjectRegions* generate_print_object_regions( const float xy_contour_compensation, const std::vector &painting_extruders, std::vector &variant_index, - const bool has_painted_fuzzy_skin) + const bool has_painted_fuzzy_skin, + // Per-part gradient: slot_per_part_enabled[s-1] is true when mixed slot s has + // filament_mixed_gradient_per_part on. Empty / all-false preserves legacy behavior. + const std::vector &slot_per_part_enabled = {}) { // Reuse the old object or generate a new one. auto out = print_object_regions_old ? std::unique_ptr(print_object_regions_old) : std::make_unique(); @@ -1013,19 +1022,71 @@ static PrintObjectRegions* generate_print_object_regions( update_volume_bboxes(layer_ranges_regions, out->cached_volume_ids, model_volumes, out->trafo_bboxes, is_mm_painted ? 0.f : std::max(0.f, xy_contour_compensation)); std::vector region_set; - auto get_create_region = [®ion_set, &all_regions](PrintRegionConfig &&config) -> PrintRegion* { + // Look up or create a PrintRegion. The optional volume_tag, when valid (non-zero ObjectID), + // keys the region to one ModelVolume so two volumes with identical settings still get + // separate regions — needed so each part can run its own gradient. A default (invalid) + // tag reproduces the previous lookup exactly. + auto get_create_region = [®ion_set, &all_regions](PrintRegionConfig &&config, ObjectID volume_tag = ObjectID()) -> PrintRegion* { size_t hash = config.hash(); - auto it = Slic3r::lower_bound_by_predicate(region_set.begin(), region_set.end(), [&config, hash](const PrintRegion* l) { - return l->config_hash() < hash || (l->config_hash() == hash && l->config() < config); }); - if (it != region_set.end() && (*it)->config_hash() == hash && (*it)->config() == config) + auto it = Slic3r::lower_bound_by_predicate(region_set.begin(), region_set.end(), [&config, hash, volume_tag](const PrintRegion* l) { + return l->config_hash() < hash || (l->config_hash() == hash && l->config() < config) + || (l->config_hash() == hash && l->config() == config && l->gradient_volume_id() < volume_tag); }); + if (it != region_set.end() && (*it)->config_hash() == hash && (*it)->config() == config + && (*it)->gradient_volume_id() == volume_tag) return *it; // Insert into a sorted array, it has O(n) complexity, but the calling algorithm has an O(n^2*log(n)) complexity anyways. - all_regions.emplace_back(std::make_unique(std::move(config), hash, int(all_regions.size()))); + all_regions.emplace_back(std::make_unique(std::move(config), hash, int(all_regions.size()), volume_tag)); PrintRegion *region = all_regions.back().get(); region_set.emplace(it, region); return region; }; + // Per-part gradient: count how many model-part volumes in this object use each + // per-part-enabled gradient slot. Only slots with at least 2 users get their volumes + // tagged — a single-user slot gains nothing from per-volume splitting and would only + // inflate the region count. Empty slot_per_part_enabled leaves this empty, so + // compute_volume_tag below always returns an invalid tag and nothing changes. + std::vector per_part_volume_users; + if (!slot_per_part_enabled.empty()) { + per_part_volume_users.assign(slot_per_part_enabled.size(), 0); + for (const ModelVolume *mv : model_volumes) { + if (! mv->is_model_part()) + continue; + const DynamicPrintConfig *range_cfg = layer_ranges_regions.empty() ? nullptr : layer_ranges_regions.front().config; + PrintRegionConfig vol_cfg = region_config_from_model_volume(default_region_config, range_cfg, *mv, num_extruders, variant_index); + for (unsigned int s_1based : { (unsigned int)vol_cfg.outer_wall_filament_id.value, + (unsigned int)vol_cfg.inner_wall_filament_id.value, + (unsigned int)vol_cfg.sparse_infill_filament_id.value, + (unsigned int)vol_cfg.internal_solid_filament_id.value, + (unsigned int)vol_cfg.top_surface_filament_id.value, + (unsigned int)vol_cfg.bottom_surface_filament_id.value }) { + if (s_1based >= 1 + && size_t(s_1based - 1) < slot_per_part_enabled.size() + && slot_per_part_enabled[s_1based - 1]) + ++per_part_volume_users[s_1based - 1]; + } + } + } + auto compute_volume_tag = [&](const PrintRegionConfig &cfg, const ModelVolume &mv) -> ObjectID { + if (per_part_volume_users.empty()) + return ObjectID(); + auto qualifies = [&](unsigned int s_1based) { + return s_1based >= 1 + && size_t(s_1based - 1) < slot_per_part_enabled.size() + && slot_per_part_enabled[s_1based - 1] + && per_part_volume_users[s_1based - 1] >= 2; + }; + if (qualifies((unsigned int)cfg.outer_wall_filament_id.value) + || qualifies((unsigned int)cfg.inner_wall_filament_id.value) + || qualifies((unsigned int)cfg.sparse_infill_filament_id.value) + || qualifies((unsigned int)cfg.internal_solid_filament_id.value) + || qualifies((unsigned int)cfg.top_surface_filament_id.value) + || qualifies((unsigned int)cfg.bottom_surface_filament_id.value)) { + return mv.id(); + } + return ObjectID(); + }; + // Chain the regions in the order they are stored in the volumes list. for (int volume_id = 0; volume_id < int(model_volumes.size()); ++ volume_id) { const ModelVolume &volume = *model_volumes[volume_id]; @@ -1034,9 +1095,11 @@ static PrintObjectRegions* generate_print_object_regions( if (const PrintObjectRegions::BoundingBox *bbox = find_volume_extents(layer_range, volume); bbox) { if (volume.is_model_part()) { // Add a model volume, assign an existing region or generate a new one. + PrintRegionConfig vol_cfg = region_config_from_model_volume(default_region_config, layer_range.config, volume, num_extruders, variant_index); + ObjectID volume_tag = compute_volume_tag(vol_cfg, volume); layer_range.volume_regions.push_back({ &volume, -1, - get_create_region(region_config_from_model_volume(default_region_config, layer_range.config, volume, num_extruders, variant_index)), + get_create_region(std::move(vol_cfg), volume_tag), bbox }); } else if (volume.is_negative_volume()) { @@ -1121,6 +1184,12 @@ static PrintObjectRegions* generate_print_object_regions( } } + + // Save the slot_per_part_enabled bit vector that produced these regions, so the guard in + // Print::apply can detect changes on the next call even when PrintRegionConfig did not + // change. Always written — including an empty vector — so the snapshot always reflects + // the exact input used to generate the current regions. + out->last_slot_per_part_enabled = slot_per_part_enabled; return out.release(); } @@ -1141,6 +1210,17 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ std::vector used_filaments = this->extruders(true); std::unordered_set used_filament_set(used_filaments.begin(), used_filaments.end()); + // A mixed slot is virtual: the filaments actually consumed are its components, so add them + // to the used set or they would be treated as unused and stripped from the config. + { + auto* is_mixed_opt = new_full_config.option("filament_is_mixed"); + auto* comp_strs_opt = new_full_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + auto expanded = expand_mixed_filaments(used_filaments, is_mixed_opt->values, comp_strs_opt->values); + used_filament_set.insert(expanded.begin(), expanded.end()); + } + } + //new_full_config.normalize_fdm(used_filaments); new_full_config.normalize_fdm_1(); t_config_option_keys changed_keys = new_full_config.normalize_fdm_2(objects().size(), used_filaments.size()); @@ -1802,6 +1882,29 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ update_filament_self_index_cache(); } + // Per-part gradient: compute the per-slot enable bit vector once for this Print::apply pass. + // Used by generate_print_object_regions to decide which volumes deserve their own PrintRegion. + std::vector slot_per_part_enabled; + { + const auto &is_mixed_vec = m_config.filament_is_mixed.values; + const auto &grad_vec = m_config.filament_mixed_gradient.values; + const auto &per_part_vec = m_config.filament_mixed_gradient_per_part.values; + const auto &components_vec = m_config.filament_mixed_components.values; + slot_per_part_enabled.assign(is_mixed_vec.size(), false); + for (size_t i = 0; i < is_mixed_vec.size(); ++i) { + if (! is_mixed_vec[i]) + continue; + std::vector comps = parse_mixed_components(i < components_vec.size() ? components_vec[i] : ""); + if (comps.size() != 2) + continue; + if (i >= grad_vec.size() || ! grad_vec[i]) + continue; + if (i >= per_part_vec.size() || ! per_part_vec[i]) + continue; + slot_per_part_enabled[i] = true; + } + } + // All regions now have distinct settings. // Check whether applying the new region config defaults we would get different regions, // update regions or create regions from scratch. @@ -1862,6 +1965,15 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ update_apply_status((*it)->invalidate_state_by_config_options(old_config, new_config, diff_keys)); }, print_variant_index)) { + // Per-part gradient: PrintRegionConfig alone cannot reveal a change in which slots + // have per-part enabled, so compare against the snapshot taken when these regions + // were generated and regenerate on any difference (slot toggled, per-part moved + // between slots, eligibility changed via components / gradient / is_mixed). + if (print_object_regions->last_slot_per_part_enabled != slot_per_part_enabled) { + invalidate(); + model_object_status.print_object_regions_status = ModelObjectStatus::PrintObjectRegionsStatus::PartiallyValid; + print_regions_reshuffled = true; + } // Regions are valid, just keep them. } else { // Regions were reshuffled. @@ -1884,7 +1996,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ print_object.is_mm_painted() ? 0.f : float(print_object.config().xy_contour_compensation.value), painting_extruders, print_variant_index, - print_object.is_fuzzy_skin_painted()); + print_object.is_fuzzy_skin_painted(), + slot_per_part_enabled); } for (auto it = it_print_object; it != it_print_object_end; ++it) if ((*it)->m_shared_regions) { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 8083da954e..43559a3120 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -3263,6 +3263,62 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionBools { false }); + // Mixed-color filament. A slot flagged here is virtual: it is not loaded into any + // physical extruder, but resolved at slicing time into the physical filaments listed + // in filament_mixed_components, blended either by splitting each layer into + // sub-layers or by alternating whole layers (see enable_mixed_color_sublayer). + def = this->add("filament_is_mixed", coBools); + def->label = L("Is mixed filament"); + def->tooltip = L("Whether this filament slot is a mixed filament composed of multiple physical filaments"); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + + def = this->add("filament_mixed_components", coStrings); + def->label = L("Mixed filament components"); + def->tooltip = L("Comma-separated 1-based indices of component filaments, e.g. \"1,3\""); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_sublayer_ratios", coStrings); + def->label = L("Mixed filament sublayer ratios"); + def->tooltip = L("Comma-separated ratio values summing to 1.0, e.g. \"0.7,0.3\""); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient", coBools); + def->label = L("Mixed filament gradient"); + def->tooltip = L("Enable Z-direction gradient mode for mixed filament sub-layers. " + "When enabled, the sub-layer ratios vary linearly across layers."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + + def = this->add("filament_mixed_gradient_range", coStrings); + def->label = L("Mixed filament gradient range"); + def->tooltip = L("Start and end ratios for the first component in gradient mode. " + "Comma-separated pair, e.g. \"0.10,0.90\" means 10% to 90%."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient_curve", coStrings); + def->label = L("Mixed filament gradient curve"); + def->tooltip = L("Optional Photoshop-style custom curve mapping Z progress to the first " + "component ratio. Encoded as pipe-separated control points, " + "either \"x,y\" (legacy) or \"x,y,m_in,m_out\" when a tangent override " + "is needed (empty token or \"nan\" means use PCHIP default). " + "x in [0,1]; y is clamped to the configured ratio range, " + "e.g. \"0,0.15|0.5,0.50|1,0.85\". When empty, the linear " + "gradient_range is used instead."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient_per_part", coBools); + def->label = L("Mixed filament per-part gradient"); + def->tooltip = L("When gradient mode is enabled, apply the gradient to each part of an " + "assembly independently rather than treating the whole assembly as one " + "Z range."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + // defined in bits // 0 means cannot support, 1 means support // 0 bit: can support in left extruder @@ -7402,6 +7458,14 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionFloats { 1. }); + def = this->add("enable_mixed_color_sublayer", coBool); + def->label = L("Mixed color sublayer"); + def->tooltip = L("Enable mixed color sublayer splitting. When enabled, layers containing mixed color " + "filaments will be split into sub-layers to achieve color mixing effects."); + def->category = L("Quality"); + def->mode = comSimple; + def->set_default_value(new ConfigOptionBool(false)); + def = this->add("enable_prime_tower", coBool); def->label = L("Enable"); def->tooltip = L("The wiping tower can be used to clean up residue on the nozzle and stabilize the chamber pressure inside the nozzle in order to avoid appearance defects when printing objects."); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 255c8721b9..330151c4d3 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1538,6 +1538,14 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, filament_colour)) ((ConfigOptionStrings, filament_vendor)) ((ConfigOptionBools, filament_is_support)) + // Mixed-color filament: a virtual slot realized from 2-3 physical filaments. + ((ConfigOptionBools, filament_is_mixed)) + ((ConfigOptionStrings, filament_mixed_components)) + ((ConfigOptionStrings, filament_mixed_sublayer_ratios)) + ((ConfigOptionBools, filament_mixed_gradient)) + ((ConfigOptionStrings, filament_mixed_gradient_range)) + ((ConfigOptionStrings, filament_mixed_gradient_curve)) + ((ConfigOptionBools, filament_mixed_gradient_per_part)) ((ConfigOptionInts, filament_printable)) ((ConfigOptionInts, filament_extruder_compatibility)) ((ConfigOptionFloats, filament_change_length)) @@ -1838,6 +1846,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( ((ConfigOptionInts, nozzle_temperature_range_low)) ((ConfigOptionInts, nozzle_temperature_range_high)) ((ConfigOptionFloats, wipe_distance)) + ((ConfigOptionBool, enable_mixed_color_sublayer)) ((ConfigOptionBool, enable_prime_tower)) ((ConfigOptionBool, prime_tower_enable_framework)) // BBS: change wipe_tower_x and wipe_tower_y data type to floats to add partplate logic diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp new file mode 100644 index 0000000000..187f218863 --- /dev/null +++ b/src/libslic3r/TexturePainting.cpp @@ -0,0 +1,663 @@ +#include "TexturePainting.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include "TextureToColor/TextureToColor.hpp" +#include "TextureToColor/ColorUtils.hpp" + +#include "Model.hpp" +#include "TriangleMesh.hpp" +#include "TriangleSelector.hpp" + +namespace Slic3r { + +static cv::Mat decode_texture_image(const TextureImage& img) { + if (img.data.empty()) + return {}; + + // Raw encoded image data (PNG/JPEG) from glTF loader: width == -1 + if (img.width <= 0 || img.height <= 0) { + std::vector buf(img.data.begin(), img.data.end()); + cv::Mat raw(1, static_cast(buf.size()), CV_8UC1, buf.data()); + cv::Mat decoded = cv::imdecode(raw, cv::IMREAD_COLOR); + return decoded; + } + + int cv_type = (img.channels == 4) ? CV_8UC4 : CV_8UC3; + std::vector pixel_buf(img.data.begin(), img.data.end()); + cv::Mat src(img.height, img.width, cv_type, pixel_buf.data()); + + cv::Mat bgr; + if (img.channels == 4) + cv::cvtColor(src, bgr, cv::COLOR_RGBA2BGR); + else if (img.channels == 3) + cv::cvtColor(src, bgr, cv::COLOR_RGB2BGR); + else + return {}; + + return bgr; +} + +static void build_tex2color_mesh( + const TexturedMesh& textured, + tex2color::TriMesh& mesh, + std::vector>& uv_coords) +{ + const size_t nv = textured.vertices.size(); + const size_t nf = textured.indices.size(); + + mesh.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) { + mesh.vertices[i] = Vec3f( + textured.vertices[i][0], + textured.vertices[i][1], + textured.vertices[i][2]); + } + + mesh.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) { + mesh.indices[i] = Vec3i32( + textured.indices[i][0], + textured.indices[i][1], + textured.indices[i][2]); + } + + uv_coords.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + uv_coords[fi].resize(3); + for (int vi = 0; vi < 3; ++vi) { + if (textured.has_face_uvs()) { + int uv_idx = textured.uv_indices[fi][vi]; + if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { + uv_coords[fi][vi] = Vec2f( + textured.uv_coords[uv_idx][0], + textured.uv_coords[uv_idx][1]); + } else { + uv_coords[fi][vi] = Vec2f(0.f, 0.f); + } + } else { + int vtx_idx = textured.indices[fi][vi]; + if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { + uv_coords[fi][vi] = Vec2f( + textured.uvs[vtx_idx][0], + textured.uvs[vtx_idx][1]); + } else { + uv_coords[fi][vi] = Vec2f(0.f, 0.f); + } + } + } + } +} + +static void extract_painted_mesh( + const tex2color::TriMesh& color_mesh, + const std::vector>& face_colors, + PaintedMesh& painted) +{ + const size_t nv = color_mesh.vertices.size(); + const size_t nf = color_mesh.indices.size(); + + painted.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) { + const auto& v = color_mesh.vertices[i]; + painted.vertices[i] = {v.x(), v.y(), v.z()}; + } + + painted.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) { + const auto& f = color_mesh.indices[i]; + painted.indices[i] = {f[0], f[1], f[2]}; + } + + painted.face_colors = face_colors; + + std::set> unique_colors(face_colors.begin(), face_colors.end()); + painted.cluster_colors.assign(unique_colors.begin(), unique_colors.end()); +} + +// Build a vertically-stacked atlas from multiple textures and remap per-face UVs. +// +// Sub-textures are laid out left-aligned (x=0) at successive y offsets, with +// atlas_w taken as the maximum width across all sub-textures. UVs must therefore +// be remapped on BOTH axes so that faces belonging to a sub-texture narrower +// than atlas_w sample inside that sub-texture's region (left side of the atlas) +// instead of the right-side zero-padding. Materials that carry only a baseColor +// (no map_Kd / glTF baseColorTexture) get their own 1x1 swatch at the bottom of +// the atlas so their faces sample the correct flat colour rather than being +// silently aliased onto textures[0]. +static bool build_multi_texture_atlas( + const TexturedMesh& textured, + cv::Mat& out_atlas, + std::vector>& out_uv_coords) +{ + std::vector decoded; + decoded.reserve(textured.textures.size()); + for (const auto& ti : textured.textures) + decoded.push_back(decode_texture_image(ti)); + + const bool has_mapping = !textured.material_texture_map.empty(); + const size_t nf = textured.indices.size(); + + auto resolve_tex_idx = [&](int mat_idx) -> int { + if (!has_mapping || mat_idx < 0 + || static_cast(mat_idx) >= textured.material_texture_map.size()) + return -1; + const int ti = textured.material_texture_map[mat_idx]; + if (ti < 0 || static_cast(ti) >= decoded.size() || decoded[ti].empty()) + return -1; + return ti; + }; + + // Determine atlas width (max width across all textures) and per-texture row offsets. + int atlas_w = 0; + int atlas_h = 0; + std::vector y_offsets(decoded.size(), 0); + int first_usable_tex = -1; + for (size_t i = 0; i < decoded.size(); ++i) { + if (decoded[i].empty()) continue; + if (first_usable_tex < 0) first_usable_tex = static_cast(i); + y_offsets[i] = atlas_h; + atlas_w = std::max(atlas_w, decoded[i].cols); + atlas_h += decoded[i].rows; + } + if (atlas_w == 0 || atlas_h == 0) + return false; + + // Collect materials that have a baseColor but no usable texture so we can + // route their faces to a dedicated 1x1 solid swatch instead of aliasing + // them onto textures[0]. + std::map mat_solid_y; // mat_idx -> y row in atlas + std::map> mat_solid_color; // mat_idx -> baseColor (RGBA) + for (size_t fi = 0; fi < nf; ++fi) { + const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + if (mat_idx < 0) continue; + if (resolve_tex_idx(mat_idx) >= 0) continue; + if (static_cast(mat_idx) >= textured.material_colors.size()) continue; + if (mat_solid_y.find(mat_idx) != mat_solid_y.end()) continue; + mat_solid_y[mat_idx] = atlas_h++; + mat_solid_color[mat_idx] = textured.material_colors[mat_idx]; + } + + out_atlas = cv::Mat::zeros(atlas_h, atlas_w, CV_8UC3); + for (size_t i = 0; i < decoded.size(); ++i) { + if (decoded[i].empty()) continue; + cv::Mat roi = out_atlas(cv::Rect(0, y_offsets[i], decoded[i].cols, decoded[i].rows)); + decoded[i].copyTo(roi); + } + for (const auto& kv : mat_solid_color) { + const auto& c = kv.second; + // OpenCV stores BGR; baseColor is RGBA in [0,1]. + out_atlas.at(mat_solid_y[kv.first], 0) = cv::Vec3b( + static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f))); + } + + out_uv_coords.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + const int tex_idx = resolve_tex_idx(mat_idx); + + // Pick the atlas region this face samples from. + int y_off = 0, x_off = 0, th = atlas_h, tw = atlas_w; + bool use_solid = false; + if (tex_idx >= 0) { + y_off = y_offsets[tex_idx]; + th = decoded[tex_idx].rows; + tw = decoded[tex_idx].cols; + } else if (mat_idx >= 0 && mat_solid_y.count(mat_idx) > 0) { + y_off = mat_solid_y[mat_idx]; + th = 1; + tw = 1; + use_solid = true; + } else if (first_usable_tex >= 0) { + // Last-resort fallback: faces without a material or without any + // baseColor still need somewhere to sample; the first usable + // texture preserves legacy behaviour and, with the per-axis + // remapping below, no longer aliases onto the zero-padded right + // margin even when sub-textures have unequal widths. + y_off = y_offsets[first_usable_tex]; + th = decoded[first_usable_tex].rows; + tw = decoded[first_usable_tex].cols; + } + + out_uv_coords[fi].resize(3); + for (int vi = 0; vi < 3; ++vi) { + float u = 0.f, v = 0.f; + if (textured.has_face_uvs()) { + int uv_idx = textured.uv_indices[fi][vi]; + if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { + u = textured.uv_coords[uv_idx][0]; + v = textured.uv_coords[uv_idx][1]; + } + } else { + int vtx_idx = textured.indices[fi][vi]; + if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { + u = textured.uvs[vtx_idx][0]; + v = textured.uvs[vtx_idx][1]; + } + } + if (use_solid) { + // Aim at the centre of the 1x1 swatch so bilinear sampling + // (in tex2color) cannot drift into neighbouring rows. + const float u_atlas = (x_off + 0.5f) / static_cast(atlas_w); + const float v_atlas = (y_off + 0.5f) / static_cast(atlas_h); + out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); + } else { + // Wrap to [0,1) on both axes (OBJ tile UVs may step outside + // the unit square), then scale by the sub-texture extents so + // samples land inside its actual region. Without scaling u, + // any sub-texture narrower than atlas_w would have all its + // faces sampled from the right-side zero-padding. + u = u - std::floor(u); + v = v - std::floor(v); + const float u_atlas = (x_off + u * tw) / static_cast(atlas_w); + const float v_atlas = (y_off + v * th) / static_cast(atlas_h); + out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); + } + } + } + return true; +} + +bool texture_to_painting( + const TexturedMesh& textured, + PaintedMesh& painted, + const TexturePaintingSettings& settings, + PaintProgressCallback progress, + PaintCancelCallback cancel) +{ + if (textured.vertices.empty() || textured.indices.empty() || textured.textures.empty()) + return false; + + cv::Mat texture; + tex2color::TriMesh input_mesh; + std::vector> uv_coords; + + const bool multi_tex = textured.textures.size() > 1 && !textured.material_texture_map.empty(); + + if (multi_tex) { + if (!build_multi_texture_atlas(textured, texture, uv_coords)) + return false; + // Build mesh geometry (atlas UVs already computed above) + const size_t nv = textured.vertices.size(); + const size_t nf = textured.indices.size(); + input_mesh.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) + input_mesh.vertices[i] = Vec3f( + textured.vertices[i][0], textured.vertices[i][1], textured.vertices[i][2]); + input_mesh.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) + input_mesh.indices[i] = Vec3i32( + textured.indices[i][0], textured.indices[i][1], textured.indices[i][2]); + } else { + texture = decode_texture_image(textured.textures[0]); + if (texture.empty()) + return false; + build_tex2color_mesh(textured, input_mesh, uv_coords); + } + + tex2color::TextureToColorSettings algo_settings; + algo_settings.target_colors_num = settings.target_colors_num; + algo_settings.smooth_weight = settings.smooth_weight; + algo_settings.oversampling_iters = settings.oversampling_iters; + switch (settings.mesh_repair_decision) { + case TexturePaintingSettings::MeshRepairDecision::Ask: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; + break; + case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; + break; + case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: + default: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; + break; + } + + tex2color::AlgoProgressCallback algo_progress = nullptr; + if (progress) { + algo_progress = [&progress](tex2color::AlgoProgress p) { + progress(p.percent, p.message); + }; + } + + tex2color::AlgoCancelCallback algo_cancel = nullptr; + if (cancel) { + algo_cancel = [&cancel]() -> bool { return cancel(); }; + } + + tex2color::TriMesh color_mesh; + std::vector> face_colors; + algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; + algo_settings.mesh_repair_callback = settings.mesh_repair_callback; + + bool ok = tex2color::TextureToColor( + input_mesh, uv_coords, texture, + color_mesh, face_colors, + algo_settings, algo_progress, algo_cancel); + + if (!ok) + return false; + + extract_painted_mesh(color_mesh, face_colors, painted); + return true; +} + +double compute_delta_e( + const std::array& rgb1, + const std::array& rgba2) +{ + return tex2color::color_utils::calc_rgb_color_difference_by_ciede2000( + rgb1, + { + static_cast(rgba2[0] * 255.0f), + static_cast(rgba2[1] * 255.0f), + static_cast(rgba2[2] * 255.0f) + }); +} + +std::vector match_clusters_to_filaments( + const std::vector>& cluster_colors, + const std::vector>& filament_colors, + const std::vector& /*filament_names*/) +{ + std::vector matches(cluster_colors.size()); + + for (size_t ci = 0; ci < cluster_colors.size(); ++ci) { + matches[ci].cluster_index = static_cast(ci); + matches[ci].cluster_color = cluster_colors[ci]; + matches[ci].delta_e = 1e9; + + for (size_t fi = 0; fi < filament_colors.size(); ++fi) { + double de = compute_delta_e(cluster_colors[ci], filament_colors[fi]); + if (de < matches[ci].delta_e) { + matches[ci].delta_e = de; + matches[ci].filament_index = static_cast(fi); + matches[ci].filament_color = filament_colors[fi]; + } + } + } + return matches; +} + +bool apply_painted_mesh_to_volume( + const PaintedMesh& painted, + const std::vector& matches, + ModelVolume& volume) +{ + if (painted.face_colors.empty() || matches.empty()) + return false; + + const auto& cluster_colors = painted.cluster_colors; + std::map, int> color_to_filament; + for (const auto& m : matches) { + if (m.cluster_index >= 0 && m.cluster_index < (int)cluster_colors.size() && m.filament_index >= 0) + color_to_filament[cluster_colors[m.cluster_index]] = m.filament_index; + } + + indexed_triangle_set its; + its.vertices.resize(painted.vertices.size()); + for (size_t i = 0; i < painted.vertices.size(); ++i) { + its.vertices[i] = Vec3f( + painted.vertices[i][0], + painted.vertices[i][1], + painted.vertices[i][2]); + } + its.indices.resize(painted.indices.size()); + for (size_t i = 0; i < painted.indices.size(); ++i) { + its.indices[i] = Vec3i32( + painted.indices[i][0], + painted.indices[i][1], + painted.indices[i][2]); + } + + TriangleMesh new_mesh(std::move(its)); + + // The volume already went through ModelObject::add_volume -> + // center_geometry_after_creation, which translated its mesh by + // -source.mesh_offset (and folded that shift into the volume + // transformation). The painted mesh, however, is derived from the + // raw textured mesh and is therefore expressed in the original + // un-centered coordinate frame. Reuse the exact recorded shift to + // align it -- do NOT compute it from the bounding-box centers of + // the two meshes: tex2color::TextureToColor performs subdivision + // and CGAL polygon-soup repair, so the painted vertex count and + // bbox no longer match the original textured mesh and a bbox- + // center alignment would silently displace the geometry. + // + // If the model has been scaled by Model::convert_from_meters / + // convert_from_imperial_units after load, the painted mesh fed + // here is already in millimetres (Model::convert_* also scales + // texture_mesh in place) while source.mesh_offset was recorded + // before the conversion and therefore still lives in the original + // pre-scaled frame. Bring it into the same frame as the painted + // vertices so the alignment shift below stays correct on the + // textured-import path. This compensation is scoped to this + // function so that other (non-textured) import paths are not + // affected. + Vec3d mesh_offset = volume.source.mesh_offset; + double unit_scale = 1.0; + if (volume.source.is_converted_from_meters) + unit_scale = 1000.0; + else if (volume.source.is_converted_from_inches) + unit_scale = 25.4; + if (unit_scale != 1.0) + mesh_offset *= unit_scale; + + if (!mesh_offset.isApprox(Vec3d::Zero())) + new_mesh.translate(-mesh_offset.cast()); + new_mesh.set_init_shift(mesh_offset); + + // Log bbox drift for diagnostics. Subdivision + CGAL polygon-soup + // repair routinely changes vertex count and bbox, so moderate drift + // is expected and must not block the apply. + if (!new_mesh.empty() && !volume.mesh().empty()) { + const Vec3d new_center = new_mesh.bounding_box().center(); + const Vec3d cur_center = volume.mesh().bounding_box().center(); + const double diag = volume.mesh().bounding_box().size().norm(); + const double drift = (new_center - cur_center).norm(); + if (drift > 0.05 * std::max(1.0, diag)) + BOOST_LOG_TRIVIAL(warning) + << "apply_painted_mesh_to_volume: painted bbox center drifted by " + << drift << " (bbox diag=" << diag + << ", unit_scale=" << unit_scale + << ", from_meters=" << volume.source.is_converted_from_meters + << ", from_inches=" << volume.source.is_converted_from_inches << ")"; + else if (drift > 1e-3 * std::max(1.0, diag)) + BOOST_LOG_TRIVIAL(info) + << "apply_painted_mesh_to_volume: minor bbox drift " + << drift << " (bbox diag=" << diag + << ", unit_scale=" << unit_scale << ")"; + } + + volume.set_mesh(std::move(new_mesh)); + volume.calculate_convex_hull(); + + // Re-center the replaced mesh so its bbox center sits at the origin, + // matching what center_geometry_after_creation did for the original mesh. + // CGAL repair / subdivision may shift the bbox center (drift); without + // re-centering, the volume offset (which was computed for the original + // centered mesh) no longer matches, causing the model to float or clip. + // Pass false to keep source.mesh_offset unchanged. + volume.center_geometry_after_creation(false); + volume.invalidate_convex_hull_2d(); + + // Mesh geometry has been replaced; any per-face annotation indexed + // against the previous triangle set is now stale. mmu_segmentation_facets + // is rewritten below from the new selector; reset the others so future + // import paths that carry support / seam / fuzzy_skin painting cannot + // leak indices from the old mesh into the new one. + volume.supported_facets.reset(); + volume.fuzzy_skin_facets.reset(); + volume.seam_facets.reset(); + + if (ModelObject* obj = volume.get_object()) + obj->invalidate_bounding_box(); + + TriangleSelector selector(volume.mesh()); + for (size_t fi = 0; fi < painted.face_colors.size() && fi < (size_t)volume.mesh().its.indices.size(); ++fi) { + auto it = color_to_filament.find(painted.face_colors[fi]); + if (it != color_to_filament.end()) { + int extruder_idx = it->second; + auto state = static_cast( + static_cast(EnforcerBlockerType::Extruder1) + extruder_idx); + if (state <= EnforcerBlockerType::ExtruderMax) + selector.set_facet(static_cast(fi), state); + } + } + + volume.mmu_segmentation_facets.set(selector); + return true; +} + +bool decode_texture_to_pixels( + const TextureImage& img, + std::vector& out_pixels, + int& out_w, int& out_h) +{ + cv::Mat decoded = decode_texture_image(img); + if (decoded.empty()) + return false; + + // decoded is BGR, CV_8UC3 + out_w = decoded.cols; + out_h = decoded.rows; + size_t nbytes = (size_t)out_w * out_h * 3; + out_pixels.resize(nbytes); + + if (decoded.isContinuous()) { + std::memcpy(out_pixels.data(), decoded.data, nbytes); + } else { + for (int r = 0; r < out_h; ++r) + std::memcpy(out_pixels.data() + r * out_w * 3, decoded.ptr(r), out_w * 3); + } + return true; +} + +// Sample face color from texture using 3 explicit UV values (centroid + bilinear). +static std::array sample_face_from_uvs( + const cv::Mat& tex, + const std::array& uv0, + const std::array& uv1, + const std::array& uv2) +{ + float cu = (uv0[0] + uv1[0] + uv2[0]) / 3.f; + float cv_val = (uv0[1] + uv1[1] + uv2[1]) / 3.f; + + cu = cu - std::floor(cu); + cv_val = cv_val - std::floor(cv_val); + + float fx = cu * (tex.cols - 1); + float fy = cv_val * (tex.rows - 1); + + int x0 = std::clamp(static_cast(fx), 0, tex.cols - 1); + int y0 = std::clamp(static_cast(fy), 0, tex.rows - 1); + int x1 = std::min(x0 + 1, tex.cols - 1); + int y1 = std::min(y0 + 1, tex.rows - 1); + + float wx = fx - x0; + float wy = fy - y0; + + const int ch = tex.channels(); + auto sample = [&](int row, int col) -> std::array { + const uchar* ptr = tex.data + row * tex.step[0] + col * ch; + return {static_cast(ptr[2]), static_cast(ptr[1]), static_cast(ptr[0])}; + }; + + auto c00 = sample(y0, x0); + auto c10 = sample(y0, x1); + auto c01 = sample(y1, x0); + auto c11 = sample(y1, x1); + + std::array color; + for (int i = 0; i < 3; ++i) { + float top = c00[i] * (1.f - wx) + c10[i] * wx; + float bot = c01[i] * (1.f - wx) + c11[i] * wx; + color[i] = static_cast(std::clamp(top * (1.f - wy) + bot * wy, 0.f, 255.f)); + } + return color; +} + +// Legacy overload: look up UVs from per-vertex array by vertex indices. +static std::array sample_face_from_texture( + const cv::Mat& tex, + const std::vector>& uvs, + const std::array& face) +{ + std::array uv0 = {0.f, 0.f}, uv1 = {0.f, 0.f}, uv2 = {0.f, 0.f}; + if (face[0] >= 0 && static_cast(face[0]) < uvs.size()) uv0 = uvs[face[0]]; + if (face[1] >= 0 && static_cast(face[1]) < uvs.size()) uv1 = uvs[face[1]]; + if (face[2] >= 0 && static_cast(face[2]) < uvs.size()) uv2 = uvs[face[2]]; + return sample_face_from_uvs(tex, uv0, uv1, uv2); +} + +bool sample_original_face_colors( + const TexturedMesh& textured, + std::vector>& out_face_colors) +{ + if (textured.indices.empty()) + return false; + + // Decode all textures up front + std::vector decoded_textures; + decoded_textures.reserve(textured.textures.size()); + for (const auto& ti : textured.textures) { + decoded_textures.push_back(decode_texture_image(ti)); + } + + const bool has_mapping = !textured.material_texture_map.empty(); + const size_t nf = textured.indices.size(); + out_face_colors.resize(nf); + + for (size_t fi = 0; fi < nf; ++fi) { + int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + + int tex_idx = -1; + if (has_mapping && mat_idx >= 0 && static_cast(mat_idx) < textured.material_texture_map.size()) + tex_idx = textured.material_texture_map[mat_idx]; + else if (!decoded_textures.empty()) + tex_idx = 0; // fallback: single-texture model + + if (tex_idx >= 0 && static_cast(tex_idx) < decoded_textures.size() + && !decoded_textures[tex_idx].empty()) { + if (textured.has_face_uvs()) { + const auto& ui = textured.uv_indices[fi]; + auto get_uv = [&](int vi) -> std::array { + int idx = ui[vi]; + if (idx >= 0 && static_cast(idx) < textured.uv_coords.size()) + return textured.uv_coords[idx]; + return {0.f, 0.f}; + }; + out_face_colors[fi] = sample_face_from_uvs( + decoded_textures[tex_idx], get_uv(0), get_uv(1), get_uv(2)); + } else { + out_face_colors[fi] = sample_face_from_texture( + decoded_textures[tex_idx], textured.uvs, textured.indices[fi]); + } + } else if (has_mapping && mat_idx >= 0 + && static_cast(mat_idx) < textured.material_colors.size()) { + // No texture — use baseColorFactor as solid color + const auto& c = textured.material_colors[mat_idx]; + out_face_colors[fi] = { + static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)) + }; + } else { + out_face_colors[fi] = {192, 192, 192}; // default gray + } + } + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/TexturePainting.hpp b/src/libslic3r/TexturePainting.hpp new file mode 100644 index 0000000000..ac98e968c7 --- /dev/null +++ b/src/libslic3r/TexturePainting.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct indexed_triangle_set; + +namespace Slic3r { + +class TriangleMesh; +class ModelVolume; + +struct TextureImage { + int width = 0; + int height = 0; + int channels = 4; + std::vector data; +}; + +struct TexturedMesh { + std::vector> vertices; + std::vector> indices; + std::vector> uvs; + std::vector textures; + std::vector material_ids; + // material index -> index in textures[] (-1 if no texture, use material_colors) + std::vector material_texture_map; + // per-material baseColorFactor (RGBA 0-1), indexed by material index + std::vector> material_colors; + + // Per-face independent UV support (for OBJ where the same vertex can have + // different texture coordinates on different faces). + std::vector> uv_coords; // UV coordinate pool + std::vector> uv_indices; // per-face UV indices into uv_coords + + bool has_face_uvs() const { return !uv_indices.empty() && !uv_coords.empty(); } +}; + +struct PaintedMesh { + std::vector> vertices; + std::vector> indices; + std::vector> face_colors; // per-face RGB [0..255] + std::vector> cluster_colors; +}; + +using PaintProgressCallback = std::function; +using PaintCancelCallback = std::function; +using PaintMeshRepairCallback = std::function progress_callback, + std::function cancel_callback, + std::string* error_message)>; + +struct TexturePaintingSettings { + std::size_t target_colors_num = 4; + double smooth_weight = 0.5; + std::size_t oversampling_iters = 0; + enum class MeshRepairDecision { + Ask, + ImportWithoutRepair, + RepairAndImport + }; + MeshRepairDecision mesh_repair_decision = MeshRepairDecision::ImportWithoutRepair; + bool* mesh_repair_decision_required = nullptr; + PaintMeshRepairCallback mesh_repair_callback; +}; + +struct FilamentMatch { + int cluster_index = -1; + int filament_index = -1; + double delta_e = 0.0; + std::array cluster_color = {0,0,0}; + std::array filament_color = {0,0,0,1}; +}; + +bool texture_to_painting( + const TexturedMesh& textured, + PaintedMesh& painted, + const TexturePaintingSettings& settings = {}, + PaintProgressCallback progress = nullptr, + PaintCancelCallback cancel = nullptr); + +std::vector match_clusters_to_filaments( + const std::vector>& cluster_colors, + const std::vector>& filament_colors, + const std::vector& filament_names); + +double compute_delta_e( + const std::array& rgb1, + const std::array& rgba2); + +bool apply_painted_mesh_to_volume( + const PaintedMesh& painted, + const std::vector& matches, + ModelVolume& volume); + +// Decode a TextureImage (which may contain raw PNG/JPEG bytes) into BGR pixel data. +// On success, populates out_pixels (BGR, 3 bytes/pixel) and sets out_w/out_h. +bool decode_texture_to_pixels( + const TextureImage& img, + std::vector& out_pixels, + int& out_w, int& out_h); + +// Sample per-face colors from the correct texture per material_ids. +// Uses material_texture_map / material_colors for multi-material GLBs. +// Falls back to textures[0] when the mapping is absent. +bool sample_original_face_colors( + const TexturedMesh& textured, + std::vector>& out_face_colors); + +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/Callbacks.hpp b/src/libslic3r/TextureToColor/Callbacks.hpp new file mode 100644 index 0000000000..70084f585d --- /dev/null +++ b/src/libslic3r/TextureToColor/Callbacks.hpp @@ -0,0 +1,15 @@ +#pragma once +#include + +namespace Slic3r { namespace tex2color { + +struct AlgoProgress { + int percent = 0; + const char* message = ""; +}; + +using AlgoProgressCallback = std::function; +using AlgoCancelCallback = std::function; + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/CgalUtils.hpp b/src/libslic3r/TextureToColor/CgalUtils.hpp new file mode 100644 index 0000000000..109d454827 --- /dev/null +++ b/src/libslic3r/TextureToColor/CgalUtils.hpp @@ -0,0 +1,173 @@ +#pragma once +#include "TriMesh.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace tex2color { +namespace cgalutils { + +using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel; +using CGALMesh = CGAL::Surface_mesh; + +inline CGALMesh trimesh_to_cgal(const TriMesh& mesh) { + CGALMesh cm; + std::vector vmap(mesh.vertices.size()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) + vmap[i] = cm.add_vertex(Kernel::Point_3(mesh.vertices[i].x(), mesh.vertices[i].y(), mesh.vertices[i].z())); + for (const auto& f : mesh.indices) { + cm.add_face(vmap[f[0]], vmap[f[1]], vmap[f[2]]); + } + return cm; +} + +inline TriMesh cgal_to_trimesh(const CGALMesh& cm) { + TriMesh mesh; + std::map vmap; + size_t idx = 0; + for (auto v : cm.vertices()) { + if (!cm.is_valid(v) || cm.is_removed(v)) continue; + auto p = cm.point(v); + mesh.vertices.push_back(Vec3f((float)p.x(), (float)p.y(), (float)p.z())); + vmap[v] = idx++; + } + for (auto f : cm.faces()) { + if (!cm.is_valid(f) || cm.is_removed(f)) continue; + auto h = cm.halfedge(f); + auto v0 = cm.target(h); + auto v1 = cm.target(cm.next(h)); + auto v2 = cm.target(cm.next(cm.next(h))); + mesh.indices.push_back(Vec3i32((int)vmap[v0], (int)vmap[v1], (int)vmap[v2])); + } + return mesh; +} + +inline bool is_mesh_halfedge_compatible(const TriMesh& mesh) { + std::vector> vtx_to_adj_faces(mesh.vertices.size()); + std::size_t edge_id = 0; + std::vector> edge_to_faces; + std::vector> vtx_to_prev_vtxs(mesh.vertices.size()); + std::vector> vtx_to_next_vtxs(mesh.vertices.size()); + std::vector> vtx_vtx_to_edge(mesh.vertices.size()); + + for (std::size_t fid = 0; fid < mesh.indices.size(); ++fid) { + const TriFace& face = mesh.indices[fid]; + if (face[0] == face[1] || face[1] == face[2] || face[2] == face[0]) { + return false; + } + for (std::size_t i = 0; i < 3; ++i) { + if (static_cast(face[i]) >= mesh.vertices.size()) { + return false; + } + vtx_to_adj_faces[face[i]].insert(fid); + + std::size_t prev_vtx = face[(i + 2) % 3]; + std::size_t next_vtx = face[(i + 1) % 3]; + + if (vtx_to_prev_vtxs[face[i]].count(prev_vtx)) { + return false; + } + vtx_to_prev_vtxs[face[i]].insert(prev_vtx); + + if (vtx_to_next_vtxs[face[i]].count(next_vtx)) { + return false; + } + vtx_to_next_vtxs[face[i]].insert(next_vtx); + } + + for (std::size_t i = 0; i < 3; ++i) { + std::size_t va = face[i]; + std::size_t vb = face[(i + 1) % 3]; + if (!vtx_vtx_to_edge[va].count(vb)) { + vtx_vtx_to_edge[va][vb] = edge_id; + vtx_vtx_to_edge[vb][va] = edge_id; + ++edge_id; + edge_to_faces.emplace_back(std::unordered_set()); + } + edge_to_faces[vtx_vtx_to_edge[va][vb]].insert(fid); + } + } + + for (std::size_t vid = 0; vid < mesh.vertices.size(); ++vid) { + if (vtx_to_adj_faces[vid].empty()) { + continue; + } + std::unordered_set visited_faces; + std::queue face_queue; + face_queue.push(*(vtx_to_adj_faces[vid].begin())); + visited_faces.insert(*(vtx_to_adj_faces[vid].begin())); + while (!face_queue.empty()) { + std::size_t fid = face_queue.front(); + face_queue.pop(); + const TriFace& face = mesh.indices[fid]; + for (std::size_t i = 0; i < 3; ++i) { + if (static_cast(face[i]) != vid) { + continue; + } + std::size_t v_next = face[(i + 1) % 3]; + std::size_t v_prev = face[(i + 2) % 3]; + for (std::size_t nbr : {v_next, v_prev}) { + std::size_t eid = vtx_vtx_to_edge[vid][nbr]; + for (std::size_t adj_fid : edge_to_faces[eid]) { + if (!visited_faces.count(adj_fid) && vtx_to_adj_faces[vid].count(adj_fid)) { + visited_faces.insert(adj_fid); + face_queue.push(adj_fid); + } + } + } + break; + } + } + + for (std::size_t fid : vtx_to_adj_faces[vid]) { + if (!visited_faces.count(fid)) { + return false; + } + } + } + + return true; +} + +inline bool convert_trimesh_to_cgal(const TriMesh& mesh, CGALMesh& cgal_mesh) { + cgal_mesh = trimesh_to_cgal(mesh); + return cgal_mesh.number_of_faces() > 0 || mesh.indices.empty(); +} + +inline bool convert_trimesh_to_cgal( + const TriMesh& mesh, const std::vector& vertex_uvs, + CGALMesh& cgal_mesh, std::vector& cgal_vertex_uvs) +{ + cgal_mesh.clear(); + std::vector vmap(mesh.vertices.size()); + cgal_vertex_uvs.clear(); + + for (size_t i = 0; i < mesh.vertices.size(); ++i) { + vmap[i] = cgal_mesh.add_vertex(Kernel::Point_3( + mesh.vertices[i].x(), mesh.vertices[i].y(), mesh.vertices[i].z())); + } + + cgal_vertex_uvs.resize(cgal_mesh.num_vertices()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) { + if (i < vertex_uvs.size()) + cgal_vertex_uvs[vmap[i]] = vertex_uvs[i]; + else + cgal_vertex_uvs[vmap[i]] = Vec2f(0.f, 0.f); + } + + for (const auto& f : mesh.indices) + cgal_mesh.add_face(vmap[f[0]], vmap[f[1]], vmap[f[2]]); + + return true; +} + +} // namespace cgalutils +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/ColorUtils.cpp b/src/libslic3r/TextureToColor/ColorUtils.cpp new file mode 100644 index 0000000000..7127a5d364 --- /dev/null +++ b/src/libslic3r/TextureToColor/ColorUtils.cpp @@ -0,0 +1,1643 @@ +#include "ColorUtils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CgalUtils.hpp" +#include "libslic3r/AABBTreeIndirect.hpp" +#include + +namespace Slic3r { namespace tex2color { +namespace color_utils { + +// #define DEBUG_FLAG + +#ifndef M_PI +#define M_PI 3.1415926535897932 +#endif + +#ifndef EPSILON +#define EPSILON 1e-6 +#endif + +#ifndef DOUBLE_LIMITS +#define DOUBLE_LIMITS +#define Double_MAX std::numeric_limits::max() +#define Double_MIN -std::numeric_limits::max() +#endif // !DOUBLE_LIMITS + +namespace PMP = CGAL::Polygon_mesh_processing; + +using cgalutils::CGALMesh; +using CGALKernel = cgalutils::Kernel; + +static constexpr double TOPO_SMOOTH_WEIGHT_THRESHOLD = 0.3; + +namespace detail { +template +double average_edge_length_impl(const Mesh& m) { + double total = 0.0; + size_t count = 0; + for (auto e : m.edges()) { + auto h = m.halfedge(e); + auto p0 = m.point(m.source(h)); + auto p1 = m.point(m.target(h)); + total += std::sqrt(CGAL::squared_distance(p0, p1)); + ++count; + } + return count > 0 ? total / count : 1.0; +} +} // namespace detail + +typedef CGAL::Aff_transformation_3 Affine_transformation_3; +typedef boost::graph_traits::halfedge_descriptor halfedge_descriptor; +typedef boost::graph_traits::edge_descriptor edge_descriptor; +typedef boost::graph_traits::vertex_descriptor vertex_descriptor; +typedef CGAL::AABB_face_graph_triangle_primitive Primitive; +typedef CGAL::AABB_traits Traits; +typedef CGAL::AABB_tree Tree; +typedef CGALMesh::template Property_map VNMap; + +static inline ColorDouble convert_rgb_uint_to_rgb_double(const Color& color) { + return ColorDouble{static_cast(color[0]), static_cast(color[1]), static_cast(color[2])}; +} + +static void normalize(CGALKernel::Vector_3& vec) { + double squared_length = vec.squared_length(); + if (squared_length > EPSILON) { + vec /= sqrt(squared_length); + } +} + +static double get_angle_between_vectors(const CGALKernel::Vector_3& v1, const CGALKernel::Vector_3& v2) { + CGALKernel::Vector_3 dir1{v1}, dir2{v2}; + normalize(dir1); + normalize(dir2); + double product_dot = dir1.x() * dir2.x() + dir1.y() * dir2.y() + dir1.z() * dir2.z(); + if (product_dot > 1.0 - EPSILON) { + return 0.0; + } else if (product_dot < -1.0 + EPSILON) { + return 180.0; + } + return std::acos(product_dot) / M_PI * 180.0; +} + +static void calc_face_normals(const CGALMesh& mesh, std::vector& face_normals) { + std::size_t fcnt = mesh.number_of_faces(); + face_normals.resize(fcnt); + for (auto face : mesh.faces()) { + std::vector points; + for (auto vtx : mesh.vertices_around_face(mesh.halfedge(face))) { + points.push_back(mesh.point(vtx)); + } + Eigen::Vector3d pos1(points[0].x(), points[0].y(), points[0].z()); + Eigen::Vector3d pos2(points[1].x(), points[1].y(), points[1].z()); + Eigen::Vector3d pos3(points[2].x(), points[2].y(), points[2].z()); + face_normals[face] = (pos3 - pos2).cross(pos1 - pos2); + face_normals[face].normalize(); + } + return; +} + +static bool check_and_repair_self_intersect(CGALMesh& mesh, bool* is_self_intersect_status = nullptr) { + // true means the mesh is no self intersect now + // false means the mesh is still self intersect + auto is_self_intersect = PMP::does_self_intersect(mesh); + if (is_self_intersect_status != nullptr) { + *is_self_intersect_status = is_self_intersect; + } + if (is_self_intersect) { + bool repair = PMP::experimental::remove_self_intersections(mesh); + if (repair) { + return true; + } else { + return false; + } + } + return true; +} + +static bool save_polylines(const std::string& file_name, const std::vector>& polylines) { + std::vector points; + std::vector> lines; + for (auto& polyline : polylines) { + std::size_t begin_pt_idx = points.size(); + for (auto& pt : polyline) { + points.push_back(pt); + } + for (std::size_t i = 1; i < polyline.size(); ++i) { + lines.emplace_back(begin_pt_idx + i, begin_pt_idx + i + 1); // obj is begin at 1 + } + } + std::ofstream output_file(file_name, std::ios::out); + for (auto& point : points) { + output_file << "v " << point[0] << " " << point[1] << " " << point[2] << "\n"; + } + for (auto& line : lines) { + output_file << "l " << line.first << " " << line.second << "\n"; + } + output_file.close(); + return true; +} + +static bool smooth_region_topo_boundary(CGALMesh& mesh, std::vector& face_labels, std::size_t max_iters = 20) { + // Topological smoothing: reassign face labels + std::size_t iter = 0; + while (iter < max_iters) { + ++iter; + bool flip_flag = false; + for (auto face : mesh.faces()) { + std::size_t same_label_count = 0; + std::unordered_map map_label_to_cnt; + std::size_t max_adj_cnt = 0; + std::size_t max_adj_label = face_labels[face]; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (adj_face == CGALMesh::null_face() || !mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + if (face_labels[adj_face] == face_labels[face]) { + ++same_label_count; + } else { + ++map_label_to_cnt[face_labels[adj_face]]; + if (map_label_to_cnt[face_labels[adj_face]] > max_adj_cnt) { + max_adj_cnt = map_label_to_cnt[face_labels[adj_face]]; + max_adj_label = face_labels[adj_face]; + } + } + } + if (max_adj_cnt > same_label_count) { + face_labels[face] = max_adj_label; + flip_flag = true; + } + } + + if (!flip_flag) { + break; + } + } + return true; +} + +static bool smooth_region_geom_boundary(CGALMesh& mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + // 1. extract boundary vertices and make polines + std::unordered_map map_vtx_to_degree; + std::unordered_set segment_boundary_edges; + std::unordered_set feature_edges; + std::unordered_set feature_vertices; + constexpr double feature_angle = 45; + for (const auto& edge : mesh.edges()) { + if (!mesh.is_valid(edge) || mesh.is_border(edge)) { + continue; + } + auto source = mesh.source(mesh.halfedge(edge)); + auto target = mesh.target(mesh.halfedge(edge)); + auto face_1 = mesh.face(mesh.halfedge(edge)); + auto face_2 = mesh.face(mesh.opposite(mesh.halfedge(edge))); + auto normal_1 = PMP::compute_face_normal(face_1, mesh); + auto normal_2 = PMP::compute_face_normal(face_2, mesh); + double angle = get_angle_between_vectors(normal_1, normal_2); + if (angle > feature_angle) { + feature_edges.insert(edge); + feature_vertices.insert(source); + feature_vertices.insert(target); + } + + if (face_labels[face_1] == face_labels[face_2]) { + continue; + } + + segment_boundary_edges.insert(edge); + ++map_vtx_to_degree[source]; + ++map_vtx_to_degree[target]; + } + + // 2. smooth each polyline + std::vector> polylines; + std::unordered_set visited_edges; + + std::function&)> trace_polyline = [&](std::vector& polyline) -> void { + if (polyline.empty()) { + return; + } + CGAL::SM_Vertex_index curr_vtx = polyline.back(); + if (map_vtx_to_degree[curr_vtx] != 2) { + return; + } + for (const auto& halfedge : mesh.halfedges_around_target(mesh.halfedge(curr_vtx))) { + CGAL::SM_Edge_index edge = mesh.edge(halfedge); + if (visited_edges.count(edge) || !segment_boundary_edges.count(edge)) { + continue; + } + visited_edges.insert(edge); + CGAL::SM_Vertex_index adj_vtx = mesh.source(halfedge); + if (!map_vtx_to_degree.count(adj_vtx)) { + continue; + } + polyline.push_back(adj_vtx); + return trace_polyline(polyline); + } + }; + + // 2.1. open polyline: from T nodes search other 2-degree nodes + for (auto& [src_vtx, degree] : map_vtx_to_degree) { + if (degree == 2) { + continue; + } + for (auto& src_halfedge : mesh.halfedges_around_target(mesh.halfedge(src_vtx))) { + CGAL::SM_Edge_index src_edge = mesh.edge(src_halfedge); + if (visited_edges.count(src_edge) || !segment_boundary_edges.count(src_edge)) { + continue; + } + visited_edges.insert(src_edge); + CGAL::SM_Vertex_index adj_vtx = mesh.source(src_halfedge); + if (!map_vtx_to_degree.count(adj_vtx)) { + continue; + } + std::vector polyline{src_vtx, adj_vtx}; + trace_polyline(polyline); + polylines.push_back(std::move(polyline)); + } + } + + // 2.2. closed polylines + for (auto edge : segment_boundary_edges) { + if (visited_edges.count(edge)) { + continue; + } + visited_edges.insert(edge); + CGAL::SM_Halfedge_index halfedge = mesh.halfedge(edge); + std::vector polyline{mesh.source(halfedge), mesh.target(halfedge)}; + trace_polyline(polyline); + if (polyline.front() != polyline.back()) { + std::cerr << "[Error]: loop polyline but not closed!!!\n"; + } + polylines.push_back(std::move(polyline)); + } + + // 3. smooth boundary + Tree boundary_tree(mesh.faces().begin(), mesh.faces().end(), mesh); + boundary_tree.accelerate_distance_queries(); + + constexpr std::size_t max_iters = 5; + const double smooth_weight = smooth_parameters.smooth_weight; // Controls smoothing intensity; larger values produce smoother results. Range: 0.1~1.0. + double origin_weight = std::max(1.0 - smooth_weight, 0.0); + for (std::size_t iter = 0; iter < max_iters; ++iter) { + for (const auto& polyline : polylines) { + std::size_t pt_cnt = polyline.size(); + std::vector points(pt_cnt); + for (std::size_t pt_idx = 1; pt_idx + 1 < pt_cnt; ++pt_idx) { + std::vector pts{mesh.point(polyline[pt_idx - 1]), mesh.point(polyline[pt_idx + 1])}; + CGALKernel::Point_3 smooth_pt = CGAL::ORIGIN + ((CGAL::centroid(pts.begin(), pts.end()) - CGAL::ORIGIN) * smooth_weight + + (mesh.point(polyline[pt_idx]) - CGAL::ORIGIN) * origin_weight); + points[pt_idx] = boundary_tree.closest_point(smooth_pt); + } + + if (polyline.front() == polyline.back()) { + if (feature_vertices.count(polyline.front())) { + continue; + } + std::vector pts{mesh.point(polyline[1]), mesh.point(polyline[pt_cnt - 2])}; + CGALKernel::Point_3 smooth_pt = CGAL::ORIGIN + ((CGAL::centroid(pts.begin(), pts.end()) - CGAL::ORIGIN) * smooth_weight + + (mesh.point(polyline.front()) - CGAL::ORIGIN) * origin_weight); + mesh.point(polyline.front()) = boundary_tree.closest_point(smooth_pt); + } + + for (std::size_t pt_idx = 1; pt_idx + 1 < pt_cnt; ++pt_idx) { + if (feature_vertices.count(polyline[pt_idx])) { + continue; + } + mesh.point(polyline[pt_idx]) = points[pt_idx]; + } + } + } + + for (auto& polyline : polylines) { + for (auto& vtx : polyline) { + mesh.point(vtx) = boundary_tree.closest_point(mesh.point(vtx)); + } + } + + return true; +} + +// HSV, XYZ, and LAB are only used internally for computing color differences, so they are declared in this cpp file only. +typedef std::array HSV; +typedef std::array XYZ; // Intermediate space for converting between LAB and RGB +typedef std::array LAB; // CIELAB was designed to match human visual perception; the standard method for perceptual color difference +// Common white points +const XYZ D65_WHITE = {0.95047, 1.0, 1.08883}; + +/** + * @brief Convert an RGB color to the HSV color space. + * + * @param rgb Input RGB color [R, G, B], range 0~255. + * @return HSV output [H, S, V], H in 0~360, S and V in 0~1. + */ +static HSV convert_rgb_to_hsv(const RGB& rgb) { + // Normalize to [0, 1] + double r = rgb[0] / 255.0; + double g = rgb[1] / 255.0; + double b = rgb[2] / 255.0; + + double max = std::max({r, g, b}); + double min = std::min({r, g, b}); + double delta = max - min; + + // Compute hue H + double h = 0; + if (delta == 0) { + h = 0; // Gray; hue is undefined + } else { + if (max == r) { + h = 60.0 * fmod((g - b) / delta, 6.0); + } else if (max == g) { + h = 60.0 * ((b - r) / delta + 2.0); + } else { // max == b + h = 60.0 * ((r - g) / delta + 4.0); + } + if (h < 0) { + h += 360.0; + } + } + + // Compute saturation S + double s = (max == 0) ? 0 : (delta / max); + + // Compute value V + double v = max; + + return {h, s, v}; +} + +/** + * @brief Convert an HSV color to the RGB color space. + * + * @param hsv Input HSV color [H, S, V], H in 0~360, S and V in 0~1. + * @return RGB output [R, G, B], range 0~255. + */ +static RGB convert_hsv_to_rgb(const HSV& hsv) { + double h = hsv[0]; + double s = hsv[1]; + double v = hsv[2]; + + double c = v * s; + double x = c * (1 - std::abs(fmod(h / 60.0, 2.0) - 1)); + double m = v - c; + + double r, g, b; + + if (h < 60) { + r = c; + g = x; + b = 0; + } else if (h < 120) { + r = x; + g = c; + b = 0; + } else if (h < 180) { + r = 0; + g = c; + b = x; + } else if (h < 240) { + r = 0; + g = x; + b = c; + } else if (h < 300) { + r = x; + g = 0; + b = c; + } else { + r = c; + g = 0; + b = x; + } + + return {static_cast((r + m) * 255 + 0.5), static_cast((g + m) * 255 + 0.5), static_cast((b + m) * 255 + 0.5)}; +} + +static XYZ convert_rgb_to_xyz(const RGB& color_rgb) { + ColorDouble rgb{static_cast(color_rgb[0]), static_cast(color_rgb[1]), static_cast(color_rgb[2])}; + auto gammaCorrect = [](double v) -> double { + v = v / 255.0; + if (v > 0.04045) { + return std::pow((v + 0.055) / 1.055, 2.4); + } else { + return v / 12.92; + } + }; + + double r = gammaCorrect(rgb[0]); + double g = gammaCorrect(rgb[1]); + double b = gammaCorrect(rgb[2]); + + // sRGB to XYZ matrix + return {r * 0.4124564 + g * 0.3575761 + b * 0.1804375, r * 0.2126729 + g * 0.7151522 + b * 0.0721750, r * 0.0193339 + g * 0.1191920 + b * 0.9503041}; +} + +// sRGB non-linear channel values [0,1] (double) -> linear light -> XYZ; equivalent to convert_rgb_to_xyz when v=n/255 +static XYZ convert_srgb01_to_xyz(double rs, double gs, double bs) { + auto gamma_correct = [](double v) -> double { + v = std::clamp(v, 0.0, 1.0); + return (v > 0.04045) ? std::pow((v + 0.055) / 1.055, 2.4) : (v / 12.92); + }; + const double r = gamma_correct(rs); + const double g = gamma_correct(gs); + const double b = gamma_correct(bs); + return {r * 0.4124564 + g * 0.3575761 + b * 0.1804375, r * 0.2126729 + g * 0.7151522 + b * 0.0721750, r * 0.0193339 + g * 0.1191920 + b * 0.9503041}; +} + +static LAB convert_xyz_to_lab(const XYZ& xyz) { + auto f = [](double t) -> double { + const double delta = 6.0 / 29.0; + if (t > delta * delta * delta) { + return std::cbrt(t); + } else { + return t / (3.0 * delta * delta) + 4.0 / 29.0; + } + }; + + // D65 white point + double xn = D65_WHITE[0], yn = D65_WHITE[1], zn = D65_WHITE[2]; + + double fx = f(xyz[0] / xn); + double fy = f(xyz[1] / yn); + double fz = f(xyz[2] / zn); + + return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)}; +} + +// ColorDouble here represents sRGB [0,1]; see calc_rgb_color_difference_by_ciede2000_srgb01 header comment +static LAB convert_srgb01_to_lab(const ColorDouble& srgb01) { + return convert_xyz_to_lab(convert_srgb01_to_xyz(srgb01[0], srgb01[1], srgb01[2])); +} + +static LAB convert_rgb_to_lab(const RGB& rgb) { + return convert_xyz_to_lab(convert_rgb_to_xyz(rgb)); +} + +static Color convert_lab_to_rgb(const LAB& lab) { + // Lab → XYZ + const double delta = 6.0 / 29.0; + const double delta2x3 = 3.0 * delta * delta; + + double fy = (lab[0] + 16.0) / 116.0; + double fx = lab[1] / 500.0 + fy; + double fz = fy - lab[2] / 200.0; + + double x = D65_WHITE[0] * (fx > delta ? fx * fx * fx : delta2x3 * (fx - 4.0 / 29.0)); + double y = D65_WHITE[1] * (fy > delta ? fy * fy * fy : delta2x3 * (fy - 4.0 / 29.0)); + double z = D65_WHITE[2] * (fz > delta ? fz * fz * fz : delta2x3 * (fz - 4.0 / 29.0)); + + // XYZ -> linear RGB (sRGB inverse matrix) + double r_lin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; + double g_lin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; + double b_lin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; + + // linear RGB -> sRGB (inverse gamma correction) + auto inverse_gamma = [](double v) -> double { + v = std::max(v, 0.0); + return v <= 0.0031308 ? 12.92 * v : 1.055 * std::pow(v, 1.0 / 2.4) - 0.055; + }; + + auto to_uint8 = [](double v) -> std::size_t { return static_cast(std::clamp(std::round(v * 255.0), 0.0, 255.0)); }; + + return {to_uint8(inverse_gamma(r_lin)), to_uint8(inverse_gamma(g_lin)), to_uint8(inverse_gamma(b_lin))}; +} + +/** + * @brief CIEDE2000 color-difference computation. + * + * Currently the most accurate color-difference formula, recommended by CIE as the industry standard. + * + * @param lab1 LAB values of the first color. + * @param lab2 LAB values of the second color. + * @return Color difference (typically < 1.0 is imperceptible to the human eye). + */ +static double ciede2000(const std::array& lab1, const std::array& lab2) { + // Parameters in the CIE L*C*h* formula + double L1 = lab1[0], a1 = lab1[1], b1 = lab1[2]; + double L2 = lab2[0], a2 = lab2[1], b2 = lab2[2]; + + // Compute C1 and C2 + double C1 = std::sqrt(a1 * a1 + b1 * b1); + double C2 = std::sqrt(a2 * a2 + b2 * b2); + double C_avg = (C1 + C2) / 2.0; + + // G factor (compensates for non-linearity in the mid-low chroma region) + double C7 = C_avg * C_avg * C_avg * C_avg * C_avg * C_avg * C_avg; + double G = 0.5 * (1.0 - std::sqrt(C7 / (C7 + 6103515625.0))); + + // a1' and a2' + double a1_prime = (1.0 + G) * a1; + double a2_prime = (1.0 + G) * a2; + + // C'1 and C'2 + double C1_prime = std::sqrt(a1_prime * a1_prime + b1 * b1); + double C2_prime = std::sqrt(a2_prime * a2_prime + b2 * b2); + double C_prime_avg = (C1_prime + C2_prime) / 2.0; + + // h'1 and h'2 + double h1_prime = std::atan2(b1, a1_prime); + double h2_prime = std::atan2(b2, a2_prime); + if (h1_prime < 0) { + h1_prime += 2 * M_PI; + } + if (h2_prime < 0) { + h2_prime += 2 * M_PI; + } + + // Compute dh' + double dh_prime; + if (std::abs(h1_prime - h2_prime) <= M_PI) { + dh_prime = h2_prime - h1_prime; + } else if (h2_prime <= h1_prime) { + dh_prime = h2_prime - h1_prime + 2 * M_PI; + } else { + dh_prime = h2_prime - h1_prime - 2 * M_PI; + } + + // Compute dH' + double dH_prime = 2.0 * std::sqrt(C1_prime * C2_prime) * std::sin(dh_prime / 2.0); + + // Compute dL' + double dL_prime = L2 - L1; + + // Compute dC' + double dC_prime = C2_prime - C1_prime; + + // Compute h_prime_avg + double h_prime_avg; + if (std::abs(h1_prime - h2_prime) > M_PI) { + h_prime_avg = (h1_prime + h2_prime + 2 * M_PI) / 2.0; + } else { + h_prime_avg = (h1_prime + h2_prime) / 2.0; + } + + // Compute T + double T = 1.0 - 0.17 * std::cos(h_prime_avg - M_PI / 6.0) + 0.24 * std::cos(2.0 * h_prime_avg) + 0.32 * std::cos(3.0 * h_prime_avg + M_PI / 30.0) - + 0.20 * std::cos(4.0 * h_prime_avg - 3.0 * M_PI / 6.0); + + // Compute rotation term R_T = -R_C * sin(2*delta_theta), where delta_theta = 30 * exp(-((h_bar'-275)/25)^2) + // h_prime_avg is in radians; convert to degrees for delta_theta; 2*delta_theta = 60 * exp(...), convert back to radians for sin + double h_prime_avg_deg = h_prime_avg * 180.0 / M_PI; + double C_prime_avg_7 = std::pow(C_prime_avg, 7); + double R = -2.0 * std::sqrt(C_prime_avg_7 / (C_prime_avg_7 + 6103515625.0)) * + std::sin((60.0 * M_PI / 180.0) * std::exp(-std::pow((h_prime_avg_deg - 275.0) / 25.0, 2))); + + // Compute SL, SC, SH + double L_prime_avg = (L1 + L2) / 2.0; + double SL = 1.0 + 0.015 * std::pow(L_prime_avg - 50.0, 2) / std::sqrt(20 + std::pow(L_prime_avg - 50.0, 2)); + double SC = 1.0 + 0.045 * C_prime_avg; + double SH = 1.0 + 0.015 * C_prime_avg * T; + + // Final color difference + double deltaE = std::sqrt(std::pow(dL_prime / SL, 2) + std::pow(dC_prime / SC, 2) + std::pow(dH_prime / SH, 2) + R * (dC_prime / SC) * (dH_prime / SH)); + + return deltaE; +} + +double calc_rgb_color_difference_by_ciede2000(const RGB& rgb1, const RGB& rgb2) { + auto lab1 = convert_rgb_to_lab(rgb1); + auto lab2 = convert_rgb_to_lab(rgb2); + return ciede2000(lab1, lab2); +} + +double calc_rgb_color_difference_by_ciede2000_srgb01(const ColorDouble& rgb1, const ColorDouble& rgb2) { + const LAB lab1 = convert_srgb01_to_lab(rgb1); + const LAB lab2 = convert_srgb01_to_lab(rgb2); + return ciede2000(lab1, lab2); +} + +// Working-space distance function type: inputs are two colors in the same space (RGB-double or Lab) +using WorkingDistFunc = double (*)(const ColorDouble&, const ColorDouble&); + +// Farthest Point Sampling (FPS) initialization algorithm. +// The first center is the point nearest to the global centroid; subsequent centers are the points farthest from the existing set. +static std::vector farthest_point_sampling_init(const std::vector& working_colors, std::size_t k, WorkingDistFunc dist_func) { + std::vector centers(k); + + // 1. Compute the global centroid and pick the nearest point as the first center + ColorDouble centroid = {0.0, 0.0, 0.0}; + for (const auto& c : working_colors) { + centroid[0] += c[0]; + centroid[1] += c[1]; + centroid[2] += c[2]; + } + const auto n = static_cast(working_colors.size()); + centroid[0] /= n; + centroid[1] /= n; + centroid[2] /= n; + + double best_dist = std::numeric_limits::max(); + std::size_t first_idx = 0; + for (std::size_t i = 0; i < working_colors.size(); ++i) { + double d = dist_func(working_colors[i], centroid); + if (d < best_dist) { + best_dist = d; + first_idx = i; + } + } + centers[0] = working_colors[first_idx]; + + // Minimum distance from each point to the already-selected center set + std::vector min_distances(working_colors.size(), std::numeric_limits::max()); + + // 2. Greedily select the remaining K-1 centers: pick the point with the largest min_distance each time + for (std::size_t i = 1; i < k; ++i) { + const ColorDouble& last_center = centers[i - 1]; + + // Update each point's minimum distance with the newly added center + double farthest_dist = -1.0; + std::size_t farthest_idx = 0; + for (std::size_t c_idx = 0; c_idx < working_colors.size(); ++c_idx) { + double d = dist_func(working_colors[c_idx], last_center); + if (d < min_distances[c_idx]) { + min_distances[c_idx] = d; + } + if (min_distances[c_idx] > farthest_dist) { + farthest_dist = min_distances[c_idx]; + farthest_idx = c_idx; + } + } + + centers[i] = working_colors[farthest_idx]; + } + + return centers; +} + +bool remesh_mesh(TriMesh& bbs_mesh, std::vector& face_labels, double target_edge_length_ratio) { + if (face_labels.size() != bbs_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "Input mesh face count does not match label count"; + return false; + } + + // Back up face labels for recovery after remeshing. + std::vector face_labels_of_original_mesh(face_labels); + + CGALMesh cgal_mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, cgal_mesh); + + // AABBTreeIndirect references vertices/faces externally, so snapshot the + // pre-remesh geometry by moving it out of bbs_mesh (which is overwritten + // below with the post-remesh mesh). std::move on std::vector is O(1). + TriVertices old_vertices = std::move(bbs_mesh.vertices); + TriFaces old_indices = std::move(bbs_mesh.indices); + auto original_mesh_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); + + std::unordered_set feature_edges; + std::unordered_set feature_vertices; + CGALMesh::Property_map constrained_edges = + cgal_mesh.add_property_map("constrained_edges", false).first; + CGALMesh::Property_map constrained_vertices = + cgal_mesh.add_property_map("constrained_vertices", false).first; + + // An edge is considered a geometric feature edge if its dihedral angle is less than 135 degrees (loose threshold) + constexpr double feature_angle = 135; + + auto is_feature_edge = [&](CGAL::SM_Edge_index edge) -> bool { + if (cgal_mesh.is_border(edge)) { + return true; + } + auto halfedge_1 = cgal_mesh.halfedge(edge); + auto halfedge_2 = cgal_mesh.opposite(halfedge_1); + auto face_1 = cgal_mesh.face(halfedge_1); + auto face_2 = cgal_mesh.face(halfedge_2); + if (face_labels[face_1] != face_labels[face_2]) { + // Boundary between different color regions; treated as a feature edge + return true; + } + // TODO: CGAL remeshing tends to crash when too many constrained edges are added; needs handling + //auto normal_1 = PMP::compute_face_normal(face_1, cgal_mesh); + //auto normal_2 = PMP::compute_face_normal(face_2, cgal_mesh); + //double angle = 180 - get_angle_between_vectors(normal_1, normal_2); + //BOOST_LOG_TRIVIAL(debug) << "end.\n"; + //return angle > feature_angle; + return false; + }; + + for (auto edge : cgal_mesh.edges()) { + if (is_feature_edge(edge)) { + feature_edges.insert(edge); + feature_vertices.insert(cgal_mesh.source(cgal_mesh.halfedge(edge))); + feature_vertices.insert(cgal_mesh.target(cgal_mesh.halfedge(edge))); + constrained_edges[edge] = true; + constrained_vertices[cgal_mesh.source(cgal_mesh.halfedge(edge))] = true; + constrained_vertices[cgal_mesh.target(cgal_mesh.halfedge(edge))] = true; + } + } + +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "remesh_mesh: feature_edges.size() = " << feature_edges.size() << ".\n"; + + std::vector> polylines; + for (auto edge : feature_edges) { + auto src_vtx = cgal_mesh.source(cgal_mesh.halfedge(edge)); + auto trg_vtx = cgal_mesh.target(cgal_mesh.halfedge(edge)); + polylines.push_back({cgal_mesh.point(src_vtx), cgal_mesh.point(trg_vtx)}); + } + save_polylines("ColorUtils_remesh_feature_lines.obj", polylines); +#endif // DEBUG_FLAG + + std::size_t iters = 5; +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "PMP::isotropic_remeshing start...\n"; +#endif // DEBUG_FLAG + // TODO: CGAL remeshing preserves geometric boundaries but does not maintain face labels well; colors need to be recomputed + PMP::isotropic_remeshing(cgal_mesh.faces(), target_edge_length_ratio * detail::average_edge_length_impl(cgal_mesh), cgal_mesh, + CGAL::parameters::number_of_iterations(iters) + .protect_constraints(true) + .edge_is_constrained_map(constrained_edges) + .vertex_is_constrained_map(constrained_vertices) + .collapse_constraints(true)); +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "PMP::isotropic_remeshing finshed...\n"; +#endif // DEBUG_FLAG + if (PMP::does_self_intersect(cgal_mesh)) { + PMP::experimental::remove_self_intersections(cgal_mesh); + } + + bbs_mesh.clear(); + + std::unordered_map map_cgal_vtx_to_bbs_vtx; + TriVertices bbs_vertices; + TriFaces bbs_faces; + face_labels.clear(); + face_labels.reserve(cgal_mesh.number_of_faces()); + + for (const auto& cgal_vtx : cgal_mesh.vertices()) { + if (!cgal_mesh.is_valid(cgal_vtx) || cgal_mesh.is_removed(cgal_vtx) || cgal_mesh.is_isolated(cgal_vtx)) { + continue; + } + if (!map_cgal_vtx_to_bbs_vtx.count(cgal_vtx)) { + map_cgal_vtx_to_bbs_vtx[cgal_vtx] = bbs_vertices.size(); + const auto& cgal_point = cgal_mesh.point(cgal_vtx); + bbs_vertices.emplace_back(TriVertex(cgal_point.x(), cgal_point.y(), cgal_point.z())); + } + } + + for (const auto& cgal_face : cgal_mesh.faces()) { + if (!cgal_mesh.is_valid(cgal_face) || cgal_mesh.is_removed(cgal_face)) { + continue; + } + TriFace bbs_face; + std::size_t face_vid = 0; + for (auto cgal_vtx : cgal_mesh.vertices_around_face(cgal_mesh.halfedge(cgal_face))) { + do { if (!(map_cgal_vtx_to_bbs_vtx.count(cgal_vtx))) { BOOST_LOG_TRIVIAL(warning) << "CGAL mesh contains a face with an invalid vertex"; return false; } } while(0); + bbs_face[face_vid] = map_cgal_vtx_to_bbs_vtx[cgal_vtx]; + ++face_vid; + } + bbs_faces.push_back(std::move(bbs_face)); + } + + bbs_mesh = TriMesh(bbs_faces, bbs_vertices); + + face_labels.resize(bbs_mesh.indices.size()); + tbb::parallel_for(tbb::blocked_range(0, bbs_mesh.indices.size()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + auto& face = bbs_mesh.indices[fid]; + Vec3f face_centroid = (bbs_mesh.vertices[face[0]] + bbs_mesh.vertices[face[1]] + bbs_mesh.vertices[face[2]]) / 3.0; + size_t hit_idx = 0; + Vec3f closest; + AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + old_vertices, old_indices, original_mesh_tree, face_centroid, hit_idx, closest); + face_labels[fid] = face_labels_of_original_mesh[hit_idx]; + } + }); + + return true; +} + +bool is_closed(const TriMesh& bbs_mesh) { + CGALMesh cgal_mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, cgal_mesh); + +#ifdef DEBUG_FLAG + std::size_t border_edges_count = 0; + std::size_t edges_count = 0; + for (auto edge : cgal_mesh.edges()) { + if (cgal_mesh.is_border(edge)) { + ++border_edges_count; + } + ++edges_count; + } + + BOOST_LOG_TRIVIAL(debug) << "border edges count = " << border_edges_count << "\n"; + BOOST_LOG_TRIVIAL(debug) << "edges count = " << edges_count << "\n"; + + std::size_t border_faces_count = 0; + std::size_t faces_count = 0; + for (auto face : cgal_mesh.faces()) { + for (auto halfedge : cgal_mesh.halfedges_around_face(cgal_mesh.halfedge(face))) { + auto edge = cgal_mesh.edge(halfedge); + if (cgal_mesh.is_border(edge)) { + ++border_faces_count; + break; + } + } + ++faces_count; + } + + BOOST_LOG_TRIVIAL(debug) << "border faces count = " << border_faces_count << "\n"; + BOOST_LOG_TRIVIAL(debug) << "faces count = " << faces_count << "\n"; + + BOOST_LOG_TRIVIAL(debug) << "vertices count = " << cgal_mesh.number_of_vertices() << "\n"; + std::size_t num_of_components = 0; + std::unordered_set visited_faces; + for (auto src_face : cgal_mesh.faces()) { + if (visited_faces.count(src_face)) { + continue; + } + ++num_of_components; + std::queue que; + que.push(src_face); + visited_faces.insert(src_face); + while (!que.empty()) { + auto curr_face = que.front(); + que.pop(); + for (auto adj_face : cgal_mesh.faces_around_face(cgal_mesh.halfedge(curr_face))) { + if (!cgal_mesh.is_valid(adj_face) || cgal_mesh.is_removed(adj_face) || visited_faces.count(adj_face)) { + continue; + } + que.push(adj_face); + visited_faces.insert(adj_face); + } + } + } + BOOST_LOG_TRIVIAL(debug) << "components count = " << num_of_components << "\n"; +#endif // DEBUG_FLAG + + return CGAL::is_closed(cgal_mesh); +} + +static bool smooth_region_labels(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + CGALMesh mesh; + cgalutils::convert_trimesh_to_cgal(tri_mesh, mesh); + + if (mesh.number_of_faces() != face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "Face count does not match label count"; + return false; + } + + if (smooth_parameters.smooth_weight >= TOPO_SMOOTH_WEIGHT_THRESHOLD) { + // Topological smoothing: reassign face labels. + smooth_region_topo_boundary(mesh, face_labels); + } + + if (smooth_parameters.smooth_weight > EPSILON) { + // Geometric smoothing: smooth polylines and project back onto the original mesh. + smooth_region_geom_boundary(mesh, face_labels, smooth_parameters); + } + + tri_mesh = cgalutils::cgal_to_trimesh(mesh); + + return true; +} + +bool smooth_region(TriMesh& tri_mesh, std::vector>& face_colors, const SmoothParameters& smooth_parameters) { + // Convert colors to labels + std::size_t label_next = 0; + std::vector face_labels; + face_labels.reserve(face_colors.size()); + std::map, std::size_t> map_color_to_label; + std::unordered_map> map_label_to_color; + for (auto& color : face_colors) { + if (!map_color_to_label.count(color)) { + map_color_to_label[color] = label_next; + map_label_to_color[label_next] = color; + ++label_next; + } + face_labels.push_back(map_color_to_label[color]); + } + + if (!smooth_region_labels(tri_mesh, face_labels, smooth_parameters)) + return false; + + // Convert labels back to colors + for (std::size_t fid = 0; fid < face_labels.size(); ++fid) { + face_colors[fid] = map_label_to_color[face_labels[fid]]; + } + + return true; +} + +bool smooth_region(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + return smooth_region_labels(tri_mesh, face_labels, smooth_parameters); +} + +// Compute the squared Euclidean distance between two colors (RGB vectors) +double calc_rgb_color_difference_by_squared_rgb_double(const ColorDouble& c1, const ColorDouble& c2) { + double dr = c1[0] - c2[0]; + double dg = c1[1] - c2[1]; + double db = c1[2] - c2[2]; + return dr * dr + dg * dg + db * db; +} + +// Compute the squared Euclidean distance between two colors (RGB vectors) +double calc_rgb_color_difference_by_squared_rgb(const RGB& c1, const RGB& c2) { + auto c1d = convert_rgb_uint_to_rgb_double(c1); + auto c2d = convert_rgb_uint_to_rgb_double(c2); + return calc_rgb_color_difference_by_squared_rgb_double(c1d, c2d); +} + +// K-Means core: run FPS initialization + assign/update iterations in working space, return cluster centers +static std::vector kmeans_core(const std::vector& working_colors, std::size_t k, std::size_t max_iter, WorkingDistFunc dist_func, + const std::function& cancel_cb = nullptr) { + std::vector centers = farthest_point_sampling_init(working_colors, k, dist_func); + std::vector assignments(working_colors.size()); + + for (std::size_t iter = 0; iter < max_iter; ++iter) { + if (cancel_cb && cancel_cb()) return centers; + std::atomic changed(false); + + tbb::parallel_for(tbb::blocked_range(0, working_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + double min_dist = std::numeric_limits::max(); + std::size_t best_cluster = 0; + + for (std::size_t j = 0; j < k; ++j) { + double d = dist_func(working_colors[i], centers[j]); + if (d < min_dist) { + min_dist = d; + best_cluster = j; + } + } + + if (assignments[i] != best_cluster) { + changed.store(true, std::memory_order_relaxed); + assignments[i] = best_cluster; + } + } + }); + + if (!changed.load()) { + break; + } + + std::vector new_centers(k, {0.0, 0.0, 0.0}); + std::vector counts(k, 0); + + for (std::size_t i = 0; i < working_colors.size(); ++i) { + std::size_t cluster_id = assignments[i]; + ++counts[cluster_id]; + new_centers[cluster_id][0] += working_colors[i][0]; + new_centers[cluster_id][1] += working_colors[i][1]; + new_centers[cluster_id][2] += working_colors[i][2]; + } + + for (std::size_t i = 0; i < k; ++i) { + if (counts[i] == 0) { + double max_min_dist = -1.0; + std::size_t best_idx = 0; + for (std::size_t p = 0; p < working_colors.size(); ++p) { + double nearest = std::numeric_limits::max(); + for (std::size_t c = 0; c < k; ++c) { + if (c == i || counts[c] == 0) { + continue; + } + double d = dist_func(working_colors[p], centers[c]); + if (d < nearest) { + nearest = d; + } + } + if (nearest > max_min_dist) { + max_min_dist = nearest; + best_idx = p; + } + } + centers[i] = working_colors[best_idx]; + } else { + centers[i][0] = new_centers[i][0] / counts[i]; + centers[i][1] = new_centers[i][1] / counts[i]; + centers[i][2] = new_centers[i][2] / counts[i]; + } + } + } + + return centers; +} + +// K-Means clustering algorithm +std::vector cluster_k_means(const std::vector& colors, const ClusterParameters& cluster_parameters) { + std::size_t k = cluster_parameters.cluster_k; + std::size_t max_iter = cluster_parameters.max_iter; + + if (k == 0 || colors.empty()) { + return {}; + } + + const bool use_lab = (cluster_parameters.color_difference_method != ColorDifferenceMethod::RGB); + WorkingDistFunc working_dist_func = use_lab ? ciede2000 : calc_rgb_color_difference_by_squared_rgb_double; + + // ========================================== + // 1. Preprocessing: deduplicate + pre-convert to working space + // ========================================== + std::vector unique_colors = colors; + std::sort(unique_colors.begin(), unique_colors.end()); + auto last = std::unique(unique_colors.begin(), unique_colors.end()); + unique_colors.erase(last, unique_colors.end()); + +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "Input colors: " << colors.size() << ", Unique colors: " << unique_colors.size(); +#endif // DEBUG_FLAG + + if (unique_colors.size() < k) { + BOOST_LOG_TRIVIAL(warning) << "Unique color count (" << unique_colors.size() << ") is less than target K (" << k << "). Adjusting K."; + k = unique_colors.size(); + if (k == 0) { + return {}; + } + return unique_colors; + } + + std::vector working_colors(unique_colors.size()); + for (std::size_t i = 0; i < unique_colors.size(); ++i) { + if (use_lab) { + working_colors[i] = convert_rgb_to_lab(unique_colors[i]); + } else { + working_colors[i] = {static_cast(unique_colors[i][0]), static_cast(unique_colors[i][1]), static_cast(unique_colors[i][2])}; + } + } + + // ========================================== + // 2. K-Means clustering + // ========================================== + auto centers = kmeans_core(working_colors, k, max_iter, working_dist_func, cluster_parameters.cancel_callback); + + // ========================================== + // 3. Output: convert from working space back to RGB + // ========================================== + std::vector result(k); + for (std::size_t i = 0; i < k; ++i) { + if (use_lab) { + result[i] = convert_lab_to_rgb(centers[i]); + } else { + result[i] = {static_cast(std::round(centers[i][0])), static_cast(std::round(centers[i][1])), + static_cast(std::round(centers[i][2]))}; + } + } + + return result; +} + +std::vector cluster_to_specified_colors(const std::vector& colors, const std::vector& specified_colors) { + std::vector cluster_colors = colors; + std::vector specified_double_colors; + specified_double_colors.reserve(specified_colors.size()); + for (auto& color : specified_colors) { + specified_double_colors.push_back(ColorDouble{static_cast(color[0]), static_cast(color[1]), static_cast(color[2])}); + } + + tbb::parallel_for(tbb::blocked_range(0, cluster_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + ColorDouble p_color{static_cast(cluster_colors[i][0]), static_cast(cluster_colors[i][1]), + static_cast(cluster_colors[i][2])}; + + double min_dist = std::numeric_limits::max(); + std::size_t best_cluster = 0; + + for (std::size_t j = 0; j < specified_double_colors.size(); ++j) { + double d = calc_rgb_color_difference_by_squared_rgb_double(p_color, specified_double_colors[j]); + if (d < min_dist) { + min_dist = d; + best_cluster = j; + } + } + + cluster_colors[i] = specified_colors[best_cluster]; + } + }); + + return cluster_colors; +} + +// Color PCA struct +struct ColorPCA { + std::size_t color_idx; // Index of the original color in ColorList + double pca_value; // Projection value onto the first principal component + + ColorPCA(std::size_t c_idx, double pca_val) + : color_idx(c_idx), + pca_value(pca_val) {} + + // Comparison operator (ascending order) + bool operator<(const ColorPCA& other) const { return pca_value < other.pca_value; } + + // Equality check + bool operator==(const ColorPCA& other) const { return color_idx == other.color_idx; } +}; + +[[maybe_unused]] static std::vector sort_colors_by_pca(const std::vector& colors) { + const std::size_t n = colors.size(); + + if (n == 0) { + return {}; + } + + if (n == 1) { + return {{0, 0.0}}; + } + + // Step 1: Data preprocessing - normalize to [0, 1] + Eigen::MatrixXd data(n, 3); + + for (std::size_t i = 0; i < n; ++i) { + data(i, 0) = static_cast(colors[i][0]) / 255.0; // R + data(i, 1) = static_cast(colors[i][1]) / 255.0; // G + data(i, 2) = static_cast(colors[i][2]) / 255.0; // B + } + + // Step 2: Compute mean and center the data + Eigen::RowVector3d mean = data.colwise().mean(); + Eigen::MatrixXd centered = data.rowwise() - mean; + + // Step 3: Compute covariance matrix (3x3) + Eigen::Matrix3d cov = (centered.adjoint() * centered) / static_cast(n - 1); + + // Step 4: Eigenvalue decomposition + Eigen::SelfAdjointEigenSolver solver(cov); + + if (solver.info() != Eigen::Success) { +#ifdef DEBUG_FLAG + std::cerr << "PCA: Eigenvalue decomposition failed" << std::endl; +#endif // DEBUG_FLAG + // Fallback: return an approximate result sorted by luminance. + // Luminance is a key perceptual feature; convert RGB to grayscale (L = 0.299R + 0.587G + 0.114B) and sort in ascending order. + std::vector result; + result.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + double luminance = 0.299 * colors[i][0] + 0.587 * colors[i][1] + 0.114 * colors[i][2]; + result.push_back({i, luminance}); + } + std::sort(result.begin(), result.end()); + return result; + } + + // Get eigenvalues and eigenvectors (sorted by eigenvalue in descending order) + Eigen::Vector3d eigenvalues = solver.eigenvalues(); + Eigen::Matrix3d eigenvectors = solver.eigenvectors(); + + // Step 5: Find the eigenvector corresponding to the largest eigenvalue (first principal component) + Eigen::MatrixXd::Index max_eigenvalue_idx; + eigenvalues.maxCoeff(&max_eigenvalue_idx); + + Eigen::Vector3d first_principal_component = eigenvectors.col(max_eigenvalue_idx); + + // Step 6: Project centered data onto the first principal component + Eigen::VectorXd projections = centered * first_principal_component; + + // Step 7: Build result and sort + std::vector result; + result.reserve(n); + + for (std::size_t i = 0; i < n; ++i) { + result.push_back({i, projections(i)}); + } + + std::sort(result.begin(), result.end()); + + return result; +} + +std::vector cluster_adaptive(const std::vector& colors, const ClusterParameters& cluster_parameters) { + if (colors.empty()) { + return {}; + } + + const double max_color_distance = cluster_parameters.max_color_distance; + const std::size_t max_iter = cluster_parameters.max_iter; + const bool use_lab = (cluster_parameters.color_difference_method != ColorDifferenceMethod::RGB); + WorkingDistFunc working_dist_func = use_lab ? ciede2000 : calc_rgb_color_difference_by_squared_rgb_double; + + // ========================================== + // 1. Deduplicate + convert to working space + // ========================================== + std::vector unique_colors = colors; + std::sort(unique_colors.begin(), unique_colors.end()); + auto last = std::unique(unique_colors.begin(), unique_colors.end()); + unique_colors.erase(last, unique_colors.end()); + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive: colors=" << colors.size() + << " unique=" << unique_colors.size() + << " max_color_distance=" << max_color_distance; + + if (unique_colors.size() <= 1) { + return unique_colors; + } + + std::vector working_colors(unique_colors.size()); + for (std::size_t i = 0; i < unique_colors.size(); ++i) { + if (use_lab) { + working_colors[i] = convert_rgb_to_lab(unique_colors[i]); + } else { + working_colors[i] = {static_cast(unique_colors[i][0]), static_cast(unique_colors[i][1]), static_cast(unique_colors[i][2])}; + } + } + + // ========================================== + // 2. Binary search k: find the smallest k where P99 radius <= max_color_distance + // ========================================== + const std::size_t max_k = cluster_parameters.max_cluster_k; + std::size_t lo = 1; + std::size_t hi = std::min(max_k, unique_colors.size()); + std::size_t best_k = 0; + std::vector best_centers; + + constexpr double kRadiusPercentile = 0.99; + + auto calc_max_radius = [&](const std::vector& centers) -> double { + std::vector distances(working_colors.size()); + tbb::parallel_for(tbb::blocked_range(0, working_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + double min_dist = std::numeric_limits::max(); + for (const auto& center : centers) { + double d = working_dist_func(working_colors[i], center); + if (d < min_dist) { + min_dist = d; + } + } + distances[i] = min_dist; + } + }); + if (distances.empty()) { + return 0.0; + } + std::size_t idx = std::min(static_cast(distances.size() * kRadiusPercentile), distances.size() - 1); + std::nth_element(distances.begin(), distances.begin() + idx, distances.end()); + return distances[idx]; + }; + + const auto& cancel_cb = cluster_parameters.cancel_callback; + + while (lo <= hi) { + if (cancel_cb && cancel_cb()) return {}; + std::size_t mid = lo + (hi - lo) / 2; + auto centers = kmeans_core(working_colors, mid, max_iter, working_dist_func, cancel_cb); + if (cancel_cb && cancel_cb()) return {}; + double max_radius = calc_max_radius(centers); + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive binary search: k=" << mid << " max_radius=" << max_radius; + + if (max_radius <= max_color_distance) { + best_k = mid; + best_centers = std::move(centers); + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (best_k == 0) { + best_k = std::min(max_k, unique_colors.size()); + best_centers = kmeans_core(working_colors, best_k, max_iter, working_dist_func, cancel_cb); + BOOST_LOG_TRIVIAL(warning) << "cluster_adaptive: binary search found no k satisfying max_radius<=" + << max_color_distance << ", fallback to k=" << best_k; + } + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive: best_k=" << best_k; + + // ========================================== + // 3. Convert centers back to RGB + // ========================================== + std::vector result(best_k); + for (std::size_t i = 0; i < best_k; ++i) { + if (use_lab) { + result[i] = convert_lab_to_rgb(best_centers[i]); + } else { + result[i] = {static_cast(std::round(best_centers[i][0])), static_cast(std::round(best_centers[i][1])), + static_cast(std::round(best_centers[i][2]))}; + } + } + + return result; +} + +static std::vector> get_connected_face_groups(const CGALMesh& mesh) { + std::vector> face_groups; + std::unordered_set visited_faces; + for (auto src_face : mesh.faces()) { + if (visited_faces.count(src_face)) { + continue; + } + std::vector face_group; + std::queue que; + que.push(src_face); + visited_faces.insert(src_face); + while (!que.empty()) { + auto curr_face = que.front(); + que.pop(); + face_group.push_back(curr_face); + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(curr_face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face) || visited_faces.count(adj_face)) { + continue; + } + que.push(adj_face); + visited_faces.insert(adj_face); + } + } + face_groups.push_back(std::move(face_group)); + } + return face_groups; +} + +bool get_components(const TriMesh& bbs_mesh, const std::vector& bbs_vertex_uvs, std::vector& component_meshes, + std::vector>& component_vertex_uvs) { + component_meshes.clear(); + component_vertex_uvs.clear(); + + if (bbs_mesh.vertices.size() != bbs_vertex_uvs.size()) { + BOOST_LOG_TRIVIAL(warning) << "Input mesh vertex count does not match texture coordinate count"; + return false; + } + + CGALMesh cgal_mesh; + std::vector cgal_vertex_uvs; + if (!cgalutils::convert_trimesh_to_cgal(bbs_mesh, bbs_vertex_uvs, cgal_mesh, cgal_vertex_uvs)) { + BOOST_LOG_TRIVIAL(warning) << "Mesh conversion failed"; + return false; + } + + auto face_groups = get_connected_face_groups(cgal_mesh); + + for (const auto& faces : face_groups) { + std::unordered_map map_cgal_vtx_to_bbs_vtx; + TriVertices bbs_vertices; + TriFaces bbs_faces; + std::vector bbs_vertex_uvs; + + for (auto cgal_face : faces) { + TriFace bbs_face; + std::size_t face_vid = 0; + for (auto cgal_vtx : cgal_mesh.vertices_around_face(cgal_mesh.halfedge(cgal_face))) { + if (!map_cgal_vtx_to_bbs_vtx.count(cgal_vtx)) { + map_cgal_vtx_to_bbs_vtx[cgal_vtx] = bbs_vertices.size(); + const auto& cgal_point = cgal_mesh.point(cgal_vtx); + bbs_vertices.push_back(TriVertex(cgal_point.x(), cgal_point.y(), cgal_point.z())); + bbs_vertex_uvs.push_back(cgal_vertex_uvs[cgal_vtx]); + } + bbs_face[face_vid] = map_cgal_vtx_to_bbs_vtx[cgal_vtx]; + ++face_vid; + } + bbs_faces.push_back(std::move(bbs_face)); + } + + component_meshes.push_back(TriMesh(bbs_faces, bbs_vertices)); + component_vertex_uvs.push_back(std::move(bbs_vertex_uvs)); + } + + return true; +} + +bool calc_nearest_color_id(const std::vector& colors, const RGB& color, std::size_t& nearest_color_id) { + if (colors.empty()) { + BOOST_LOG_TRIVIAL(warning) << "No color list provided"; + return false; + } + double min_dist = std::numeric_limits::max(); + nearest_color_id = 0; + for (std::size_t i = 0; i < colors.size(); ++i) { + double dist = calc_rgb_color_difference_by_ciede2000(colors[i], color); + if (dist < min_dist) { + min_dist = dist; + nearest_color_id = i; + } + } + return true; +} + +bool mesh_cluster(const TriMesh& bbs_mesh, const std::vector& cluster_centers, std::vector& map_face_to_rgb, + std::vector& map_face_to_cluster_id) { + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "No cluster centers provided"; + return false; + } + + CGALMesh mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, mesh); + if (mesh.number_of_faces() != bbs_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "CGAL mesh face count does not match BBS mesh face count"; + return false; + } + if (mesh.number_of_faces() != map_face_to_rgb.size()) { + BOOST_LOG_TRIVIAL(warning) << "CGAL mesh face count does not match RGB count"; + return false; + } + + if (cluster_centers.size() == 1) { + std::fill(map_face_to_rgb.begin(), map_face_to_rgb.end(), cluster_centers[0]); +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "input cluster centers' size is 1, so we set all face RGB as same with it and return.\n"; +#endif + return true; + } + + std::vector map_face_to_area(mesh.number_of_faces()); + tbb::parallel_for(tbb::blocked_range(0, mesh.number_of_faces()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + CGAL::SM_Face_index face(fid); + map_face_to_area[fid] = std::max(PMP::face_area(face, mesh), EPSILON); + } + }); + + // Step 1: Identify faces that definitely belong to a cluster center. + // A face is definitively assigned when dist1 * absolute_difference_times < dist2 (nearest vs. second-nearest center). + constexpr double absolute_difference_times = 1.5; + // dE <= 1.0: imperceptible to the human eye, high-precision color matching + // dE <= 2.0: slight difference, noticeable by experts; printing / image processing standard + // dE <= 3.0: noticeable by ordinary observers; general quality control + constexpr double difference_epsilon = 3.0; + constexpr std::size_t invalid_cluster_id = std::numeric_limits::max(); + map_face_to_cluster_id.resize(mesh.number_of_faces(), invalid_cluster_id); + std::vector>> map_face_to_dists(mesh.number_of_faces(), + std::vector>(cluster_centers.size())); + tbb::parallel_for(tbb::blocked_range(0, mesh.number_of_faces()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + CGAL::SM_Face_index face(fid); + std::vector>& dist_and_cid_vec = map_face_to_dists[fid]; + for (std::size_t cluster_id = 0; cluster_id < cluster_centers.size(); ++cluster_id) { + double dist = calc_rgb_color_difference_by_ciede2000(map_face_to_rgb[fid], cluster_centers[cluster_id]); + //dist_and_cid_vec.emplace_back(dist, cluster_id); + dist_and_cid_vec[cluster_id] = std::pair{dist, cluster_id}; + } + std::sort(dist_and_cid_vec.begin(), dist_and_cid_vec.end()); + if (dist_and_cid_vec[0].first < difference_epsilon || dist_and_cid_vec[0].first * absolute_difference_times < dist_and_cid_vec[1].first) { + map_face_to_cluster_id[fid] = dist_and_cid_vec[0].second; + } + } + }); + + std::unordered_set unclusted_fids; + for (std::size_t fid = 0; fid < mesh.number_of_faces(); ++fid) { + if (map_face_to_cluster_id[fid] == invalid_cluster_id) { + unclusted_fids.insert(fid); + } + } + + auto convert_unclustered_to_clustered = [&](const std::unordered_set& iter_clustered_fids) -> bool { + if (iter_clustered_fids.empty()) { + return false; + } + for (auto& fid : iter_clustered_fids) { + unclusted_fids.erase(fid); + } + return true; + }; + + // Step 2: Flood. Use faces computed in the previous step as seeds and propagate outward. + while (!unclusted_fids.empty()) { + bool changed = false; + std::unordered_set iter_clustered_fids; + // If an uncolored face has an adjacent color whose count exceeds the sum of all other colors, assign that color + for (auto fid : unclusted_fids) { + CGAL::SM_Face_index face(fid); + std::size_t count = 0; + std::unordered_map map_cluster_id_to_count; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + ++count; + ++map_cluster_id_to_count[map_face_to_cluster_id[adj_face]]; + } + for (auto& [cluster_id, cnt] : map_cluster_id_to_count) { + if (cluster_id != invalid_cluster_id && cnt * 2 > count) { + map_face_to_cluster_id[fid] = cluster_id; + iter_clustered_fids.insert(fid); + break; + } + } + } + changed = convert_unclustered_to_clustered(iter_clustered_fids) || changed; + iter_clustered_fids.clear(); + + // If a face's nearest cluster center (by color distance) happens to have an adjacent face, assign that color too + for (auto fid : unclusted_fids) { + CGAL::SM_Face_index face(fid); + std::unordered_set adj_cluster_ids; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + if (map_face_to_cluster_id[adj_face] == invalid_cluster_id) { + continue; + } + adj_cluster_ids.insert(map_face_to_cluster_id[adj_face]); + } + if (adj_cluster_ids.count(map_face_to_dists[fid].front().second)) { + map_face_to_cluster_id[fid] = map_face_to_dists[fid].front().second; + iter_clustered_fids.insert(fid); + } + } + changed = convert_unclustered_to_clustered(iter_clustered_fids) || changed; + iter_clustered_fids.clear(); + + // If no face colors were modified in this iteration, stop + if (!changed) { + break; + } + } + + // Step 3: Handle remaining unclustered faces (run after the above operations complete) + constexpr bool use_average_color = true; + for (auto src_fid : std::vector(unclusted_fids.begin(), unclusted_fids.end())) { + if (!unclusted_fids.count(src_fid)) { + continue; + } + // Compute connected unclustered faces + std::queue que; + std::unordered_set connected_unclustered_faces; + que.push(src_fid); + connected_unclustered_faces.insert(src_fid); + double sum_r = 0, sum_g = 0, sum_b = 0; + double sum_area = 0.0; + std::unordered_map map_cluster_id_to_adj_area; + while (!que.empty()) { + auto curr_fid = que.front(); + que.pop(); + sum_r += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][0]; + sum_g += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][1]; + sum_b += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][2]; + sum_area += map_face_to_area[curr_fid]; + CGAL::SM_Face_index curr_face(curr_fid); + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(curr_face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { // Invalid face + continue; + } + if (map_face_to_cluster_id[adj_face] != invalid_cluster_id) { + map_cluster_id_to_adj_area[map_face_to_cluster_id[adj_face]] += map_face_to_area[adj_face]; + } else { + if (!connected_unclustered_faces.count(adj_face)) { // Already clustered or already recorded + que.push(adj_face); + connected_unclustered_faces.insert(adj_face); + } + } + } + } + std::size_t matched_cluster_id = invalid_cluster_id; + if (use_average_color) { + // Use average color + RGB average_color{static_cast(sum_r / sum_area), static_cast(sum_g / sum_area), + static_cast(sum_b / sum_area)}; + double min_dist = std::numeric_limits::max(); + for (auto& [cluster_id, area] : map_cluster_id_to_adj_area) { + double dist = calc_rgb_color_difference_by_ciede2000(average_color, cluster_centers[cluster_id]); + if (dist < min_dist) { + min_dist = dist; + matched_cluster_id = cluster_id; + } + } + } else { + // Use adjacent area + double adj_max_area = 0.0; + for (auto& [cluster_id, area] : map_cluster_id_to_adj_area) { + if (area > adj_max_area) { + adj_max_area = area; + matched_cluster_id = cluster_id; + } + } + } + for (auto fid : connected_unclustered_faces) { + map_face_to_cluster_id[fid] = matched_cluster_id; + unclusted_fids.erase(fid); + } + } + + // Convert cluster center IDs to colors + for (std::size_t fid = 0; fid < mesh.number_of_faces(); ++fid) { + if (map_face_to_cluster_id[fid] == invalid_cluster_id) { + map_face_to_rgb[fid] = cluster_centers[0]; + map_face_to_cluster_id[fid] = 0; // Prevent out-of-bounds errors when using cluster_id later + } else { + map_face_to_rgb[fid] = cluster_centers[map_face_to_cluster_id[fid]]; + } + } + + return true; +} + +} // namespace color_utils + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/ColorUtils.hpp b/src/libslic3r/TextureToColor/ColorUtils.hpp new file mode 100644 index 0000000000..05849109f4 --- /dev/null +++ b/src/libslic3r/TextureToColor/ColorUtils.hpp @@ -0,0 +1,207 @@ +#pragma once + +#include "Callbacks.hpp" +#include "TriMesh.hpp" + +namespace Slic3r { namespace tex2color { + +namespace color_utils { +struct ClusterParameters; + +typedef std::array Color; // RGB: [R, G, B] 0~255 +typedef std::vector ColorList; +typedef std::array ColorDouble; +typedef std::array RGB; + +// Function pointer type that points to a specific color-difference function based on the chosen method. +using DistanceFunction = double (*)(const Color&, const Color&); + +// Color space used for computing color differences. +enum struct ColorDifferenceMethod : std::size_t { + RGB = 0, // Simplest and fastest + Lab = 1 // Most perceptually accurate +}; + +struct ClusterParameters { + ColorDifferenceMethod color_difference_method = ColorDifferenceMethod::Lab; // Method for measuring color difference; Lab is the most accurate + + double max_color_distance = 25; // Max intra-cluster radius (CIEDE2000 dE) for adaptive clustering; ignored by the fixed-K algorithm + + std::size_t cluster_k = 10; // Target number of cluster centers; ignored by the adaptive algorithm + + std::size_t max_cluster_k = 32; // Max cluster count upper bound for adaptive algorithm + + std::size_t max_iter = 50; // Maximum number of iterations + + std::function cancel_callback; // Optional cancellation check; returns true when the caller requests abort +}; + +struct SmoothParameters { + double smooth_weight = 0.5; // Controls smoothing intensity; larger values produce smoother results. Range: [0.0, 1.0] +}; + +/** + * @brief Compute the squared Euclidean distance between two RGB colors. + * + * @param[in] rgb1 First RGB color [R, G, B], range 0~255. + * @param[in] rgb2 Second RGB color [R, G, B], range 0~255. + * @return Squared Euclidean distance: (R1-R2)^2 + (G1-G2)^2 + (B1-B2)^2. + */ +double calc_rgb_color_difference_by_squared_rgb(const RGB& rgb1, const RGB& rgb2); + +/** + * @brief Compute the squared Euclidean distance between two RGB colors (double precision). + * + * @param[in] c1 First RGB color [R, G, B], as double. + * @param[in] c2 Second RGB color [R, G, B], as double. + * @return Squared Euclidean distance: (R1-R2)^2 + (G1-G2)^2 + (B1-B2)^2. + */ +double calc_rgb_color_difference_by_squared_rgb_double(const ColorDouble& c1, const ColorDouble& c2); + +/** + * @brief Compute the CIEDE2000 color difference between two RGB colors. + * + * Currently the most accurate color-difference formula, recommended by CIE as the industry standard. + * - dE <= 1.0: imperceptible to the human eye, high-precision color matching. + * - dE <= 2.0: slight difference, noticeable by experts; printing / image processing standard. + * - dE <= 3.0: noticeable by ordinary observers; general quality control. + * + * @param[in] rgb1 First RGB color [R, G, B], range 0~255. + * @param[in] rgb2 Second RGB color [R, G, B], range 0~255. + * @return CIEDE2000 color difference; smaller values indicate more similar colors. + */ +double calc_rgb_color_difference_by_ciede2000(const RGB& rgb1, const RGB& rgb2); + +/** + * @brief Compute the CIEDE2000 color difference between two sRGB colors (double precision, non-linear channels in [0,1]). + * + * Uses the same XYZ/Lab/dE00 pipeline as calc_rgb_color_difference_by_ciede2000 but without uint8 + * quantization or the intermediate x255 conversion; suitable for bisection, color blending, and other + * iterative scenarios. Note: ColorDouble here represents [R,G,B] in [0,1], which differs from the + * 0~255 scale used by other interfaces in this file. Callers should follow the naming convention. + * + * @param[in] rgb1 rgb2 sRGB non-linear channel values, recommended range [0,1]. + */ +double calc_rgb_color_difference_by_ciede2000_srgb01(const ColorDouble& rgb1, const ColorDouble& rgb2); + +/** + * @brief K-Means clustering algorithm that minimizes the sum of squared errors. + * + * Uses K-Means++ initialization to iteratively find the optimal cluster centers. + * + * @param[in] colors Input color list. + * @param[in] cluster_parameters Clustering parameters including cluster count, max iterations, color-difference method, etc. + * @return List of cluster-center colors whose size equals cluster_parameters.cluster_k. + */ +std::vector cluster_k_means(const std::vector& colors, const ClusterParameters& cluster_parameters); + +/** + * @brief Adaptive K-Means clustering that determines an appropriate number of clusters under a max color-distance constraint. + * + * Automatically finds the optimal cluster count via binary search so that max_color_distance is satisfied. + * + * @param[in] colors Input color list. + * @param[in] cluster_parameters Clustering parameters; cluster_k is ignored and determined automatically. + * @return List of cluster-center colors whose count is determined by the algorithm based on max_color_distance. + */ +std::vector cluster_adaptive(const std::vector& colors, const ClusterParameters& cluster_parameters); + +/** + * @brief Cluster a color list to a set of specified cluster centers. + * + * For each input color, find the nearest specified cluster center and replace it. + * + * @param[in] colors Input color list. + * @param[in] specified_colors Specified cluster-center colors. + * @return Clustered color list where each color is replaced by its nearest center. + */ +std::vector cluster_to_specified_colors(const std::vector& colors, const std::vector& specified_colors); + +/** + * @brief Remesh the mesh while preserving color boundaries. + * + * Performs isotropic remeshing while protecting color boundaries. Edges whose two adjacent + * faces have different colors are marked as feature edges and will not be modified. + * + * @param[in,out] mesh Input mesh; modified in-place after remeshing. + * @param[in,out] face_labels Face color labels; updated to match the new mesh. + * @param[in] target_edge_length_ratio Ratio of target average edge length to input average edge length; >1 simplifies, <1 refines. + * @return true on success, false on failure. + */ +bool remesh_mesh(TriMesh& mesh, std::vector& face_labels, double target_edge_length_ratio); + +/** + * @brief Check whether the mesh is closed (watertight). + * + * A mesh is closed if it has no boundary edges, i.e. every edge is shared by exactly two faces. + * + * @param[in] tri_mesh Input mesh. + * @return true if the mesh is closed, false if it has boundary edges. + */ +bool is_closed(const TriMesh& tri_mesh); + +/** + * @brief Smooth region boundaries (RGB color labels). + * + * Applies topological smoothing (label reassignment) and geometric smoothing (boundary vertex relocation). + * + * @param[in,out] tri_mesh Input mesh; modified in-place after smoothing. + * @param[in,out] face_labels Face color labels (RGB format); updated after smoothing. + * @param[in] smooth_parameters Smoothing control parameters. + * @return true on success, false on failure. + */ +bool smooth_region(TriMesh& tri_mesh, std::vector>& face_labels, const SmoothParameters& smooth_parameters = SmoothParameters()); + +/** + * @brief Smooth region boundaries (integer labels). + * + * Applies topological smoothing (label reassignment) and geometric smoothing (boundary vertex relocation). + * + * @param[in,out] tri_mesh Input mesh; modified in-place after smoothing. + * @param[in,out] face_labels Integer face labels; updated after smoothing. + * @param[in] smooth_parameters Smoothing control parameters. + * @return true on success, false on failure. + */ +bool smooth_region(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters = SmoothParameters()); + +/** + * @brief Split the mesh into connected components. + * + * Based on face connectivity, the mesh is split into independent components, each forming a + * standalone mesh. Texture coordinates for each component are preserved. + * + * @param[in] mesh Input mesh. + * @param[in] vertex_uvs Vertex texture coordinates. + * @param[out] component_meshes Output list of component meshes. + * @param[out] component_vertex_uvs Output list of texture coordinates per component. + * @return true on success, false on failure. + */ +bool get_components(const TriMesh& mesh, const std::vector& vertex_uvs, std::vector& component_meshes, + std::vector>& component_vertex_uvs); + +/** + * @brief Find the ID of the nearest color in a color list to a given color. + * + * @param[in] colors Color list. + * @param[in] color Target color. + * @param[out] nearest_color_id ID of the nearest color found. + * @return true on success, false on failure. + */ +bool calc_nearest_color_id(const std::vector& colors, const RGB& color, std::size_t& nearest_color_id); + +/** + * @brief Cluster mesh face colors based on given cluster centers. + * + * @param[in] mesh Input mesh. + * @param[in] cluster_centers Cluster-center RGB colors. + * @param[in, out] map_face_to_rgb RGB color per face; updated to the nearest cluster center after clustering. + * @param[out] map_face_to_cluster_id Cluster-center ID per face; updated to the nearest cluster center ID. + * @return true on success, false on failure. + */ +bool mesh_cluster(const TriMesh& mesh, const std::vector& cluster_centers, std::vector& map_face_to_rgb, + std::vector& map_face_to_cluster_id); + +} // namespace color_utils + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/Repair.hpp b/src/libslic3r/TextureToColor/Repair.hpp new file mode 100644 index 0000000000..44dc30c144 --- /dev/null +++ b/src/libslic3r/TextureToColor/Repair.hpp @@ -0,0 +1,252 @@ +#pragma once +#include "TriMesh.hpp" +#include "CgalUtils.hpp" +#include "Callbacks.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace tex2color { + +namespace PMP = CGAL::Polygon_mesh_processing; + +// Default upper bound on the number of half-edges in any single boundary cycle +// that CloseBoundariesAndRepairManifoldness will attempt to triangulate. The +// cost of triangulate_hole grows non-linearly with cycle length, so this caps +// the worst-case per-hole work rather than the aggregate boundary size: a mesh +// with many small holes is still fully repaired, while a mesh containing one +// pathologically large hole skips triangulation entirely. +inline constexpr std::size_t MAX_REPAIRABLE_MESH_HOLE_EDGES = 500; + +// Default upper bound on the aggregate number of boundary half-edges in the +// mesh (summed across every boundary cycle). When the total boundary length is +// excessive, even if each individual cycle is short, triangulating all of them +// usually indicates a severely fragmented input (e.g. heavily damaged scans) +// and rarely yields a usable result, so we skip hole closing entirely. +inline constexpr std::size_t MAX_REPAIRABLE_MESH_BOUNDARY_EDGES = 5000; + +struct RepairSetting +{ + // Skip triangulating a boundary cycle whose half-edge count exceeds this. + std::size_t max_hole_edges = MAX_REPAIRABLE_MESH_HOLE_EDGES; + // Skip hole closing entirely when the total boundary half-edge count + // (summed across all cycles) exceeds this. + std::size_t max_boundary_edges = MAX_REPAIRABLE_MESH_BOUNDARY_EDGES; +}; + +struct BoundaryEdgeStats +{ + std::size_t total_boundary_edges = 0; + std::size_t max_cycle_edges = 0; + std::size_t cycle_count = 0; +}; + +// Read-only inspection of the mesh's boundary cycles. Caller is responsible for +// any pre-processing (e.g. stitch_borders) needed for the count to be meaningful. +inline BoundaryEdgeStats ComputeBoundaryEdgeStats(const cgalutils::CGALMesh& cgal_mesh) +{ + using CGALMesh = cgalutils::CGALMesh; + using HalfedgeDescriptor = boost::graph_traits::halfedge_descriptor; + + std::vector border_cycles; + PMP::extract_boundary_cycles(cgal_mesh, std::back_inserter(border_cycles)); + + BoundaryEdgeStats stats; + stats.cycle_count = border_cycles.size(); + for (const HalfedgeDescriptor h0 : border_cycles) { + std::size_t len = 0; + HalfedgeDescriptor h = h0; + do { + ++len; + h = next(h, cgal_mesh); + } while (h != h0); + stats.max_cycle_edges = std::max(stats.max_cycle_edges, len); + stats.total_boundary_edges += len; + } + return stats; +} + +// Unconditionally close every boundary cycle of the mesh and repair non-manifold +// vertices. The caller (e.g. RepairMesh) is expected to gate this call based on +// boundary statistics; entering this function always triggers triangulation. +inline void CloseBoundariesAndRepairManifoldness(cgalutils::CGALMesh& cgal_mesh) +{ + using CGALMesh = cgalutils::CGALMesh; + using HalfedgeDescriptor = boost::graph_traits::halfedge_descriptor; + using FaceDescriptor = boost::graph_traits::face_descriptor; + + PMP::stitch_borders(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); + + std::vector border_cycles; + PMP::extract_boundary_cycles(cgal_mesh, std::back_inserter(border_cycles)); + + for (const HalfedgeDescriptor h : border_cycles) { + std::vector patch_faces; + PMP::triangulate_hole(cgal_mesh, h, std::back_inserter(patch_faces)); + } + + PMP::remove_degenerate_faces(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); +} + +inline bool RepairMesh(const TriMesh& mesh, + std::shared_ptr& out_mesh, + AlgoProgressCallback progress_callback = nullptr, + AlgoCancelCallback cancel_callback = nullptr, + const RepairSetting& setting = RepairSetting{}) +{ + using Clock = std::chrono::steady_clock; + auto elapsed_ms = [](Clock::time_point t0) { + return std::chrono::duration_cast(Clock::now() - t0).count(); + }; + + const Clock::time_point t_total = Clock::now(); + + // Convert TriMesh to polygon soup (point container + triangle index container) + std::vector soup_points; + std::vector> soup_triangles; + + soup_points.reserve(mesh.vertices.size()); + for (const TriVertex& v : mesh.vertices) { + soup_points.emplace_back(v.x(), v.y(), v.z()); + } + + soup_triangles.reserve(mesh.indices.size()); + for (const TriFace& f : mesh.indices) { + soup_triangles.push_back({static_cast(f[0]), + static_cast(f[1]), + static_cast(f[2])}); + } + + if (progress_callback) { + progress_callback({30, "Repairing polygon soup"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + { + const auto t0 = Clock::now(); + PMP::repair_polygon_soup(soup_points, soup_triangles); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=repair_polygon_soup took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({50, "Orienting polygon soup"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + { + const auto t0 = Clock::now(); + PMP::orient_polygon_soup(soup_points, soup_triangles); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=orient_polygon_soup took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({70, "Converting to CGAL mesh"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + cgalutils::CGALMesh cgal_mesh; + { + const auto t0 = Clock::now(); + PMP::polygon_soup_to_polygon_mesh(soup_points, soup_triangles, cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=polygon_soup_to_polygon_mesh took=" + << elapsed_ms(t0) << " ms"; + } + + { + const auto t0 = Clock::now(); + PMP::remove_degenerate_faces(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=remove_degenerate_faces took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({80, "Closing mesh boundaries"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + // Stitch borders and duplicate non-manifold vertices first so that the + // boundary statistics below reflect the post-stitch topology; otherwise + // boundaries that would close on stitching inflate the counts and may + // cause the gate to skip hole filling unnecessarily. + BoundaryEdgeStats stats; + { + const auto t0 = Clock::now(); + PMP::stitch_borders(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); + stats = ComputeBoundaryEdgeStats(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=boundary_stats took=" + << elapsed_ms(t0) << " ms" + << " total_boundary_edges=" << stats.total_boundary_edges + << " max_cycle_edges=" << stats.max_cycle_edges + << " cycle_count=" << stats.cycle_count; + } + + const bool can_repair_holes = + stats.total_boundary_edges <= setting.max_boundary_edges && + stats.max_cycle_edges <= setting.max_hole_edges; + + if (can_repair_holes) { + const auto t0 = Clock::now(); + CloseBoundariesAndRepairManifoldness(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=close_boundaries took=" + << elapsed_ms(t0) << " ms"; + } else { + BOOST_LOG_TRIVIAL(info) + << "TextureToColor: RepairMesh skip hole closing" + << ", total_boundary_edges=" << stats.total_boundary_edges + << " (limit=" << setting.max_boundary_edges << ")" + << ", max_cycle_edges=" << stats.max_cycle_edges + << " (limit=" << setting.max_hole_edges << ")" + << ", cycle_count=" << stats.cycle_count; + } + + if (progress_callback) { + progress_callback({85, "Converting from CGAL mesh"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + std::shared_ptr out; + { + const auto t0 = Clock::now(); + out = std::make_shared(cgalutils::cgal_to_trimesh(cgal_mesh)); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=cgal_to_trimesh took=" + << elapsed_ms(t0) << " ms"; + } + + out_mesh = std::move(out); + if (progress_callback) { + progress_callback({100, "Done"}); + } + + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh total=" << elapsed_ms(t_total) << " ms"; + + return true; +} + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp new file mode 100644 index 0000000000..e3afc63cd9 --- /dev/null +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -0,0 +1,789 @@ +#include "TextureToColor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "CgalUtils.hpp" +#include "ColorUtils.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include +#include +#include "Repair.hpp" +#include "libslic3r/AABBTreeIndirect.hpp" +#include + +namespace Slic3r { namespace tex2color { + +using namespace color_utils; + +// #define OUTPUT_TEST_RESULT + +static void SaveToOFF(const std::string& path, const TriMesh& mesh, const std::vector& face_colors) +{ + std::filesystem::create_directories(std::filesystem::path(path).parent_path()); + std::ofstream ofs(path); + if (!ofs.is_open()) { + BOOST_LOG_TRIVIAL(warning) << "SaveToOFF: failed to open " << path; + return; + } + + const auto& vertices = mesh.vertices; + const auto& faces = mesh.indices; + + ofs << "OFF\n"; + ofs << vertices.size() << " " << faces.size() << " 0\n"; + + for (const auto& v : vertices) { + ofs << v.x() << " " << v.y() << " " << v.z() << "\n"; + } + + for (std::size_t i = 0; i < faces.size(); ++i) { + const auto& f = faces[i]; + ofs << "3 " << f[0] << " " << f[1] << " " << f[2]; + if (i < face_colors.size()) { + ofs << " " << face_colors[i][0] / 255.0 + << " " << face_colors[i][1] / 255.0 + << " " << face_colors[i][2] / 255.0 + << " 1.0"; + } + ofs << "\n"; + } +} + +static std::vector count_cluster_label_usage(const std::vector& face_labels, std::size_t cluster_count) +{ + std::vector usage(cluster_count, 0); + for (std::size_t label : face_labels) { + if (label < cluster_count) { + ++usage[label]; + } + } + return usage; +} + +static bool discard_unused_cluster_centers(std::vector& cluster_centers, std::vector& face_labels, const char* stage_name) +{ + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot discard unused cluster centers at " << stage_name + << ", no cluster center is available."; + return false; + } + + const std::vector usage = count_cluster_label_usage(face_labels, cluster_centers.size()); + std::vector label_remap(cluster_centers.size(), std::numeric_limits::max()); + std::vector used_cluster_centers; + used_cluster_centers.reserve(cluster_centers.size()); + + for (std::size_t cluster_id = 0; cluster_id < cluster_centers.size(); ++cluster_id) { + if (usage[cluster_id] == 0) { + continue; + } + label_remap[cluster_id] = used_cluster_centers.size(); + used_cluster_centers.push_back(cluster_centers[cluster_id]); + } + + if (used_cluster_centers.size() == cluster_centers.size()) { + return true; + } + if (used_cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot discard unused cluster centers at " << stage_name + << ", no face uses any valid cluster center."; + return false; + } + + for (std::size_t& label : face_labels) { + if (label >= label_remap.size() || label_remap[label] == std::numeric_limits::max()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot remap cluster label " << label + << " at " << stage_name << "."; + return false; + } + label = label_remap[label]; + } + + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: discarded " << (cluster_centers.size() - used_cluster_centers.size()) + << " unused adaptive cluster centers at " << stage_name << "."; + cluster_centers = std::move(used_cluster_centers); + return true; +} + +static bool ensure_all_cluster_centers_used(const std::vector& source_face_colors, const std::vector& cluster_centers, + std::vector& face_labels, const char* stage_name) +{ + if (source_face_colors.size() != face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot preserve cluster colors at " << stage_name + << ", face color count does not match label count."; + return false; + } + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot preserve cluster colors at " << stage_name + << ", no cluster center is available."; + return false; + } + if (cluster_centers.size() > face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot use all cluster centers at " << stage_name + << ", centers=" << cluster_centers.size() << " faces=" << face_labels.size() << "."; + return false; + } + + std::vector usage = count_cluster_label_usage(face_labels, cluster_centers.size()); + std::size_t missing_count = 0; + for (std::size_t cluster_id = 0; cluster_id < usage.size(); ++cluster_id) { + if (usage[cluster_id] != 0) { + continue; + } + ++missing_count; + + double best_cost = std::numeric_limits::max(); + std::size_t best_face_id = std::numeric_limits::max(); + std::size_t best_old_cluster_id = std::numeric_limits::max(); + + for (std::size_t fid = 0; fid < face_labels.size(); ++fid) { + const std::size_t old_cluster_id = face_labels[fid]; + if (old_cluster_id >= cluster_centers.size() || usage[old_cluster_id] <= 1) { + continue; + } + + const double old_dist = calc_rgb_color_difference_by_ciede2000(source_face_colors[fid], cluster_centers[old_cluster_id]); + const double new_dist = calc_rgb_color_difference_by_ciede2000(source_face_colors[fid], cluster_centers[cluster_id]); + const double cost = new_dist - old_dist; + if (cost < best_cost) { + best_cost = cost; + best_face_id = fid; + best_old_cluster_id = old_cluster_id; + } + } + + if (best_face_id == std::numeric_limits::max()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: failed to assign a seed face for unused cluster " << cluster_id + << " at " << stage_name << "."; + continue; + } + + face_labels[best_face_id] = cluster_id; + --usage[best_old_cluster_id]; + ++usage[cluster_id]; + } + + if (missing_count > 0) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: reassigned seed faces for " << missing_count + << " unused cluster centers at " << stage_name << "."; + } + + for (std::size_t count : usage) { + if (count == 0) { + return false; + } + } + return true; +} + +// Bilinear interpolation texture sampling; sub-pixel precision avoids nearest-neighbor aliasing +static RGB get_pixel_color(float u, float v, const cv::Mat& texture) { + u = u - std::floor(u); + v = v - std::floor(v); + + // glTF UV convention: (0,0) = top-left, v increases downward + float fx = u * (texture.cols - 1); + float fy = v * (texture.rows - 1); + + int x0 = std::clamp(static_cast(fx), 0, texture.cols - 1); + int y0 = std::clamp(static_cast(fy), 0, texture.rows - 1); + int x1 = std::min(x0 + 1, texture.cols - 1); + int y1 = std::min(y0 + 1, texture.rows - 1); + + float wx = fx - x0; + float wy = fy - y0; + + const int ch = texture.channels(); + auto sample = [&](int row, int col) -> std::array { + const uchar* ptr = texture.data + row * texture.step[0] + col * ch; + return {static_cast(ptr[2]), static_cast(ptr[1]), static_cast(ptr[0])}; + }; + + auto c00 = sample(y0, x0); + auto c10 = sample(y0, x1); + auto c01 = sample(y1, x0); + auto c11 = sample(y1, x1); + + // Bilinear blend: lerp(lerp(c00,c10,wx), lerp(c01,c11,wx), wy) + RGB color; + for (int i = 0; i < 3; ++i) { + float top = c00[i] * (1.0f - wx) + c10[i] * wx; + float bot = c01[i] * (1.0f - wx) + c11[i] * wx; + color[i] = static_cast(std::clamp(top * (1.0f - wy) + bot * wy, 0.0f, 255.0f)); + } + return color; +} + +// 7-point triangular Gaussian quadrature barycentric coordinates and weights (precision sufficient for capturing texture detail within faces) +static constexpr std::array, 7> GAUSS_TRI_BARY = {{ + {1.0f / 3.0f, 1.0f / 3.0f, 1.0f / 3.0f}, + {0.059715871f, 0.470142064f, 0.470142064f}, + {0.470142064f, 0.059715871f, 0.470142064f}, + {0.470142064f, 0.470142064f, 0.059715871f}, + {0.797426985f, 0.101286507f, 0.101286507f}, + {0.101286507f, 0.797426985f, 0.101286507f}, + {0.101286507f, 0.101286507f, 0.797426985f}, +}}; +static constexpr std::array GAUSS_TRI_WEIGHT = {0.225f, 0.132394152f, 0.132394152f, 0.132394152f, 0.125939181f, 0.125939181f, 0.125939181f}; +static_assert( + []() constexpr { + float sum = 0.0f; + for (auto w : GAUSS_TRI_WEIGHT) { + sum += w; + } + return sum > 0.999f && sum < 1.001f; + }(), + "Sum of Gaussian quadrature weights must be 1.0"); + +// Multi-point Gaussian quadrature sampling on a single face; returns weighted average color. +// GAUSS_TRI_WEIGHT sums to 1.0 (Hammer quadrature formula), no normalization needed. +static RGB sample_face_color(const std::array& uvs, const cv::Mat& texture) { + float r = 0.0f, g = 0.0f, b = 0.0f; + for (int k = 0; k < 7; ++k) { + float u = GAUSS_TRI_BARY[k][0] * uvs[0].x() + GAUSS_TRI_BARY[k][1] * uvs[1].x() + GAUSS_TRI_BARY[k][2] * uvs[2].x(); + float v = GAUSS_TRI_BARY[k][0] * uvs[0].y() + GAUSS_TRI_BARY[k][1] * uvs[1].y() + GAUSS_TRI_BARY[k][2] * uvs[2].y(); + RGB c = get_pixel_color(u, v, texture); + float w = GAUSS_TRI_WEIGHT[k]; + r += w * c[0]; + g += w * c[1]; + b += w * c[2]; + } + return RGB{static_cast(std::clamp(r, 0.0f, 255.0f)), static_cast(std::clamp(g, 0.0f, 255.0f)), + static_cast(std::clamp(b, 0.0f, 255.0f))}; +} + +// Use array instead of vector for UV storage to avoid per-face heap allocations at million-face scale +using FaceUVArray = std::array; + +static bool linear_subdivision(TriMesh& mesh, std::vector& uv_coords, const std::function& sub_progress = nullptr) { + const auto& original_vertices = mesh.vertices; + const auto& original_faces = mesh.indices; + TriVertices sub_vertices = mesh.vertices; + sub_vertices.reserve(original_vertices.size() + original_faces.size() * 3); + TriFaces sub_faces; + std::vector sub_uv_coords; + + // Single-level flat map with edge key encoding replaces nested unordered_map; + // merges two vertex indices into a single uint64_t to reduce hash lookups and indirection. + if (original_vertices.size() >= (1ULL << 32)) [[unlikely]] { + BOOST_LOG_TRIVIAL(warning) << "[boundary] " << __FUNCTION__ << " vertex_count=" << original_vertices.size() << " exceeds 32-bit edge_key encoding range, skipping subdivision"; + return false; + } + auto edge_key = [](std::size_t a, std::size_t b) -> uint64_t { + return a < b ? ((static_cast(a) << 32) | b) : ((static_cast(b) << 32) | a); + }; + std::unordered_map map_edge_to_sub_vtx; + map_edge_to_sub_vtx.reserve(original_faces.size() * 3 / 2); + + for (const auto& face : original_faces) { + for (std::size_t i = 0; i < 3; ++i) { + std::size_t vtx_1 = face[i]; + std::size_t vtx_2 = face[(i + 1) % 3]; + uint64_t key = edge_key(vtx_1, vtx_2); + if (map_edge_to_sub_vtx.count(key) > 0) { + continue; + } + TriVertex edge_vtx = (original_vertices[vtx_1] + original_vertices[vtx_2]) * 0.5; + map_edge_to_sub_vtx[key] = sub_vertices.size(); + sub_vertices.push_back(edge_vtx); + } + } + if (sub_progress) { + sub_progress(50); + } + + // Subdivide faces and their UVs: each original face splits into 4 sub-faces (parallel writes, no contention) + const std::size_t N = original_faces.size(); + sub_faces.resize(N * 4); + sub_uv_coords.resize(N * 4); + std::atomic has_missing_edge{false}; + + tbb::parallel_for(tbb::blocked_range(0, N), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + const std::size_t base = fid * 4; + const auto& face = original_faces[fid]; + std::size_t vtx_0 = face[0]; + std::size_t vtx_1 = face[1]; + std::size_t vtx_2 = face[2]; + + auto it01 = map_edge_to_sub_vtx.find(edge_key(vtx_0, vtx_1)); + auto it12 = map_edge_to_sub_vtx.find(edge_key(vtx_1, vtx_2)); + auto it20 = map_edge_to_sub_vtx.find(edge_key(vtx_2, vtx_0)); + if (it01 == map_edge_to_sub_vtx.end() || it12 == map_edge_to_sub_vtx.end() || it20 == map_edge_to_sub_vtx.end()) [[unlikely]] { + has_missing_edge.store(true, std::memory_order_relaxed); + Vec3i32 degen(vtx_0, vtx_0, vtx_0); + FaceUVArray degen_uv = {uv_coords[fid][0], uv_coords[fid][0], uv_coords[fid][0]}; + for (int k = 0; k < 4; ++k) { + sub_faces[base + k] = degen; + sub_uv_coords[base + k] = degen_uv; + } + continue; + } + std::size_t e01 = it01->second; + std::size_t e12 = it12->second; + std::size_t e20 = it20->second; + + const Vec2f& uv0 = uv_coords[fid][0]; + const Vec2f& uv1 = uv_coords[fid][1]; + const Vec2f& uv2 = uv_coords[fid][2]; + Vec2f uv_e01 = (uv0 + uv1) * 0.5f; + Vec2f uv_e12 = (uv1 + uv2) * 0.5f; + Vec2f uv_e20 = (uv2 + uv0) * 0.5f; + + sub_faces[base + 0] = Vec3i32(vtx_0, e01, e20); + sub_uv_coords[base + 0] = {uv0, uv_e01, uv_e20}; + + sub_faces[base + 1] = Vec3i32(e01, vtx_1, e12); + sub_uv_coords[base + 1] = {uv_e01, uv1, uv_e12}; + + sub_faces[base + 2] = Vec3i32(e01, e12, e20); + sub_uv_coords[base + 2] = {uv_e01, uv_e12, uv_e20}; + + sub_faces[base + 3] = Vec3i32(e20, e12, vtx_2); + sub_uv_coords[base + 3] = {uv_e20, uv_e12, uv2}; + } + }); + // Remove degenerate triangles (three identical vertices) to avoid impacting downstream SDF / Remesh steps + if (has_missing_edge.load(std::memory_order_relaxed)) { + std::size_t write_idx = 0; + for (std::size_t i = 0; i < sub_faces.size(); ++i) { + if (sub_faces[i][0] == sub_faces[i][1] && sub_faces[i][1] == sub_faces[i][2]) { + continue; + } + if (write_idx != i) { + sub_faces[write_idx] = sub_faces[i]; + sub_uv_coords[write_idx] = sub_uv_coords[i]; + } + ++write_idx; + } + BOOST_LOG_TRIVIAL(warning) << "[warning] linear_subdivision has missing edge vertex, removed " << (sub_faces.size() - write_idx) << " degenerate triangles"; + sub_faces.resize(write_idx); + sub_uv_coords.resize(write_idx); + } + + if (sub_progress) { + sub_progress(100); + } + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: input faces count = " << mesh.indices.size() << "."; + mesh = TriMesh(sub_faces, sub_vertices); + uv_coords = std::move(sub_uv_coords); + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: output faces count = " << mesh.indices.size() << "."; + return true; +} + +bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& texture_mesh_uv_coords, const cv::Mat& texture, TriMesh& color_mesh, + std::vector>& face_colors, const TextureToColorSettings& settings, AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback) { + auto report = [&](int pct, const char* msg) { + if (progress_callback) { + progress_callback({pct, msg}); + } + }; + auto sub_report = [&](int sub_pct, int range_start, int range_end, const char* msg) { + int pct = range_start + sub_pct * (range_end - range_start) / 100; + report(pct, msg); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " cancelled"; + return true; + } + return false; + }; + + color_mesh.clear(); + face_colors.clear(); + + report(0, "Initializing"); + if (cancelled()) { + return false; + } + + if (texture_mesh.indices.size() == 0) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture mesh has no faces."; + return false; + } + if (texture.empty()) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture is empty."; + return false; + } + if (texture.channels() < 3) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture must have at least 3 channels, got " << texture.channels(); + return false; + } + if (texture_mesh_uv_coords.size() != texture_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: uv_coords size is not equal to texture mesh faces size."; + return false; + } + for (std::size_t fid = 0; fid < texture_mesh.indices.size(); ++fid) { + if (texture_mesh_uv_coords[fid].size() != 3) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: uv_coords of single face size is not equal to 3."; + return false; + } + } + color_mesh = texture_mesh; + + using Clock = std::chrono::high_resolution_clock; + const auto t_total_start = Clock::now(); + auto t_step = t_total_start; + auto lap = [&](const char* step_name) { + auto now = Clock::now(); + double ms = std::chrono::duration(now - t_step).count(); + BOOST_LOG_TRIVIAL(debug) << "[timing] " << step_name << ": " << ms << "ms" + << " faces=" << color_mesh.facets_count(); + t_step = now; + }; + + report(5, "Oversampling"); + if (cancelled()) { + return false; + } + + // Step 1: Oversampling (subdivision while propagating UVs) + // Convert external vector> to internal vector> to eliminate inner-level heap allocations + std::vector color_mesh_uv_coords(texture_mesh_uv_coords.size()); + for (std::size_t i = 0; i < texture_mesh_uv_coords.size(); ++i) { + color_mesh_uv_coords[i] = {texture_mesh_uv_coords[i][0], texture_mesh_uv_coords[i][1], texture_mesh_uv_coords[i][2]}; + } + { + // Estimate total iterations and map each iteration's sub-progress to the [5, 25] range + size_t estimated_iters = 0; + if (settings.oversampling_iters > 0) { + estimated_iters = settings.oversampling_iters; + } else { + size_t fc = color_mesh.facets_count(); + while (fc < settings.oversampling_min_face_count) { + fc *= 4; + ++estimated_iters; + } + if (estimated_iters == 0) { + estimated_iters = 1; + } + } + + auto make_iter_progress = [&](size_t iter) { + return [&, iter, estimated_iters](int pct) { + int iter_start = static_cast(iter * 100 / estimated_iters); + int iter_end = static_cast((iter + 1) * 100 / estimated_iters); + int sub_pct = iter_start + pct * (iter_end - iter_start) / 100; + sub_report(sub_pct, 5, 25, "Oversampling"); + }; + }; + + if (settings.oversampling_iters > 0) { + for (size_t i = 0; i < settings.oversampling_iters && color_mesh.facets_count() * 4.0 < settings.oversampling_max_face_count; ++i) { + if (cancelled()) return false; + linear_subdivision(color_mesh, color_mesh_uv_coords, make_iter_progress(i)); + } + } else { + size_t iter = 0; + while (color_mesh.facets_count() < settings.oversampling_min_face_count) { + if (cancelled()) return false; + linear_subdivision(color_mesh, color_mesh_uv_coords, make_iter_progress(iter++)); + } + } + } + + lap("Oversampling"); + + face_colors.resize(color_mesh.indices.size()); + + report(25, "Computing face colors"); + if (cancelled()) { + return false; + } + + // Step 2: Compute each face's color (7-point Gaussian quadrature + bilinear interpolation sampling) + { + std::atomic done_faces{0}; + std::atomic cancel_requested{false}; + const size_t total_faces = color_mesh.indices.size(); + const size_t report_interval = std::max(total_faces / 20, 1); + tbb::parallel_for(tbb::blocked_range(0, total_faces), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + if (cancel_requested.load(std::memory_order_relaxed)) return; + face_colors[fid] = sample_face_color(color_mesh_uv_coords[fid], texture); + size_t cnt = done_faces.fetch_add(1, std::memory_order_relaxed) + 1; + if (cnt % report_interval == 0) { + if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } + sub_report(static_cast(cnt * 100 / total_faces), 25, 40, "Computing face colors"); + } + } + }); + if (cancel_requested.load() || cancelled()) return false; + } + lap("Computing face colors"); +#ifdef OUTPUT_TEST_RESULT + SaveToOFF("texture_to_color_0_initialize.off", color_mesh, face_colors); +#endif + + report(40, "Repairing mesh"); + if (cancelled()) { + return false; + } + + // Sub-stage timing helper for the "Repairing mesh" outer lap. Logs each + // sub-phase under a [timing][Repairing mesh] prefix so that regressions in + // mesh inspection, RepairMesh, AABB resampling, etc. can be attributed + // to a specific sub-stage without changing the outer lap structure. + auto sub_lap = [&](const char* sub_name, Clock::time_point t0) { + double ms = std::chrono::duration(Clock::now() - t0).count(); + BOOST_LOG_TRIVIAL(debug) << "[timing][Repairing mesh] " << sub_name << ": " << ms << "ms"; + }; + + // Step 3: Repair mesh + // Many textured models have non-manifold, non-closed, or other issues that need to be fixed beforehand + auto resample_repaired_mesh = [&](TriMesh&& repaired_mesh) -> bool { + // AABBTreeIndirect references vertices/faces externally, so snapshot the + // pre-repair geometry by moving them out of color_mesh before it gets + // overwritten with the repaired mesh below. std::move on std::vector is + // O(1) (pointer adoption), no element copy. + const auto t_aabb = Clock::now(); + TriVertices old_vertices = std::move(color_mesh.vertices); + TriFaces old_indices = std::move(color_mesh.indices); + auto before_repair_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); + sub_lap("resample.aabb_build", t_aabb); + + color_mesh = std::move(repaired_mesh); + + const auto t_is_closed = Clock::now(); + if (is_closed(color_mesh)) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repaired mesh is closed."; + } else { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repaired mesh is open."; + } + sub_lap("resample.is_closed", t_is_closed); + + // New faces after repair inherit old face colors via centroid nearest-neighbor lookup. + // Since the mesh barely changes after repair, resampling via centroid nearest-neighbor is sufficient. + const auto t_resample = Clock::now(); + std::vector new_face_colors(color_mesh.facets_count()); + tbb::parallel_for(tbb::blocked_range(0, color_mesh.facets_count()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + const auto& face = color_mesh.indices[fid]; + Vec3f center = (color_mesh.vertices[face[0]] + color_mesh.vertices[face[1]] + color_mesh.vertices[face[2]]) / 3.0f; + size_t hit_idx = 0; + Vec3f closest; + AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + old_vertices, old_indices, before_repair_tree, center, hit_idx, closest); + new_face_colors[fid] = face_colors[hit_idx]; + } + }); + face_colors = std::move(new_face_colors); + sub_lap("resample.parallel_nearest", t_resample); + return true; + }; + + auto repair_and_resample_mesh = [&]() -> bool { + std::shared_ptr repaired_mesh; + const auto t_repair = Clock::now(); + bool success = RepairMesh(color_mesh, repaired_mesh); + sub_lap("RepairMesh", t_repair); + if (success == false) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repair mesh failed."; + return false; + } + if (cancelled()) return false; + return resample_repaired_mesh(std::move(*repaired_mesh)); + }; + + { + const auto t_stats = Clock::now(); + TriangleMesh stats_mesh(static_cast(color_mesh)); + const auto& stats = stats_mesh.stats(); + sub_lap("stats_check", t_stats); + // Orca's TriangleMeshStats defines manifold() as open_edges == 0 and does not track + // non-manifold edges/vertices separately, so BBS's "!manifold() || has_open_edges()" + // collapses to this single test and the extra counters drop out of the log. + if (!stats.manifold()) { + BOOST_LOG_TRIVIAL(info) << "TextureToColor: mesh has non-manifold geometry or open boundaries, open_edges=" + << stats.open_edges; + if (settings.mesh_repair_decision == MeshRepairDecision::Ask) { + if (settings.mesh_repair_decision_required) + *settings.mesh_repair_decision_required = true; + return false; + } + if (settings.mesh_repair_decision == MeshRepairDecision::RepairAndImport) { + indexed_triangle_set repaired_its; + std::string repair_error; + const auto t_win3d = Clock::now(); + bool repaired = settings.mesh_repair_callback && settings.mesh_repair_callback(static_cast(color_mesh), repaired_its, + [&](const char* message, unsigned percent) { + sub_report(static_cast(percent), 40, 60, message ? message : "Repairing mesh"); + }, + [&]() { return cancelled(); }, &repair_error); + sub_lap("windows_3d_repair", t_win3d); + if (repaired) { + if (cancelled()) return false; + BOOST_LOG_TRIVIAL(info) << "TextureToColor: Windows 3D mesh repair finished."; + if (!resample_repaired_mesh(TriMesh(std::move(repaired_its)))) + return false; + } else { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: Windows 3D mesh repair failed: " << repair_error; + } + } else { + BOOST_LOG_TRIVIAL(info) << "TextureToColor: importing mesh without Windows 3D repair."; + } + } + } + + const auto t_halfedge = Clock::now(); + const bool halfedge_ok = cgalutils::is_mesh_halfedge_compatible(color_mesh); + sub_lap("is_mesh_halfedge_compatible", t_halfedge); + if (!halfedge_ok && repair_and_resample_mesh() == false) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repair and resample mesh failed."; + return false; + } + lap("Repairing mesh"); +#ifdef OUTPUT_TEST_RESULT + SaveToOFF("texture_to_color_1_repair.off", color_mesh, face_colors); +#endif + + report(65, "Color clustering"); + if (cancelled()) { + return false; + } + + // Step 5: Color clustering + std::vector cluster_centers; + std::vector clustered_face_colors = face_colors; + std::vector clustered_face_labels(face_colors.size()); + const bool adaptive_cluster = settings.target_colors_num == 0; + + // Compute cluster centers + if (adaptive_cluster) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: use cluster adaptive method."; + ClusterParameters para; + para.max_color_distance = settings.max_color_distance; + para.max_cluster_k = settings.max_cluster_k; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_adaptive(face_colors, para); + if (cancelled()) return false; + } else { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: use cluster k-means method."; + ClusterParameters para; + para.cluster_k = settings.target_colors_num; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_k_means(face_colors, para); + if (cancelled()) return false; + } + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: the k is " << cluster_centers.size() << "."; + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: no cluster center generated."; + return false; + } + const std::set unique_cluster_centers(cluster_centers.begin(), cluster_centers.end()); + if (unique_cluster_centers.size() != cluster_centers.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cluster centers contain duplicated RGB values, unique exported colors may be fewer than centers."; + } + + report(70, "Assigning cluster labels"); + if (cancelled()) { + return false; + } + + // Assign each face's color to the nearest cluster center + constexpr bool use_simple_cluster = true; // Complex algorithm is still being optimized; use simple assignment for now + if (use_simple_cluster) { + std::atomic done_cluster{0}; + std::atomic cancel_requested{false}; + const size_t total_cluster = color_mesh.indices.size(); + const size_t cluster_interval = std::max(total_cluster / 20, 1); + tbb::parallel_for(tbb::blocked_range(0, total_cluster), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + if (cancel_requested.load(std::memory_order_relaxed)) return; + auto& face_color = face_colors[fid]; + auto nearest_color_id = std::numeric_limits::max(); + bool success = calc_nearest_color_id(cluster_centers, face_color, nearest_color_id); + if (success == false) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: calc nearest color id failed."; + continue; + } + clustered_face_labels[fid] = nearest_color_id; + clustered_face_colors[fid] = cluster_centers[nearest_color_id]; + size_t cnt = done_cluster.fetch_add(1, std::memory_order_relaxed) + 1; + if (cnt % cluster_interval == 0) { + if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } + sub_report(static_cast(cnt * 100 / total_cluster), 70, 85, "Assigning cluster labels"); + } + } + }); + if (cancel_requested.load() || cancelled()) return false; + } else { + bool success = mesh_cluster(color_mesh, cluster_centers, clustered_face_colors, clustered_face_labels); + if (success == false) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: mesh cluster failed."; + return false; + } + } + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "cluster assignment")) { + return false; + } + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "cluster assignment"); + } + lap("Color clustering & labeling"); +#ifdef OUTPUT_TEST_RESULT + for (std::size_t i = 0; i < clustered_face_colors.size(); ++i) { + clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; + } + SaveToOFF("texture_to_color_3_cluster.off", color_mesh, clustered_face_colors); +#endif + + report(85, "Smoothing colors"); + if (cancelled()) { + return false; + } + + // Step 6: Post-process colors + SmoothParameters smooth_parameters; + smooth_parameters.smooth_weight = settings.smooth_weight; + if (!smooth_region(color_mesh, clustered_face_labels, smooth_parameters)) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: smooth region failed."; + return false; + } + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: smooth region success."; + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "color smoothing")) { + return false; + } + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "color smoothing"); + } + report(95, "Updating face colors"); + if (cancelled()) { + return false; + } + for (std::size_t i = 0; i < clustered_face_colors.size(); ++i) { + clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; + } + const std::set unique_exported_colors(clustered_face_colors.begin(), clustered_face_colors.end()); + if (unique_exported_colors.size() < cluster_centers.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: final exported unique colors (" << unique_exported_colors.size() + << ") are fewer than cluster centers (" << cluster_centers.size() + << "), likely due to duplicate centers or unsatisfied seed assignment."; + } +#ifdef OUTPUT_TEST_RESULT + SaveToOFF("texture_to_color_4_smooth.off", color_mesh, clustered_face_colors); +#endif + + face_colors = std::move(clustered_face_colors); + lap("Smoothing colors"); + double total_ms = std::chrono::duration(Clock::now() - t_total_start).count(); + BOOST_LOG_TRIVIAL(debug) << "[timing] TextureToColor total: " << total_ms << "ms" + << " faces=" << color_mesh.facets_count(); + report(100, "Completed"); + return true; +} + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TextureToColor.hpp b/src/libslic3r/TextureToColor/TextureToColor.hpp new file mode 100644 index 0000000000..f18cce5759 --- /dev/null +++ b/src/libslic3r/TextureToColor/TextureToColor.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include "Callbacks.hpp" +#include "TriMesh.hpp" +#include "opencv2/core.hpp" +#include +#include + +namespace Slic3r { namespace tex2color { + +enum class MeshRepairDecision { + Ask, + ImportWithoutRepair, + RepairAndImport +}; + +using MeshRepairCallback = std::function progress_callback, + std::function cancel_callback, + std::string* error_message)>; + +struct TextureToColorSettings { + std::size_t target_colors_num = 4; // 目标颜色数量, 为0时, 自适应计算; 否则计算指定数目的颜色聚类 + + double smooth_weight = 0.5; // 光顺权重, 范围[0, 1], 0表示不进行光顺, 1表示完全光顺 + + // 当超采样迭代次数大于0时, 进行指定迭代次数的超采样; 否则, 自适应超采样 + std::size_t oversampling_iters = 0; // 超采样迭代次数 + std::size_t oversampling_min_face_count = 10000; // 自适应采样: 当face_count小于oversampling_min_face_count时, 进行超采样 + std::size_t oversampling_max_face_count = 1000000; // 无论输入参数如何, 超采样后的面片数不能超过oversampling_max_face_count + + double max_color_distance = 25.0; // 自适应聚类允许的最大簇内半径(CIEDE2000 ΔE) + std::size_t max_cluster_k = 32; // 自适应聚类的最大颜色数量上限 + + MeshRepairDecision mesh_repair_decision = MeshRepairDecision::ImportWithoutRepair; + + // Set by TextureToColor when Ask is selected and mesh repair needs user confirmation. + bool* mesh_repair_decision_required = nullptr; + + MeshRepairCallback mesh_repair_callback; +}; + +/** + * @brief 将纹理贴图转换为网格面片颜色, 并通过聚类和光顺生成可用于多色打印的着色网格 + * + * 基于纹理网格的UV坐标对纹理图像进行采样, 计算每个面片的颜色, + * 然后对颜色进行聚类(K-Means或自适应)和区域光顺, 最终输出带颜色信息的网格 + * + * @param[in] texture_mesh 带有UV坐标的输入三角网格 + * @param[in] uv_coords 每个面片的UV坐标, 大小等于面片数, 每个面片有三个UV坐标 + * @param[in] texture 纹理图像 + * @param[out] color_mesh 输出的着色网格 + * @param[out] face_colors 输出的着色网格的面片颜色, 大小等于面片数, 颜色值为[R, G, B], 范围0~255 + * @param[in] settings 算法参数, 包括目标颜色数量、光顺权重等 + * @param[in] progress_callback 进度回调函数 + * @param[in] cancel_callback 取消回调函数 + * @return 成功返回true, 输入数据无效(空网格、无UV、空纹理等)返回false + */ +bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& uv_coords, const cv::Mat& texture, TriMesh& color_mesh, + std::vector>& face_colors, const TextureToColorSettings& settings = TextureToColorSettings(), + AlgoProgressCallback progress_callback = nullptr, AlgoCancelCallback cancel_callback = nullptr); + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TriMesh.hpp b/src/libslic3r/TextureToColor/TriMesh.hpp new file mode 100644 index 0000000000..d557ae1d2e --- /dev/null +++ b/src/libslic3r/TextureToColor/TriMesh.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include "Point.hpp" + +namespace Slic3r { namespace tex2color { + +using TriVertex = stl_vertex; +using TriVertices = std::vector; +using TriFace = stl_triangle_vertex_indices; +using TriFaces = std::vector; + +struct TriMesh : ::indexed_triangle_set { + TriMesh() = default; + TriMesh(const TriMesh&) = default; + TriMesh& operator=(const TriMesh&) = default; + TriMesh(TriMesh&&) = default; + TriMesh& operator=(TriMesh&&) = default; + TriMesh(const ::indexed_triangle_set& d) : ::indexed_triangle_set(d) {} + TriMesh(::indexed_triangle_set&& d) : ::indexed_triangle_set(std::move(d)) {} + TriMesh(std::vector indices_, + std::vector vertices_) + : ::indexed_triangle_set(std::move(indices_), std::move(vertices_)) {} + + std::size_t facets_count() const { return indices.size(); } +}; + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TriangleSelector.cpp b/src/libslic3r/TriangleSelector.cpp index 3b032cc57e..12f314a799 100644 --- a/src/libslic3r/TriangleSelector.cpp +++ b/src/libslic3r/TriangleSelector.cpp @@ -1983,6 +1983,20 @@ void TriangleSelector::seed_fill_unselect_all_triangles() triangle.unselect_by_seed_fill(); } +void TriangleSelector::shift_states_above(EnforcerBlockerType threshold, int delta) +{ + for (Triangle &triangle : m_triangles) { + if (triangle.is_split() || !triangle.valid()) + continue; + EnforcerBlockerType s = triangle.get_state(); + if (s >= threshold && s != EnforcerBlockerType::NONE) { + int new_val = (int)s + delta; + if (new_val >= 0) + triangle.set_state(EnforcerBlockerType(new_val)); + } + } +} + void TriangleSelector::seed_fill_apply_on_triangles(EnforcerBlockerType new_state) { for (Triangle &triangle : m_triangles) diff --git a/src/libslic3r/TriangleSelector.hpp b/src/libslic3r/TriangleSelector.hpp index 11517f5c6c..41d189cdd1 100644 --- a/src/libslic3r/TriangleSelector.hpp +++ b/src/libslic3r/TriangleSelector.hpp @@ -369,6 +369,9 @@ public: // For all triangles, remove the flag indicating that the triangle was selected by seed fill. void seed_fill_unselect_all_triangles(); + // Shift all triangle states >= threshold by delta (used when inserting filaments) + void shift_states_above(EnforcerBlockerType threshold, int delta); + // For all triangles selected by seed fill, set new EnforcerBlockerType and remove flag indicating that triangle was selected by seed fill. // The operation may merge split triangles if they are being assigned the same color. void seed_fill_apply_on_triangles(EnforcerBlockerType new_state); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 9174b044ec..b014b44bb2 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -353,6 +353,16 @@ set(SLIC3R_GUI_SOURCES GUI/Monitor.hpp GUI/MonitorPage.cpp GUI/MonitorPage.hpp + GUI/MixedFilamentDialog.cpp + GUI/MixedFilamentDialog.hpp + GUI/GradientCurveEditor.cpp + GUI/GradientCurveEditor.hpp + GUI/ColorDecomposeDialog.cpp + GUI/ColorDecomposeDialog.hpp + GUI/ColorDecomposeSupport.cpp + GUI/ColorDecomposeSupport.hpp + GUI/TextureImportDialog.cpp + GUI/TextureImportDialog.hpp GUI/Mouse3DController.cpp GUI/Mouse3DController.hpp GUI/MsgDialog.cpp diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp new file mode 100644 index 0000000000..3877d71e10 --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -0,0 +1,943 @@ +#include "ColorDecomposeDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include "wx/graphics.h" + +#include "I18N.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "format.hpp" +#include "Widgets/ComboBox.hpp" +#include "Widgets/DropDown.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/CheckBox.hpp" +#include "Widgets/Label.hpp" +#include "wxExtensions.hpp" +#include "ColorDecomposeSupport.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" + +namespace Slic3r { +namespace GUI { + +static const wxColour COLOR_BRAND("#00AE42"); +static const wxColour COLOR_BORDER_NORMAL("#EEEEEE"); +static const wxColour COLOR_BG_CARD("#F8F8F8"); +static const wxColour COLOR_LABEL_GREY("#ACACAC"); +static const wxColour COLOR_TEXT_DARK("#262E30"); +static const wxColour COLOR_DIVIDER("#EEEEEE"); + +// Standard CMYW base colors +static const wxColour CMYW_CYAN(0, 255, 255); +static const wxColour CMYW_MAGENTA(255, 0, 255); +static const wxColour CMYW_YELLOW(255, 255, 0); +static const wxColour CMYW_WHITE(255, 255, 255); + +// Standard RYBW base colors +static const wxColour RYBW_RED(255, 0, 0); +static const wxColour RYBW_YELLOW(255, 255, 0); +static const wxColour RYBW_BLUE(0, 0, 255); +static const wxColour RYBW_WHITE(255, 255, 255); + +static size_t mode_index(DecomposeMode mode) +{ + return static_cast(mode); +} + +static ColorDecomposeRgb wx_colour_to_recipe_rgb(const wxColour& color) +{ + return { + static_cast(color.Red()), + static_cast(color.Green()), + static_cast(color.Blue()) + }; +} + +static wxColour hex_to_wx_colour(const std::string& hex, const wxColour& fallback) +{ + wxColour color(hex); + return color.IsOk() ? color : fallback; +} + +static bool same_rgb(const wxColour& lhs, const wxColour& rhs) +{ + return lhs.Red() == rhs.Red() && lhs.Green() == rhs.Green() && lhs.Blue() == rhs.Blue(); +} + +static DecomposeBaseColor standard_base_color_from_key(const std::string& key) +{ + if (key == "Cyan") return DecomposeBaseColor::Cyan; + if (key == "Magenta") return DecomposeBaseColor::Magenta; + if (key == "Yellow") return DecomposeBaseColor::Yellow; + if (key == "White") return DecomposeBaseColor::White; + if (key == "Red") return DecomposeBaseColor::Red; + if (key == "Green") return DecomposeBaseColor::Green; + if (key == "Blue") return DecomposeBaseColor::Blue; + return DecomposeBaseColor::None; +} + +static wxColour pure_color_for_base(DecomposeBaseColor base) +{ + switch (base) { + case DecomposeBaseColor::Cyan: return CMYW_CYAN; + case DecomposeBaseColor::Magenta: return CMYW_MAGENTA; + case DecomposeBaseColor::Yellow: return CMYW_YELLOW; + case DecomposeBaseColor::White: return CMYW_WHITE; + case DecomposeBaseColor::Red: return RYBW_RED; + case DecomposeBaseColor::Blue: return RYBW_BLUE; + default: return *wxBLACK; + } +} + +static DecomposeBaseColor standard_base_color_for(DecomposeMode mode, const wxColour& color) +{ + if (mode == DecomposeMode::CMYW) { + if (same_rgb(color, CMYW_CYAN)) return DecomposeBaseColor::Cyan; + if (same_rgb(color, CMYW_MAGENTA)) return DecomposeBaseColor::Magenta; + if (same_rgb(color, CMYW_YELLOW)) return DecomposeBaseColor::Yellow; + if (same_rgb(color, CMYW_WHITE)) return DecomposeBaseColor::White; + } else if (mode == DecomposeMode::RYBW) { + if (same_rgb(color, RYBW_RED)) return DecomposeBaseColor::Red; + if (same_rgb(color, RYBW_YELLOW)) return DecomposeBaseColor::Yellow; + if (same_rgb(color, RYBW_BLUE)) return DecomposeBaseColor::Blue; + if (same_rgb(color, RYBW_WHITE)) return DecomposeBaseColor::White; + } + return DecomposeBaseColor::None; +} + +static ColorDecomposeResult to_dialog_result(const ColorDecomposeRecipeResult& recipe, + const wxColour& fallback) +{ + ColorDecomposeResult result; + result.mode = recipe.mode; + result.matched_color = hex_to_wx_colour(recipe.matched_color_hex, fallback); + for (const auto& comp_recipe : recipe.components) { + DecomposeComponent comp; + comp.colour = hex_to_wx_colour(comp_recipe.color_hex, fallback); + comp.ratio = comp_recipe.ratio; + comp.filament_index = static_cast(comp_recipe.filament_index); + comp.base_color = standard_base_color_from_key(comp_recipe.base_color); + if (comp.base_color == DecomposeBaseColor::None) + comp.base_color = standard_base_color_for(recipe.mode, comp.colour); + result.components.push_back(comp); + } + return result; +} + +static wxPanel* create_h_divider(wxWindow* parent, int fixed_width = -1) +{ + const int h = parent->FromDIP(1); + int w = fixed_width > 0 ? fixed_width : -1; + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(w, h)); + panel->SetMinSize(wxSize(w, h)); + if (fixed_width > 0) + panel->SetMaxSize(wxSize(fixed_width, h)); + panel->SetBackgroundColour(StateColor::darkModeColorFor(COLOR_DIVIDER)); + return panel; +} + +static wxStaticText* create_mode_group_label(wxWindow* parent, const wxString& text) +{ + auto* label = new wxStaticText(parent, wxID_ANY, text); + label->SetFont(Label::Body_11); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_GREY)); + return label; +} + +static void match_parent_bg(wxWindow* w, const wxColour& bg) +{ + w->SetBackgroundColour(bg); +} + +static bool material_type_matches(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return false; + return a == b || a == b + " Basic" || b == a + " Basic"; +} + + +ColorDecomposeDialog::ColorDecomposeDialog(wxWindow* parent, + int filament_idx, + const wxColour& target_color, + const std::vector& physical_colors, + const std::vector& filament_names, + const std::vector& filament_types, + size_t current_filament_count, + size_t max_filament_count, + std::vector physical_config_indices) + : DPIDialog(parent, wxID_ANY, _L("Decompose Color"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_filament_idx(filament_idx) + , m_target_color(target_color) + , m_physical_colors(physical_colors) + , m_filament_names(filament_names) + , m_filament_types(filament_types) + , m_current_filament_count(current_filament_count) + , m_max_filament_count(max_filament_count) + , m_physical_config_indices(std::move(physical_config_indices)) +{ + for (const auto& t : m_filament_types) { + if (std::find(m_project_types.begin(), m_project_types.end(), t) == m_project_types.end()) + m_project_types.push_back(t); + } + + if (m_filament_idx >= 0 && static_cast(m_filament_idx) < m_filament_types.size()) + m_preferred_type = m_filament_types[m_filament_idx]; + else if (!m_project_types.empty()) + m_preferred_type = m_project_types.front(); + + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); + // Restore target swatch after dark mode color remapping + if (m_target_swatch) + m_target_swatch->SetBackgroundColour(m_target_color); + + update_card_visibility(); + Fit(); + compute_decomposition(); + update_matched_color_display(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + (void)suggested_rect; + Fit(); + Refresh(); +} + +void ColorDecomposeDialog::build_ui() +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + + auto* main_sizer = new wxBoxSizer(wxVERTICAL); + + const int selector_side_margin = FromDIP(26); + const int selector_top_gap = FromDIP(22); + const int content_side_margin = FromDIP(30); + const int target_section_top_gap = FromDIP(18); + + main_sizer->AddSpacer(selector_top_gap); + main_sizer->Add(create_filament_selector(), 0, wxEXPAND | wxLEFT | wxRIGHT, selector_side_margin); + main_sizer->AddSpacer(target_section_top_gap); + main_sizer->Add(create_target_color_section(), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_h_divider(this), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_mode_selection_section(), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_button_panel(), 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, content_side_margin); + + SetSizer(main_sizer); + SetMinSize(wxSize(FromDIP(477), FromDIP(380))); + Fit(); + CenterOnParent(); +} + +wxBoxSizer* ColorDecomposeDialog::create_filament_selector() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + m_type_combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(-1, FromDIP(36)), 0, nullptr, wxCB_READONLY); + m_type_combo->SetFont(Label::Body_13); + + m_combo_item_types.clear(); + int default_sel = -1; + + // --- Group 1: Project filament list (deduplicated by type) --- + m_type_combo->Append(_L("Project Filament List"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + m_combo_item_types.push_back(std::string()); + + std::set seen_types; + for (size_t i = 0; i < m_filament_names.size(); ++i) { + const std::string& type = (i < m_filament_types.size()) ? m_filament_types[i] : "PLA"; + if (!seen_types.insert(type).second) + continue; + int idx = m_type_combo->Append(wxString::FromUTF8(m_filament_names[i])); + m_combo_item_types.push_back(type); + if (type == m_preferred_type && default_sel < 0) + default_sel = idx; + } + + // --- Group 2: Standard mode material recommendations --- + static const char* kStandardTypes[] = { + kDecomposePlaBasicType + }; + + m_type_combo->Append(_L("Standard Mode Recommendations"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + m_combo_item_types.push_back(std::string()); + + for (size_t s = 0; s < sizeof(kStandardTypes) / sizeof(kStandardTypes[0]); ++s) { + // Always show standard recommendations, even if the same type already + // appears in the project filament list above. + const std::string label = std::string(kDecomposeBambuPresetPrefix) + kStandardTypes[s]; + int idx = m_type_combo->Append(wxString::FromUTF8(label)); + m_combo_item_types.push_back(kStandardTypes[s]); + if (kStandardTypes[s] == m_preferred_type && default_sel < 0) + default_sel = idx; + } + + if (default_sel < 0) { + for (int i = 0; i < static_cast(m_combo_item_types.size()); ++i) { + if (!m_combo_item_types[i].empty()) { + default_sel = i; + break; + } + } + } + + if (default_sel >= 0) { + m_type_combo->SetSelection(default_sel); + if (!m_combo_item_types[default_sel].empty()) + m_preferred_type = m_combo_item_types[default_sel]; + } + + m_type_combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& evt) { + evt.StopPropagation(); + int sel = m_type_combo->GetSelection(); + if (sel >= 0 && static_cast(sel) < m_combo_item_types.size() + && !m_combo_item_types[sel].empty()) { + m_preferred_type = m_combo_item_types[sel]; + } + update_card_visibility(); + compute_decomposition(); + update_matched_color_display(); + update_ok_button_state(); + }); + + sizer->Add(m_type_combo, 1, wxEXPAND); + return sizer; +} + +static wxPanel* create_color_swatch(wxWindow* parent, const wxColour& color, int size) +{ + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(size, size)); + panel->SetBackgroundColour(color); + panel->SetMinSize(wxSize(size, size)); + return panel; +} + +wxBoxSizer* ColorDecomposeDialog::create_target_color_section() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + auto* label = new wxStaticText(this, wxID_ANY, _L("Target Color")); + label->SetFont(Label::Head_14); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(19)); + + m_target_swatch = create_color_swatch(this, m_target_color, FromDIP(28)); + sizer->Add(m_target_swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_target_rgb_text = new wxStaticText(this, wxID_ANY, + wxString::Format("RGB: %d, %d, %d", m_target_color.Red(), m_target_color.Green(), m_target_color.Blue())); + m_target_rgb_text->SetFont(Label::Body_13); + m_target_rgb_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(m_target_rgb_text, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + auto* arrow_text = new wxStaticText(this, wxID_ANY, wxString::FromUTF8("\xe2\x86\x92")); + arrow_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(arrow_text, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_matched_swatch = create_color_swatch(this, m_target_color, FromDIP(28)); + sizer->Add(m_matched_swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_matched_rgb_text = new wxStaticText(this, wxID_ANY, + wxString::Format("RGB: %d, %d, %d", m_target_color.Red(), m_target_color.Green(), m_target_color.Blue())); + m_matched_rgb_text->SetFont(Label::Head_13); + m_matched_rgb_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(m_matched_rgb_text, 0, wxALIGN_CENTER_VERTICAL); + + return sizer; +} + +wxPanel* ColorDecomposeDialog::create_mode_card(wxWindow* parent, DecomposeMode mode, + const wxString& title) +{ + const int pad = FromDIP(12); + + auto* card = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + card->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto* card_sizer = new wxBoxSizer(wxVERTICAL); + + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* title_label = new wxStaticText(card, wxID_ANY, title); + title_label->SetFont(Label::Body_14); + title_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#909090"))); + match_parent_bg(title_label, StateColor::darkModeColorFor(COLOR_BG_CARD)); + title_sizer->Add(title_label, 1, wxALIGN_CENTER_VERTICAL); + + auto* chk = new ::CheckBox(card); + chk->SetValue(mode == m_selected_mode); + match_parent_bg(chk, StateColor::darkModeColorFor(COLOR_BG_CARD)); + switch (mode) { + case DecomposeMode::MaterialList: m_chk_material_list = chk; break; + case DecomposeMode::CMYW: m_chk_cmyw = chk; break; + case DecomposeMode::RYBW: m_chk_rybw = chk; break; + } + chk->Bind(wxEVT_TOGGLEBUTTON, [this, mode](wxCommandEvent& e) { + select_mode(mode); + e.Skip(); // let CheckBox::update() re-sync its bitmap to GetValue() + }); + title_sizer->Add(chk, 0, wxALIGN_CENTER_VERTICAL); + + card_sizer->Add(title_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, pad); + + card_sizer->Add(create_h_divider(card), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + + auto* colors_sizer = new wxBoxSizer(wxHORIZONTAL); + card_sizer->Add(colors_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + + auto& controls = m_mode_cards[mode_index(mode)]; + controls.card = card; + controls.components_sizer = colors_sizer; + + card->SetSizer(card_sizer); + card->SetMinSize(wxSize(FromDIP(128), FromDIP(111))); + card->SetMaxSize(wxSize(FromDIP(128), FromDIP(111))); + + card->Bind(wxEVT_PAINT, [this, card, mode](wxPaintEvent&) { + wxBufferedPaintDC dc(card); + wxSize sz = card->GetClientSize(); + dc.SetBackground(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.Clear(); + + bool selected = (m_selected_mode == mode); + wxColour border_col = selected + ? StateColor::darkModeColorFor(COLOR_BRAND) + : StateColor::darkModeColorFor(COLOR_BORDER_NORMAL); + const int border_width = FromDIP(selected ? 2 : 1); + const double inset = border_width / 2.0; + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (gc) { + gc->SetPen(wxPen(border_col, border_width)); + gc->SetBrush(wxBrush(StateColor::darkModeColorFor(COLOR_BG_CARD))); + gc->DrawRoundedRectangle(inset, inset, sz.x - 2 * inset, sz.y - 2 * inset, FromDIP(8)); + } else { + const int fallback_inset = (border_width + 1) / 2; + dc.SetPen(wxPen(border_col, border_width)); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(COLOR_BG_CARD))); + dc.DrawRoundedRectangle(fallback_inset, fallback_inset, sz.x - 2 * fallback_inset, sz.y - 2 * fallback_inset, FromDIP(8)); + } + }); + + std::function bind_click; + bind_click = [this, mode, chk, &bind_click](wxWindow* w) { + if (w == chk || dynamic_cast<::CheckBox*>(w)) + return; + w->Bind(wxEVT_LEFT_UP, [this, mode](wxMouseEvent&) { + select_mode(mode); + }); + w->SetCursor(wxCursor(wxCURSOR_HAND)); + for (auto* child : w->GetChildren()) + bind_click(child); + }; + bind_click(card); + + return card; +} + +wxBoxSizer* ColorDecomposeDialog::create_mode_selection_section() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + auto* section_label = new wxStaticText(this, wxID_ANY, _L("Select Color Decomposition")); + section_label->SetFont(Label::Head_14); + section_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(section_label, 0, wxBOTTOM, FromDIP(4)); + + auto* modes_sizer = new wxBoxSizer(wxHORIZONTAL); + + // --- Arbitrary mode column (wrapped in a panel so the whole column hides together) --- + m_arb_column_panel = new wxPanel(this, wxID_ANY); + m_arb_column_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + auto* arb_col = new wxBoxSizer(wxVERTICAL); + { + auto* arb_header_sizer = new wxBoxSizer(wxHORIZONTAL); + arb_header_sizer->Add(create_mode_group_label(m_arb_column_panel, _L("Arbitrary Mode")), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + arb_header_sizer->Add(create_h_divider(m_arb_column_panel, FromDIP(88)), 0, wxALIGN_CENTER_VERTICAL); + arb_col->Add(arb_header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + m_card_material_list = create_mode_card(m_arb_column_panel, DecomposeMode::MaterialList, + _L("Material List")); + arb_col->Add(m_card_material_list, 0, wxEXPAND); + } + m_arb_column_panel->SetSizer(arb_col); + modes_sizer->Add(m_arb_column_panel, 0, wxEXPAND | wxRIGHT, FromDIP(16)); + + // --- Standard mode column --- + auto* std_col = new wxBoxSizer(wxVERTICAL); + { + auto* std_header_sizer = new wxBoxSizer(wxHORIZONTAL); + std_header_sizer->Add(create_mode_group_label(this, _L("Standard Mode")), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + std_header_sizer->Add(create_h_divider(this), 1, wxALIGN_CENTER_VERTICAL); + std_col->Add(std_header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + auto* cards_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_card_cmyw = create_mode_card(this, DecomposeMode::CMYW, "CMYW"); + cards_sizer->Add(m_card_cmyw, 0, wxRIGHT, FromDIP(12)); + + m_card_rybw = create_mode_card(this, DecomposeMode::RYBW, "RYBW"); + cards_sizer->Add(m_card_rybw, 0); + + std_col->Add(cards_sizer, 0, wxEXPAND); + } + modes_sizer->Add(std_col, 0, wxEXPAND); + + sizer->Add(modes_sizer, 0, wxEXPAND); + + m_no_card_hint = new wxStaticText(this, wxID_ANY, + _L("At least two filaments of the same material type are required for decomposition")); + m_no_card_hint->SetFont(Label::Body_13); + m_no_card_hint->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#909090"))); + m_no_card_hint->Wrap(FromDIP(400)); + m_no_card_hint->Hide(); + sizer->Add(m_no_card_hint, 0, wxTOP, FromDIP(8)); + + m_limit_warning_panel = new wxPanel(this, wxID_ANY); + m_limit_warning_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + auto* warning_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* warn_bmp = new wxStaticBitmap(m_limit_warning_panel, wxID_ANY, + create_scaled_bitmap("obj_warning", m_limit_warning_panel, 16), + wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); + m_limit_warning_text = new wxStaticText(m_limit_warning_panel, wxID_ANY, wxEmptyString); + m_limit_warning_text->SetFont(Label::Body_13); + m_limit_warning_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D32F2F"))); + m_limit_warning_text->Wrap(FromDIP(400)); + warning_sizer->Add(warn_bmp, 0, wxALIGN_TOP | wxRIGHT, FromDIP(6)); + warning_sizer->Add(m_limit_warning_text, 1, wxEXPAND); + m_limit_warning_panel->SetSizer(warning_sizer); + m_limit_warning_panel->Hide(); + sizer->Add(m_limit_warning_panel, 0, wxEXPAND | wxTOP, FromDIP(8)); + + return sizer; +} + +wxBoxSizer* ColorDecomposeDialog::create_button_panel() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + sizer->AddStretchSpacer(); + + m_btn_cancel = new Button(this, _L("Cancel")); + m_btn_cancel->SetBackgroundColor(StateColor::darkModeColorFor(*wxWHITE)); + m_btn_cancel->SetBorderColor(StateColor::darkModeColorFor(wxColour("#CECECE"))); + m_btn_cancel->SetTextColor(StateColor::darkModeColorFor(wxColour("#262E30"))); + m_btn_cancel->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + m_btn_ok = new Button(this, _L("OK")); + m_btn_ok->SetBackgroundColor(StateColor( + std::make_pair(wxColour("#C2C2C2"), (int) StateColor::Disabled), + std::make_pair(wxColour("#00AE42"), (int) StateColor::Normal))); + m_btn_ok->SetBorderColor(StateColor( + std::make_pair(wxColour("#C2C2C2"), (int) StateColor::Disabled), + std::make_pair(wxColour("#00AE42"), (int) StateColor::Normal))); + m_btn_ok->SetTextColor(StateColor( + std::make_pair(*wxWHITE, (int) StateColor::Disabled), + std::make_pair(*wxWHITE, (int) StateColor::Normal))); + m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + EndModal(wxID_OK); + }); + + sizer->Add(m_btn_cancel, 0, wxRIGHT, FromDIP(12)); + sizer->Add(m_btn_ok, 0); + + return sizer; +} + +void ColorDecomposeDialog::select_mode(DecomposeMode mode) +{ + m_selected_mode = mode; + m_result = m_mode_results[mode_index(mode)]; + update_card_styles(); + update_matched_color_display(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_card_styles() +{ + if (m_card_material_list) m_card_material_list->Refresh(); + if (m_card_cmyw) m_card_cmyw->Refresh(); + if (m_card_rybw) m_card_rybw->Refresh(); + + if (m_chk_material_list) + m_chk_material_list->SetValue(m_selected_mode == DecomposeMode::MaterialList); + if (m_chk_cmyw) + m_chk_cmyw->SetValue(m_selected_mode == DecomposeMode::CMYW); + if (m_chk_rybw) + m_chk_rybw->SetValue(m_selected_mode == DecomposeMode::RYBW); +} + +void ColorDecomposeDialog::update_card_visibility() +{ + // Count physical filaments of the same type (excluding the source filament) + int same_type_count = 0; + for (size_t i = 0; i < m_filament_types.size(); ++i) { + if (static_cast(i) == m_filament_idx) + continue; + if (material_type_matches(m_filament_types[i], m_preferred_type)) + ++same_type_count; + } + + bool show_arb = (same_type_count >= 2); + bool show_cmyw = (m_preferred_type == kDecomposePlaBasicType); + bool show_rybw = (m_preferred_type == kDecomposePlaBasicType); + + if (m_arb_column_panel) m_arb_column_panel->Show(show_arb); + if (m_card_material_list) m_card_material_list->Show(show_arb); + if (m_card_cmyw) m_card_cmyw->Show(show_cmyw); + if (m_card_rybw) m_card_rybw->Show(show_rybw); + + bool any_visible = show_arb || show_cmyw || show_rybw; + if (m_no_card_hint) + m_no_card_hint->Show(!any_visible); + + // Auto-select a visible mode when current selection becomes hidden + if (any_visible) { + bool cur_visible = false; + if (m_selected_mode == DecomposeMode::MaterialList && show_arb) cur_visible = true; + if (m_selected_mode == DecomposeMode::CMYW && show_cmyw) cur_visible = true; + if (m_selected_mode == DecomposeMode::RYBW && show_rybw) cur_visible = true; + if (!cur_visible) { + if (show_arb) select_mode(DecomposeMode::MaterialList); + else if (show_cmyw) select_mode(DecomposeMode::CMYW); + else select_mode(DecomposeMode::RYBW); + } + } + + Layout(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_filament_limit_warning() +{ + if (!m_limit_warning_panel || !m_limit_warning_text) + return; + + size_t missing_new = 0; + if (m_missing_calculator) { + missing_new = m_missing_calculator(m_result); + } else { + const size_t source_physical_idx = m_filament_idx >= 0 ? static_cast(m_filament_idx) : size_t(-1); + const std::vector* indices = + m_physical_config_indices.empty() ? nullptr : &m_physical_config_indices; + missing_new = count_decompose_new_physical_filaments( + m_result, m_physical_colors, m_filament_types, source_physical_idx, indices); + } + // A result with fewer than 2 components (e.g. target color is already a + // standard base color shown as "100%") creates no mixed filament and no new + // physical filament, so it can never exceed the limit. + const bool creates_mixed = m_result.components.size() >= 2; + // +1 for the mixed filament slot that will be created after decomposition. + const size_t needed = m_current_filament_count + missing_new + 1; + const bool blocked = creates_mixed && needed > m_max_filament_count; + + const bool was_shown = m_limit_warning_panel->IsShown(); + + if (!blocked) { + if (was_shown) { + m_limit_warning_panel->Hide(); + Layout(); + Fit(); + CenterOnParent(); + } + return; + } + + wxString mode_name; + switch (m_selected_mode) { + case DecomposeMode::CMYW: mode_name = "CMYW"; break; + case DecomposeMode::RYBW: mode_name = "RYBW"; break; + case DecomposeMode::MaterialList: mode_name = _L("Material List"); break; + } + + const wxString warning_text = format_wxstr( + _L("The material list supports at most %1% colors. After %2% decomposition, the material count would exceed %1%. Please delete unused filaments on the main screen before decomposing."), + m_max_filament_count, mode_name); + + // Show first so the panel is laid out and the text control gets its real + // width, then wrap to that width so the paragraph fills the content area. + m_limit_warning_panel->Show(); + Layout(); + const int avail = m_limit_warning_text->GetClientSize().x; + m_limit_warning_text->SetLabel(warning_text); + if (avail > FromDIP(50)) + m_limit_warning_text->Wrap(avail); + + Layout(); + // Only resize/recenter when the warning panel actually toggled from hidden + // to shown. While already visible, switching modes must not re-Fit/recenter + // the dialog, which would make it jump on every card switch. + if (!was_shown) { + Fit(); + CenterOnParent(); + } +} + +void ColorDecomposeDialog::set_missing_physical_calculator(std::function fn) +{ + m_missing_calculator = std::move(fn); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_ok_button_state() +{ + if (!m_btn_ok) return; + update_filament_limit_warning(); + bool any_card_visible = (m_card_material_list && m_card_material_list->IsShown()) + || (m_card_cmyw && m_card_cmyw->IsShown()) + || (m_card_rybw && m_card_rybw->IsShown()); + const bool blocked = m_limit_warning_panel && m_limit_warning_panel->IsShown(); + m_btn_ok->Enable(any_card_visible && !blocked); + Layout(); +} + +void ColorDecomposeDialog::update_mode_card_content(DecomposeMode mode) +{ + auto& controls = m_mode_cards[mode_index(mode)]; + auto* sizer = controls.components_sizer; + auto* card = controls.card; + if (!sizer || !card) + return; + + sizer->Clear(true); + const auto& components = m_mode_results[mode_index(mode)].components; + const size_t count = components.size(); + if (count == 0) { + card->Layout(); + card->Refresh(); + return; + } + + const int swatch_sz = FromDIP(24); + const int plus_gap = FromDIP(24); + const wxFont& ratio_font = Label::Body_13; + auto bind_select = [this, mode](wxWindow* w) { + w->Bind(wxEVT_LEFT_UP, [this, mode](wxMouseEvent&) { + select_mode(mode); + }); + w->SetCursor(wxCursor(wxCURSOR_HAND)); + }; + + for (size_t i = 0; i < count; ++i) { + auto* col = new wxBoxSizer(wxVERTICAL); + auto* swatch = create_color_swatch(card, components[i].colour, swatch_sz); + bind_select(swatch); + col->Add(swatch, 0, wxALIGN_CENTER_HORIZONTAL); + auto* ratio_text = new wxStaticText(card, wxID_ANY, wxString::Format("%d%%", components[i].ratio)); + ratio_text->SetFont(ratio_font); + ratio_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + match_parent_bg(ratio_text, StateColor::darkModeColorFor(COLOR_BG_CARD)); + bind_select(ratio_text); + col->Add(ratio_text, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(4)); + sizer->Add(col, 0, wxALIGN_TOP); + + if (i + 1 < count) { + sizer->AddStretchSpacer(); + auto* plus_panel = new wxPanel(card, wxID_ANY, wxDefaultPosition, wxSize(plus_gap, swatch_sz)); + plus_panel->SetMinSize(wxSize(plus_gap, swatch_sz)); + plus_panel->SetMaxSize(wxSize(plus_gap, swatch_sz)); + plus_panel->SetBackgroundColour(StateColor::darkModeColorFor(COLOR_BG_CARD)); + auto* plus_sizer = new wxBoxSizer(wxVERTICAL); + auto* plus_label = new wxStaticText(plus_panel, wxID_ANY, "+"); + plus_label->SetFont(Label::Body_13); + plus_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + match_parent_bg(plus_label, StateColor::darkModeColorFor(COLOR_BG_CARD)); + bind_select(plus_panel); + bind_select(plus_label); + plus_sizer->AddStretchSpacer(); + plus_sizer->Add(plus_label, 0, wxALIGN_CENTER_HORIZONTAL); + plus_sizer->AddStretchSpacer(); + plus_panel->SetSizer(plus_sizer); + sizer->Add(plus_panel, 0, wxALIGN_TOP); + sizer->AddStretchSpacer(); + } + } + + const int card_width = FromDIP(128 + (count > 2 ? static_cast(count - 2) * 31 : 0)); + card->SetMinSize(wxSize(card_width, FromDIP(111))); + card->SetMaxSize(wxSize(card_width, FromDIP(111))); + + card->Layout(); + card->Refresh(); +} + +void ColorDecomposeDialog::update_mode_card_contents() +{ + update_mode_card_content(DecomposeMode::MaterialList); + update_mode_card_content(DecomposeMode::CMYW); + update_mode_card_content(DecomposeMode::RYBW); + Layout(); + Fit(); +} + +void ColorDecomposeDialog::update_matched_color_display() +{ + if (!m_result.matched_color.IsOk()) + m_result.matched_color = m_target_color; + + if (m_matched_swatch) { + m_matched_swatch->SetBackgroundColour(m_result.matched_color); + m_matched_swatch->Refresh(); + } + if (m_matched_rgb_text) { + m_matched_rgb_text->SetLabel(wxString::Format("RGB: %d, %d, %d", + m_result.matched_color.Red(), m_result.matched_color.Green(), m_result.matched_color.Blue())); + } +} + +bool ColorDecomposeDialog::try_build_single_base_result(DecomposeMode mode, ColorDecomposeResult& out) const +{ + // Gate by preferred type, matching card visibility: CMYW and RYBW only for PLA Basic. + if (mode == DecomposeMode::CMYW || mode == DecomposeMode::RYBW) { + if (m_preferred_type != kDecomposePlaBasicType) + return false; + } else { + return false; + } + + static const DecomposeBaseColor cmyw_bases[] = { + DecomposeBaseColor::Cyan, DecomposeBaseColor::Magenta, + DecomposeBaseColor::Yellow, DecomposeBaseColor::White + }; + static const DecomposeBaseColor rybw_bases[] = { + DecomposeBaseColor::Red, DecomposeBaseColor::Yellow, + DecomposeBaseColor::Blue, DecomposeBaseColor::White + }; + const DecomposeBaseColor* bases = (mode == DecomposeMode::CMYW) ? cmyw_bases : rybw_bases; + const size_t base_count = (mode == DecomposeMode::CMYW) + ? sizeof(cmyw_bases) / sizeof(cmyw_bases[0]) + : sizeof(rybw_bases) / sizeof(rybw_bases[0]); + + const std::string target_hex = decompose_normalize_color_hex( + m_target_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + + for (size_t i = 0; i < base_count; ++i) { + const DecomposeBaseColor base = bases[i]; + DecomposeOfficialComponent official = + lookup_decompose_official_component(m_preferred_type, base, pure_color_for_base(base)); + if (decompose_normalize_color_hex(official.color_hex) != target_hex) + continue; + + out = ColorDecomposeResult{}; + out.mode = mode; + out.matched_color = hex_to_wx_colour(official.color_hex, m_target_color); + DecomposeComponent comp; + comp.colour = out.matched_color; + comp.ratio = 100; + comp.filament_index = -1; + comp.base_color = base; + out.components.push_back(comp); + return true; + } + return false; +} + +void ColorDecomposeDialog::compute_decomposition() +{ + auto fallback_result = [this](DecomposeMode mode, const std::vector& components) { + ColorDecomposeResult result; + result.mode = mode; + result.components = components; + int total = 0; + double r = 0.0, g = 0.0, b = 0.0; + for (const auto& comp : result.components) + total += comp.ratio; + if (total <= 0) + total = 100; + for (const auto& comp : result.components) { + const double w = static_cast(comp.ratio) / total; + r += comp.colour.Red() * w; + g += comp.colour.Green() * w; + b += comp.colour.Blue() * w; + } + result.matched_color = result.components.empty() + ? m_target_color + : wxColour(static_cast(std::clamp(r, 0.0, 255.0)), + static_cast(std::clamp(g, 0.0, 255.0)), + static_cast(std::clamp(b, 0.0, 255.0))); + return result; + }; + + std::vector physical_filaments; + physical_filaments.reserve(m_physical_colors.size()); + for (size_t i = 0; i < m_physical_colors.size(); ++i) { + if (m_filament_idx >= 0 && i == static_cast(m_filament_idx)) + continue; + ColorDecomposePhysicalFilament filament; + filament.color_hex = m_physical_colors[i]; + filament.name = i < m_filament_names.size() ? m_filament_names[i] : ""; + filament.type = i < m_filament_types.size() ? m_filament_types[i] : ""; + filament.filament_index = static_cast(i + 1); + physical_filaments.push_back(std::move(filament)); + } + + const ColorDecomposeRgb target_rgb = wx_colour_to_recipe_rgb(m_target_color); + + auto material_recipe = recommend_from_physical_filaments(target_rgb, physical_filaments, m_preferred_type); + if (material_recipe.valid) { + m_mode_results[mode_index(DecomposeMode::MaterialList)] = + to_dialog_result(material_recipe, m_target_color); + } else { + std::vector components; + for (size_t i = 0; i < std::min(2, physical_filaments.size()); ++i) { + DecomposeComponent comp; + comp.colour = wxColour(physical_filaments[i].color_hex); + comp.ratio = 50; + comp.filament_index = static_cast(physical_filaments[i].filament_index); + components.push_back(comp); + } + if (components.empty()) { + components.push_back({m_target_color, 100, -1}); + } else if (components.size() == 1) { + components.front().ratio = 100; + } + m_mode_results[mode_index(DecomposeMode::MaterialList)] = + fallback_result(DecomposeMode::MaterialList, components); + } + + ColorDecomposeResult single_base; + if (try_build_single_base_result(DecomposeMode::CMYW, single_base)) { + m_mode_results[mode_index(DecomposeMode::CMYW)] = single_base; + } else { + auto cmyw_recipe = lookup_standard_recipe(target_rgb, ColorDecomposeRecipeMode::CMYW, m_preferred_type); + m_mode_results[mode_index(DecomposeMode::CMYW)] = cmyw_recipe.valid + ? to_dialog_result(cmyw_recipe, m_target_color) + : fallback_result(DecomposeMode::CMYW, { + {CMYW_YELLOW, 50, -1, DecomposeBaseColor::Yellow}, + {CMYW_CYAN, 50, -1, DecomposeBaseColor::Cyan} + }); + } + + if (try_build_single_base_result(DecomposeMode::RYBW, single_base)) { + m_mode_results[mode_index(DecomposeMode::RYBW)] = single_base; + } else { + auto rybw_recipe = lookup_standard_recipe(target_rgb, ColorDecomposeRecipeMode::RYBW, m_preferred_type); + m_mode_results[mode_index(DecomposeMode::RYBW)] = rybw_recipe.valid + ? to_dialog_result(rybw_recipe, m_target_color) + : fallback_result(DecomposeMode::RYBW, { + {RYBW_YELLOW, 50, -1, DecomposeBaseColor::Yellow}, + {RYBW_BLUE, 50, -1, DecomposeBaseColor::Blue} + }); + } + + m_result = m_mode_results[mode_index(m_selected_mode)]; + update_mode_card_contents(); + update_ok_button_state(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ColorDecomposeDialog.hpp b/src/slic3r/GUI/ColorDecomposeDialog.hpp new file mode 100644 index 0000000000..419dfe8cea --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeDialog.hpp @@ -0,0 +1,152 @@ +#ifndef slic3r_ColorDecomposeDialog_hpp_ +#define slic3r_ColorDecomposeDialog_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GUI_Utils.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" + +class Button; +class CheckBox; +class ComboBox; + +namespace Slic3r { +namespace GUI { + +using DecomposeMode = ColorDecomposeRecipeMode; + +enum class DecomposeBaseColor { + None, + Cyan, + Magenta, + Yellow, + White, + Red, + Green, + Blue +}; + +struct DecomposeComponent { + wxColour colour; + int ratio{50}; // percentage + int filament_index{-1}; // 1-based physical filament index, -1 if standard base color + DecomposeBaseColor base_color{DecomposeBaseColor::None}; +}; + +struct ColorDecomposeResult { + DecomposeMode mode{DecomposeMode::MaterialList}; + wxColour matched_color; + std::vector components; +}; + +class ColorDecomposeDialog : public DPIDialog +{ +public: + ColorDecomposeDialog(wxWindow* parent, + int filament_idx, + const wxColour& target_color, + const std::vector& physical_colors, + const std::vector& filament_names, + const std::vector& filament_types, + size_t current_filament_count = 0, + size_t max_filament_count = 32, + std::vector physical_config_indices = {}); + + ColorDecomposeResult get_result() const { return m_result; } + + // Override the "new physical filaments" count used by the filament-limit + // warning. The Texture import path supplies its own calculator so the + // pre-check shares the exact reuse rule as its write-back (existing + + // virtual physical filaments), instead of the project-config based default + // that cannot see not-yet-committed virtual base colors. + void set_missing_physical_calculator(std::function fn); + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void build_ui(); + wxBoxSizer* create_filament_selector(); + wxBoxSizer* create_target_color_section(); + wxBoxSizer* create_mode_selection_section(); + wxPanel* create_mode_card(wxWindow* parent, DecomposeMode mode, const wxString& title); + wxBoxSizer* create_button_panel(); + + void select_mode(DecomposeMode mode); + void update_card_styles(); + void update_card_visibility(); + void update_mode_card_content(DecomposeMode mode); + void update_mode_card_contents(); + void update_matched_color_display(); + void update_ok_button_state(); + void update_filament_limit_warning(); + + void compute_decomposition(); + + // When the target color is exactly one of the standard base colors for the + // preferred type, the standard card should show that base at 100% instead of + // a mix. PLA Basic covers CMYW and RYBW. + bool try_build_single_base_result(DecomposeMode mode, ColorDecomposeResult& out) const; + + struct ModeCardControls { + wxPanel* card{nullptr}; + wxBoxSizer* components_sizer{nullptr}; + }; + + ColorDecomposeResult m_result; + std::array m_mode_results; + std::array m_mode_cards; + int m_filament_idx{-1}; + wxColour m_target_color; + std::vector m_physical_colors; + std::vector m_filament_names; + std::vector m_filament_types; + std::vector m_project_types; + std::string m_preferred_type; + // Dropdown selectable item index -> material type string + std::vector m_combo_item_types; + size_t m_current_filament_count{0}; + size_t m_max_filament_count{32}; + std::vector m_physical_config_indices; + std::function m_missing_calculator; + + // UI controls + ComboBox* m_type_combo{nullptr}; + wxPanel* m_target_swatch{nullptr}; + wxStaticText* m_target_rgb_text{nullptr}; + wxPanel* m_matched_swatch{nullptr}; + wxStaticText* m_matched_rgb_text{nullptr}; + + // Mode cards + wxPanel* m_card_material_list{nullptr}; + wxPanel* m_card_cmyw{nullptr}; + wxPanel* m_card_rybw{nullptr}; + wxPanel* m_arb_column_panel{nullptr}; + CheckBox* m_chk_material_list{nullptr}; + CheckBox* m_chk_cmyw{nullptr}; + CheckBox* m_chk_rybw{nullptr}; + DecomposeMode m_selected_mode{DecomposeMode::MaterialList}; + + // Hint shown when no mode card is visible + wxStaticText* m_no_card_hint{nullptr}; + + // Warning shown when decomposition would exceed filament limit + wxPanel* m_limit_warning_panel{nullptr}; + wxStaticText* m_limit_warning_text{nullptr}; + + Button* m_btn_ok{nullptr}; + Button* m_btn_cancel{nullptr}; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_ColorDecomposeDialog_hpp_ diff --git a/src/slic3r/GUI/ColorDecomposeSupport.cpp b/src/slic3r/GUI/ColorDecomposeSupport.cpp new file mode 100644 index 0000000000..e8fb4c9082 --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeSupport.cpp @@ -0,0 +1,386 @@ +#include "ColorDecomposeSupport.hpp" +#include "MixedFilamentDialog.hpp" +#include "GUI_App.hpp" +#include "MsgDialog.hpp" +#include "I18N.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/Utils.hpp" + +#include "nlohmann/json.hpp" + +#include +#include +#include + +using json = nlohmann::json; + +namespace Slic3r { namespace GUI { + +std::string decompose_normalize_color_hex(std::string color) +{ + if (color.size() >= 7) + color = color.substr(0, 7); + std::transform(color.begin(), color.end(), color.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + return color; +} + +const char* decompose_base_color_en(DecomposeBaseColor color) +{ + switch (color) { + case DecomposeBaseColor::Cyan: return "Cyan"; + case DecomposeBaseColor::Magenta: return "Magenta"; + case DecomposeBaseColor::Yellow: return "Yellow"; + case DecomposeBaseColor::White: return "White"; + case DecomposeBaseColor::Red: return "Red"; + case DecomposeBaseColor::Green: return "Green"; + case DecomposeBaseColor::Blue: return "Blue"; + default: return ""; + } +} + +wxString decompose_base_color_display(DecomposeBaseColor color) +{ + switch (color) { + case DecomposeBaseColor::Cyan: return _L("Cyan"); + case DecomposeBaseColor::Magenta: return _L("Magenta"); + case DecomposeBaseColor::Yellow: return _L("Yellow"); + case DecomposeBaseColor::White: return _L("White"); + case DecomposeBaseColor::Red: return _L("Red"); + case DecomposeBaseColor::Green: return _L("Green"); + case DecomposeBaseColor::Blue: return _L("Blue"); + default: return wxString(); + } +} + +std::string decompose_basic_type_from_source(size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_types) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + if (auto* filament_id_opt = project_config.option("filament_id")) { + if (source_config_idx < filament_id_opt->values.size()) { + const std::string& filament_id = filament_id_opt->values[source_config_idx]; + if (filament_id == kDecomposePetgFilamentId) + return kDecomposePetgBasicType; + if (filament_id == kDecomposePlaFilamentId) + return kDecomposePlaBasicType; + } + } + + if (source_physical_idx < physical_types.size()) { + const std::string& type = physical_types[source_physical_idx]; + if (type == kDecomposePetgShortType || type == kDecomposePetgBasicType) + return kDecomposePetgBasicType; + if (type == kDecomposePlaShortType || type == kDecomposePlaBasicType) + return kDecomposePlaBasicType; + } + return kDecomposePlaBasicType; +} + +std::string decompose_basic_filament_id(const std::string& basic_type) +{ + if (basic_type == kDecomposePetgBasicType) + return kDecomposePetgFilamentId; + return kDecomposePlaFilamentId; +} + +void set_created_standard_component_metadata(size_t config_idx, const DecomposeOfficialComponent& component) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + if (!component.filament_id.empty()) { + if (auto* filament_id_opt = project_config.option("filament_id")) { + while (filament_id_opt->values.size() <= config_idx) + filament_id_opt->values.push_back(""); + filament_id_opt->values[config_idx] = component.filament_id; + } + } + + const std::string type = component.filament_id == kDecomposePetgFilamentId ? kDecomposePetgShortType : + component.filament_id == kDecomposePlaFilamentId ? kDecomposePlaShortType : ""; + if (!type.empty()) { + if (auto* type_opt = project_config.option("filament_type")) { + while (type_opt->values.size() <= config_idx) + type_opt->values.push_back(""); + type_opt->values[config_idx] = type; + } + } +} + +DecomposeOfficialComponent lookup_decompose_official_component( + const std::string& basic_type, + DecomposeBaseColor base_color, + const wxColour& fallback) +{ + DecomposeOfficialComponent result; + result.base_color = base_color; + result.color_hex = decompose_normalize_color_hex(fallback.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + result.filament_id = decompose_basic_filament_id(basic_type); + + const char* color_name = decompose_base_color_en(base_color); + if (color_name[0] == '\0') + return result; + + // Some materials name a standard base color differently in the color-code + // table. PETG Basic's RYBW blue base is "Reflex Blue" (deep blue, B00, + // #001489), not "Blue". Match by an ordered list of exact English names so + // "Navy Blue" (B01, #0086D6) is never picked up by mistake. + std::vector candidate_names; + candidate_names.emplace_back(color_name); + if (base_color == DecomposeBaseColor::Blue && basic_type == kDecomposePetgBasicType) + candidate_names.emplace_back("Reflex Blue"); + + std::ifstream ifs(resources_dir() + "/profiles/BBL/filament/filaments_color_codes.json"); + if (!ifs) + return result; + + json root = json::parse(ifs, nullptr, false); + if (root.is_discarded() || !root.contains("data") || !root["data"].is_array()) + return result; + + for (const std::string& candidate : candidate_names) { + for (const auto& item : root["data"]) { + if (!item.is_object() || item.value("fila_type", "") != basic_type) + continue; + if (!item.contains("fila_color_name")) + continue; + const auto& names = item["fila_color_name"]; + if (!names.is_object() || names.value("en", "") != candidate) + continue; + if (item.contains("fila_color") && item["fila_color"].is_array() && !item["fila_color"].empty()) + result.color_hex = decompose_normalize_color_hex(item["fila_color"][0].get()); + result.filament_id = item.value("fila_id", result.filament_id); + return result; + } + } + return result; +} + +std::string find_decompose_standard_preset_name(size_t source_config_idx, const std::string& basic_type) +{ + const PresetBundle& preset_bundle = *wxGetApp().preset_bundle; + if (source_config_idx < preset_bundle.filament_presets.size()) { + const std::string& source_name = preset_bundle.filament_presets[source_config_idx]; + if (source_name.find(std::string(kDecomposeBambuPresetPrefix) + basic_type) != std::string::npos) + return source_name; + } + + const std::string prefix = std::string(kDecomposeBambuPresetPrefix) + basic_type + " @BBL "; + for (const std::string& preset_name : preset_bundle.filament_presets) { + if (preset_name.find(prefix) == 0) + return preset_name; + } + + return {}; +} + +std::string official_basic_type_from_preset_name(const std::string& preset_name) +{ + if (preset_name.find(std::string(kDecomposeBambuPresetPrefix) + kDecomposePlaBasicType) != std::string::npos) + return kDecomposePlaBasicType; + if (preset_name.find(std::string(kDecomposeBambuPresetPrefix) + kDecomposePetgBasicType) != std::string::npos) + return kDecomposePetgBasicType; + return {}; +} + +std::string filament_type_for_color_decompose(Preset* preset) +{ + if (!preset) + return kDecomposePlaShortType; + + std::string display_type; + std::string ft = preset->config.get_filament_type(display_type); + const std::string basic = official_basic_type_from_preset_name(preset->name); + if (!basic.empty()) + ft = basic; + if (ft.empty()) + ft = kDecomposePlaShortType; + return ft; +} + +int find_existing_decompose_component( + const DecomposeOfficialComponent& component, + const std::vector& physical_colors, + const std::vector& physical_config_indices, + size_t source_config_idx) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* filament_id_opt = project_config.option("filament_id"); + auto* type_opt = project_config.option("filament_type"); + const PresetBundle& preset_bundle = *wxGetApp().preset_bundle; + const size_t num_physical = physical_colors.size(); + const std::string expected_basic_type = component.filament_id == kDecomposePetgFilamentId ? kDecomposePetgBasicType : + component.filament_id == kDecomposePlaFilamentId ? kDecomposePlaBasicType : ""; + const std::string expected_short_type = expected_basic_type == kDecomposePetgBasicType ? kDecomposePetgShortType : + expected_basic_type == kDecomposePlaBasicType ? kDecomposePlaShortType : ""; + const std::string expected_preset_part = expected_basic_type.empty() ? "" : std::string(kDecomposeBambuPresetPrefix) + expected_basic_type; + for (size_t i = 0; i < num_physical && i < physical_config_indices.size(); ++i) { + const size_t config_idx = physical_config_indices[i]; + const std::string slot_color = decompose_normalize_color_hex(physical_colors[i]); + const std::string slot_filament_id = (filament_id_opt && config_idx < filament_id_opt->values.size()) ? filament_id_opt->values[config_idx] : ""; + const std::string slot_type = (type_opt && config_idx < type_opt->values.size()) ? type_opt->values[config_idx] : ""; + const std::string preset_name = config_idx < preset_bundle.filament_presets.size() ? preset_bundle.filament_presets[config_idx] : ""; + if (config_idx == source_config_idx) { + continue; + } + if (slot_color != component.color_hex) { + continue; + } + + if (!component.filament_id.empty() && slot_filament_id == component.filament_id) { + return static_cast(config_idx + 1); + } + + if (!expected_basic_type.empty() && (slot_type == expected_basic_type || slot_type == expected_short_type)) { + return static_cast(config_idx + 1); + } + + if (!expected_preset_part.empty() && preset_name.find(expected_preset_part) != std::string::npos) { + return static_cast(config_idx + 1); + } + + const bool has_material_hint = !slot_filament_id.empty() || !slot_type.empty() || !preset_name.empty(); + if (!expected_basic_type.empty() && has_material_hint) + continue; + + return static_cast(config_idx + 1); + } + return -1; +} + +bool prepare_decompose_mixed_result( + const ColorDecomposeResult& result, + size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_colors, + const std::vector& physical_types, + const std::vector& physical_config_indices, + MixedFilamentResult& out_result, + std::vector& missing) +{ + out_result = {}; + missing.clear(); + if (result.components.size() < 2) { + return false; + } + + const bool standard_mode = result.mode == DecomposeMode::CMYW || result.mode == DecomposeMode::RYBW; + std::string basic_type; + std::string preset_name; + if (standard_mode) { + basic_type = decompose_basic_type_from_source(source_config_idx, source_physical_idx, physical_types); + preset_name = find_decompose_standard_preset_name(source_config_idx, basic_type); + } + + for (size_t i = 0; i < result.components.size(); ++i) { + const DecomposeComponent& comp = result.components[i]; + out_result.ratios.push_back(comp.ratio); + if (!standard_mode) { + if (comp.filament_index <= 0) { + return false; + } + const size_t physical_idx = static_cast(comp.filament_index - 1); + if (physical_idx >= physical_config_indices.size()) { + return false; + } + out_result.components.push_back(static_cast(physical_config_indices[physical_idx] + 1)); + continue; + } + + if (comp.base_color == DecomposeBaseColor::None) { + return false; + } + DecomposeOfficialComponent official_component = + lookup_decompose_official_component(basic_type, comp.base_color, comp.colour); + int existing_idx = find_existing_decompose_component(official_component, physical_colors, + physical_config_indices, source_config_idx); + if (existing_idx > 0) { + out_result.components.push_back(static_cast(existing_idx)); + continue; + } + + DecomposeMissingComponent missing_comp; + missing_comp.component_idx = out_result.components.size(); + missing_comp.official_component = official_component; + missing_comp.preset_name = preset_name; + missing_comp.display_name = decompose_base_color_display(comp.base_color) + + wxString::FromUTF8(" ") + wxString::FromUTF8(basic_type); + missing.push_back(std::move(missing_comp)); + out_result.components.push_back(0); + } + + const bool ok = out_result.components.size() == out_result.ratios.size() && out_result.components.size() >= 2; + return ok; +} + +size_t count_decompose_new_physical_filaments( + const ColorDecomposeResult& result, + const std::vector& physical_colors, + const std::vector& physical_types, + size_t source_physical_idx, + const std::vector* physical_config_indices) +{ + if (result.mode != DecomposeMode::CMYW && result.mode != DecomposeMode::RYBW) + return 0; + + std::vector fallback_indices; + const std::vector* indices = physical_config_indices; + if (!indices) { + fallback_indices.resize(physical_colors.size()); + for (size_t i = 0; i < fallback_indices.size(); ++i) + fallback_indices[i] = i; + indices = &fallback_indices; + } + + size_t source_config_idx = size_t(-1); + if (source_physical_idx < indices->size()) + source_config_idx = (*indices)[source_physical_idx]; + + const std::string basic_type = + decompose_basic_type_from_source(source_config_idx, source_physical_idx, physical_types); + + size_t missing_count = 0; + for (const DecomposeComponent& comp : result.components) { + if (comp.base_color == DecomposeBaseColor::None) + continue; + DecomposeOfficialComponent official_component = + lookup_decompose_official_component(basic_type, comp.base_color, comp.colour); + int existing_idx = find_existing_decompose_component(official_component, physical_colors, + *indices, source_config_idx); + if (existing_idx <= 0) + ++missing_count; + } + return missing_count; +} + +bool confirm_create_decompose_missing_components(wxWindow* parent, const std::vector& missing) +{ + if (missing.empty()) + return true; + + static const char* config_key = "not_show_color_decompose_missing_component_tip"; + if (wxGetApp().app_config->get(config_key) == "1") { + return true; + } + + wxString missing_text; + for (size_t i = 0; i < missing.size(); ++i) { + if (i > 0) + missing_text += _L(", "); + missing_text += missing[i].display_name; + } + + wxString message = _L("The current filament list does not contain ") + missing_text + + _L(". A project filament required by the mixed filament will be created automatically after decomposition."); + + MessageDialog dlg(parent, message, _L("Tip"), wxOK | wxCANCEL | wxICON_INFORMATION); + dlg.show_dsa_button(); + int res = dlg.ShowModal(); + if (res == wxID_OK && dlg.get_checkbox_state()) + wxGetApp().app_config->set(config_key, "1"); + return res == wxID_OK; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ColorDecomposeSupport.hpp b/src/slic3r/GUI/ColorDecomposeSupport.hpp new file mode 100644 index 0000000000..a982c51303 --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeSupport.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_GUI_ColorDecomposeSupport_hpp_ +#define slic3r_GUI_ColorDecomposeSupport_hpp_ + +#include +#include +#include +#include +#include "ColorDecomposeDialog.hpp" + +class wxWindow; + +namespace Slic3r { +class Preset; +namespace GUI { + +// ---- Constants ---- + +inline constexpr const char* kDecomposePlaBasicType = "PLA Basic"; +inline constexpr const char* kDecomposePetgBasicType = "PETG Basic"; +inline constexpr const char* kDecomposePlaShortType = "PLA"; +inline constexpr const char* kDecomposePetgShortType = "PETG"; +inline constexpr const char* kDecomposePlaFilamentId = "GFA00"; +inline constexpr const char* kDecomposePetgFilamentId = "GFG00"; +inline constexpr const char* kDecomposeBambuPresetPrefix = "Bambu "; + +// ---- Types ---- + +struct DecomposeOfficialComponent { + DecomposeBaseColor base_color{DecomposeBaseColor::None}; + std::string color_hex; + std::string filament_id; +}; + +struct DecomposeMissingComponent { + size_t component_idx{0}; + DecomposeOfficialComponent official_component; + std::string preset_name; + wxString display_name; +}; + +struct MixedFilamentResult; + +// ---- Functions ---- + +std::string decompose_normalize_color_hex(std::string color); + +const char* decompose_base_color_en(DecomposeBaseColor color); + +wxString decompose_base_color_display(DecomposeBaseColor color); + +std::string decompose_basic_type_from_source(size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_types); + +std::string decompose_basic_filament_id(const std::string& basic_type); + +void set_created_standard_component_metadata(size_t config_idx, const DecomposeOfficialComponent& component); + +DecomposeOfficialComponent lookup_decompose_official_component( + const std::string& basic_type, + DecomposeBaseColor base_color, + const wxColour& fallback); + +std::string find_decompose_standard_preset_name(size_t source_config_idx, const std::string& basic_type); + +// Returns "PLA Basic" / "PETG Basic" when preset_name names an official Bambu +// basic filament, else an empty string. +std::string official_basic_type_from_preset_name(const std::string& preset_name); + +// Resolve display type for color-decompose: official Bambu Basic overrides +// get_filament_type when preset name matches; empty/missing -> "PLA". +std::string filament_type_for_color_decompose(Preset* preset); + +int find_existing_decompose_component( + const DecomposeOfficialComponent& component, + const std::vector& physical_colors, + const std::vector& physical_config_indices, + size_t source_config_idx); + +bool prepare_decompose_mixed_result( + const ColorDecomposeResult& result, + size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_colors, + const std::vector& physical_types, + const std::vector& physical_config_indices, + MixedFilamentResult& out_result, + std::vector& missing); + +// For standard modes: how many base colors are not reusable from physical list. +// MaterialList returns 0. When physical_config_indices is null, indices are 0..n-1. +size_t count_decompose_new_physical_filaments( + const ColorDecomposeResult& result, + const std::vector& physical_colors, + const std::vector& physical_types, + size_t source_physical_idx, + const std::vector* physical_config_indices); + +bool confirm_create_decompose_missing_components(wxWindow* parent, + const std::vector& missing); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_ColorDecomposeSupport_hpp_ diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index de94bb6b4b..519e75d9d2 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -577,17 +577,30 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con } // BBS - static const char* keys[] = { "support_filament", "support_interface_filament"}; + // A per-role filament override must name a real, physical filament. Out-of-range values are + // stale; a mixed-color slot is virtual and cannot be driven directly by a role override, so + // both are reset to 0 ("inherit the object's filament"). The object's own extruder assignment + // is what legitimately carries a mixed slot. Orca splits BBS's wall/solid_infill roles into + // six keys, so all of them are checked here. + static const char* keys[] = { "support_filament", "support_interface_filament", + "outer_wall_filament_id", "inner_wall_filament_id", + "sparse_infill_filament_id", "internal_solid_filament_id", + "top_surface_filament_id", "bottom_surface_filament_id" }; for (int i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { std::string key = std::string(keys[i]); auto* opt = dynamic_cast(config->option(key, false)); if (opt != nullptr) { - if (opt->getInt() > filament_cnt) { + int val = opt->getInt(); + bool out_of_range = val > filament_cnt; + bool is_mixed = (val > 0 && val <= filament_cnt && + wxGetApp().preset_bundle->is_mixed_filament(val - 1)); + if (out_of_range || is_mixed) { DynamicPrintConfig new_conf = *config; - const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); int new_value = 0; - if (conf_temp != nullptr && conf_temp->has(key)) { - new_value = conf_temp->opt_int(key); + if (out_of_range) { + const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); + if (conf_temp != nullptr && conf_temp->has(key)) + new_value = conf_temp->opt_int(key); } new_conf.set_key_value(key, new ConfigOptionInt(new_value)); apply(config, &new_conf); @@ -595,6 +608,37 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con } } + // Sub-layer splitting divides each layer by the mix ratio; an adaptive layer profile makes + // those sub-layer heights vary per layer, which degrades the blend. Warn once per enable. + { + static bool s_mixed_sublayer_warned = false; + bool sublayer_on = config->opt_bool("enable_mixed_color_sublayer"); + if (sublayer_on && !s_mixed_sublayer_warned && + wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + bool has_variable_layer = false; + for (const auto* obj : wxGetApp().model().objects) { + if (obj->layer_height_profile.get().size() > 4) { + has_variable_layer = true; + break; + } + } + if (has_variable_layer) { + MessageDialog dialog(m_msg_dlg_parent, + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + "", wxICON_WARNING | wxOK); + dialog.show_dsa_button(); + is_msg_dlg_already_exist = true; + dialog.ShowModal(); + is_msg_dlg_already_exist = false; + if (dialog.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + s_mixed_sublayer_warned = true; + } + } + if (!sublayer_on) + s_mixed_sublayer_warned = false; + } + if (config->opt_enum("seam_slope_type") != SeamScarfType::None && config->get_abs_value("seam_slope_start_height") >= layer_height) { const wxString msg_text = _(L("seam_slope_start_height need to be smaller than layer_height.\nReset to 0.")); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index fa6902de8d..967e02a907 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -155,6 +155,11 @@ std::string& get_filament_mixture_warning_text(){ return filament_mixture_warning_text; } +std::string& get_single_extruder_mixed_filament_warning_text(){ + static std::string single_extruder_mixed_filament_warning_text; + return single_extruder_mixed_filament_warning_text; +} + static std::string format_number(float value) { @@ -2984,6 +2989,9 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re bool mix_pla_and_petg = cur_plate->check_mixture_of_pla_and_petg(full_config_temp); _set_warning_notification(EWarning::MixUsePLAAndPETG, !mix_pla_and_petg); + bool single_extruder_mixed_risk = cur_plate->check_single_extruder_mixed_filament_risk(full_config_temp, get_single_extruder_mixed_filament_warning_text()); + _set_warning_notification(EWarning::SingleExtruderMixedFilament, single_extruder_mixed_risk); + bool filament_nozzle_compatible = cur_plate->check_compatible_of_nozzle_and_filament(full_config_temp, wxGetApp().preset_bundle->filament_presets, get_nozzle_filament_incompatible_text()); _set_warning_notification(EWarning::NozzleFilamentIncompatible, !filament_nozzle_compatible); @@ -3010,6 +3018,7 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re _set_warning_notification(EWarning::TPUPrintableError, false); _set_warning_notification(EWarning::FilamentPrintableError, false); _set_warning_notification(EWarning::MixUsePLAAndPETG, false); + _set_warning_notification(EWarning::SingleExtruderMixedFilament, false); _set_warning_notification(EWarning::PrimeTowerOutside, false); _set_warning_notification(EWarning::MultiExtruderPrintableError,false); _set_warning_notification(EWarning::MultiExtruderHeightOutside,false); @@ -10570,6 +10579,9 @@ void GLCanvas3D::_set_warning_notification(EWarning warning, bool state) case EWarning::MixUsePLAAndPETG: text = _u8L("PLA and PETG filaments detected in the mixture. Adjust parameters according to the Wiki to ensure print quality."); break; + case EWarning::SingleExtruderMixedFilament: + text = get_single_extruder_mixed_filament_warning_text(); + break; case EWarning::PrimeTowerOutside: text = _u8L("The prime tower extends beyond the plate boundary."); break; diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 17497edf16..84dbd5d652 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -391,6 +391,7 @@ class GLCanvas3D PrimeTowerOutside, NozzleFilamentIncompatible, MixtureFilamentIncompatible, + SingleExtruderMixedFilament, FlushingVolumeZero }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 761228550f..0ee0230ea9 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -731,6 +731,10 @@ void GLGizmoMmuSegmentation::init_model_triangle_selectors() continue; int extruder_idx = (mv->extruder_id() > 0) ? mv->extruder_id() - 1 : 0; + // A volume may be assigned to a mixed-color slot, whose index can sit past the + // physical colour list; fall back to the first colour rather than reading OOB. + if (extruder_idx >= (int)m_extruders_colors.size()) + extruder_idx = 0; std::vector ebt_colors; ebt_colors.push_back(m_extruders_colors[size_t(extruder_idx)]); ebt_colors.insert(ebt_colors.end(), m_extruders_colors.begin(), m_extruders_colors.end()); @@ -753,6 +757,9 @@ void GLGizmoMmuSegmentation::update_triangle_selectors_colors() TriangleSelectorPatch* selector = dynamic_cast(m_triangle_selectors[i].get()); int extruder_idx = m_volumes_extruder_idxs[i]; int extruder_color_idx = std::max(0, extruder_idx - 1); + // As above: a mixed-color slot can index past the physical colour list. + if (extruder_color_idx >= (int)m_extruders_colors.size()) + extruder_color_idx = 0; std::vector ebt_colors; ebt_colors.push_back(m_extruders_colors[extruder_color_idx]); ebt_colors.insert(ebt_colors.end(), m_extruders_colors.begin(), m_extruders_colors.end()); diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp new file mode 100644 index 0000000000..b3d3436465 --- /dev/null +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -0,0 +1,644 @@ +#include "GradientCurveEditor.hpp" +#include "GUI_App.hpp" +#include "GuiColor.hpp" +#include "I18N.hpp" +#include "Widgets/StateColor.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +wxDEFINE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); + +namespace { +// Layout (Figma "Property 1=Default", 214.06 x 179.63 px reference). +// Plot rect occupies the upper-left region; right + bottom margins host axis arrows / labels. +constexpr double kPlotLeftRatio = 0.0316; +constexpr double kPlotRightRatio = 0.6766; +constexpr double kPlotTopRatio = 0.1529; +constexpr double kPlotBottomRatio = 0.8474; +constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. + +// Hit / stroke (DIP). +constexpr int kHitRadius = 6; +constexpr int kCurveHitRadius = 5; +constexpr int kPointRadius = 4; // anchor outer radius (DIP) +constexpr int kStrokeUnselected = 2; +constexpr int kStrokeSelected = 4; +constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling - matches kGridColor pen and 2DBed convention) +constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) +constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) + +// Light-mode design tokens from Figma. Resolved through StateColor::darkModeColorFor() +// at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> +// #818183, #262E30 -> #EFEFF0, *wxWHITE -> #2D2D31). Don't read these directly in paint; +// always go through the resolved locals declared at the top of on_paint(). +const wxColour kGridColor (238, 238, 238); // #EEEEEE grey 300 +const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 +const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 +const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 + +// LAB (DeltaE76) threshold for "curve color is too close to the background". Below this +// we paint a subtle axis-color outline so the curve doesn't visually vanish; above this +// we draw the curve plain. ~15 is "perceptible but still close", looser than the strict +// 5.0 used by FlushPredict::is_similar_color but loose enough that a pastel pink on white +// or a charcoal on #2B2B2B still triggers an outline. +constexpr float kBgSimilarThreshold = 15.0f; +constexpr int kOutlineExtraDip = 2; +} // namespace + +GradientCurveEditor::GradientCurveEditor(wxWindow* parent, + const wxColour& color_low, + const wxColour& color_high) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE) + , m_color_low(color_low) + , m_color_high(color_high) +{ + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetBackgroundColour(wxGetApp().get_window_default_clr()); + // Wide enough so the X-axis "Material Ratio" label fits past the arrow tip without overlap. + // 260 (was 240): adds room for the "Material Ratio" label that gets shifted right by the + // longer axis arrow; the hosting MixedFilamentDialog grows to 470 DIP to accommodate. + SetMinSize(FromDIP(wxSize(260, 200))); + + reset_to_linear(0.10, 0.90); + + Bind(wxEVT_PAINT, &GradientCurveEditor::on_paint, this); + Bind(wxEVT_LEFT_DOWN, &GradientCurveEditor::on_left_down, this); + Bind(wxEVT_LEFT_UP, &GradientCurveEditor::on_left_up, this); + Bind(wxEVT_RIGHT_DOWN, &GradientCurveEditor::on_right_down, this); + Bind(wxEVT_MOTION, &GradientCurveEditor::on_motion, this); + Bind(wxEVT_LEAVE_WINDOW,&GradientCurveEditor::on_leave, this); + Bind(wxEVT_SIZE, &GradientCurveEditor::on_size, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_drag_mode = DragMode::None; + m_drag_idx = -1; + m_dragged_moved = false; + }); +} + +void GradientCurveEditor::set_points(const PointList& pts) +{ + m_points = pts; + normalize_points(); + Refresh(); +} + +void GradientCurveEditor::set_colors(const wxColour& color_low, const wxColour& color_high) +{ + m_color_low = color_low; + m_color_high = color_high; + Refresh(); +} + +void GradientCurveEditor::set_selected_curve(int curve_idx) +{ + const int new_sel = (curve_idx == 0) ? 0 : 1; + if (m_selected_curve == new_sel) return; + m_selected_curve = new_sel; + Refresh(); +} + +void GradientCurveEditor::reset_to_linear(double y0, double y1) +{ + auto clamp_y = [](double v) { + return std::max(kGradientMinRatio, std::min(kGradientMaxRatio, v)); + }; + m_points.clear(); + GradientAnchor a0; a0.x = 0.0; a0.y = clamp_y(y0); + GradientAnchor a1; a1.x = 1.0; a1.y = clamp_y(y1); + m_points.push_back(a0); + m_points.push_back(a1); + m_selected_curve = 0; + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::reverse() +{ + // Mirror y around 0.5. Tangents are slopes dy/dx so they flip sign to keep the + // local shape consistent across the mirror; NaN tangents remain "use PCHIP default". + for (auto& p : m_points) { + p.y = 1.0 - p.y; + if (std::isfinite(p.m_in)) p.m_in = -p.m_in; + if (std::isfinite(p.m_out)) p.m_out = -p.m_out; + } + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::normalize_points() +{ + if (m_points.empty()) { + GradientAnchor a0; a0.x = 0.0; a0.y = kGradientMinRatio; + GradientAnchor a1; a1.x = 1.0; a1.y = kGradientMaxRatio; + m_points.push_back(a0); + m_points.push_back(a1); + return; + } + + for (auto& p : m_points) { + p.x = std::max(0.0, std::min(1.0, p.x)); + p.y = std::max(kGradientMinRatio, std::min(kGradientMaxRatio, p.y)); + } + std::sort(m_points.begin(), m_points.end(), + [](const GradientAnchor& a, const GradientAnchor& b) { + return a.x < b.x; + }); + + if (m_points.size() < 2) { + GradientAnchor tail; tail.x = 1.0; tail.y = m_points.front().y; + m_points.push_back(tail); + } + + m_points.front().x = 0.0; + m_points.back().x = 1.0; +} + +void GradientCurveEditor::emit_changed() +{ + wxCommandEvent evt(wxEVT_GRADIENT_CURVE_CHANGED, GetId()); + evt.SetEventObject(this); + ProcessWindowEvent(evt); +} + +wxRect GradientCurveEditor::plot_rect() const +{ + const wxSize sz = GetClientSize(); + const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); + const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); + const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); + const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); + // Force square 1:1 so X/Y axes share the same scale and grid cells stay square. Anchor at + // the top-left so the "100%" labels on the bottom/right still align with the plot edges. + const int side = std::max(1, std::min(x2 - x, y2 - y)); + return wxRect(x, y, side, side); +} + +wxPoint GradientCurveEditor::data_to_px(double x, double y) const +{ + const wxRect r = plot_rect(); + const int px = r.x + static_cast(std::lround(x * r.width)); + // y axis is inverted: y=1 should sit at the top. + const int py = r.y + static_cast(std::lround((1.0 - y) * r.height)); + return wxPoint(px, py); +} + +void GradientCurveEditor::px_to_data(int px, int py, double& x, double& y) const +{ + const wxRect r = plot_rect(); + const double w = std::max(1, r.width); + const double h = std::max(1, r.height); + x = std::max(0.0, std::min(1.0, (px - r.x) / w)); + y = std::max(0.0, std::min(1.0, 1.0 - (py - r.y) / h)); +} + +double GradientCurveEditor::sample_curve_y(double x) const +{ + GradientCurve gc; + gc.points = m_points; + return sample_gradient_curve(gc, x); +} + +int GradientCurveEditor::hit_test(int px, int py) const +{ + const int tol = FromDIP(kHitRadius); + int best_idx = -1; + int best_d2 = tol * tol; + for (size_t i = 0; i < m_points.size(); ++i) { + // Anchor visual y is curve-specific: component 1's anchor sits at (x, 1 - stored_y). + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + const wxPoint p = data_to_px(m_points[i].x, vy); + const int dx = px - p.x; + const int dy = py - p.y; + const int d2 = dx * dx + dy * dy; + if (d2 <= best_d2) { + best_idx = static_cast(i); + best_d2 = d2; + } + } + return best_idx; +} + +int GradientCurveEditor::hit_test_curve(int px, int py, int* seg_out) const +{ + if (seg_out) *seg_out = -1; + if (m_points.size() < 2) return -1; + const int tol = FromDIP(kCurveHitRadius); + const int tol2 = tol * tol; + + auto dist2_to_seg = [&](int ax, int ay, int bx, int by) -> int { + const double dx = bx - ax; + const double dy = by - ay; + const double l2 = dx * dx + dy * dy; + if (l2 == 0.0) { + const double ddx = px - ax; + const double ddy = py - ay; + return static_cast(ddx * ddx + ddy * ddy); + } + double t = ((px - ax) * dx + (py - ay) * dy) / l2; + t = std::max(0.0, std::min(1.0, t)); + const double ex = ax + t * dx; + const double ey = ay + t * dy; + const double ddx = px - ex; + const double ddy = py - ey; + return static_cast(ddx * ddx + ddy * ddy); + }; + + // Hit-test against the same dense Hermite polyline that on_paint draws, so the + // clickable line follows the visual curve exactly (no offset on the bent parts). + // When a hit is found, also report the index of the left anchor of the data-space + // segment that covers cursor x; needed by the segment-bend interaction. + const wxRect rc = plot_rect(); + const int samples = std::max(128, rc.width * 2); + auto seg_for_x = [&](double cursor_x) -> int { + for (size_t i = 1; i < m_points.size(); ++i) { + if (cursor_x <= m_points[i].x) + return static_cast(i - 1); + } + return static_cast(m_points.size() - 2); + }; + + auto curve_hit = [&](int curve_idx) -> bool { + wxPoint prev; + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + const wxPoint cur = data_to_px(x, vy); + if (s > 0 && dist2_to_seg(prev.x, prev.y, cur.x, cur.y) <= tol2) + return true; + prev = cur; + } + return false; + }; + + // Prefer the selected curve so overlapping segments don't unintentionally steal focus. + if (curve_hit(m_selected_curve)) { + if (seg_out) { + double nx = 0, dummy = 0; + px_to_data(px, py, nx, dummy); + *seg_out = seg_for_x(nx); + } + return m_selected_curve; + } + const int other = 1 - m_selected_curve; + if (curve_hit(other)) { + if (seg_out) { + double nx = 0, dummy = 0; + px_to_data(px, py, nx, dummy); + *seg_out = seg_for_x(nx); + } + return other; + } + return -1; +} + +void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) +{ + // Resolve theme colors every paint so dark-mode toggles (no re-construction) take + // effect without an explicit listener. Window bg is read from GUI_App, not + // GetBackgroundColour(), since the latter is snapshotted at construction time. + const wxColour bg = wxGetApp().get_window_default_clr(); + const wxColour grid_color = StateColor::darkModeColorFor(kGridColor); + const wxColour axis_color = StateColor::darkModeColorFor(kAxisColor); + const wxColour label_muted = StateColor::darkModeColorFor(kLabelMuted); + const wxColour label_strong = StateColor::darkModeColorFor(kLabelStrong); + const wxColour point_fill = StateColor::darkModeColorFor(*wxWHITE); + + wxAutoBufferedPaintDC raw_dc(this); + raw_dc.SetBackground(wxBrush(bg)); + raw_dc.Clear(); + + // Render through wxGCDC so curves, arrows and anchor circles get anti-aliased; the buffered + // DC is the actual back buffer that gets blitted to the window. + wxGCDC dc(raw_dc); + + const wxRect rc = plot_rect(); + if (rc.width <= 0 || rc.height <= 0) + return; + + // 10x10 light grid (10 lines including outer borders, 9 equal divisions). + dc.SetPen(wxPen(grid_color, 1)); + for (int i = 0; i <= kGridDivisions; ++i) { + const int x = rc.x + rc.width * i / kGridDivisions; + const int y = rc.y + rc.height * i / kGridDivisions; + dc.DrawLine(x, rc.y, x, rc.y + rc.height); + dc.DrawLine(rc.x, y, rc.x + rc.width, y); + } + + // Set the label font first so text width measurements drive arrow / label placement. + wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); + dc.SetFont(label_font); + + const wxString axis_y_title = _L("Material Ratio"); + const wxString axis_x_title = _L("Model Height"); + const wxString pct_text = wxT("100%"); + const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); + const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); + + wxFont strong_font = label_font; + strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); + dc.SetFont(strong_font); + const wxSize pct_text_sz = dc.GetTextExtent(pct_text); + dc.SetFont(label_font); + + // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the + // canvas top edge; X-axis extends past the plot right toward the canvas right edge. + const int arrow_half = FromDIP(kAxisArrowHalf); + const int arrow_len = FromDIP(kAxisArrowLen); + const wxSize sz = GetClientSize(); + dc.SetPen(wxPen(axis_color, kStrokeAxis)); + dc.SetBrush(wxBrush(axis_color)); + + // Y-axis: vertical line at plot_left, from arrow tip near canvas top down to plot bottom. + const int y_axis_x = rc.x; + const int y_title_pct_gap = FromDIP(1); + const int y_title_bottom_pad = FromDIP(2); + const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); + const int y_arrow_tip_y = y_title_y; + const int y_arrow_ty = y_arrow_tip_y + arrow_len; + dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); + { + wxPoint tri[3] = { + wxPoint(y_axis_x, y_arrow_tip_y), + wxPoint(y_axis_x - arrow_half, y_arrow_ty), + wxPoint(y_axis_x + arrow_half, y_arrow_ty), + }; + dc.DrawPolygon(3, tri); + } + + // X-axis arrow tip: stays just past the plot ideally, but is clamped so the trailing + // "Material Ratio" label still fits inside the canvas without overlapping the arrow. + const int x_axis_y = rc.y + rc.height; + const int x_label_gap = FromDIP(4); + const int x_edge_pad = FromDIP(6); + const int x_arrow_ideal = rc.x + rc.width + FromDIP(10); + const int x_arrow_max = sz.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; + const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, + std::min(x_arrow_ideal, x_arrow_max)); + const int x_arrow_tip_x = x_arrow_tx + arrow_len; + const int x_title_x = x_arrow_tip_x + x_label_gap; + dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); + { + wxPoint tri[3] = { + wxPoint(x_arrow_tip_x, x_axis_y), + wxPoint(x_arrow_tx, x_axis_y - arrow_half), + wxPoint(x_arrow_tx, x_axis_y + arrow_half), + }; + dc.DrawPolygon(3, tri); + } + + // Labels. + // "Model Height" and "100%" share the same left x; the gap is larger than the + // axis-arrow half-base so the text never visually touches the Y-axis arrow. + const int label_left_x = y_axis_x + FromDIP(10); + dc.SetTextForeground(label_muted); + dc.DrawText(axis_y_title, label_left_x, y_title_y); + + dc.SetFont(strong_font); + dc.SetTextForeground(label_strong); + dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); + + // Bottom-right "100%" sits under the right end of the plot; "Material Ratio" follows the + // X-axis arrow tip (placement was already clamped above to leave room). + dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); + dc.SetFont(label_font); + dc.SetTextForeground(label_muted); + dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); + + if (m_points.size() < 2) + return; + + auto color_for_curve = [&](int curve_idx) -> wxColour { + wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; + // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. + // Lift alpha so the curve stays visible while still hinting at transparency. + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; + }; + + auto build_polyline = [&](int curve_idx) -> std::vector { + const int samples = std::max(128, rc.width * 2); + std::vector poly; + poly.reserve(samples + 1); + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + poly.push_back(data_to_px(x, vy)); + } + return poly; + }; + + auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { + dc.SetPen(wxPen(col, FromDIP(stroke_dip))); + dc.DrawLines(static_cast(poly.size()), poly.data()); + }; + + // Outline only when the curve color is perceptually close to the background; otherwise + // the plain filament color reads fine and the extra stroke would look heavy. + // Outline tone is intentionally softer than axis_color so it disambiguates the curve + // from the bg without competing with the structural axis/grid: light mode uses a pale + // grey, dark mode uses a slightly-above-bg grey (gDarkColors has no entry for these). + const wxColour outline_color = wxGetApp().dark_mode() + ? wxColour(90, 90, 94) // > bg #2B2B2B, < axis #818183 + : wxColour(200, 200, 200); // > grid #EEEEEE, < axis #6B6B6B + auto needs_outline = [&](const wxColour& c) { + return calc_color_distance(c, bg) < kBgSimilarThreshold; + }; + + auto draw_one = [&](int curve_idx, int stroke_dip) { + const auto poly = build_polyline(curve_idx); + const wxColour col = color_for_curve(curve_idx); + if (needs_outline(col)) + draw_polyline(poly, outline_color, stroke_dip + kOutlineExtraDip); + draw_polyline(poly, col, stroke_dip); + }; + + // Draw unselected first so the selected curve sits on top. + const int other = 1 - m_selected_curve; + draw_one(other, kStrokeUnselected); + draw_one(m_selected_curve, kStrokeSelected); + + // Control points (selected curve only): hollow circle with axis-color border, theme-aware fill. + const int r = FromDIP(kPointRadius); + dc.SetPen(wxPen(axis_color, 1)); + dc.SetBrush(wxBrush(point_fill)); + for (size_t i = 0; i < m_points.size(); ++i) { + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + const wxPoint p = data_to_px(m_points[i].x, vy); + dc.DrawCircle(p.x, p.y, r); + } +} + +void GradientCurveEditor::on_left_down(wxMouseEvent& evt) +{ + const wxPoint pos = evt.GetPosition(); + m_dragged_moved = false; + + // 1) Anchor on the selected curve takes precedence over everything else. + // Dragging an anchor resets its tangent overrides so the surrounding curve + // returns to PCHIP-default shape (matches user expectation that pulling an + // anchor "straightens out" the local mess). + const int idx = hit_test(pos.x, pos.y); + if (idx >= 0) { + m_drag_mode = DragMode::Anchor; + m_drag_idx = idx; + // Only emit a change event when clearing the tangents actually mutates + // the curve. A plain click on an already-default anchor must not trigger + // re-slicing through the changed-event listener. + const bool had_tangent = std::isfinite(m_points[idx].m_in) + || std::isfinite(m_points[idx].m_out); + m_points[idx].m_in = std::numeric_limits::quiet_NaN(); + m_points[idx].m_out = std::numeric_limits::quiet_NaN(); + if (!HasCapture()) + CaptureMouse(); + Refresh(); + if (had_tangent) + emit_changed(); + return; + } + + // 2) Line-body hit. Determine which curve and which segment. + int seg = -1; + const int curve_hit = hit_test_curve(pos.x, pos.y, &seg); + if (curve_hit < 0) { + m_drag_mode = DragMode::None; + evt.Skip(); + return; + } + + // 3) Non-selected curve hit -> switch selection only, no drag arming. + if (curve_hit != m_selected_curve) { + m_selected_curve = curve_hit; + m_drag_mode = DragMode::None; + Refresh(); + evt.Skip(); + return; + } + + // 4) Selected curve line body hit -> insert a new anchor at cursor x (snapped + // to the current smooth curve so the initial click is visually invisible) + // and immediately enter Anchor drag mode. PS Curves style: the drag-bend + // interaction has no separate "bend without anchor" mode; pressing and + // dragging on the line is equivalent to clicking to add then dragging the + // fresh anchor. Trades the previous (failed) "no anchor on drag" promise + // for genuine cursor tracking, since a single cubic between two existing + // anchors mathematically cannot put its peak under an off-center cursor. + double nx = 0, dummy = 0; + px_to_data(pos.x, pos.y, nx, dummy); + if (nx <= 0.0 || nx >= 1.0 || seg < 0) { + m_drag_mode = DragMode::None; + evt.Skip(); + return; + } + GradientAnchor a; + a.x = nx; + a.y = sample_curve_y(nx); + const size_t insert_idx = static_cast(seg) + 1; + m_points.insert(m_points.begin() + insert_idx, a); + + m_drag_mode = DragMode::Anchor; + m_drag_idx = static_cast(insert_idx); + if (!HasCapture()) + CaptureMouse(); + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::on_left_up(wxMouseEvent& evt) +{ + if (HasCapture()) + ReleaseMouse(); + + // Anchor mode (either an existing anchor or one freshly inserted by on_left_down) + // already fired emit_changed on mouse_down; only fire again here if the user + // actually dragged so the slicer doesn't re-run on a pure click. + if (m_drag_mode == DragMode::Anchor && m_dragged_moved) + emit_changed(); + + m_drag_mode = DragMode::None; + m_drag_idx = -1; + m_dragged_moved = false; + (void)evt; +} + +void GradientCurveEditor::on_right_down(wxMouseEvent& evt) +{ + const wxPoint pos = evt.GetPosition(); + const int idx = hit_test(pos.x, pos.y); + if (idx > 0 && static_cast(idx) + 1 < m_points.size()) { + // Interior anchor on the selected curve -> delete it. Endpoints stay locked. + m_points.erase(m_points.begin() + idx); + Refresh(); + emit_changed(); + return; + } + // Right-click on the non-selected curve switches selection (never deletes). + const int curve_hit = hit_test_curve(pos.x, pos.y); + if (curve_hit >= 0 && curve_hit != m_selected_curve) { + m_selected_curve = curve_hit; + Refresh(); + return; + } + evt.Skip(); +} + +void GradientCurveEditor::on_motion(wxMouseEvent& evt) +{ + if (!evt.LeftIsDown() || m_drag_mode != DragMode::Anchor) { + evt.Skip(); + return; + } + if (static_cast(m_drag_idx) >= m_points.size()) + return; + + const wxPoint pos = evt.GetPosition(); + double nx = 0, vy = 0; + px_to_data(pos.x, pos.y, nx, vy); + + auto& p = m_points[m_drag_idx]; + const bool is_first = (m_drag_idx == 0); + const bool is_last = (static_cast(m_drag_idx) + 1 == m_points.size()); + + // Endpoints stay locked at x=0 / x=1; interior anchors clamp into + // (left_neighbor.x, right_neighbor.x) so they can't cross or coincide. + if (!is_first && !is_last) { + const double xl = m_points[m_drag_idx - 1].x; + const double xr = m_points[m_drag_idx + 1].x; + const double eps = 1e-4; + nx = std::max(xl + eps, std::min(xr - eps, nx)); + p.x = nx; + } + // y is constrained to the reserved blend band so neither component ever + // reaches 0% / 100%, matching the sampler's clamp. + p.y = std::max(kGradientMinRatio, + std::min(kGradientMaxRatio, to_stored_y(m_selected_curve, vy))); + m_dragged_moved = true; + Refresh(); +} + +void GradientCurveEditor::on_leave(wxMouseEvent& evt) +{ + evt.Skip(); +} + +void GradientCurveEditor::on_size(wxSizeEvent& evt) +{ + Refresh(); + evt.Skip(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/GradientCurveEditor.hpp b/src/slic3r/GUI/GradientCurveEditor.hpp new file mode 100644 index 0000000000..8412db3df2 --- /dev/null +++ b/src/slic3r/GUI/GradientCurveEditor.hpp @@ -0,0 +1,115 @@ +#ifndef slic3r_GradientCurveEditor_hpp_ +#define slic3r_GradientCurveEditor_hpp_ + +#include +#include +#include +#include +#include + +#include "libslic3r/FilamentMixer.hpp" + +namespace Slic3r { +namespace GUI { + +// Photoshop-style curve editor for "Z progress -> first-component ratio" mapping. +// Curve evaluation uses cubic Hermite with PCHIP defaults plus optional per-anchor +// tangent overrides (m_in / m_out, NaN = use PCHIP default). The same evaluator +// (FilamentMixer::sample_gradient_curve) is shared with the slicing backend so what +// the editor renders matches the G-code output 1:1. +// +// Interaction model (PS Curves style): +// - Click or press-and-drag on the line body inserts a new anchor at the cursor x +// (snapped to the current smooth curve, NaN tangents) and starts dragging it. +// A pure click leaves an anchor sitting exactly on the previous curve shape; a +// drag moves the new anchor freely so the bump follows the cursor 1:1. +// - Dragging an existing anchor moves (x, y) and clears its m_in / m_out so the +// local curve returns to the PCHIP default shape around it. +// - Right-click on an interior anchor deletes it; endpoints stay locked. +class GradientCurveEditor : public wxPanel +{ +public: + using PointList = std::vector; + + GradientCurveEditor(wxWindow* parent, + const wxColour& color_low = wxColour(217, 217, 217), + const wxColour& color_high = wxColour(217, 217, 217)); + + // Replace the entire point list. The widget enforces x in [0,1], y in [0,1], + // sorts by x, and clamps the first / last x to 0 / 1. Tangent overrides are + // preserved as-is (NaN entries continue to use PCHIP defaults). + void set_points(const PointList& pts); + const PointList& get_points() const { return m_points; } + + void set_colors(const wxColour& color_low, const wxColour& color_high); + + // Which curve currently responds to drag / add / delete and is drawn with the thick stroke. + // 0 = first component (color_low), 1 = second component (color_high). Storage layer is + // unaffected: m_points always represents component 0's ratio. + void set_selected_curve(int curve_idx); + int get_selected_curve() const { return m_selected_curve; } + + // Reset to a two-point linear curve from y0 at t=0 to y1 at t=1. + // Clears all tangent overrides. + void reset_to_linear(double y0, double y1); + // Flip the curve top to bottom (all y -> 1 - y; tangents negated to mirror shape). + void reverse(); + +private: + enum class DragMode { + None, // nothing armed + Anchor, // dragging an anchor (either existing or just inserted from a line hit) + }; + + void normalize_points(); + void emit_changed(); + + void on_paint(wxPaintEvent& evt); + void on_left_down(wxMouseEvent& evt); + void on_left_up(wxMouseEvent& evt); + void on_right_down(wxMouseEvent& evt); + void on_motion(wxMouseEvent& evt); + void on_leave(wxMouseEvent& evt); + void on_size(wxSizeEvent& evt); + + // Coordinate mapping between data (x, y in [0,1]) and pixels in plot area. + wxRect plot_rect() const; + wxPoint data_to_px(double x, double y) const; + void px_to_data(int px, int py, double& x, double& y) const; + // Anchor hit test for the currently-selected curve (uses translated visual y). + int hit_test(int px, int py) const; // returns point index or -1 + // Line-body hit test across both curves. Returns 0/1 for which curve was hit, -1 if none. + // Prefers the selected curve when both are within threshold. seg_out (when non-null) + // receives the left-anchor index of the segment that was hit on the returned curve; + // on_left_down uses it to know where in m_points to insert a freshly-added anchor. + int hit_test_curve(int px, int py, int* seg_out = nullptr) const; + + // Sample the curve in stored space (component 0) at x. + double sample_curve_y(double x) const; + + // Symmetric translation between visual y (what the user sees / clicks) and stored y + // (component 0's ratio in m_points). + static double to_stored_y(int curve_idx, double visual_y) { + return (curve_idx == 0) ? visual_y : (1.0 - visual_y); + } + static double to_visual_y(int curve_idx, double stored_y) { + return (curve_idx == 0) ? stored_y : (1.0 - stored_y); + } + + PointList m_points; + wxColour m_color_low; + wxColour m_color_high; + + int m_selected_curve = 0; + DragMode m_drag_mode = DragMode::None; + int m_drag_idx = -1; // valid when m_drag_mode == Anchor + bool m_dragged_moved = false; +}; + +// Custom event raised when the curve is edited (drag / add / remove / reset / reverse). +wxDECLARE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GradientCurveEditor_hpp_ diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 3b1cf1dffd..5f7d69244e 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -2330,6 +2330,11 @@ bool MainFrame::get_enable_slice_status() } } + // A mixed filament whose components were deleted, or whose components disagree in type, + // cannot be resolved at slicing time. Block the slice until the user fixes it. + if (enable && m_plater->sidebar().has_broken_mixed_filament()) + enable = false; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": m_slice_select %1%, enable= %2% ")%m_slice_select %enable; return enable; } diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp new file mode 100644 index 0000000000..241f051905 --- /dev/null +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -0,0 +1,1983 @@ +#include "MixedFilamentDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Utils.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "I18N.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "GradientCurveEditor.hpp" +#include "wxExtensions.hpp" +#include "Tab.hpp" +#include "libslic3r/Preset.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/CheckBox.hpp" +#include "Widgets/ComboBox.hpp" +#include "Widgets/DropDown.hpp" +#include "Widgets/Label.hpp" + +namespace Slic3r { +namespace GUI { + +static constexpr int MAX_COMPONENTS = 3; +static constexpr int MIN_COMPONENT_RATIO = 10; + +// Lightweight self-painting label used for both dual-color and triple-color +// ratio percentage display. Hover shows a rounded-rect background; click +// fires wxEVT_LEFT_DOWN which the owning dialog binds to start_ratio_editor. +class RatioLabelPanel : public wxPanel +{ +public: + RatioLabelPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE) + { + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetCursor(wxCursor(wxCURSOR_HAND)); + SetToolTip(_L("Click to edit ratio")); + SetFont(::Label::Body_10); + + Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& e) { m_hovered = true; Refresh(); e.Skip(); }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& e) { m_hovered = false; Refresh(); e.Skip(); }); + Bind(wxEVT_PAINT, &RatioLabelPanel::on_paint, this); + } + + void SetLabel(const wxString& text) override + { + if (m_text == text) return; + m_text = text; + update_best_size(); + Refresh(); + } + wxString GetLabel() const override { return m_text; } + +private: + void update_best_size() + { + wxClientDC dc(this); + dc.SetFont(GetFont()); + wxSize ts = dc.GetTextExtent(m_text); + int pad_x = FromDIP(4), pad_y = FromDIP(3); + SetMinSize(wxSize(ts.GetWidth() + pad_x * 2, ts.GetHeight() + pad_y * 2)); + InvalidateBestSize(); + } + + void on_paint(wxPaintEvent&) + { + wxBufferedPaintDC dc(this); + wxSize sz = GetClientSize(); + + wxColour parent_bg = GetParent() ? GetParent()->GetBackgroundColour() + : StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(wxBrush(parent_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + if (m_hovered) { + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#F8F8F8")))); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); + dc.DrawRoundedRectangle(0, 0, sz.GetWidth(), sz.GetHeight(), FromDIP(3)); + } + + dc.SetFont(GetFont()); + dc.SetTextForeground(m_hovered ? wxColour("#00AE42") + : StateColor::darkModeColorFor(wxColour("#262E30"))); + wxSize ts = dc.GetTextExtent(m_text); + int x = (sz.GetWidth() - ts.GetWidth()) / 2; + int y = (sz.GetHeight() - ts.GetHeight()) / 2; + dc.DrawText(m_text, x, y); + } + + wxString m_text; + bool m_hovered{false}; +}; + +static wxColour blend_colors(const wxColour& a, const wxColour& b, double ratio_a) +{ + unsigned char r, g, bl; + Slic3r::filament_mixer_lerp(a.Red(), a.Green(), a.Blue(), + b.Red(), b.Green(), b.Blue(), + static_cast(1.0 - ratio_a), + &r, &g, &bl); + return wxColour(r, g, bl); +} + +static wxColour blend_n_colors(const std::vector& cols, const std::vector& weights) +{ + std::vector hex_colors; + std::vector int_weights; + for (size_t i = 0; i < cols.size() && i < weights.size(); ++i) { + hex_colors.push_back(cols[i].GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + // Scale double weights (e.g. 0.5) to int (5000) for blend_color_multi; + // only relative magnitude matters. + int_weights.push_back(static_cast(std::lround(weights[i] * 10000))); + } + std::string hex = Slic3r::blend_color_multi(hex_colors, int_weights); + return wxColour(hex); +} + +// ---- Constructors ---- + +MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types) + : DPIDialog(parent, wxID_ANY, _L("Add Mixed Filament"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_edit_mode(false) + , m_physical_colors(physical_colors) + , m_physical_names(physical_names) + , m_physical_types(physical_types) +{ + m_result.components = {1, (physical_colors.size() >= 2) ? 2u : 1u}; + m_result.ratios = {50, 50}; + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); + + wxImage img; + if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_twocolor.png")), wxBITMAP_TYPE_PNG)) + m_preview_bmp_two = wxBitmap(img); + if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_threecolor.png")), wxBITMAP_TYPE_PNG)) + m_preview_bmp_three = wxBitmap(img); +} + +MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, + const MixedFilamentResult& existing, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types) + : DPIDialog(parent, wxID_ANY, _L("Edit Mixed Filament"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_result(existing) + , m_edit_mode(true) + , m_physical_colors(physical_colors) + , m_physical_names(physical_names) + , m_physical_types(physical_types) +{ + if (m_result.components.size() < 2) { + m_result.components = {1, (physical_colors.size() >= 2) ? 2u : 1u}; + m_result.ratios = {50, 50}; + } + if (m_result.ratios.size() >= 3) { + int sum = 0; + for (int r : m_result.ratios) sum += r; + if (sum > 0) { + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; + } + } + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); + + wxImage img; + if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_twocolor.png")), wxBITMAP_TYPE_PNG)) + m_preview_bmp_two = wxBitmap(img); + if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_threecolor.png")), wxBITMAP_TYPE_PNG)) + m_preview_bmp_three = wxBitmap(img); +} + +void MixedFilamentDialog::on_dpi_changed(const wxRect&) +{ + int h = (num_components() >= 3) ? FromDIP(680) : FromDIP(580); + SetSize(FromDIP(439), h); + Refresh(); +} + +wxColour MixedFilamentDialog::comp_colour(size_t i) const +{ + unsigned int c = comp(i); + if (c >= 1 && c <= m_physical_colors.size()) + return wxColour(m_physical_colors[c - 1]); + return wxColour("#D9D9D9"); +} + +static wxBitmap make_alpha_bitmap(int w, int h, + const std::function& draw_fn) +{ + wxBitmap bmp(w, h); + wxMemoryDC memdc; +#ifdef __WXOSX__ + bmp.UseAlpha(); + memdc.SelectObject(bmp); +#else + { + wxImage img(w, h); + img.InitAlpha(); + memset(img.GetAlpha(), 0, w * h); + bmp = wxBitmap(std::move(img)); + } + memdc.SelectObject(bmp); +#endif + { +#ifdef __WXMSW__ + wxGCDC dc(memdc); +#else + wxDC& dc = memdc; +#endif + draw_fn(dc); + } + memdc.SelectObject(wxNullBitmap); + return bmp; +} + +wxBitmap MixedFilamentDialog::make_swatch_bitmap(size_t idx) +{ + int swatch_sz = FromDIP(20); + int pad_left = FromDIP(2); + int pad_right = FromDIP(6); + int bmp_w = pad_left + swatch_sz + pad_right; + int bmp_h = swatch_sz; + + // Reuse the sidebar clr_picker swatch (get_extruder_color_icon) so the + // checkerboard (transparent.svg tiling), border and label style match the + // sidebar exactly, instead of a self-drawn rounded rect / programmatic grid. + std::string color_hex = "#D9D9D9"; + if (idx < m_physical_colors.size()) + color_hex = m_physical_colors[idx]; + std::string label = std::to_string(idx + 1); + + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + + return make_alpha_bitmap(bmp_w, bmp_h, [&](wxDC& dc) { + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, pad_left, 0); + }); +} + +void MixedFilamentDialog::reset_manual_ratio_state() +{ + m_ratio_manual_order.clear(); + if (m_ratio_editor_panel) + m_ratio_editor_panel->Hide(); + // Restore any label hidden by an in-flight editor so it can never be left + // permanently invisible if the editor is dismissed without a commit. + if (m_ratio_editor_anchor) { + m_ratio_editor_anchor->Show(); + m_ratio_editor_anchor = nullptr; + } +} + +void MixedFilamentDialog::refresh_ratio_labels() +{ + if (m_label_ratio_a) + m_label_ratio_a->SetLabel(wxString::Format(wxT("%d%%"), ratio(0))); + if (m_label_ratio_b) + m_label_ratio_b->SetLabel(wxString::Format(wxT("%d%%"), ratio(1))); + if (m_ratio_sizer) + m_ratio_sizer->Layout(); + if (m_triangle_panel) + m_triangle_panel->Refresh(); +} + +void MixedFilamentDialog::sync_triangle_weights_from_ratios() +{ + if (m_result.ratios.size() < 3) + return; + + int sum = 0; + for (int r : m_result.ratios) + sum += r; + if (sum <= 0) + return; + + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; +} + +void MixedFilamentDialog::apply_manual_ratio(size_t idx, int value) +{ + const size_t n = num_components(); + if (idx >= n) + return; + if (m_result.ratios.size() != n) + m_result.ratios.assign(n, n > 0 ? 100 / (int)n : 0); + bool manual_stale = false; + for (size_t o : m_ratio_manual_order) { + if (o >= n) { manual_stale = true; break; } + } + if (manual_stale) + reset_manual_ratio_state(); + + int max_value = (int)(100 - (n - 1) * MIN_COMPONENT_RATIO); + value = std::clamp(value, MIN_COMPONENT_RATIO, std::max(MIN_COMPONENT_RATIO, max_value)); + + if (n == 2) { + if (idx == 0) { + m_result.ratios[0] = value; + m_result.ratios[1] = 100 - value; + } else { + m_result.ratios[1] = value; + m_result.ratios[0] = 100 - value; + } + m_result.ratios[0] = std::clamp(m_result.ratios[0], MIN_COMPONENT_RATIO, 100 - MIN_COMPONENT_RATIO); + m_result.ratios[1] = 100 - m_result.ratios[0]; + } else if (n >= 3) { + m_result.ratios[idx] = value; + int remaining = 100 - value; + + std::vector others; + int others_sum = 0; + for (size_t i = 0; i < n; ++i) { + if (i == idx) continue; + others.push_back(i); + others_sum += m_result.ratios[i]; + } + + if (!others.empty()) { + if (others_sum > 0) { + int assigned = 0; + for (size_t k = 0; k < others.size(); ++k) { + int nv = (int)((double)remaining * m_result.ratios[others[k]] / others_sum + 0.5); + nv = std::max(nv, MIN_COMPONENT_RATIO); + m_result.ratios[others[k]] = nv; + assigned += nv; + } + while (assigned != remaining) { + if (assigned > remaining) { + int pick = -1; + for (size_t k = 0; k < others.size(); ++k) + if (m_result.ratios[others[k]] > MIN_COMPONENT_RATIO + && (pick < 0 || m_result.ratios[others[k]] > m_result.ratios[others[pick]])) + pick = (int)k; + if (pick < 0) break; + --m_result.ratios[others[pick]]; --assigned; + } else { + int pick = 0; + for (size_t k = 1; k < others.size(); ++k) + if (m_result.ratios[others[k]] > m_result.ratios[others[pick]]) + pick = (int)k; + ++m_result.ratios[others[pick]]; ++assigned; + } + } + } else { + int base = remaining / (int)others.size(); + for (size_t k = 0; k < others.size(); ++k) + m_result.ratios[others[k]] = base; + m_result.ratios[others.back()] += remaining - base * (int)others.size(); + } + } + } + + refresh_ratio_labels(); + sync_triangle_weights_from_ratios(); + update_preview(); +} + +void MixedFilamentDialog::apply_dragged_triangle_ratio(int r0, int r1, int r2) +{ + if (m_result.ratios.size() < 3) + return; + + int ratios[3] = { + std::clamp(r0, MIN_COMPONENT_RATIO, 100), + std::clamp(r1, MIN_COMPONENT_RATIO, 100), + std::clamp(r2, MIN_COMPONENT_RATIO, 100) + }; + + int sum = ratios[0] + ratios[1] + ratios[2]; + while (sum > 100) { + int idx = 0; + for (int i = 1; i < 3; ++i) { + if (ratios[i] > ratios[idx]) + idx = i; + } + if (ratios[idx] <= MIN_COMPONENT_RATIO) + break; + --ratios[idx]; + --sum; + } + while (sum < 100) { + int idx = 0; + for (int i = 1; i < 3; ++i) { + if (ratios[i] < ratios[idx]) + idx = i; + } + ++ratios[idx]; + ++sum; + } + + m_result.ratios[0] = ratios[0]; + m_result.ratios[1] = ratios[1]; + m_result.ratios[2] = ratios[2]; + sync_triangle_weights_from_ratios(); + reset_manual_ratio_state(); + update_preview(); +} + +void MixedFilamentDialog::start_ratio_editor(size_t idx, wxWindow* anchor, const wxRect& anchor_rect) +{ + if (!anchor || idx >= m_result.ratios.size()) + return; + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + + if (!m_ratio_editor_panel) { + wxColour bg = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + wxColour fg = StateColor::darkModeColorFor(wxColour("#262E30")); + + m_ratio_editor_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, + wxDefaultSize, wxBORDER_SIMPLE); + m_ratio_editor_panel->SetBackgroundColour(bg); + + auto* hsizer = new wxBoxSizer(wxHORIZONTAL); + + m_ratio_editor = new wxTextCtrl(m_ratio_editor_panel, wxID_ANY, wxEmptyString, + wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_ENTER | wxTE_RIGHT | wxBORDER_NONE); + m_ratio_editor->SetFont(::Label::Body_10); + m_ratio_editor->SetMaxLength(3); + m_ratio_editor->SetBackgroundColour(bg); + m_ratio_editor->SetForegroundColour(fg); + // Default wxTextCtrl best width (~140px) is too wide for the sizer to + // shrink, which would push the "%" suffix out of the panel. Cap the + // editor's min width to the digits only (ratios are always two digits). + { + wxClientDC mdc(m_ratio_editor); + mdc.SetFont(::Label::Body_10); + int digits_w = mdc.GetTextExtent(wxT("88")).GetWidth(); + m_ratio_editor->SetMinSize(wxSize(digits_w + FromDIP(2), -1)); + } + + auto* pct_label = new wxStaticText(m_ratio_editor_panel, wxID_ANY, wxT("%")); + pct_label->SetFont(::Label::Body_10); + pct_label->SetForegroundColour(fg); + pct_label->SetBackgroundColour(bg); + pct_label->SetMinSize(pct_label->GetBestSize()); + + hsizer->Add(m_ratio_editor, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(2)); + hsizer->Add(pct_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + m_ratio_editor_panel->SetSizer(hsizer); + m_ratio_editor_panel->Hide(); + + m_ratio_editor->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { commit_ratio_editor(true); }); + m_ratio_editor->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent& e) { + commit_ratio_editor(true); + e.Skip(); + }); + m_ratio_editor->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_ESCAPE) + commit_ratio_editor(false); + else + e.Skip(); + }); + } + + m_ratio_editor_idx = idx; + + // Keep the editor in the same window hierarchy as the clicked label so the + // z-order is reliable and the editor fully covers the anchor (dual-color + // labels live on the dialog, triple-color labels live on the triangle + // panel). + wxWindow* target_parent = anchor->GetParent(); + if (target_parent && m_ratio_editor_panel->GetParent() != target_parent) + m_ratio_editor_panel->Reparent(target_parent); + + // Hide the label being edited to avoid its (hover-state) text leaking out + // next to the editor; restored on commit. + m_ratio_editor_anchor = anchor; + anchor->Hide(); + + wxPoint pos = anchor->GetPosition() + anchor_rect.GetTopLeft(); + // Match the editor to the label (hover box) size so the inline editor and + // the hover state look identical. A small floor keeps the "%" suffix from + // being squeezed out on very narrow labels. + wxSize size = anchor->GetSize(); + size.SetWidth(std::max(size.GetWidth(), FromDIP(30))); + size.SetHeight(std::max(size.GetHeight(), FromDIP(18))); + m_ratio_editor_panel->SetSize(wxRect(pos, size)); + m_ratio_editor_panel->Layout(); + m_ratio_editor->SetValue(wxString::Format(wxT("%d"), ratio(idx))); + m_ratio_editor_panel->Show(); + m_ratio_editor_panel->Raise(); + m_ratio_editor->SetFocus(); + m_ratio_editor->SelectAll(); + m_ratio_editor_panel->Refresh(); + Update(); +} + +void MixedFilamentDialog::commit_ratio_editor(bool apply) +{ + if (!m_ratio_editor_panel || !m_ratio_editor_panel->IsShown() || m_ratio_editor_committing) + return; + + m_ratio_editor_committing = true; + + // Restore the hidden anchor before applying the ratio, so any sizer layout + // triggered by refresh_ratio_labels() accounts for the visible label. + m_ratio_editor_panel->Hide(); + if (m_ratio_editor_anchor) { + m_ratio_editor_anchor->Show(); + m_ratio_editor_anchor = nullptr; + } + + if (apply) { + wxString value = m_ratio_editor->GetValue(); + value.Trim(true); + value.Trim(false); + if (value.EndsWith(wxT("%"))) + value.RemoveLast(); + + long parsed = 0; + if (value.ToLong(&parsed)) + apply_manual_ratio(m_ratio_editor_idx, (int)parsed); + else + refresh_ratio_labels(); + } + + m_ratio_editor_committing = false; +} + +void MixedFilamentDialog::commit_ratio_editor_from_background(wxMouseEvent& e) +{ + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) { + wxPoint mouse_in_panel = m_ratio_editor_panel->ScreenToClient(wxGetMousePosition()); + if (!m_ratio_editor_panel->GetClientRect().Contains(mouse_in_panel)) + commit_ratio_editor(true); + } + e.Skip(); +} + +// ---- UI Construction ---- + +void MixedFilamentDialog::build_ui() +{ + const wxColour mc_bg = StateColor::darkModeColorFor(*wxWHITE); + const wxColour mc_bg_sub = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + const wxColour mc_border = StateColor::darkModeColorFor(wxColour("#CECECE")); + const wxColour mc_text = StateColor::darkModeColorFor(wxColour("#262E30")); + const wxColour mc_dim_text = StateColor::darkModeColorFor(wxColour("#ACACAC")); + + SetBackgroundColour(mc_bg); + Bind(wxEVT_LEFT_DOWN, &MixedFilamentDialog::commit_ratio_editor_from_background, this); + SetSize(FromDIP(439), FromDIP(580)); + + auto* main_sizer = new wxBoxSizer(wxVERTICAL); + + auto* top_sizer = new wxBoxSizer(wxHORIZONTAL); + top_sizer->Add(create_preview_panel(), 0, wxALL, FromDIP(20)); + + m_right_sizer = new wxBoxSizer(wxVERTICAL); + m_right_sizer->Add(create_material_selection(), 0, wxEXPAND); + m_right_sizer->Add(create_gradient_section(), 0, wxEXPAND | wxTOP, FromDIP(7)); + + m_ratio_sizer = create_ratio_slider(); + m_right_sizer->Add(m_ratio_sizer, 0, wxEXPAND | wxTOP, FromDIP(7)); + + m_triangle_sizer = create_triangle_picker(); + m_right_sizer->Add(m_triangle_sizer, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(7)); + + top_sizer->Add(m_right_sizer, 1, wxTOP | wxRIGHT | wxBOTTOM, FromDIP(20)); + main_sizer->Add(top_sizer, 0, wxEXPAND); + + main_sizer->Add(create_recommendation_grid(), 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(25)); + + // Warning panel: red bordered box with exclamation icon + text + m_warning_sizer = new wxBoxSizer(wxVERTICAL); + m_warning_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(48))); + m_warning_panel->SetMinSize(wxSize(-1, FromDIP(48))); + m_warning_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_warning_panel->Bind(wxEVT_PAINT, &MixedFilamentDialog::paint_warning_panel, this); + m_warning_sizer->Add(m_warning_panel, 0, wxEXPAND); + main_sizer->Add(m_warning_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(25)); + m_warning_panel->Hide(); + + main_sizer->Add(create_button_panel(), 0, wxALIGN_RIGHT | wxALL, FromDIP(20)); + + SetSizer(main_sizer); + + rebuild_all_combos(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + + Layout(); + CentreOnParent(); +} + +wxBoxSizer* MixedFilamentDialog::create_preview_panel() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + m_preview_canvas = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(129), FromDIP(129))); + m_preview_canvas->SetBackgroundStyle(wxBG_STYLE_PAINT); + + m_preview_canvas->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_preview_canvas); + wxSize sz = m_preview_canvas->GetClientSize(); + size_t n = num_components(); + + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + if (n == 0) return; + + int swatch_sz = FromDIP(80); + int x0 = (sz.GetWidth() - swatch_sz) / 2; + int y0 = (sz.GetHeight() - swatch_sz) / 2; + double radius = FromDIP(6); + + if (m_result.gradient_enabled && n == 2) { + Slic3r::GradientCurve curve; + if (!m_result.gradient_curve.empty()) { + curve.points = m_result.gradient_curve; + } else { + double yStart = (m_result.gradient_direction == 0) ? kGradientMaxRatio : kGradientMinRatio; + double yEnd = (m_result.gradient_direction == 0) ? kGradientMinRatio : kGradientMaxRatio; + curve.points = {{0.0, yStart, NAN, NAN}, {1.0, yEnd, NAN, NAN}}; + } + + wxColour colA = comp_colour(0); + wxColour colB = comp_colour(1); + const int bands = std::max(80, swatch_sz); + double band_h = static_cast(swatch_sz) / bands; + dc.SetPen(*wxTRANSPARENT_PEN); + for (int b = 0; b < bands; ++b) { + double t = 1.0 - (b + 0.5) / bands; + double r1 = Slic3r::sample_gradient_curve(curve, t); + double r2 = 1.0 - r1; + wxColour band_col = blend_n_colors({colA, colB}, {r1, r2}); + dc.SetBrush(wxBrush(band_col)); + int by = y0 + static_cast(b * band_h); + int bh = static_cast((b + 1) * band_h) - static_cast(b * band_h) + 1; + dc.DrawRectangle(x0, by, swatch_sz, bh); + } + + // Mask corners: overdraw a thick background-colored rounded rect frame + // so the inner edge forms the desired rounded corners. + // Known limitation: this assumes the panel background equals + // darkModeColorFor(white). wxGraphicsContext::Clip(path) is not + // available in our wxWidgets build (only Clip(wxRegion) exists). + int r = static_cast(radius); + wxColour bg = StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(bg, r * 2)); + dc.DrawRoundedRectangle(x0 - r, y0 - r, swatch_sz + r * 2, swatch_sz + r * 2, radius * 2); + } else { + std::vector cols; + std::vector weights; + for (size_t i = 0; i < n; ++i) { + cols.push_back(comp_colour(i)); + weights.push_back(ratio(i) / 100.0); + } + wxColour mixed = blend_n_colors(cols, weights); + dc.SetBrush(wxBrush(mixed)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRoundedRectangle(x0, y0, swatch_sz, swatch_sz, radius); + } + }); + + sizer->Add(m_preview_canvas, 0, wxALIGN_CENTER); + + auto* label = new wxStaticText(this, wxID_ANY, _L("Effect Preview")); + label->SetForegroundColour(wxColour("#909090")); + label->SetFont(::Label::Body_13); + sizer->Add(label, 0, wxALIGN_CENTER | wxTOP, FromDIP(4)); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_material_selection() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + // Summary panel — draws N components dynamically + m_summary_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(234), FromDIP(40))); + m_summary_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_summary_panel->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_summary_panel); + wxSize sz = m_summary_panel->GetClientSize(); + + wxColour sum_bg = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + wxColour sum_text = StateColor::darkModeColorFor(wxColour("#262E30")); + dc.SetBrush(wxBrush(sum_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + int swatch_sz = FromDIP(20); + int y_center = (sz.GetHeight() - swatch_sz) / 2; + int x = FromDIP(13); + + dc.SetFont(::Label::Body_13); + + auto draw_summary_swatch = [&](size_t comp_idx) { + unsigned int c = comp(comp_idx); + std::string color_hex = "#D9D9D9"; + if (c >= 1 && c <= m_physical_colors.size()) + color_hex = m_physical_colors[c - 1]; + std::string label = std::to_string(c); + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, x, y_center); + x += swatch_sz + FromDIP(4); + }; + + if (m_result.gradient_enabled && num_components() == 2) { + size_t idx_a = (m_result.gradient_direction == 0) ? 0 : 1; + size_t idx_b = 1 - idx_a; + draw_summary_swatch(idx_a); + + dc.SetTextForeground(sum_text); + wxString arrow = wxT("\u2192"); + wxSize arrow_sz = dc.GetTextExtent(arrow); + dc.DrawText(arrow, x, y_center + (swatch_sz - arrow_sz.GetHeight()) / 2); + x += arrow_sz.GetWidth() + FromDIP(4); + + draw_summary_swatch(idx_b); + } else { + for (size_t i = 0; i < num_components(); ++i) { + if (i > 0) { + dc.SetTextForeground(sum_text); + wxString plus = wxT("+"); + wxSize plus_sz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, y_center + (swatch_sz - plus_sz.GetHeight()) / 2); + x += plus_sz.GetWidth() + FromDIP(4); + } + draw_summary_swatch(i); + + dc.SetTextForeground(sum_text); + wxString pct = wxString::Format(wxT("%d%%"), ratio(i)); + wxSize pct_sz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, y_center + (swatch_sz - pct_sz.GetHeight()) / 2); + x += pct_sz.GetWidth() + FromDIP(4); + } + } + }); + sizer->Add(m_summary_panel, 0, wxEXPAND); + + auto* sel_label = new wxStaticText(this, wxID_ANY, _L("Select Mixed Materials")); + sel_label->SetForegroundColour(wxColour("#909090")); + sel_label->SetFont(::Label::Body_12); + sizer->Add(sel_label, 0, wxTOP, FromDIP(6)); + + m_material_rows_sizer = new wxBoxSizer(wxVERTICAL); + + m_combo_filaments.clear(); + m_combo_to_physical.clear(); + for (size_t i = 0; i < m_result.components.size(); ++i) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(i + 1)); + auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); + lbl->SetFont(::Label::Body_12); + row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); + combo->SetKeepDropArrow(true); + combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); + row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); + + m_combo_filaments.push_back(combo); + m_combo_to_physical.push_back({}); + m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); + } + + sizer->Add(m_material_rows_sizer, 0, wxEXPAND); + + auto* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_btn_add_material = new Button(this, _L("+ Add Material")); + m_btn_add_material->SetBackgroundColor(wxColour("#F8F8F8")); + m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); + m_btn_add_material->SetTextColor(wxColour("#262E30")); + m_btn_add_material->SetMinSize(wxSize(-1, FromDIP(24))); + m_btn_add_material->SetCursor(wxCursor(wxCURSOR_HAND)); + m_btn_add_material->EnableTooltipEvenDisabled(); + m_btn_add_material->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_material(); }); + btn_sizer->Add(m_btn_add_material, 1, wxRIGHT, FromDIP(6)); + + m_btn_remove_material = new Button(this, _L("- Delete Material")); + m_btn_remove_material->SetBackgroundColor(wxColour("#F8F8F8")); + m_btn_remove_material->SetBorderColor(wxColour("#EEEEEE")); + m_btn_remove_material->SetTextColor(wxColour("#262E30")); + m_btn_remove_material->SetMinSize(wxSize(-1, FromDIP(24))); + m_btn_remove_material->SetCursor(wxCursor(wxCURSOR_HAND)); + m_btn_remove_material->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_material(); }); + m_btn_remove_material->Hide(); + btn_sizer->Add(m_btn_remove_material, 1, 0, 0); + + sizer->Add(btn_sizer, 0, wxEXPAND | wxTOP, FromDIP(9)); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_ratio_slider() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + auto* ratio_label = new wxStaticText(this, wxID_ANY, _L("Ratio")); + ratio_label->SetForegroundColour(wxColour("#909090")); + ratio_label->SetFont(::Label::Body_12); + sizer->Add(ratio_label, 0, wxBOTTOM, FromDIP(4)); + + m_ratio_bar = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(27))); + m_ratio_bar->SetMinSize(wxSize(-1, FromDIP(27))); + m_ratio_bar->SetBackgroundStyle(wxBG_STYLE_PAINT); + + m_ratio_bar->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_ratio_bar); + wxSize sz = m_ratio_bar->GetClientSize(); + + wxColour col_a = comp_colour(0), col_b = comp_colour(1); + + for (int x = 0; x < sz.GetWidth(); ++x) { + double t = (double)x / sz.GetWidth(); + wxColour c = blend_colors(col_a, col_b, 1.0 - t); + dc.SetPen(wxPen(c)); + dc.DrawLine(x, 0, x, sz.GetHeight()); + } + + int div_x = (int)(ratio(1) / 100.0 * sz.GetWidth()); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour(80, 80, 80)), FromDIP(4))); + dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + dc.SetPen(wxPen(*wxWHITE, FromDIP(2))); + dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + }); + + m_ratio_bar->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + m_dragging = true; + m_ratio_bar->CaptureMouse(); + int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); + on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); + }); + + m_ratio_bar->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { + if (!m_dragging) return; + int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); + on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); + }); + + m_ratio_bar->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { + if (m_dragging) { + m_dragging = false; + if (m_ratio_bar->HasCapture()) + m_ratio_bar->ReleaseMouse(); + } + }); + + sizer->Add(m_ratio_bar, 0, wxEXPAND); + + auto* pct_sizer = new wxBoxSizer(wxHORIZONTAL); + m_label_ratio_a = new RatioLabelPanel(this); + m_label_ratio_a->SetLabel(wxString::Format(wxT("%d%%"), ratio(0))); + m_label_ratio_b = new RatioLabelPanel(this); + m_label_ratio_b->SetLabel(wxString::Format(wxT("%d%%"), ratio(1))); + auto bind_ratio_click = [this](RatioLabelPanel* label, size_t idx) { + label->Bind(wxEVT_LEFT_DOWN, [this, label, idx](wxMouseEvent&) { + wxRect rect(wxPoint(0, 0), label->GetClientSize()); + start_ratio_editor(idx, label, rect); + }); + }; + bind_ratio_click(m_label_ratio_a, 0); + bind_ratio_click(m_label_ratio_b, 1); + pct_sizer->Add(m_label_ratio_a, 0); + pct_sizer->AddStretchSpacer(1); + pct_sizer->Add(m_label_ratio_b, 0); + sizer->Add(pct_sizer, 0, wxEXPAND | wxTOP, FromDIP(2)); + + return sizer; +} + +// ---- Triangle (ternary) ratio picker ---- + +// Barycentric coordinate utilities +struct TriPoint { double x, y; }; + +static double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) +{ + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); +} + +static bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double total = tri_signed_area2(v0, v1, v2); + if (std::abs(total) < 1e-9) return false; + double s0 = tri_signed_area2(p, v1, v2) / total; + double s1 = tri_signed_area2(v0, p, v2) / total; + double s2 = 1.0 - s0 - s1; + return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; +} + +static void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2) +{ + double total = std::abs(tri_signed_area2(v0, v1, v2)); + if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } + w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; + w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; + w2 = 1.0 - w0 - w1; + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } +} + +static TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } + return {w0 * v0.x + w1 * v1.x + w2 * v2.x, + w0 * v0.y + w1 * v1.y + w2 * v2.y}; +} + +wxBoxSizer* MixedFilamentDialog::create_triangle_picker() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + int panel_w = FromDIP(160); + int panel_h = FromDIP(160); + m_triangle_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(panel_w, panel_h)); + m_triangle_panel->SetMinSize(wxSize(panel_w, panel_h)); + m_triangle_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_triangle_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto get_vertices = [this]() -> std::tuple { + wxSize sz = m_triangle_panel->GetClientSize(); + double pw = sz.GetWidth(), ph = sz.GetHeight(); + double margin = FromDIP(20); + double avail = std::min(pw, ph) - 2 * margin; + double side = avail; + double tri_h = side * std::sqrt(3.0) / 2.0; + double cx = pw / 2.0; + double top_y = (ph - tri_h) / 2.0; + double bot_y = top_y + tri_h; + TriPoint v0 = {cx, top_y}; // top + TriPoint v1 = {cx - side / 2.0, bot_y}; // bottom-left + TriPoint v2 = {cx + side / 2.0, bot_y}; // bottom-right + return {v0, v1, v2}; + }; + + m_triangle_panel->Bind(wxEVT_PAINT, [this, get_vertices](wxPaintEvent&) { + wxBufferedPaintDC dc(m_triangle_panel); + wxSize sz = m_triangle_panel->GetClientSize(); + auto [v0, v1, v2] = get_vertices(); + + wxColour tri_bg = StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(wxBrush(tri_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + wxColour c0 = comp_colour(0), c1 = comp_colour(1), c2 = comp_colour(2); + + const bool cache_valid = m_tri_cache_bmp.IsOk() && + m_tri_cache_size == sz && + m_tri_cache_c0 == c0 && m_tri_cache_c1 == c1 && m_tri_cache_c2 == c2; + + if (!cache_valid) { + int min_y = (int)std::min({v0.y, v1.y, v2.y}); + int max_y = (int)std::max({v0.y, v1.y, v2.y}); + int min_x = (int)std::min({v0.x, v1.x, v2.x}); + int max_x = (int)std::max({v0.x, v1.x, v2.x}); + + m_tri_cache_bmp = wxBitmap(sz.GetWidth(), sz.GetHeight(), 24); + wxMemoryDC mdc(m_tri_cache_bmp); + mdc.SetBrush(wxBrush(tri_bg)); + mdc.SetPen(*wxTRANSPARENT_PEN); + mdc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + for (int py = min_y; py <= max_y; ++py) { + for (int px = min_x; px <= max_x; ++px) { + TriPoint p = {(double)px, (double)py}; + if (!tri_contains(p, v0, v1, v2)) continue; + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + unsigned char mr, mg, mb; + if (w0 + w1 > 1e-6) { + float t01 = static_cast(w1 / (w0 + w1)); + Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), + c1.Red(), c1.Green(), c1.Blue(), + t01, &mr, &mg, &mb); + float t2 = static_cast(w2); + Slic3r::filament_mixer_lerp(mr, mg, mb, + c2.Red(), c2.Green(), c2.Blue(), + t2, &mr, &mg, &mb); + } else { + mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); + } + mdc.SetPen(wxPen(wxColour(mr, mg, mb))); + mdc.DrawPoint(px, py); + } + } + + mdc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); + mdc.SetBrush(*wxTRANSPARENT_BRUSH); + wxPoint pts[3] = {{(int)v0.x, (int)v0.y}, {(int)v1.x, (int)v1.y}, {(int)v2.x, (int)v2.y}}; + mdc.DrawPolygon(3, pts); + + mdc.SelectObject(wxNullBitmap); + m_tri_cache_c0 = c0; m_tri_cache_c1 = c1; m_tri_cache_c2 = c2; + m_tri_cache_size = sz; + } + + dc.DrawBitmap(m_tri_cache_bmp, 0, 0); + + // Drag handle (always redrawn on top of cached bitmap) + double hx = m_tri_wx * v0.x + m_tri_wy * v1.x + m_tri_wz * v2.x; + double hy = m_tri_wx * v0.y + m_tri_wy * v1.y + m_tri_wz * v2.y; + int handle_r = FromDIP(5); + dc.SetBrush(*wxWHITE_BRUSH); + dc.SetPen(wxPen(wxColour("#262E30"), FromDIP(2))); + dc.DrawCircle((int)hx, (int)hy, handle_r); + + if (m_result.ratios.size() >= 3) { + dc.SetFont(::Label::Body_10); + wxSize ts0 = dc.GetTextExtent(wxString::Format(wxT("%d%%"), m_result.ratios[0])); + int top_label_y = std::max(0, (int)(v0.y - ts0.GetHeight() - FromDIP(4))); + + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(wxColour("#909090")); + dc.DrawText(_L("Ratio"), FromDIP(2), top_label_y); + + // Position the real RatioLabelPanel children + for (int i = 0; i < 3 && i < (int)m_triangle_ratio_labels.size(); ++i) { + if (!m_triangle_ratio_labels[i]) continue; + m_triangle_ratio_labels[i]->SetLabel( + wxString::Format(wxT("%d%%"), m_result.ratios[i])); + wxSize lsz = m_triangle_ratio_labels[i]->GetMinSize(); + int lx = 0, ly = 0; + if (i == 0) { + lx = (int)(v0.x - lsz.GetWidth() / 2); + ly = top_label_y; + } else if (i == 1) { + lx = (int)(v1.x - lsz.GetWidth() / 2); + ly = (int)(v1.y + FromDIP(3)); + } else { + lx = (int)(v2.x - lsz.GetWidth() / 2); + ly = (int)(v2.y + FromDIP(3)); + } + m_triangle_ratio_labels[i]->SetSize(lx, ly, lsz.GetWidth(), lsz.GetHeight()); + } + } + }); + + auto handle_mouse = [this, get_vertices](wxMouseEvent& e, bool is_down) { + auto [v0, v1, v2] = get_vertices(); + TriPoint p = {(double)e.GetX(), (double)e.GetY()}; + + if (is_down) { + // Only start dragging when the press lands inside the triangle; + // clicks outside the triangle must not change the mix ratio. + if (!tri_contains(p, v0, v1, v2)) + return; + m_dragging = true; + m_triangle_panel->CaptureMouse(); + } + + if (!m_dragging) return; + + TriPoint clamped = tri_clamp(p, v0, v1, v2); + tri_barycentric(clamped, v0, v1, v2, m_tri_wx, m_tri_wy, m_tri_wz); + + int r0 = (int)(m_tri_wx * 100 + 0.5); + int r1 = (int)(m_tri_wy * 100 + 0.5); + int r2 = 100 - r0 - r1; + r0 = std::clamp(r0, 0, 100); + r1 = std::clamp(r1, 0, 100); + r2 = std::clamp(r2, 0, 100); + + apply_dragged_triangle_ratio(r0, r1, r2); + }; + + // Create 3 RatioLabelPanel children on the triangle panel + m_triangle_ratio_labels.fill(nullptr); + for (int i = 0; i < 3; ++i) { + auto* lbl = new RatioLabelPanel(m_triangle_panel); + lbl->SetLabel(wxString::Format(wxT("%d%%"), + (i < (int)m_result.ratios.size()) ? m_result.ratios[i] : 33)); + size_t idx = (size_t)i; + lbl->Bind(wxEVT_LEFT_DOWN, [this, lbl, idx](wxMouseEvent&) { + wxRect rect(wxPoint(0, 0), lbl->GetClientSize()); + start_ratio_editor(idx, lbl, rect); + }); + m_triangle_ratio_labels[i] = lbl; + } + + m_triangle_panel->Bind(wxEVT_LEFT_DOWN, [this, handle_mouse](wxMouseEvent& e) { + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + handle_mouse(e, true); + }); + m_triangle_panel->Bind(wxEVT_MOTION, [this, handle_mouse](wxMouseEvent& e) { + if (m_dragging) + handle_mouse(e, false); + }); + m_triangle_panel->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { + if (m_dragging) { + m_dragging = false; + if (m_triangle_panel->HasCapture()) + m_triangle_panel->ReleaseMouse(); + } + }); + + sizer->Add(m_triangle_panel, 0); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_gradient_section() +{ + m_gradient_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_chk_gradient = new ::CheckBox(this); + m_chk_gradient->SetValue(m_result.gradient_enabled); + m_chk_gradient->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { e.Skip(); on_gradient_toggled(); }); + m_gradient_sizer->Add(m_chk_gradient, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + + m_label_gradient = new wxStaticText(this, wxID_ANY, _L("Gradient Effect")); + m_label_gradient->SetFont(::Label::Body_13); + m_gradient_sizer->Add(m_label_gradient, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + m_combo_gradient_dir = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(152), FromDIP(24)), 0, nullptr, wxCB_READONLY); + m_combo_gradient_dir->SetKeepDropArrow(true); + update_gradient_direction_items(); + m_combo_gradient_dir->SetSelection(m_result.gradient_direction); + m_combo_gradient_dir->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_gradient_direction_changed(); }); + m_combo_gradient_dir->Show(m_result.gradient_enabled); + + m_gradient_sizer->Add(m_combo_gradient_dir, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + auto* outer = new wxBoxSizer(wxVERTICAL); + outer->Add(m_gradient_sizer, 0, wxEXPAND); + + // Custom curve editor: visible only when gradient is on and exactly 2 components are mixed. + m_curve_sizer = new wxBoxSizer(wxVERTICAL); + m_curve_editor = new GradientCurveEditor(this, comp_colour(0), comp_colour(1)); + if (!m_result.gradient_curve.empty()) + m_curve_editor->set_points(m_result.gradient_curve); + else + m_curve_editor->reset_to_linear((m_result.gradient_direction == 0) ? 0.9 : 0.1, + (m_result.gradient_direction == 0) ? 0.1 : 0.9); + m_curve_editor->Bind(wxEVT_GRADIENT_CURVE_CHANGED, + [this](wxCommandEvent&) { on_gradient_curve_changed(); }); + m_curve_sizer->Add(m_curve_editor, 0, wxEXPAND | wxTOP, FromDIP(4)); + + outer->Add(m_curve_sizer, 0, wxEXPAND | wxTOP, FromDIP(6)); + const bool curve_visible = m_result.gradient_enabled && num_components() == 2; + m_curve_sizer->ShowItems(curve_visible); + + // Per-part gradient toggle sits BELOW the curve editor. + m_per_part_gradient_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_chk_per_part_gradient = new ::CheckBox(this); + m_chk_per_part_gradient->SetValue(m_result.per_part_gradient); + m_chk_per_part_gradient->Bind(wxEVT_TOGGLEBUTTON, + [this](wxCommandEvent& e) { e.Skip(); on_per_part_gradient_toggled(); }); + m_per_part_gradient_sizer->Add(m_chk_per_part_gradient, 0, + wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + + m_label_per_part_gradient = new wxStaticText(this, wxID_ANY, _L("Enable per-part gradient effect")); + m_label_per_part_gradient->SetFont(::Label::Body_13); + m_per_part_gradient_sizer->Add(m_label_per_part_gradient, 0, + wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + outer->Add(m_per_part_gradient_sizer, 0, wxEXPAND | wxTOP, FromDIP(2)); + m_per_part_gradient_sizer->ShowItems(m_result.gradient_enabled); + + return outer; +} + +wxBoxSizer* MixedFilamentDialog::create_recommendation_grid() +{ + auto* outer = new wxBoxSizer(wxVERTICAL); + + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* rec_label = new wxStaticText(this, wxID_ANY, _L("Mixing Recommendations")); + rec_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#ACACAC"))); + rec_label->SetFont(::Label::Body_10); + title_sizer->Add(rec_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + + auto* rec_line = new wxPanel(this, wxID_ANY); + rec_line->SetMinSize(wxSize(-1, 1)); + rec_line->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#DFDFDF"))); + title_sizer->Add(rec_line, 1, wxALIGN_CENTER_VERTICAL); + + outer->Add(title_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + m_recommendation_scroll = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(116))); + m_recommendation_scroll->SetScrollRate(0, 5); + m_recommendation_scroll->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); + + m_recommendation_grid = new wxWrapSizer(wxHORIZONTAL, wxWRAPSIZER_DEFAULT_FLAGS); + auto* scroll_inner_sizer = new wxBoxSizer(wxVERTICAL); + scroll_inner_sizer->Add(m_recommendation_grid, 1, wxEXPAND | wxLEFT | wxTOP, FromDIP(8)); + m_recommendation_scroll->SetSizer(scroll_inner_sizer); + + rebuild_recommendation_items(); + + outer->Add(m_recommendation_scroll, 1, wxEXPAND | wxTOP, FromDIP(4)); + return outer; +} + +void MixedFilamentDialog::rebuild_recommendation_items() +{ + if (!m_recommendation_scroll || !m_recommendation_grid) + return; + + static constexpr int MAX_RECOMMENDATIONS = 100; + + m_recommendation_scroll->Freeze(); + m_recommendation_grid->Clear(true); + + size_t n = m_physical_colors.size(); + int count = 0; + + // Group physical filaments by type (only same-type combos are recommended) + std::map> type_groups; + for (size_t i = 0; i < n; ++i) { + std::string t = (i < m_physical_types.size()) ? m_physical_types[i] : "PLA"; + // Skip support filaments (type ends with "-S") + if (t.size() >= 2 && t.compare(t.size() - 2, 2, "-S") == 0) + continue; + type_groups[t].push_back(i); + } + + if (num_components() >= 3) { + // Three-color: C(g,3) x 3 variants per same-type group + for (auto& [type, indices] : type_groups) { + if (count >= MAX_RECOMMENDATIONS) break; + size_t g = indices.size(); + for (size_t ai = 0; ai < g && count < MAX_RECOMMENDATIONS; ++ai) { + for (size_t bi = ai + 1; bi < g && count < MAX_RECOMMENDATIONS; ++bi) { + for (size_t ci = bi + 1; ci < g && count < MAX_RECOMMENDATIONS; ++ci) { + size_t idx[3] = {indices[ai], indices[bi], indices[ci]}; + // 3 variants: each filament takes the 50% role in turn + for (int dominant = 0; dominant < 3 && count < MAX_RECOMMENDATIONS; ++dominant) { + size_t i0 = idx[(dominant + 1) % 3]; // 25% + size_t i1 = idx[(dominant + 2) % 3]; // 25% + size_t i2 = idx[dominant]; // 50% + + wxColour ca(m_physical_colors[i0]); + wxColour cb(m_physical_colors[i1]); + wxColour cc(m_physical_colors[i2]); + wxColour mixed = blend_n_colors({ca, cb, cc}, {0.25, 0.25, 0.50}); + + auto* item = new wxPanel(m_recommendation_scroll, wxID_ANY, + wxDefaultPosition, wxSize(FromDIP(20), FromDIP(20))); + item->SetBackgroundColour(mixed); + item->SetCursor(wxCursor(wxCURSOR_HAND)); + + unsigned int ca_1 = (unsigned int)(i0 + 1); + unsigned int cb_1 = (unsigned int)(i1 + 1); + unsigned int cc_1 = (unsigned int)(i2 + 1); + item->Bind(wxEVT_LEFT_UP, [this, ca_1, cb_1, cc_1](wxMouseEvent&) { + on_recommendation_clicked_triple(ca_1, cb_1, cc_1); + }); + item->SetToolTip(wxString::Format(wxT("%s + %s + %s"), + wxString::FromUTF8(m_physical_names[i0]), + wxString::FromUTF8(m_physical_names[i1]), + wxString::FromUTF8(m_physical_names[i2]))); + + m_recommendation_grid->Add(item, 0, wxRIGHT | wxBOTTOM, FromDIP(6)); + ++count; + } + } + } + } + } + } else { + // Two-color: C(g,2) per same-type group + for (auto& [type, indices] : type_groups) { + if (count >= MAX_RECOMMENDATIONS) break; + size_t g = indices.size(); + for (size_t ai = 0; ai < g && count < MAX_RECOMMENDATIONS; ++ai) { + for (size_t bi = ai + 1; bi < g && count < MAX_RECOMMENDATIONS; ++bi) { + size_t i = indices[ai]; + size_t j = indices[bi]; + + wxColour ca(m_physical_colors[i]); + wxColour cb(m_physical_colors[j]); + wxColour mixed = blend_colors(ca, cb, 0.5); + + auto* item = new wxPanel(m_recommendation_scroll, wxID_ANY, + wxDefaultPosition, wxSize(FromDIP(20), FromDIP(20))); + item->SetBackgroundColour(mixed); + item->SetCursor(wxCursor(wxCURSOR_HAND)); + + unsigned int comp_a = (unsigned int)(i + 1); + unsigned int comp_b = (unsigned int)(j + 1); + item->Bind(wxEVT_LEFT_UP, [this, comp_a, comp_b](wxMouseEvent&) { + on_recommendation_clicked(comp_a, comp_b); + }); + item->SetToolTip(wxString::Format(wxT("%s + %s"), + wxString::FromUTF8(m_physical_names[i]), + wxString::FromUTF8(m_physical_names[j]))); + + m_recommendation_grid->Add(item, 0, wxRIGHT | wxBOTTOM, FromDIP(6)); + ++count; + } + } + } + } + + m_recommendation_scroll->SetScrollbars(0, FromDIP(20), 0, 1); + m_recommendation_scroll->FitInside(); + m_recommendation_scroll->Layout(); + m_recommendation_scroll->Thaw(); +} + +wxBoxSizer* MixedFilamentDialog::create_button_panel() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + m_btn_cancel = new Button(this, _L("Cancel")); + m_btn_cancel->SetBackgroundColor(*wxWHITE); + m_btn_cancel->SetBorderColor(wxColour("#CECECE")); + m_btn_cancel->SetTextColor(wxColour("#262E30")); + m_btn_cancel->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + m_btn_ok = new Button(this, _L("OK")); + m_btn_ok->SetBackgroundColor(wxColour("#00AE42")); + m_btn_ok->SetBorderColor(wxColour("#00AE42")); + m_btn_ok->SetTextColor(*wxWHITE); + m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + + sizer->Add(m_btn_cancel, 0, wxRIGHT, FromDIP(12)); + sizer->Add(m_btn_ok, 0); + + return sizer; +} + +void MixedFilamentDialog::rebuild_all_combos() +{ + m_combo_to_physical.resize(m_combo_filaments.size()); + + for (size_t i = 0; i < m_combo_filaments.size(); ++i) { + std::set others_selected; + std::set others_types; + for (size_t k = 0; k < m_result.components.size(); ++k) { + if (k == i) continue; + unsigned int phys = m_result.components[k]; + others_selected.insert(phys); + if (phys >= 1 && phys <= m_physical_types.size()) + others_types.insert(m_physical_types[phys - 1]); + } + + auto* combo = m_combo_filaments[i]; + combo->Clear(); + m_combo_to_physical[i].clear(); + + int restore_sel = -1; + unsigned int cur_phys = (i < m_result.components.size()) ? m_result.components[i] : 0; + + if (cur_phys == 0) { + combo->Append(_L("-- Select --")); + m_combo_to_physical[i].push_back(0); + restore_sel = 0; + } + + for (size_t j = 0; j < m_physical_names.size(); ++j) { + unsigned int phys_1based = (unsigned int)(j + 1); + + if (others_selected.count(phys_1based)) + continue; + + int style = 0; + if (!others_types.empty() && !m_physical_types.empty()) { + std::string this_type = (j < m_physical_types.size()) ? m_physical_types[j] : "PLA"; + if (others_types.find(this_type) == others_types.end()) + style = DD_ITEM_STYLE_DIMMED; + } + + int idx = combo->Append(wxString::FromUTF8(m_physical_names[j]), make_swatch_bitmap(j), style); + m_combo_to_physical[i].push_back(phys_1based); + + if (phys_1based == cur_phys) + restore_sel = idx; + } + + if (restore_sel >= 0) + combo->SetSelection(restore_sel); + else if (combo->GetCount() > 0) + combo->SetSelection(0); + } +} + +void MixedFilamentDialog::refresh_curve_editor_colors() +{ + if (m_curve_editor) + m_curve_editor->set_colors(comp_colour(0), comp_colour(1)); +} + +// ---- Event Handlers ---- + +void MixedFilamentDialog::on_filament_changed() +{ + for (size_t i = 0; i < m_combo_filaments.size() && i < m_result.components.size(); ++i) { + int sel = m_combo_filaments[i]->GetSelection(); + if (sel >= 0 && i < m_combo_to_physical.size() && sel < (int)m_combo_to_physical[i].size()) + m_result.components[i] = m_combo_to_physical[i][sel]; + } + + refresh_curve_editor_colors(); + rebuild_all_combos(); + update_gradient_direction_items(); + update_preview(); + update_ok_button_state(); +} + +void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) +{ + if (m_result.ratios.size() < 2) return; + m_result.ratios[0] = new_ratio_a; + m_result.ratios[1] = 100 - new_ratio_a; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + update_preview(); +} + +void MixedFilamentDialog::on_gradient_toggled() +{ + bool checked = m_chk_gradient->GetValue(); + + if (checked) { + auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (!print_config.opt_bool("enable_mixed_color_sublayer")) { + wxMessageDialog dlg(this, + _L("Gradient effect requires 'Mixed color sublayer' to be enabled. Enable it now?"), + _L("Mixed Color Sublayer"), + wxYES_NO | wxICON_QUESTION); + if (dlg.ShowModal() == wxID_YES) { + DynamicPrintConfig new_conf; + new_conf.set_key_value("enable_mixed_color_sublayer", new ConfigOptionBool(true)); + wxGetApp().get_tab(Preset::TYPE_PRINT)->load_config(new_conf); + } else { + m_chk_gradient->SetValue(false); + return; + } + } + } + + m_result.gradient_enabled = m_chk_gradient->GetValue(); + + if (m_ratio_sizer) + m_ratio_sizer->ShowItems(!m_result.gradient_enabled && num_components() == 2); + if (m_combo_gradient_dir) + m_combo_gradient_dir->Show(m_result.gradient_enabled); + if (m_per_part_gradient_sizer) + m_per_part_gradient_sizer->ShowItems(m_result.gradient_enabled); + if (m_curve_sizer) + m_curve_sizer->ShowItems(m_result.gradient_enabled && num_components() == 2); + if (!m_result.gradient_enabled) { + m_result.per_part_gradient = false; + if (m_chk_per_part_gradient) m_chk_per_part_gradient->SetValue(false); + } + + // Toggling the curve editor changes the right column height (and width when + // turning gradient on), so the dialog must follow or the recommendation list + // gets squeezed off-screen. Same trick as 2-color -> 3-color switching. + const wxSize new_size = compute_dialog_size(); + if (GetSize() != new_size) { + const wxRect old_rect = GetRect(); + const wxPoint center(old_rect.x + old_rect.width / 2, + old_rect.y + old_rect.height / 2); + SetSize(new_size); + SetPosition(wxPoint(center.x - new_size.x / 2, + center.y - new_size.y / 2)); + } + + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_gradient_direction_changed() +{ + if (!m_combo_gradient_dir) return; + m_result.gradient_direction = m_combo_gradient_dir->GetSelection(); + + // Mirror the user's custom curve around y=0.5 instead of resetting it, so + // shape work (added anchors, bent segments) survives a direction toggle. + // reverse() flips y and tangent signs consistently; default two-point + // linear curves end up matching the new direction exactly (0.9->0.1 <-> 0.1->0.9). + if (m_curve_editor) { + m_curve_editor->reverse(); + m_result.gradient_curve = m_curve_editor->get_points(); + } + update_preview(); +} + +void MixedFilamentDialog::on_gradient_curve_changed() +{ + if (m_curve_editor) + m_result.gradient_curve = m_curve_editor->get_points(); + update_preview(); +} + +void MixedFilamentDialog::on_per_part_gradient_toggled() +{ + if (m_chk_per_part_gradient) + m_result.per_part_gradient = m_chk_per_part_gradient->GetValue(); +} + +void MixedFilamentDialog::on_add_material() +{ + size_t n = num_components(); + if (n >= (size_t)MAX_COMPONENTS) return; + + unsigned int new_comp = 0; + for (size_t j = 0; j < m_physical_names.size(); ++j) { + unsigned int candidate = (unsigned int)(j + 1); + bool used = false; + for (auto c : m_result.components) + if (c == candidate) { used = true; break; } + if (!used) { new_comp = candidate; break; } + } + if (new_comp == 0) return; + m_result.components.push_back(new_comp); + + int each = 100 / (int)(n + 1); + m_result.ratios.clear(); + int assigned = 0; + for (size_t i = 0; i < n; ++i) { + m_result.ratios.push_back(each); + assigned += each; + } + m_result.ratios.push_back(100 - assigned); + + if (m_result.ratios.size() >= 3) { + int sum = 0; + for (int r : m_result.ratios) sum += r; + if (sum > 0) { + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; + } + } + reset_manual_ratio_state(); + + auto* row = new wxBoxSizer(wxHORIZONTAL); + wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(n + 1)); + auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); + lbl->SetFont(::Label::Body_12); + row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); + combo->SetKeepDropArrow(true); + combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); + row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); + + m_combo_filaments.push_back(combo); + m_combo_to_physical.push_back({}); + m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + rebuild_recommendation_items(); + + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_remove_material() +{ + if (num_components() <= 2) + return; + + m_result.components.resize(2); + m_result.ratios = {50, 50}; + m_tri_wx = 0.5; + m_tri_wy = 0.5; + m_tri_wz = 0.0; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + if (m_material_rows_sizer && m_material_rows_sizer->GetItemCount() > 2) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + + if (m_combo_filaments.size() > 2) + m_combo_filaments.pop_back(); + if (m_combo_to_physical.size() > 2) + m_combo_to_physical.pop_back(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + rebuild_recommendation_items(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_recommendation_clicked(unsigned int comp_a, unsigned int comp_b) +{ + while (m_material_rows_sizer->GetItemCount() > 2) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + + while (m_combo_filaments.size() > 2) + m_combo_filaments.pop_back(); + while (m_combo_to_physical.size() > 2) + m_combo_to_physical.pop_back(); + + m_result.components = {comp_a, comp_b}; + m_result.ratios = {50, 50}; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_gradient_direction_items(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_recommendation_clicked_triple(unsigned int a, unsigned int b, unsigned int c) +{ + // Ensure we have exactly 3 combo rows + if (num_components() < 3) { + // Need to add a 3rd combo row + while (m_combo_filaments.size() < 3) { + size_t idx = m_combo_filaments.size(); + auto* row = new wxBoxSizer(wxHORIZONTAL); + wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(idx + 1)); + auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); + lbl->SetFont(::Label::Body_12); + row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); + combo->SetKeepDropArrow(true); + combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); + row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); + + m_combo_filaments.push_back(combo); + m_combo_to_physical.push_back({}); + m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); + } + } else if (num_components() > 3) { + while (m_material_rows_sizer->GetItemCount() > 3) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + while (m_combo_filaments.size() > 3) + m_combo_filaments.pop_back(); + while (m_combo_to_physical.size() > 3) + m_combo_to_physical.pop_back(); + } + + m_result.components = {a, b, c}; + m_result.ratios = {25, 25, 50}; + m_tri_wx = 0.25; + m_tri_wy = 0.25; + m_tri_wz = 0.50; + reset_manual_ratio_state(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_gradient_direction_items(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::update_preview() +{ + if (m_preview_canvas) m_preview_canvas->Refresh(); + if (m_summary_panel) m_summary_panel->Refresh(); + if (m_ratio_bar) m_ratio_bar->Refresh(); + if (m_triangle_panel) m_triangle_panel->Refresh(); +} + +void MixedFilamentDialog::paint_warning_panel(wxPaintEvent&) +{ + wxBufferedPaintDC dc(m_warning_panel); + wxSize sz = m_warning_panel->GetClientSize(); + + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + dc.SetBrush(wxBrush(wxColour(255, 245, 245))); + dc.SetPen(wxPen(wxColour("#E84C4C"), 1)); + dc.DrawRoundedRectangle(0, 0, sz.GetWidth(), sz.GetHeight(), FromDIP(4)); + + int x = FromDIP(10); + int cy = sz.GetHeight() / 2; + + int icon_r = FromDIP(7); + dc.SetBrush(wxBrush(wxColour("#E84C4C"))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawCircle(x + icon_r, cy, icon_r); + dc.SetFont(::Label::Body_10); + dc.SetTextForeground(*wxWHITE); + wxSize ex = dc.GetTextExtent(wxT("!")); + dc.DrawText(wxT("!"), x + icon_r - ex.GetWidth() / 2, cy - ex.GetHeight() / 2); + x += icon_r * 2 + FromDIP(6); + + if (m_type_mismatch_msg.empty()) return; + + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(wxColour("#E84C4C")); + wxString msg = m_type_mismatch_msg; + int avail_w = sz.GetWidth() - x - FromDIP(10); + wxSize ts = dc.GetTextExtent(msg); + if (ts.GetWidth() <= avail_w) { + dc.DrawText(msg, x, cy - ts.GetHeight() / 2); + } else { + wxArrayString lines; + wxString cur_line; + wxArrayString words; + wxStringTokenizer tkz(msg, wxT(" "), wxTOKEN_RET_EMPTY_ALL); + while (tkz.HasMoreTokens()) words.Add(tkz.GetNextToken()); + if (words.empty()) words.Add(msg); + for (size_t w = 0; w < words.size(); ++w) { + wxString test = cur_line.empty() ? words[w] : cur_line + wxT(" ") + words[w]; + if (dc.GetTextExtent(test).GetWidth() > avail_w && !cur_line.empty()) { + lines.Add(cur_line); + cur_line = words[w]; + } else { + cur_line = test; + } + } + if (!cur_line.empty()) lines.Add(cur_line); + if (lines.empty()) lines.Add(msg); + int line_h = dc.GetTextExtent(wxT("Mg")).GetHeight(); + int total_h = (int)lines.size() * line_h; + int y0 = (sz.GetHeight() - total_h) / 2; + for (size_t l = 0; l < lines.size(); ++l) + dc.DrawText(lines[l], x, y0 + (int)l * line_h); + } +} + +void MixedFilamentDialog::update_ok_button_state() +{ + if (!m_btn_ok) return; + + bool has_type_mismatch = false; + if (!m_physical_types.empty() && m_result.components.size() >= 2) { + std::map> type_groups; + for (size_t i = 0; i < m_result.components.size(); ++i) { + unsigned int phys = m_result.components[i]; + if (phys < 1 || phys > m_physical_types.size()) continue; + type_groups[m_physical_types[phys - 1]].push_back(phys); + } + has_type_mismatch = type_groups.size() > 1; + if (has_type_mismatch) { + wxString parts; + for (auto it = type_groups.begin(); it != type_groups.end(); ++it) { + if (!parts.empty()) + parts += _L(" and "); + wxString slots; + for (size_t j = 0; j < it->second.size(); ++j) { + if (!slots.empty()) slots += ", "; + slots += std::to_string(it->second[j]); + } + parts += wxString::Format(_L("Slot %s (%s)"), slots, wxString::FromUTF8(it->first)); + } + m_type_mismatch_msg = parts + " " + _L("cannot be mixed. Please select the same filament type."); + } + } + + bool has_unselected = false; + for (unsigned int c : m_result.components) { + if (c == 0) { has_unselected = true; break; } + } + + bool can_confirm = !has_type_mismatch && !has_unselected; + m_btn_ok->Enable(can_confirm); + if (has_unselected) { + m_btn_ok->SetBackgroundColor(wxColour("#CECECE")); + m_btn_ok->SetBorderColor(wxColour("#CECECE")); + m_btn_ok->SetToolTip(_L("Please select a filament for all components")); + } else if (has_type_mismatch) { + m_btn_ok->SetBackgroundColor(wxColour("#CECECE")); + m_btn_ok->SetBorderColor(wxColour("#CECECE")); + m_btn_ok->SetToolTip(_L("Cannot mix different filament types")); + } else { + m_btn_ok->SetBackgroundColor(wxColour("#00AE42")); + m_btn_ok->SetBorderColor(wxColour("#00AE42")); + m_btn_ok->SetToolTip(wxEmptyString); + } + + if (m_warning_panel) { + m_warning_panel->Show(has_type_mismatch); + Layout(); + } +} + +void MixedFilamentDialog::update_gradient_direction_items() +{ + if (!m_combo_gradient_dir) return; + + int prev_sel = m_combo_gradient_dir->GetSelection(); + m_combo_gradient_dir->Clear(); + + if (num_components() < 2) return; + + auto make_direction_bitmap = [this](size_t idx_from, size_t idx_to) -> wxBitmap { + int swatch_sz = FromDIP(20); + int arrow_w = FromDIP(16); + int gap = FromDIP(4); + int bmp_w = swatch_sz + gap + arrow_w + gap + swatch_sz; + int bmp_h = swatch_sz; + + wxColour dir_text = StateColor::darkModeColorFor(wxColour("#262E30")); + + return make_alpha_bitmap(bmp_w, bmp_h, [&](wxDC& dc) { + dc.SetFont(::Label::Body_13); + + auto draw_swatch = [&](int x, size_t idx) { + std::string color_hex = "#D9D9D9"; + if (idx < m_physical_colors.size()) + color_hex = m_physical_colors[idx]; + std::string label = std::to_string(idx + 1); + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, x, 0); + }; + + int x = 0; + draw_swatch(x, idx_from); + x += swatch_sz + gap; + + dc.SetTextForeground(dir_text); + wxString arrow = wxT("\u2192"); + wxSize arrow_sz = dc.GetTextExtent(arrow); + dc.DrawText(arrow, x + (arrow_w - arrow_sz.GetWidth()) / 2, + (bmp_h - arrow_sz.GetHeight()) / 2); + x += arrow_w + gap; + + draw_swatch(x, idx_to); + }); + }; + + size_t idx_a = (comp(0) >= 1) ? comp(0) - 1 : 0; + size_t idx_b = (comp(1) >= 1) ? comp(1) - 1 : 1; + + m_combo_gradient_dir->Append(wxT(" "), make_direction_bitmap(idx_a, idx_b)); + m_combo_gradient_dir->Append(wxT(" "), make_direction_bitmap(idx_b, idx_a)); + + if (prev_sel >= 0 && prev_sel < (int)m_combo_gradient_dir->GetCount()) + m_combo_gradient_dir->SetSelection(prev_sel); + else + m_combo_gradient_dir->SetSelection(0); +} + +wxSize MixedFilamentDialog::compute_dialog_size() const +{ + const bool is_three = (num_components() >= 3); + const bool curve_visible = !is_three && m_result.gradient_enabled; + + int w = FromDIP(439); + int h = FromDIP(580); + if (is_three) { + h = FromDIP(680); + } else if (curve_visible) { + // Wider so the gradient editor can show "Material Ratio" intact; + // +40 over the 3-color height to fit the curve editor while keeping the + // recommendation list visible (it can still scroll if needed). + w = FromDIP(470); + h = FromDIP(720); + } + return wxSize(w, h); +} + +void MixedFilamentDialog::update_component_count_ui() +{ + bool is_two = (num_components() == 2); + bool is_three = (num_components() >= 3); + + // Toggle ratio slider vs triangle picker + if (m_ratio_sizer) + m_ratio_sizer->ShowItems(is_two && !m_result.gradient_enabled); + if (m_triangle_sizer) + m_triangle_sizer->ShowItems(is_three); + + // 3-color: hide gradient entirely, force off + if (m_gradient_sizer) { + bool show_gradient = is_two; + m_chk_gradient->Show(show_gradient); + if (m_label_gradient) m_label_gradient->Show(show_gradient); + m_combo_gradient_dir->Show(show_gradient && m_result.gradient_enabled); + if (m_per_part_gradient_sizer) + m_per_part_gradient_sizer->ShowItems(show_gradient && m_result.gradient_enabled); + if (m_curve_sizer) + m_curve_sizer->ShowItems(show_gradient && m_result.gradient_enabled); + } + if (is_three) { + m_result.gradient_enabled = false; + if (m_chk_gradient) m_chk_gradient->SetValue(false); + m_result.per_part_gradient = false; + if (m_chk_per_part_gradient) m_chk_per_part_gradient->SetValue(false); + } + + if (m_btn_add_material) { + bool can_add = (num_components() < (size_t)MAX_COMPONENTS && m_physical_colors.size() > num_components()); + m_btn_add_material->Enable(can_add); + if (can_add) { + m_btn_add_material->SetTextColor(wxColour("#262E30")); + m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); + m_btn_add_material->SetToolTip(wxEmptyString); + } else { + m_btn_add_material->SetTextColor(wxColour("#CECECE")); + m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); + m_btn_add_material->SetToolTip(is_three ? _L("Maximum 3 materials for mixing") : _L("Maximum number of components reached")); + } + } + + if (m_btn_remove_material) { + m_btn_remove_material->Show(is_three); + m_btn_remove_material->Enable(is_three); + m_btn_remove_material->SetToolTip(is_three ? _L("Remove the third material") : wxString()); + } + + const wxSize new_size = compute_dialog_size(); + const wxRect old_rect = GetRect(); + const wxPoint center(old_rect.x + old_rect.width / 2, + old_rect.y + old_rect.height / 2); + SetSize(new_size); + SetPosition(wxPoint(center.x - new_size.x / 2, + center.y - new_size.y / 2)); + Layout(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp new file mode 100644 index 0000000000..a1deaa2022 --- /dev/null +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -0,0 +1,176 @@ +#ifndef slic3r_MixedFilamentDialog_hpp_ +#define slic3r_MixedFilamentDialog_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GUI_Utils.hpp" +#include "libslic3r/FilamentMixer.hpp" + +class Button; +class CheckBox; +class ComboBox; +class wxMouseEvent; +class wxScrolledWindow; +class wxTextCtrl; +class wxWrapSizer; + +namespace Slic3r { +namespace GUI { + +class GradientCurveEditor; +class RatioLabelPanel; + +struct MixedFilamentResult { + std::vector components; // 1-based physical filament indices + std::vector ratios; // percentages, sum = 100 + bool gradient_enabled = false; + int gradient_direction = 0; // 0 = A→B, 1 = B→A (only for 2-color) + bool per_part_gradient = false; // valid only when gradient_enabled == true + // Optional Photoshop-style custom curve overriding the linear A→B gradient. + // Empty -> use linear (gradient_direction). Non-empty -> cubic Hermite over [0,1]^2 + // with optional per-anchor tangent overrides (see GradientAnchor). + std::vector gradient_curve; +}; + +class MixedFilamentDialog : public DPIDialog +{ +public: + MixedFilamentDialog(wxWindow* parent, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types = {}); + + MixedFilamentDialog(wxWindow* parent, + const MixedFilamentResult& existing, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types = {}); + + MixedFilamentResult get_result() const { return m_result; } + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void build_ui(); + wxBoxSizer* create_preview_panel(); + wxBoxSizer* create_material_selection(); + wxBoxSizer* create_ratio_slider(); + wxBoxSizer* create_triangle_picker(); + wxBoxSizer* create_gradient_section(); + wxBoxSizer* create_recommendation_grid(); + wxBoxSizer* create_button_panel(); + + void on_filament_changed(); + void on_ratio_changed(int new_ratio_a); + void on_gradient_toggled(); + void on_gradient_direction_changed(); + void on_gradient_curve_changed(); + void on_per_part_gradient_toggled(); + void on_add_material(); + void on_remove_material(); + void on_recommendation_clicked(unsigned int comp_a, unsigned int comp_b); + void on_recommendation_clicked_triple(unsigned int a, unsigned int b, unsigned int c); + void apply_manual_ratio(size_t idx, int value); + void apply_dragged_triangle_ratio(int r0, int r1, int r2); + void reset_manual_ratio_state(); + void refresh_ratio_labels(); + void sync_triangle_weights_from_ratios(); + void start_ratio_editor(size_t idx, wxWindow* anchor, const wxRect& anchor_rect); + void commit_ratio_editor(bool apply); + void commit_ratio_editor_from_background(wxMouseEvent& e); + void update_preview(); + void update_ok_button_state(); + void update_gradient_direction_items(); + void update_component_count_ui(); + // Picks dialog (width, height) based on current state so the gradient curve + // editor and the recommendation list stay visible at the same time. + wxSize compute_dialog_size() const; + void rebuild_all_combos(); + void rebuild_recommendation_items(); + void refresh_curve_editor_colors(); + void paint_warning_panel(wxPaintEvent& evt); + + wxBitmap make_swatch_bitmap(size_t idx); + + // Helpers for component/ratio access + size_t num_components() const { return m_result.components.size(); } + unsigned int comp(size_t i) const { return (i < m_result.components.size()) ? m_result.components[i] : 1; } + int ratio(size_t i) const { return (i < m_result.ratios.size()) ? m_result.ratios[i] : 0; } + wxColour comp_colour(size_t i) const; + + MixedFilamentResult m_result; + bool m_edit_mode{false}; + std::vector m_physical_colors; + std::vector m_physical_names; + std::vector m_physical_types; + wxString m_type_mismatch_msg; + + // Combo item index -> 1-based physical filament index (per combo) + std::vector> m_combo_to_physical; + + // UI controls + wxPanel* m_preview_canvas{nullptr}; + wxPanel* m_summary_panel{nullptr}; + std::vector m_combo_filaments; + wxBoxSizer* m_material_rows_sizer{nullptr}; + wxPanel* m_ratio_bar{nullptr}; + wxPanel* m_triangle_panel{nullptr}; + RatioLabelPanel* m_label_ratio_a{nullptr}; + RatioLabelPanel* m_label_ratio_b{nullptr}; + wxPanel* m_ratio_editor_panel{nullptr}; + wxTextCtrl* m_ratio_editor{nullptr}; + CheckBox* m_chk_gradient{nullptr}; + wxStaticText* m_label_gradient{nullptr}; + ComboBox* m_combo_gradient_dir{nullptr}; + wxBoxSizer* m_gradient_sizer{nullptr}; + GradientCurveEditor* m_curve_editor{nullptr}; + wxBoxSizer* m_curve_sizer{nullptr}; + CheckBox* m_chk_per_part_gradient{nullptr}; + wxStaticText* m_label_per_part_gradient{nullptr}; + wxBoxSizer* m_per_part_gradient_sizer{nullptr}; + Button* m_btn_add_material{nullptr}; + Button* m_btn_remove_material{nullptr}; + Button* m_btn_ok{nullptr}; + Button* m_btn_cancel{nullptr}; + wxBoxSizer* m_warning_sizer{nullptr}; + wxPanel* m_warning_panel{nullptr}; + + wxBoxSizer* m_ratio_sizer{nullptr}; + wxBoxSizer* m_triangle_sizer{nullptr}; + wxBoxSizer* m_right_sizer{nullptr}; + + wxScrolledWindow* m_recommendation_scroll{nullptr}; + wxWrapSizer* m_recommendation_grid{nullptr}; + + // Cached preview bitmaps (loaded once at construction) + wxBitmap m_preview_bmp_two; + wxBitmap m_preview_bmp_three; + + // Drag state + bool m_dragging{false}; + std::vector m_ratio_manual_order; + size_t m_ratio_editor_idx{0}; + bool m_ratio_editor_committing{false}; + wxWindow* m_ratio_editor_anchor{nullptr}; + // Triangle picker drag point (barycentric weights) + double m_tri_wx{0.333}, m_tri_wy{0.333}, m_tri_wz{0.334}; + + // Cached triangle color bitmap (invalidated when colors or size change) + wxBitmap m_tri_cache_bmp; + wxColour m_tri_cache_c0, m_tri_cache_c1, m_tri_cache_c2; + wxSize m_tri_cache_size; + std::array m_triangle_ratio_labels{nullptr, nullptr, nullptr}; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_MixedFilamentDialog_hpp_ diff --git a/src/slic3r/GUI/NotificationManager.hpp b/src/slic3r/GUI/NotificationManager.hpp index 22720bf33e..7a3e6b8bb5 100644 --- a/src/slic3r/GUI/NotificationManager.hpp +++ b/src/slic3r/GUI/NotificationManager.hpp @@ -162,6 +162,8 @@ enum class NotificationType //BBL: plugin install hint BBLPluginInstallHint, BBLFlushingVolumeZero, + // A mixed-color filament references a deleted component, or its components disagree in type. + BBLMixedFilamentBroken, BBLPluginUpdateAvailable, BBLPreviewOnlyMode, BBLPrinterConfigUpdateAvailable, diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 910c761c06..1bbe1fc034 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -6,6 +6,7 @@ #include #include #include "libslic3r/MultiNozzleUtils.hpp" +#include "libslic3r/FilamentMixer.hpp" #include #include #include @@ -1675,6 +1676,25 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // 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. + { + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1836,6 +1856,24 @@ std::vector PartPlate::get_extruders_under_cli(bool conside_custom_gcode, D std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // 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. + { + 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)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1889,6 +1927,25 @@ std::vector PartPlate::get_extruders_without_support(bool conside_custom_gc std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // 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. + { + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1990,6 +2047,55 @@ bool PartPlate::check_tpu_printable_status(const DynamicPrintConfig & config, co return true; } +// A mixed-color filament alternates between its components constantly. On a single-nozzle +// printer every one of those switches is a full filament change plus a purge, so warn the +// user before they commit to it. Printers with more than one nozzle can keep the components +// loaded simultaneously and are not affected. +// +// BBS additionally excludes its H2C/H2D/X2D models by name; those are multi-nozzle machines +// already ruled out by the nozzle_diameter test above, so the name check is dropped here +// rather than carried over as a Bambu-specific special case. +bool PartPlate::check_single_extruder_mixed_filament_risk(const DynamicPrintConfig &config, std::string &warning_text) const +{ + warning_text.clear(); + + auto *nozzle_diameter_opt = config.option("nozzle_diameter"); + if (!nozzle_diameter_opt || nozzle_diameter_opt->values.size() > 1) + return false; + + auto *is_mixed_opt = wxGetApp().preset_bundle->project_config.option("filament_is_mixed"); + if (!is_mixed_opt || !has_any_mixed_filament(is_mixed_opt->values)) + return false; + + auto is_mixed_slot = [&](int extruder_1based) { + size_t idx = (size_t)(extruder_1based - 1); + return idx < is_mixed_opt->values.size() && is_mixed_opt->values[idx]; + }; + + const std::string mixed_warn_msg = _u8L("Printing mixed-color filament on a single-extruder printer requires frequent filament changes and flushing, " + "which may significantly increase waste and the risk of nozzle / waste-chute clogging."); + + for (int obj_idx = 0; obj_idx < (int)m_model->objects.size(); ++obj_idx) { + if (!contain_instance_totally(obj_idx, 0)) + continue; + ModelObject *mo = m_model->objects[obj_idx]; + int obj_ext = mo->config.has("extruder") ? mo->config.extruder() : 1; + if (is_mixed_slot(obj_ext)) { + warning_text = mixed_warn_msg; + return true; + } + for (ModelVolume *mv : mo->volumes) { + int vol_ext = mv->config.has("extruder") ? mv->config.extruder() : obj_ext; + if (is_mixed_slot(vol_ext)) { + warning_text = mixed_warn_msg; + return true; + } + } + } + + return false; +} + bool PartPlate::check_mixture_of_pla_and_petg(const DynamicPrintConfig &config) { bool has_pla = false; diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 47481dcad4..5760320b49 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -354,6 +354,9 @@ public: bool check_filament_printable(const DynamicPrintConfig & config, wxString& error_message); bool check_tpu_printable_status(const DynamicPrintConfig & config, const std::vector &tpu_filaments); bool check_mixture_of_pla_and_petg(const DynamicPrintConfig & config); + // Warns when a mixed-color filament is used on a single-nozzle printer, where every + // component switch costs a full filament change and purge. + bool check_single_extruder_mixed_filament_risk(const DynamicPrintConfig &config, std::string &warning_text) const; bool check_mixture_filament_compatible(const DynamicPrintConfig& config, std::string &error_msg); bool check_compatible_of_nozzle_and_filament(const DynamicPrintConfig & config, const std::vector& filament_presets, std::string& error_msg); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index af6b564ab1..676d29b961 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -166,6 +166,12 @@ #include // Needs to be last because reasons :-/ #include #include "WipeTowerDialog.hpp" +#include "MixedFilamentDialog.hpp" +#include "TextureImportDialog.hpp" +#include "libslic3r/TexturePainting.hpp" +#include "ColorDecomposeSupport.hpp" +#include "FilamentBitmapUtils.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "ObjColorDialog.hpp" #include "libslic3r/CustomGCode.hpp" @@ -202,6 +208,17 @@ static const std::pair THUMBNAIL_SIZE_3MF = { 512, 5 namespace Slic3r { namespace GUI { +// A textured mesh is only worth routing through the import dialog when it actually carries +// decoded image data; UV-only meshes have nothing to sample. +static bool has_importable_texture(const Slic3r::TexturedMesh& textured_mesh) +{ + if (textured_mesh.vertices.empty() || textured_mesh.indices.empty()) + return false; + + return std::any_of(textured_mesh.textures.begin(), textured_mesh.textures.end(), + [](const Slic3r::TextureImage& texture) { return !texture.data.empty(); }); +} + wxDEFINE_EVENT(EVT_SCHEDULE_BACKGROUND_PROCESS, SimpleEvent); wxDEFINE_EVENT(EVT_SLICING_UPDATE, SlicingStatusEvent); wxDEFINE_EVENT(EVT_SLICING_COMPLETED, wxCommandEvent); @@ -718,6 +735,22 @@ struct Sidebar::priv ScalableButton * m_bpButton_set_filament; int m_menu_filament_id = -1; wxScrolledWindow* m_panel_filament_content; + + // Mixed-color filament section. Sits directly under the physical filament list in + // scrolled_sizer. BBS hosts the equivalent widgets inside an m_filament_area_wrapper + // that Orca's sidebar has no counterpart for, so these are parented to p->scrolled. + wxPanel* m_btn_add_mixed_filament{nullptr}; // "+ Add Mixed Filament" full-width button + wxPanel* m_panel_mixed_title{nullptr}; // title row: "Mixed Filament" + add/del buttons + wxStaticText* m_text_mixed_title{nullptr}; + ScalableButton* m_btn_mixed_add{nullptr}; + ScalableButton* m_btn_mixed_del{nullptr}; + wxScrolledWindow* m_mixed_scroll_area{nullptr}; // independent scrollbar for mixed rows + wxPanel* m_panel_mixed_content{nullptr}; + wxBoxSizer* m_sizer_mixed_filaments{nullptr}; // two-column, mirrors sizer_filaments + wxPanel* m_panel_mixed_warning{nullptr}; // red bar for broken/mismatched mixes + wxStaticText* m_text_mixed_warning{nullptr}; + bool m_mixed_filament_broken{false}; + wxScrolledWindow* m_scrolledWindow_filament_content; wxStaticLine* m_staticline2; wxPanel* m_panel_project_title; @@ -2991,6 +3024,123 @@ Sidebar::Sidebar(Plater *parent) update_filaments_area_height(); // ORCA scrolled_sizer->Add(p->m_panel_filament_content, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(SidebarProps::ContentMarginV())); // ORCA use vertical margin on parent otherwise it shows scrollbar even on 1 filament + + // ---- Mixed-color filament section ---- + // A mixed filament is a virtual slot realized from 2-3 physical filaments at slicing time. + // Everything here stays hidden until at least two physical filaments exist, so a single + // filament setup looks exactly as before. + { + // 1) "+ Add Mixed Filament" button, shown only while no mixed filament exists yet. + p->m_btn_add_mixed_filament = new wxPanel(p->scrolled, wxID_ANY); + p->m_btn_add_mixed_filament->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); + p->m_btn_add_mixed_filament->SetMinSize(wxSize(-1, FromDIP(23))); + { + auto* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* icon_add = new ScalableButton(p->m_btn_add_mixed_filament, wxID_ANY, "add_filament", wxEmptyString, + wxDefaultSize, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, false, 16); + auto* add_label = new wxStaticText(p->m_btn_add_mixed_filament, wxID_ANY, _L("Add Mixed Filament"), + wxDefaultPosition, wxDefaultSize, 0); + add_label->SetFont(::Label::Body_13); + btn_sizer->AddStretchSpacer(); + btn_sizer->Add(icon_add, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + btn_sizer->Add(add_label, 0, wxALIGN_CENTER_VERTICAL); + btn_sizer->AddStretchSpacer(); + p->m_btn_add_mixed_filament->SetSizer(btn_sizer); + p->m_btn_add_mixed_filament->SetCursor(wxCursor(wxCURSOR_HAND)); + // Whole panel is the hit target, so forward clicks from the children too. + auto on_click = [this](wxMouseEvent&) { add_mixed_filament(); }; + p->m_btn_add_mixed_filament->Bind(wxEVT_LEFT_UP, on_click); + add_label->Bind(wxEVT_LEFT_UP, on_click); + icon_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); + } + scrolled_sizer->Add(p->m_btn_add_mixed_filament, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(8)); + + // 2) Title row with add / remove buttons, shown once a mixed filament exists. + p->m_panel_mixed_title = new wxPanel(p->scrolled, wxID_ANY); + p->m_panel_mixed_title->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + { + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + p->m_text_mixed_title = new wxStaticText(p->m_panel_mixed_title, wxID_ANY, _L("Mixed Filament")); + p->m_text_mixed_title->SetFont(::Label::Head_14); + title_sizer->Add(p->m_text_mixed_title, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::TitlebarMargin())); + title_sizer->AddStretchSpacer(); + + p->m_btn_mixed_del = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "delete_filament"); + p->m_btn_mixed_del->SetToolTip(_L("Remove last mixed filament")); + p->m_btn_mixed_del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + auto* plater_ptr = dynamic_cast(GetParent()); + if (!plater_ptr) return; + auto mixed_indices = plater_ptr->mixed_filament_config_indices(); + if (!mixed_indices.empty()) + delete_mixed_filament_at(mixed_indices.size() - 1); + }); + title_sizer->Add(p->m_btn_mixed_del, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + + p->m_btn_mixed_add = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "add_filament"); + p->m_btn_mixed_add->SetToolTip(_L("Add mixed filament")); + p->m_btn_mixed_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); + title_sizer->Add(p->m_btn_mixed_add, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + title_sizer->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); + + p->m_panel_mixed_title->SetSizer(title_sizer); + } + scrolled_sizer->Add(p->m_panel_mixed_title, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(8)); + + // 3) Mixed filament rows, in their own scroll area so a long mixed list does not + // push the physical filament list off screen. + p->m_mixed_scroll_area = new wxScrolledWindow(p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_mixed_scroll_area->SetScrollbars(0, 100, 1, 2); + p->m_mixed_scroll_area->SetScrollRate(0, 5); + p->m_mixed_scroll_area->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + { + auto* mix_scroll_sizer = new wxBoxSizer(wxVERTICAL); + p->m_panel_mixed_content = new wxPanel(p->m_mixed_scroll_area, wxID_ANY); + p->m_panel_mixed_content->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + + // Two columns, same idiom as sizer_filaments. + p->m_sizer_mixed_filaments = new wxBoxSizer(wxHORIZONTAL); + p->m_sizer_mixed_filaments->Add(new wxBoxSizer(wxVERTICAL), 1, wxEXPAND); + p->m_sizer_mixed_filaments->Add(new wxBoxSizer(wxVERTICAL), 1, wxEXPAND); + + auto* sizer_mixed2 = new wxBoxSizer(wxVERTICAL); + sizer_mixed2->Add(p->m_sizer_mixed_filaments, 0, wxEXPAND, 0); + p->m_panel_mixed_content->SetSizer(sizer_mixed2); + mix_scroll_sizer->Add(p->m_panel_mixed_content, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + p->m_mixed_scroll_area->SetSizer(mix_scroll_sizer); + } + p->m_mixed_scroll_area->EnableScrolling(false, true); + p->m_mixed_scroll_area->ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_DEFAULT); + p->m_mixed_scroll_area->Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { + int w = p->m_mixed_scroll_area->GetClientSize().GetWidth(); + if (w > 0) + p->m_mixed_scroll_area->SetVirtualSize(w, p->m_mixed_scroll_area->GetVirtualSize().GetHeight()); + e.Skip(); + }); + scrolled_sizer->Add(p->m_mixed_scroll_area, 0, wxEXPAND, 0); + + // 4) Warning bar for mixes whose components were deleted or whose types disagree. + p->m_panel_mixed_warning = new wxPanel(p->scrolled, wxID_ANY); + p->m_panel_mixed_warning->SetBackgroundColour(wxColour("#FDE8E8")); + { + auto* warn_sizer = new wxBoxSizer(wxHORIZONTAL); + p->m_text_mixed_warning = new wxStaticText(p->m_panel_mixed_warning, wxID_ANY, + _L("Mixed filament has invalid or mismatched components. Please re-edit affected entries.")); + p->m_text_mixed_warning->SetForegroundColour(wxColour("#D32F2F")); + p->m_text_mixed_warning->SetFont(::Label::Body_12); + p->m_text_mixed_warning->Wrap(FromDIP(360)); + warn_sizer->Add(p->m_text_mixed_warning, 1, wxALL, FromDIP(6)); + p->m_panel_mixed_warning->SetSizer(warn_sizer); + } + scrolled_sizer->Add(p->m_panel_mixed_warning, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); + + // Hidden until update_mixed_filament_list() decides otherwise. + p->m_btn_add_mixed_filament->Hide(); + p->m_panel_mixed_title->Hide(); + p->m_mixed_scroll_area->Hide(); + p->m_panel_mixed_content->Hide(); + p->m_panel_mixed_warning->Hide(); + } + // ---- End mixed-color filament section ---- } { @@ -3696,6 +3846,1110 @@ void Sidebar::change_top_border_for_mode_sizer(bool increase_border) #endif } + +// ---- Mixed-color filament sidebar support ---- +// Ported from BambuStudio's 混色耗材 feature. BBS hosts these widgets in an +// m_filament_area_wrapper that Orca's sidebar has no counterpart for, so the mixed +// section is parented to p->scrolled and sized with Orca's own row-height preference +// (filaments_area_preferred_count) rather than BBS's fixed 3-row / 12-filament cap. +void Sidebar::recalc_filament_scroll_sizes() +{ + if (!p->m_mixed_scroll_area || !p->m_mixed_scroll_area->GetSizer()) + return; + + // Same preferred-row budget the physical list uses, so both lists cap consistently. + auto left_sizer = p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); + auto combo_sizer = left_sizer->GetItem((size_t) 0)->GetSizer(); + const int row_h = combo_sizer ? combo_sizer->GetSize().GetHeight() : 0; + int preferred_rows = std::ceil(0.5 * std::stoi(wxGetApp().app_config->get("filaments_area_preferred_count"))); + const int max_h = (row_h > 0) ? preferred_rows * row_h : -1; + + auto content_size = p->m_mixed_scroll_area->GetSizer()->GetMinSize(); + if (max_h > 0 && content_size.y > max_h) { + p->m_mixed_scroll_area->SetMaxSize({-1, max_h}); + content_size.y = max_h; + } else { + p->m_mixed_scroll_area->SetMaxSize({-1, -1}); + } + p->m_mixed_scroll_area->SetMinSize({0, content_size.y}); +} +static std::string blend_mixed_color(const std::vector &comp_ids, + const std::vector &ratios, + const std::vector &color_strs) +{ + std::vector hex_colors; + hex_colors.reserve(comp_ids.size()); + for (unsigned int id : comp_ids) + hex_colors.push_back((id >= 1 && id <= color_strs.size()) ? color_strs[id - 1] : "#808080"); + return Slic3r::blend_color_multi(hex_colors, ratios); +} + +void Sidebar::update_mixed_filament_list() +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + wxWindowUpdateLocker noUpdates(this); + + const wxColour mc_bg = StateColor::darkModeColorFor(*wxWHITE); + const wxColour mc_border = StateColor::darkModeColorFor(wxColour("#CECECE")); + const wxColour mc_text = StateColor::darkModeColorFor(wxColour("#262E30")); + const wxColour mc_dim = StateColor::darkModeColorFor(wxColour("#ACACAC")); + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto mixed_indices = plater->mixed_filament_config_indices(); + size_t num_physical = p->combos_filament.size(); + + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + auto* colours_opt = project_config.option("filament_colour"); + auto* grad_opt = project_config.option("filament_mixed_gradient"); + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + + bool can_mix = (num_physical >= 2); + bool has_mixed = can_mix && !mixed_indices.empty(); + + // Check integrity of mixed filament component references + std::vector broken_slots; + if (is_mixed_opt && components_opt) + broken_slots = check_mixed_filament_integrity(is_mixed_opt->values, components_opt->values, num_physical); + std::set broken_set(broken_slots.begin(), broken_slots.end()); + + // Type consistency check + if (is_mixed_opt && components_opt) { + std::vector physical_types; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + std::string ft; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + } + if (ft.empty()) ft = "PLA"; + physical_types.push_back(ft); + } + auto type_mismatch_slots = check_mixed_filament_type_consistency( + is_mixed_opt->values, components_opt->values, physical_types); + for (size_t s : type_mismatch_slots) + broken_set.insert(s); + broken_slots.insert(broken_slots.end(), type_mismatch_slots.begin(), type_mismatch_slots.end()); + } + + bool at_limit = (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)); + p->m_btn_add_mixed_filament->Show(can_mix && !has_mixed && !at_limit); + p->m_panel_mixed_title->Show(has_mixed); + p->m_mixed_scroll_area->Show(has_mixed); + p->m_panel_mixed_content->Show(has_mixed); + if (p->m_btn_mixed_add) + p->m_btn_mixed_add->Enable(!at_limit); + p->m_panel_mixed_warning->Show(false); + + // Show/dismiss 3D canvas notification for broken mixed filaments + if (has_mixed && !broken_set.empty()) { + auto* notify = wxGetApp().plater()->get_notification_manager(); + if (notify) + notify->push_notification(NotificationType::BBLMixedFilamentBroken, + NotificationManager::NotificationLevel::ErrorNotificationLevel, + _u8L("Mixed filament has invalid or mismatched components. Please re-edit affected entries.")); + } else { + auto* notify = wxGetApp().plater()->get_notification_manager(); + if (notify) + notify->close_notification_of_type(NotificationType::BBLMixedFilamentBroken); + } + + if (has_mixed) { + auto* left_col = p->m_sizer_mixed_filaments->GetItem(size_t(0))->GetSizer(); + auto* right_col = p->m_sizer_mixed_filaments->GetItem(size_t(1))->GetSizer(); + left_col->Clear(true); + right_col->Clear(true); + + std::vector physical_colors; + if (colours_opt) { + for (size_t i = 0; i < num_physical && i < colours_opt->values.size(); ++i) + physical_colors.push_back(colours_opt->values[i]); + } + + auto make_swatch_panel = [this, mc_text](wxWindow* parent, const wxColour& col, unsigned int num) -> wxPanel* { + int swatch_sz = FromDIP(20); + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); + panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); + bool is_dark = wxGetApp().dark_mode(); + panel->Bind(wxEVT_PAINT, [panel, col, num, mc_text, is_dark](wxPaintEvent&) { + wxPaintDC dc(panel); + wxSize sz = panel->GetClientSize(); + dc.SetBackground(wxBrush(col)); + dc.Clear(); + if (!is_dark && col.Red() > 224 && col.Green() > 224 && col.Blue() > 224) { + dc.SetPen(wxPen(wxColour(130, 130, 128), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + if (is_dark && col.Red() < 45 && col.Green() < 45 && col.Blue() < 45) { + dc.SetPen(wxPen(wxColour(207, 207, 207), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + wxString txt = wxString::Format("%u", num); + dc.SetFont(::Label::Body_14); + wxSize txt_sz = dc.GetTextExtent(txt); + dc.SetTextForeground(col.GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); + dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, + (sz.GetHeight() - txt_sz.GetHeight()) / 2); + }); + return panel; + }; + + for (size_t i = 0; i < mixed_indices.size(); ++i) { + size_t cfg_idx = mixed_indices[i]; + auto* combo_and_btn_sizer = new wxBoxSizer(wxHORIZONTAL); + + combo_and_btn_sizer->Add(FromDIP(10), 0, 0, 0, 0); + + // Parse components and ratios from config strings (supports 2-N components) + std::vector comp_ids; + std::vector comp_ratios; + if (components_opt && cfg_idx < components_opt->values.size()) { + std::istringstream iss(components_opt->values[cfg_idx]); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + comp_ids.push_back(v); + } + } + if (ratios_opt && cfg_idx < ratios_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + std::istringstream iss(ratios_opt->values[cfg_idx]); + std::string tok; + while (std::getline(iss, tok, ',')) { + float v = 0; + if (std::sscanf(tok.c_str(), "%f", &v) == 1) + comp_ratios.push_back((int)(v * 100 + 0.5f)); + } + } + if (!comp_ids.empty() && comp_ratios.size() != comp_ids.size()) { + BOOST_LOG_TRIVIAL(warning) << "Mixed filament slot " << cfg_idx + << ": ratio count (" << comp_ratios.size() + << ") != component count (" << comp_ids.size() + << "), resetting to even distribution"; + int n = (int)comp_ids.size(); + comp_ratios.assign(n, 100 / n); + comp_ratios[0] += 100 - (100 / n) * n; + } + + bool is_broken = broken_set.count(cfg_idx) > 0; + + // Recalculate mixed color based on current physical colors + if (!is_broken && !comp_ids.empty() && comp_ids.size() == comp_ratios.size()) { + std::string new_mixed_color = blend_mixed_color(comp_ids, comp_ratios, physical_colors); + + if (colours_opt && cfg_idx < colours_opt->values.size() && colours_opt->values[cfg_idx] != new_mixed_color) { + colours_opt->values[cfg_idx] = new_mixed_color; + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt && cfg_idx < multi_colour_opt->values.size()) { + multi_colour_opt->values[cfg_idx] = new_mixed_color; + } + } + } + + bool is_gradient = false; + int gradient_direction = 0; + if (grad_opt && cfg_idx < grad_opt->values.size()) + is_gradient = grad_opt->values[cfg_idx]; + if (is_gradient && grad_range_opt && cfg_idx < grad_range_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range_opt->values[cfg_idx].c_str(), "%f,%f", &v0, &v1) == 2) + gradient_direction = (v0 > v1) ? 0 : 1; + } + + std::string mix_color_str = (colours_opt && cfg_idx < colours_opt->values.size()) + ? colours_opt->values[cfg_idx] : "#888888"; + wxColour mix_col(mix_color_str); + unsigned int mix_num = (unsigned int)(cfg_idx + 1); + + if (is_gradient && comp_ids.size() == 2) { + unsigned int from_id = (gradient_direction == 0) ? comp_ids[0] : comp_ids[1]; + unsigned int to_id = (gradient_direction == 0) ? comp_ids[1] : comp_ids[0]; + wxColour col_from = (from_id >= 1 && from_id <= physical_colors.size()) + ? wxColour(physical_colors[from_id - 1]) : wxColour("#D9D9D9"); + wxColour col_to = (to_id >= 1 && to_id <= physical_colors.size()) + ? wxColour(physical_colors[to_id - 1]) : wxColour("#D9D9D9"); + int swatch_sz = FromDIP(20); + auto* grad_panel = new wxPanel(p->m_panel_mixed_content, wxID_ANY, + wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); + grad_panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); + grad_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + grad_panel->Bind(wxEVT_PAINT, [grad_panel, col_from, col_to, mix_num, mc_text](wxPaintEvent&) { + wxBufferedPaintDC dc(grad_panel); + wxSize sz = grad_panel->GetClientSize(); + fill_gradient_rect_east(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), col_from, col_to); + wxString txt = wxString::Format("%u", mix_num); + dc.SetFont(::Label::Body_14); + wxSize txt_sz = dc.GetTextExtent(txt); + wxColour mid( + (col_from.Red() + col_to.Red()) / 2, + (col_from.Green() + col_to.Green()) / 2, + (col_from.Blue() + col_to.Blue()) / 2); + dc.SetTextForeground(mid.GetLuminance() > 0.5 ? mc_text : *wxWHITE); + dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, + (sz.GetHeight() - txt_sz.GetHeight()) / 2); + }); + combo_and_btn_sizer->Add(grad_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } else { + combo_and_btn_sizer->Add(make_swatch_panel(p->m_panel_mixed_content, mix_col, mix_num), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } + + auto* content_panel = new wxPanel(p->m_panel_mixed_content, wxID_ANY); + content_panel->SetBackgroundColour(mc_bg); + content_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + // Pre-compute all values the paint lambda needs (avoid capturing `this` for FromDIP) + int cp_pad = FromDIP(4); + int cp_swatch_sz = FromDIP(20); + int cp_sep_margin = FromDIP(3); + int cp_pct_left = FromDIP(2); + int cp_gap = FromDIP(2); + int cp_pct_gap = FromDIP(4); + bool cp_is_dark = wxGetApp().dark_mode(); + + // Build per-component colour list for the lambda + std::vector cp_colours; + std::vector cp_valid; + std::vector cp_ids = comp_ids; + std::vector cp_ratios = comp_ratios; + bool cp_is_gradient = is_gradient; + int cp_gradient_dir = gradient_direction; + for (size_t ci = 0; ci < comp_ids.size(); ++ci) { + bool valid = (comp_ids[ci] >= 1 && comp_ids[ci] <= physical_colors.size()); + cp_valid.push_back(valid); + cp_colours.push_back(valid ? wxColour(physical_colors[comp_ids[ci] - 1]) : wxColour("#D9D9D9")); + } + + // Reorder for gradient display: from -> to + std::vector draw_ids; + std::vector draw_ratios; + std::vector draw_colours; + std::vector draw_valid; + if (cp_is_gradient && cp_ids.size() == 2) { + int fi = (cp_gradient_dir == 0) ? 0 : 1; + int ti = 1 - fi; + draw_ids = { cp_ids[fi], cp_ids[ti] }; + draw_ratios = { cp_ratios.size() > (size_t)fi ? cp_ratios[fi] : 0, + cp_ratios.size() > (size_t)ti ? cp_ratios[ti] : 0 }; + draw_colours = { cp_colours[fi], cp_colours[ti] }; + draw_valid = { cp_valid[fi], cp_valid[ti] }; + } else { + draw_ids = cp_ids; + draw_ratios = cp_ratios; + draw_colours = cp_colours; + draw_valid = cp_valid; + } + + content_panel->Bind(wxEVT_PAINT, [content_panel, mc_bg, mc_border, mc_text, mc_dim, + cp_pad, cp_swatch_sz, cp_sep_margin, cp_pct_left, + cp_gap, cp_pct_gap, cp_is_dark, + cp_is_gradient, + draw_ids, draw_ratios, draw_colours, draw_valid](wxPaintEvent&) { + wxBufferedPaintDC dc(content_panel); + wxSize sz = content_panel->GetClientSize(); + + dc.SetBrush(wxBrush(mc_bg)); + dc.SetPen(wxPen(mc_border, 1)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + dc.SetFont(::Label::Body_13); + int x = cp_pad; + int y_swatch = (sz.GetHeight() - cp_swatch_sz) / 2; + int text_h = dc.GetTextExtent(wxT("A")).GetHeight(); + int y_text = y_swatch + (cp_swatch_sz - text_h) / 2; + int avail = sz.GetWidth() - cp_pad; + wxString ellipsis = wxT("..."); + int ellipsis_w = dc.GetTextExtent(ellipsis).GetWidth(); + + auto fits = [&](int needed) -> bool { + return (x + needed) <= (avail - ellipsis_w); + }; + + size_t n = draw_ids.size(); + for (size_t ci = 0; ci < n; ++ci) { + // Separator: "+" or arrow + if (ci > 0) { + wxString sep = cp_is_gradient ? wxT("\u2192") : wxT("+"); + int sep_w = dc.GetTextExtent(sep).GetWidth() + cp_sep_margin * 2; + if (!fits(sep_w + cp_swatch_sz)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + dc.SetTextForeground(mc_text); + dc.DrawText(sep, x + cp_sep_margin, y_text); + x += sep_w; + } + + // Swatch + if (!fits(cp_swatch_sz)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + + if (draw_valid[ci]) { + wxColour col = draw_colours[ci]; + dc.SetBrush(wxBrush(col)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + if (!cp_is_dark && col.Red() > 224 && col.Green() > 224 && col.Blue() > 224) { + dc.SetPen(wxPen(wxColour(130, 130, 128), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + } + if (cp_is_dark && col.Red() < 45 && col.Green() < 45 && col.Blue() < 45) { + dc.SetPen(wxPen(wxColour(207, 207, 207), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + } + dc.SetFont(::Label::Body_14); + wxString num = wxString::Format("%u", draw_ids[ci]); + wxSize num_sz = dc.GetTextExtent(num); + dc.SetTextForeground(col.GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); + dc.DrawText(num, x + (cp_swatch_sz - num_sz.GetWidth()) / 2, + y_swatch + (cp_swatch_sz - num_sz.GetHeight()) / 2); + dc.SetFont(::Label::Body_13); + } else { + dc.SetBrush(wxBrush(mc_bg)); + dc.SetPen(wxPen(mc_dim, 1)); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + wxString dash = wxT("\u2014"); + wxSize dash_sz = dc.GetTextExtent(dash); + dc.SetTextForeground(wxColour("#909090")); + dc.DrawText(dash, x + (cp_swatch_sz - dash_sz.GetWidth()) / 2, + y_swatch + (cp_swatch_sz - dash_sz.GetHeight()) / 2); + } + x += cp_swatch_sz + cp_gap; + + // Ratio text (skip for gradient) + if (!cp_is_gradient) { + int r = (ci < draw_ratios.size()) ? draw_ratios[ci] : 0; + wxString pct = wxString::Format("%d%%", r); + int pct_w = dc.GetTextExtent(pct).GetWidth(); + if (!fits(pct_w)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + dc.SetTextForeground(mc_text); + dc.DrawText(pct, x + cp_pct_left, y_text); + x += pct_w + cp_pct_gap; + } + } + }); + + // Tooltip: always show full info + { + wxString tip; + for (size_t ci = 0; ci < draw_ids.size(); ++ci) { + if (ci > 0) tip += cp_is_gradient ? wxT(" \u2192 ") : wxT(" + "); + int r = (ci < draw_ratios.size()) ? draw_ratios[ci] : 0; + tip += wxString::Format("%u (%d%%)", draw_ids[ci], r); + } + content_panel->SetToolTip(tip); + } + + // Repaint on resize so truncation updates + content_panel->Bind(wxEVT_SIZE, [content_panel](wxSizeEvent& e) { + content_panel->Refresh(); + e.Skip(); + }); + + content_panel->SetCursor(wxCursor(wxCURSOR_HAND)); + size_t panel_idx = i; + content_panel->Bind(wxEVT_LEFT_UP, [this, panel_idx](wxMouseEvent&) { edit_mixed_filament(panel_idx); }); + + combo_and_btn_sizer->Add(content_panel, 1, wxALL | wxEXPAND, FromDIP(2))->SetMinSize({-1, FromDIP(30)}); + + auto* menu_btn = new ScalableButton(p->m_panel_mixed_content, wxID_ANY, + is_broken ? "error" : "menu_filament"); + menu_btn->SetToolTip(is_broken ? _L("Mixed filament has broken component references") : _L("Edit / Delete / Merge")); + menu_btn->Bind(wxEVT_BUTTON, [this, panel_idx, cfg_idx](wxCommandEvent&) { + wxMenu menu; + + auto* edit_item = menu.Append(wxID_ANY, _L("Edit")); + menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { + edit_mixed_filament(panel_idx); + }, edit_item->GetId()); + + auto* del_item = menu.Append(wxID_ANY, _L("Delete")); + menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { + delete_mixed_filament_at(panel_idx); + }, del_item->GetId()); + + wxMenu* sub_menu = new wxMenu(); + std::vector icons = get_extruder_color_icons(true); + int filaments_cnt = icons.size(); + for (int j = 0; j < filaments_cnt; ++j) { + if ((size_t)j == cfg_idx) + continue; + + wxString item_name; + bool is_target_mixed = wxGetApp().preset_bundle->is_mixed_filament(j); + if (is_target_mixed) { + item_name = wxString::Format(_L("Filament %d"), j + 1); + } else { + auto preset = wxGetApp().preset_bundle->filaments.find_preset( + wxGetApp().preset_bundle->filament_presets[j]); + item_name = preset ? from_u8(preset->label(false)) + : wxString::Format(_L("Filament %d"), j + 1); + } + + auto* mi = new wxMenuItem(sub_menu, wxID_ANY, item_name); +#ifndef __linux__ + mi->SetBitmap(*icons[j]); +#endif + sub_menu->Append(mi); + sub_menu->Bind(wxEVT_MENU, [this, cfg_idx, j](wxCommandEvent&) { + change_filament(cfg_idx, j); + }, mi->GetId()); + } + if (filaments_cnt > 1) + menu.AppendSubMenu(sub_menu, _L("Merge with")); + else + delete sub_menu; + + PopupMenu(&menu); + }); + combo_and_btn_sizer->Add(menu_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); + + combo_and_btn_sizer->Add(FromDIP(16), 0, 0, 0, 0); + + int side = i % 2; + auto* col = (side == 0) ? left_col : right_col; + if (side == 1 && i > 1) col->Remove(i / 2); + col->Add(combo_and_btn_sizer, 1, wxEXPAND); + if (side == 0 && i > 0) { + right_col->AddStretchSpacer(1); + } + } + } + + recalc_filament_scroll_sizes(); + + p->m_panel_filament_content->FitInside(); + p->m_mixed_scroll_area->FitInside(); + p->scrolled->Layout(); + m_scrolled_sizer->Layout(); + p->scrolled->Layout(); + + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + obj_list()->update_objects_list_filament_column(total); + + // Sync mixed filament colors into the config used by 3D view rendering. + plater->update_filament_colors_in_full_config(); + obj_list()->update_filament_colors(); + + // Check if any broken mixed filament is used by objects on current plate. + // Scan raw extruder assignments (object / volume / height-range / painting) + // instead of get_extruders() which expands mixed slots and loses their IDs. + p->m_mixed_filament_broken = false; + if (!broken_slots.empty()) { + std::set broken_1based; + for (size_t s : broken_slots) broken_1based.insert(s + 1); + + auto* curr_plate = plater->get_partplate_list().get_curr_plate(); + if (curr_plate) { + for (auto& obj : plater->model().objects) { + if (!curr_plate->contain_instance_totally(obj, 0)) + continue; + // Check object-level extruder + int obj_ext = obj->config.has("extruder") ? obj->config.extruder() : 1; + if (broken_1based.count((size_t)obj_ext)) { + p->m_mixed_filament_broken = true; + break; + } + bool found = false; + for (auto* vol : obj->volumes) { + // Check volume-level extruder + int vol_ext = vol->config.has("extruder") ? vol->config.extruder() : obj_ext; + if (broken_1based.count((size_t)vol_ext)) { found = true; break; } + // Check color painting data (mmu segmentation facets) + if (vol->is_model_part() && !vol->mmu_segmentation_facets.empty()) { + for (size_t broken_slot : broken_1based) { + if (vol->mmu_segmentation_facets.has_facets(*vol, EnforcerBlockerType(broken_slot))) + { found = true; break; } + } + if (found) break; + } + } + if (found) { p->m_mixed_filament_broken = true; break; } + // Check height range modifier extruder overrides + for (auto& [range, cfg] : obj->layer_config_ranges) { + if (cfg.has("extruder")) { + int layer_ext = cfg.option("extruder")->getInt(); + if (layer_ext > 0 && broken_1based.count((size_t)layer_ext)) + { found = true; break; } + } + } + if (found) { p->m_mixed_filament_broken = true; break; } + } + } + } + + if (plater->canvas3D()) { + plater->canvas3D()->set_as_dirty(); + plater->get_view3D_canvas3D()->reload_scene(false); + } + + if (p->m_mixed_filament_broken) { + auto* mf = wxGetApp().mainframe; + if (mf) + mf->update_slice_print_status(MainFrame::eEventObjectUpdate, false); + } + + if (auto *tab = dynamic_cast(wxGetApp().plate_tab)) + tab->update_mixed_filament_seq_state(); + +} + +bool Sidebar::has_broken_mixed_filament() const +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return false; + return has_broken_mixed_filament(plater->get_partplate_list().get_curr_plate()); +} + +bool Sidebar::has_broken_mixed_filament(const PartPlate* plate) const +{ + if (!plate) return false; + auto* plater = dynamic_cast(GetParent()); + if (!plater) return false; + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (!is_mixed_opt || !comp_strs_opt) return false; + + size_t num_physical = p->combos_filament.size(); + auto broken_slots = check_mixed_filament_integrity(is_mixed_opt->values, comp_strs_opt->values, num_physical); + + // Type consistency check + { + std::vector physical_types; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + std::string ft; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + } + if (ft.empty()) ft = "PLA"; + physical_types.push_back(ft); + } + auto type_mismatch_slots = check_mixed_filament_type_consistency( + is_mixed_opt->values, comp_strs_opt->values, physical_types); + broken_slots.insert(broken_slots.end(), type_mismatch_slots.begin(), type_mismatch_slots.end()); + } + + if (broken_slots.empty()) return false; + + std::set broken_1based; + for (size_t s : broken_slots) broken_1based.insert(s + 1); + + // Scan model objects on the given plate for raw extruder assignments + // (don't use get_extruders() which expands mixed slots) + for (auto& entry : plater->model().objects) { + if (!plate->contain_instance_totally(entry, 0)) + continue; + // Check object-level extruder + int obj_ext = entry->config.has("extruder") ? entry->config.extruder() : 1; + if (broken_1based.count((size_t)obj_ext)) + return true; + for (auto* vol : entry->volumes) { + // Check volume-level extruder + int vol_ext = vol->config.has("extruder") ? vol->config.extruder() : obj_ext; + if (broken_1based.count((size_t)vol_ext)) + return true; + // Check color painting data (mmu segmentation facets) + if (vol->is_model_part() && !vol->mmu_segmentation_facets.empty()) { + for (size_t broken_slot : broken_1based) { + if (vol->mmu_segmentation_facets.has_facets(*vol, EnforcerBlockerType(broken_slot))) + return true; + } + } + } + // Check height range modifier extruder overrides + for (auto& [range, cfg] : entry->layer_config_ranges) { + if (cfg.has("extruder")) { + int layer_ext = cfg.option("extruder")->getInt(); + if (layer_ext > 0 && broken_1based.count((size_t)layer_ext)) + return true; + } + } + } + + return false; +} + +void Sidebar::collect_physical_filament_info(std::vector& color_strs, + std::vector& names, + std::vector& types, + std::vector* config_indices) +{ + color_strs.clear(); + names.clear(); + types.clear(); + if (config_indices) + config_indices->clear(); + + size_t num_physical = p->combos_filament.size(); + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + std::vector physical_indices; + const size_t total = wxGetApp().preset_bundle->filament_presets.size(); + physical_indices.reserve(num_physical); + for (size_t i = 0; i < total && physical_indices.size() < num_physical; ++i) { + if (!is_mixed_opt || i >= is_mixed_opt->values.size() || !is_mixed_opt->values[i]) + physical_indices.push_back(i); + } + while (physical_indices.size() < num_physical) + physical_indices.push_back(physical_indices.size()); + if (config_indices) + *config_indices = physical_indices; + + auto* colours_opt = project_config.option("filament_colour"); + if (colours_opt) { + for (size_t i = 0; i < num_physical; ++i) { + const size_t cfg_idx = physical_indices[i]; + if (cfg_idx < colours_opt->values.size()) + color_strs.push_back(colours_opt->values[cfg_idx]); + } + } + + for (size_t i = 0; i < num_physical; ++i) { + auto* combo = p->combos_filament[i]; + names.push_back(combo ? into_u8(combo->GetValue()) : "Filament " + std::to_string(i + 1)); + } + + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + const size_t cfg_idx = physical_indices[i]; + Preset* preset = nullptr; + if (cfg_idx < preset_bundle.filament_presets.size()) + preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]); + types.push_back(filament_type_for_color_decompose(preset)); + } +} + +// Serialize the dialog's custom gradient curve only when it deviates from the +// direction-implied two-point linear default. Returning an empty string keeps +// projects with the default shape bit-identical with the legacy 2-field format +// (curve string stays "" so the slicer falls back to gradient_range linear). +// Shared by add_mixed_filament / edit_mixed_filament so the "is default" rule +// stays consistent between both entry points. +static std::string serialize_mixed_gradient_curve_if_custom(const MixedFilamentResult& result) +{ + if (!(result.components.size() == 2 && !result.gradient_curve.empty())) + return {}; + + const double y0 = (result.gradient_direction == 0) ? kGradientMaxRatio : kGradientMinRatio; + const double y1 = (result.gradient_direction == 0) ? kGradientMinRatio : kGradientMaxRatio; + const double eps = 1e-4; + if (result.gradient_curve.size() == 2) { + const auto& a0 = result.gradient_curve[0]; + const auto& a1 = result.gradient_curve[1]; + // Default curve also requires no tangent overrides; any finite tangent + // means the user bent the segment, so we must serialize it. + const bool is_default = + std::abs(a0.x - 0.0) < eps + && std::abs(a1.x - 1.0) < eps + && std::abs(a0.y - y0) < eps + && std::abs(a1.y - y1) < eps + && !std::isfinite(a0.m_in) && !std::isfinite(a0.m_out) + && !std::isfinite(a1.m_in) && !std::isfinite(a1.m_out); + if (is_default) return {}; + } + + Slic3r::GradientCurve gc; + gc.points = result.gradient_curve; + return Slic3r::serialize_gradient_curve(gc); +} + +static bool create_mixed_filament_from_result( + Sidebar* sidebar, + const MixedFilamentResult& result, + const std::vector& color_strs) +{ + if (!sidebar || result.components.size() < 2 || result.ratios.size() < 2) + return false; + if (!dynamic_cast(sidebar->GetParent())) + return false; + + size_t num_physical = sidebar->combos_filament().size(); + if (num_physical < 2) + return false; + if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) + return false; + + auto& project_config = wxGetApp().preset_bundle->project_config; + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + size_t new_idx = total; + + std::string mixed_color = blend_mixed_color(result.components, result.ratios, color_strs); + wxGetApp().preset_bundle->set_num_filaments(total + 1, mixed_color); + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt) { + while (multi_colour_opt->values.size() <= new_idx) multi_colour_opt->values.push_back(""); + multi_colour_opt->values[new_idx] = mixed_color; + } + + // set_num_filaments() above is what grows these parallel arrays. Guard the writes anyway, + // matching the gradient writes below, so a sizing bug degrades into a no-op rather than a + // heap overwrite. + { + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + while (is_mixed_opt->values.size() <= new_idx) is_mixed_opt->values.push_back(false); + is_mixed_opt->values[new_idx] = true; + } + + std::string comp_str; + for (size_t i = 0; i < result.components.size(); ++i) { + if (i > 0) comp_str += ","; + comp_str += std::to_string(result.components[i]); + } + { + auto* comp_opt = project_config.option("filament_mixed_components"); + while (comp_opt->values.size() <= new_idx) comp_opt->values.push_back(std::string{}); + comp_opt->values[new_idx] = comp_str; + } + + int ratio_sum = 0; + for (int r : result.ratios) ratio_sum += r; + if (ratio_sum <= 0) ratio_sum = 100; + + std::string ratio_str; + { + CNumericLocalesSetter c_locale_setter; + for (size_t i = 0; i < result.ratios.size(); ++i) { + if (i > 0) ratio_str += ","; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", (float)result.ratios[i] / ratio_sum); + ratio_str += buf; + } + } + { + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + while (ratios_opt->values.size() <= new_idx) ratios_opt->values.push_back(std::string{}); + ratios_opt->values[new_idx] = ratio_str; + } + + if (!project_config.option("filament_mixed_gradient")) + project_config.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false})); + if (!project_config.option("filament_mixed_gradient_range")) + project_config.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_curve")) + project_config.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_per_part")) + project_config.set_key_value("filament_mixed_gradient_per_part", new ConfigOptionBools({false})); + + { + auto* grad_opt = project_config.option("filament_mixed_gradient"); + while (grad_opt->values.size() <= new_idx) grad_opt->values.push_back(false); + grad_opt->values[new_idx] = result.gradient_enabled; + } + { + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + while (grad_range_opt->values.size() <= new_idx) grad_range_opt->values.push_back(""); + if (result.gradient_enabled && result.components.size() == 2) { + const char* fmt = (result.gradient_direction == 0) ? "0.9000,0.1000" : "0.1000,0.9000"; + grad_range_opt->values[new_idx] = fmt; + } else { + grad_range_opt->values[new_idx] = ""; + } + } + { + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + while (grad_curve_opt->values.size() <= new_idx) grad_curve_opt->values.push_back(""); + grad_curve_opt->values[new_idx] = serialize_mixed_gradient_curve_if_custom(result); + } + { + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + while (per_part_opt->values.size() <= new_idx) per_part_opt->values.push_back(false); + per_part_opt->values[new_idx] = result.gradient_enabled && result.per_part_gradient; + } + + auto& presets = wxGetApp().preset_bundle->filament_presets; + if (result.components[0] >= 1 && result.components[0] <= num_physical && presets.size() > new_idx) + presets[new_idx] = presets[result.components[0] - 1]; + + size_t filament_count = wxGetApp().preset_bundle->filament_presets.size(); + wxGetApp().plater()->get_partplate_list().on_filament_added(filament_count); + wxGetApp().plater()->on_filament_count_change(filament_count); + wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); + wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); + + sidebar->update_mixed_filament_list(); + wxGetApp().plater()->update_project_dirty_from_presets(); + wxPostEvent(sidebar, SimpleEvent(EVT_SCHEDULE_BACKGROUND_PROCESS, sidebar)); + return true; +} + +void Sidebar::add_mixed_filament() +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + size_t num_physical = p->combos_filament.size(); + if (num_physical < 2) return; + if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) return; + + std::vector color_strs, names, types; + collect_physical_filament_info(color_strs, names, types); + + MixedFilamentDialog dlg(this, color_strs, names, types); + if (dlg.ShowModal() == wxID_OK) { + auto result = dlg.get_result(); + create_mixed_filament_from_result(this, result, color_strs); + } +} + +void Sidebar::edit_mixed_filament(size_t panel_idx) +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + auto mixed_indices = plater->mixed_filament_config_indices(); + if (panel_idx >= mixed_indices.size()) return; + size_t cfg_idx = mixed_indices[panel_idx]; + + std::vector color_strs, names, types; + collect_physical_filament_info(color_strs, names, types); + + auto& project_config = wxGetApp().preset_bundle->project_config; + MixedFilamentResult existing; + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + + // Parse existing components + if (components_opt && cfg_idx < components_opt->values.size()) { + const std::string& cs = components_opt->values[cfg_idx]; + std::istringstream iss(cs); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + existing.components.push_back(v); + } + } + // Parse existing ratios + if (ratios_opt && cfg_idx < ratios_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + const std::string& rs = ratios_opt->values[cfg_idx]; + std::istringstream iss(rs); + std::string tok; + while (std::getline(iss, tok, ',')) { + float v = 0; + if (std::sscanf(tok.c_str(), "%f", &v) == 1) + existing.ratios.push_back((int)(v * 100 + 0.5f)); + } + } + if (existing.components.size() < 2) { + existing.components = {1, 2}; + existing.ratios = {50, 50}; + } else if (existing.ratios.size() != existing.components.size()) { + BOOST_LOG_TRIVIAL(warning) << "Mixed filament edit: ratio count (" + << existing.ratios.size() << ") != component count (" + << existing.components.size() + << "), resetting to even distribution"; + int n = (int)existing.components.size(); + existing.ratios.assign(n, 100 / n); + existing.ratios[0] += 100 - (100 / n) * n; + } + + // Read gradient settings + auto* grad_opt = project_config.option("filament_mixed_gradient"); + if (grad_opt && cfg_idx < grad_opt->values.size()) + existing.gradient_enabled = grad_opt->values[cfg_idx]; + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + if (existing.gradient_enabled && grad_range_opt && cfg_idx < grad_range_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range_opt->values[cfg_idx].c_str(), "%f,%f", &v0, &v1) == 2) + existing.gradient_direction = (v0 > v1) ? 0 : 1; + } + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + if (existing.gradient_enabled && grad_curve_opt && cfg_idx < grad_curve_opt->values.size()) { + auto curve = Slic3r::parse_gradient_curve(grad_curve_opt->values[cfg_idx]); + existing.gradient_curve = curve.points; + } + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + if (existing.gradient_enabled && per_part_opt && cfg_idx < per_part_opt->values.size()) + existing.per_part_gradient = per_part_opt->values[cfg_idx]; + + MixedFilamentDialog dlg(this, existing, color_strs, names, types); + if (dlg.ShowModal() == wxID_OK) { + auto result = dlg.get_result(); + if (result.components.size() < 2 || result.ratios.size() < 2) return; + + // Serialize components + std::string comp_str; + for (size_t i = 0; i < result.components.size(); ++i) { + if (i > 0) comp_str += ","; + comp_str += std::to_string(result.components[i]); + } + components_opt->values[cfg_idx] = comp_str; + + // Serialize ratios + int ratio_sum = 0; + for (int r : result.ratios) ratio_sum += r; + if (ratio_sum <= 0) ratio_sum = 100; + + std::string ratio_str; + { + CNumericLocalesSetter c_locale_setter; + for (size_t i = 0; i < result.ratios.size(); ++i) { + if (i > 0) ratio_str += ","; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", (float)result.ratios[i] / ratio_sum); + ratio_str += buf; + } + } + ratios_opt->values[cfg_idx] = ratio_str; + + // Gradient settings — ensure keys exist in dynamic config + if (!project_config.option("filament_mixed_gradient")) + project_config.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false})); + if (!project_config.option("filament_mixed_gradient_range")) + project_config.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_curve")) + project_config.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_per_part")) + project_config.set_key_value("filament_mixed_gradient_per_part", new ConfigOptionBools({false})); + + { + auto* grad_opt = project_config.option("filament_mixed_gradient"); + while (grad_opt->values.size() <= cfg_idx) grad_opt->values.push_back(false); + grad_opt->values[cfg_idx] = result.gradient_enabled; + } + { + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + while (grad_range_opt->values.size() <= cfg_idx) grad_range_opt->values.push_back(""); + if (result.gradient_enabled && result.components.size() == 2) { + const char* fmt = (result.gradient_direction == 0) ? "0.9000,0.1000" : "0.1000,0.9000"; + grad_range_opt->values[cfg_idx] = fmt; + } else { + grad_range_opt->values[cfg_idx] = ""; + } + } + { + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + while (grad_curve_opt->values.size() <= cfg_idx) grad_curve_opt->values.push_back(""); + grad_curve_opt->values[cfg_idx] = serialize_mixed_gradient_curve_if_custom(result); + } + { + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + while (per_part_opt->values.size() <= cfg_idx) per_part_opt->values.push_back(false); + per_part_opt->values[cfg_idx] = result.gradient_enabled && result.per_part_gradient; + } + + // Compute blended color + std::string blended = blend_mixed_color(result.components, result.ratios, color_strs); + auto* colours_opt = project_config.option("filament_colour"); + if (colours_opt && cfg_idx < colours_opt->values.size()) + colours_opt->values[cfg_idx] = blended; + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt && cfg_idx < multi_colour_opt->values.size()) + multi_colour_opt->values[cfg_idx] = blended; + + update_mixed_filament_list(); + wxGetApp().plater()->update_project_dirty_from_presets(); + wxPostEvent(this, SimpleEvent(EVT_SCHEDULE_BACKGROUND_PROCESS, this)); + } +} + +void Sidebar::delete_mixed_filament_at(size_t panel_idx) +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + auto mixed_indices = plater->mixed_filament_config_indices(); + if (panel_idx >= mixed_indices.size()) return; + size_t cfg_idx = mixed_indices[panel_idx]; + + delete_filament(cfg_idx, -1); +} + +void Sidebar::decompose_filament_color(int filament_idx) +{ + if (filament_idx == kSidebarContextMenuFilamentId) + filament_idx = p->m_menu_filament_id; + if (filament_idx < 0) + return; + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* colours_opt = project_config.option("filament_colour"); + if (!colours_opt || static_cast(filament_idx) >= colours_opt->values.size()) + return; + + wxColour target_color(colours_opt->values[filament_idx]); + + std::vector color_strs, names, types; + std::vector physical_config_indices; + collect_physical_filament_info(color_strs, names, types, &physical_config_indices); + size_t source_physical_idx = size_t(-1); + for (size_t i = 0; i < physical_config_indices.size(); ++i) { + if (physical_config_indices[i] == static_cast(filament_idx)) { + source_physical_idx = i; + break; + } + } + + ColorDecomposeDialog dlg(this, + source_physical_idx == size_t(-1) ? -1 : static_cast(source_physical_idx), + target_color, color_strs, names, types, + wxGetApp().preset_bundle->filament_presets.size(), + static_cast(EnforcerBlockerType::ExtruderMax), + physical_config_indices); + int modal_res = dlg.ShowModal(); + if (modal_res == wxID_OK) { + ColorDecomposeResult dialog_result = dlg.get_result(); + MixedFilamentResult mixed_result; + std::vector missing_components; + if (!prepare_decompose_mixed_result(dialog_result, static_cast(filament_idx), source_physical_idx, + color_strs, types, physical_config_indices, mixed_result, missing_components)) + return; + + if (!confirm_create_decompose_missing_components(this, missing_components)) + return; + + for (const DecomposeMissingComponent& missing : missing_components) { + size_t before_count = p->combos_filament.size(); + add_custom_filament(wxColour(missing.official_component.color_hex), missing.preset_name, true); + size_t after_count = p->combos_filament.size(); + if (after_count <= before_count) + return; + set_created_standard_component_metadata(before_count, missing.official_component); + if (missing.component_idx < mixed_result.components.size()) + mixed_result.components[missing.component_idx] = static_cast(before_count + 1); + } + + if (!missing_components.empty()) { + collect_physical_filament_info(color_strs, names, types, &physical_config_indices); + } + + create_mixed_filament_from_result(this, mixed_result, color_strs); + } +} + void Sidebar::update_filaments_area_height() // ORCA { @@ -3918,6 +5172,9 @@ void Sidebar::sys_color_changed() p->scrolled->Layout(); + // Mixed rows are custom-drawn, so they need rebuilding for the new theme colours. + update_mixed_filament_list(); + p->searcher.dlg_sys_color_changed(); } @@ -3952,21 +5209,39 @@ void Sidebar::jump_to_option(size_t selected) // BBS. Move logic from Plater::on_extruders_change() to Sidebar::on_filament_count_change(). void Sidebar::on_filament_count_change(size_t num_filaments) { + // num_filaments counts every slot; mixed-color slots are virtual and get no combo of + // their own (they are rendered by update_mixed_filament_list instead), so the physical + // subset drives the combo list. + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + + std::vector physical_indices; + for (size_t i = 0; i < num_filaments; ++i) { + if (!is_mixed_opt || i >= is_mixed_opt->values.size() || !is_mixed_opt->values[i]) + physical_indices.push_back(i); + } + const size_t num_physical = physical_indices.size(); + auto& choices = combos_filament(); - if (num_filaments == choices.size()) + if (num_physical == choices.size()) { + // The ctor pre-creates one combo, so a single-filament project hits this guard before + // any layout pass has sized the scroll areas; refresh them here as well. + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); return; + } - if (choices.size() == 1 || num_filaments == 1) + if (choices.size() == 1 || num_physical == 1) choices[0]->GetDropDown().Invalidate(); wxWindowUpdateLocker noUpdates_scrolled_panel(this); size_t i = choices.size(); - while (i < num_filaments) + while (i < num_physical) { PlaterPresetComboBox* choice/*{ nullptr }*/; - init_filament_combo(&choice, i); + init_filament_combo(&choice, physical_indices[i]); int last_selection = choices.back()->GetSelection(); choices.push_back(choice); @@ -3977,11 +5252,13 @@ void Sidebar::on_filament_count_change(size_t num_filaments) } // remove unused choices if any - remove_unused_filament_combos(num_filaments); + remove_unused_filament_combos(num_physical); show_SEMM_buttons(); // ORCA update_filaments_area_height(); // ORCA + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); Layout(); p->m_panel_filament_title->Refresh(); @@ -4033,6 +5310,8 @@ void Sidebar::on_filaments_delete(size_t filament_id) } update_filaments_area_height(); // ORCA + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); Layout(); p->m_panel_filament_title->Refresh(); @@ -4106,18 +5385,93 @@ void Sidebar::edit_filament() p->editing_filament = p->m_menu_filament_id; // sync with TabPresetComboxBox's m_filament_idx } -void Sidebar::add_custom_filament(wxColour new_col) { +void Sidebar::add_custom_filament(wxColour new_col, const std::string& preset_name, bool /*skip_preset_validation*/) { if (is_new_project_in_gcode3mf()) { return; } if (p->combos_filament.size() >= MAXIMUM_EXTRUDER_NUMBER) return; + if (wxGetApp().preset_bundle->filament_presets.size() >= MAXIMUM_EXTRUDER_NUMBER) return; - int filament_count = p->combos_filament.size() + 1; + // 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. + // total == every slot (physical + mixed); insert_pos == the physical slot count. + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + size_t insert_pos = p->combos_filament.size(); + int filament_count = (int)(total + 1); std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); wxGetApp().preset_bundle->set_num_filaments(filament_count, new_color); + + // 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. + if (insert_pos < total) { + auto& presets = wxGetApp().preset_bundle->filament_presets; + std::rotate(presets.begin() + insert_pos, presets.begin() + total, presets.end()); + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto& ams_mc = wxGetApp().preset_bundle->ams_multi_color_filment; + + auto rotate_strings = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + auto rotate_ints = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + auto rotate_bools = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + + rotate_strings("filament_colour"); + rotate_strings("filament_multi_colour"); + rotate_strings("filament_colour_type"); + rotate_ints("filament_map"); + rotate_ints("filament_nozzle_map"); + rotate_ints("filament_volume_map"); + rotate_bools("filament_is_mixed"); + rotate_strings("filament_mixed_components"); + rotate_strings("filament_mixed_sublayer_ratios"); + rotate_bools("filament_mixed_gradient"); + rotate_strings("filament_mixed_gradient_range"); + rotate_strings("filament_mixed_gradient_curve"); + rotate_bools("filament_mixed_gradient_per_part"); + + if (ams_mc.size() > total) + std::rotate(ams_mc.begin() + insert_pos, ams_mc.begin() + total, ams_mc.end()); + + // Remap object/volume extruder IDs and paint data: anything >= insert_pos+1 (1-based) shifts up by 1 + int threshold_1based = (int)(insert_pos + 1); + auto ebt_threshold = EnforcerBlockerType(threshold_1based); + for (auto* obj : wxGetApp().plater()->model().objects) { + if (obj->config.has("extruder")) { + int ext = obj->config.extruder(); + if (ext >= threshold_1based) + obj->config.set("extruder", ext + 1); + } + for (auto* vol : obj->volumes) { + if (vol->config.has("extruder")) { + int ext = vol->config.extruder(); + if (ext >= threshold_1based) + vol->config.set("extruder", ext + 1); + } + vol->mmu_segmentation_facets.shift_states_above(*vol, ebt_threshold, +1); + } + } + } + + if (!preset_name.empty() && + wxGetApp().preset_bundle->filaments.find_preset(preset_name, false) && + insert_pos < wxGetApp().preset_bundle->filament_presets.size()) { + wxGetApp().preset_bundle->filament_presets[insert_pos] = preset_name; + } + wxGetApp().plater()->get_partplate_list().on_filament_added(filament_count); wxGetApp().plater()->on_filament_count_change(filament_count); wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); - auto_calc_flushing_volumes(filament_count - 1); + auto_calc_flushing_volumes(insert_pos); } bool Sidebar::is_new_project_in_gcode3mf() @@ -5457,9 +6811,34 @@ struct Plater::priv BoundingBox scaled_bed_shape_bb() const; // BBS: backup & restore + using LoadProgressCallback = std::function; std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false); std::vector load_model_objects(const ModelObjectPtrs& model_objects, bool allow_negative_z = false, bool split_object = false, bool auto_drop = true); + // Texture-to-color import: a mesh loaded with UVs + a texture map gets its faces clustered + // into printable colours, which are then matched against (or added to) the filament list. + struct TextureImportResult { + Slic3r::PaintedMesh painted; + std::vector matches; + std::vector> new_filament_colors; + std::vector new_filament_preset_names; + std::vector new_mixed_filaments; + std::vector filament_entries; + size_t existing_filament_count = 0; + bool skipped = false; + bool fallback_to_geometry_only = false; + wxString fallback_warning; + }; + + bool run_textured_mesh_import_dialog(Slic3r::Model& loaded_model, TextureImportResult& result, + std::function cancel_callback = {}, + std::function progress_callback = {}); + void apply_textured_mesh_import_result(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + const TextureImportResult& result, + LoadProgressCallback progress_callback = {}, bool update_scene = true); + void handle_textured_mesh_import(Slic3r::Model& model, const std::vector& obj_idxs, + std::function cancel_callback = {}); + fs::path get_export_file_path(GUI::FileType file_type); wxString get_export_file(GUI::FileType file_type); @@ -7730,6 +9109,38 @@ std::vector Plater::priv::load_files(const std::vector& input_ q->model().load_from(model); load_auxiliary_files(); } + // Texture-to-color: a mesh that arrived with UVs and a decoded texture gets its + // faces clustered into printable colours and matched against the filament list, + // before the objects are handed to the plater. Inert for every other model. + if (model.texture_mesh && has_importable_texture(*model.texture_mesh)) { + TextureImportResult texture_import_result; + auto cancel_cb = [&dlg, &dlg_cont]() { return !dlg_cont || dlg.WasCancelled(); }; + auto progress_cb = [&dlg, &dlg_cont, &progress_percent](int percent) { + progress_percent = std::clamp(percent, 0, 100); + dlg_cont = dlg.Update(progress_percent, _L("Matching textures to filaments")); + return dlg_cont; + }; + if (!run_textured_mesh_import_dialog(model, texture_import_result, cancel_cb, progress_cb)) { + q->skip_thumbnail_invalid = false; + return empty_result; + } + if (texture_import_result.fallback_to_geometry_only && !texture_import_result.fallback_warning.empty()) { + MessageDialog(q, texture_import_result.fallback_warning, + _L("Texture Import Warning"), + wxOK | wxICON_WARNING).ShowModal(); + } + if (!texture_import_result.painted.face_colors.empty()) { + std::vector texture_object_idxs(model.objects.size()); + std::iota(texture_object_idxs.begin(), texture_object_idxs.end(), 0); + auto apply_progress_cb = [&dlg](int percent, const wxString& msg) { + dlg.Update(std::clamp(percent, 0, 100), msg); + return true; + }; + apply_textured_mesh_import_result(model, texture_object_idxs, texture_import_result, + apply_progress_cb, false); + } + } + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", before load_model_objects, count %1%")%model.objects.size(); auto loaded_idxs = load_model_objects(model.objects, is_project_file); obj_idxs.insert(obj_idxs.end(), loaded_idxs.begin(), loaded_idxs.end()); @@ -11425,6 +12836,9 @@ void Plater::priv::on_filament_color_changed(wxCommandEvent &event) if (wxGetApp().app_config->get("auto_calculate_flush") != "disabled") { sidebar->auto_calc_flushing_volumes(modify_id); } + + // A mixed slot's colour is derived from its components, so recompute the swatches. + sidebar->update_mixed_filament_list(); } void Plater::priv::install_network_plugin(wxCommandEvent &event) @@ -13034,6 +14448,348 @@ void Plater::reset_project_dirty_initial_presets() { p->reset_project_dirty_init void Plater::render_project_state_debug_window() const { p->render_project_state_debug_window(); } #endif // ENABLE_PROJECT_DIRTY_STATE_DEBUG_WINDOW +std::vector Plater::mixed_filament_config_indices() const +{ + std::vector indices; + auto& config = wxGetApp().preset_bundle->project_config; + auto* opt = config.option("filament_is_mixed"); + if (!opt) return indices; + for (size_t i = 0; i < opt->values.size(); ++i) + if (opt->values[i]) indices.push_back(i); + return indices; +} + +std::vector Plater::physical_filament_config_indices() const +{ + std::vector indices; + auto& config = wxGetApp().preset_bundle->project_config; + auto* opt = config.option("filament_is_mixed"); + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + for (size_t i = 0; i < total; ++i) { + if (!opt || i >= opt->values.size() || !opt->values[i]) + indices.push_back(i); + } + return indices; +} + +bool Plater::priv::run_textured_mesh_import_dialog(Slic3r::Model& loaded_model, TextureImportResult& result, + std::function cancel_callback, + std::function progress_callback) +{ + if (!loaded_model.texture_mesh || !has_importable_texture(*loaded_model.texture_mesh)) return false; + + // Defense in depth: if all geometry got dropped earlier (e.g. by a future + // regression of the zero-volume cleanup) but the textured mesh is still + // alive, there is nothing for the dialog to paint onto. Skip the dialog + // gracefully so load_files() can fall through to its "no geometry" + // message instead of making the user round-trip a meaningless matcher. + if (loaded_model.objects.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: skipping dialog because the loaded model has no geometry objects"; + loaded_model.texture_mesh.reset(); + result.skipped = true; + return true; + } + + const wxString fallback_warning = _L("Texture import failed. The model appears to contain texture data, but the texture import process could not be completed. The model will be imported as geometry only."); + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: opening texture import dialog"; + + std::vector filament_entries; + { + auto& preset_bundle = *wxGetApp().preset_bundle; + auto& project_config = preset_bundle.project_config; + auto* colours_opt = project_config.option("filament_colour"); + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* type_opt = project_config.option("filament_type"); + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + const size_t total = preset_bundle.filament_presets.size(); + filament_entries.reserve(total); + for (size_t i = 0; i < total; ++i) { + TextureFilamentEntry entry; + entry.kind = (is_mixed_opt && i < is_mixed_opt->values.size() && is_mixed_opt->values[i]) ? + TextureFilamentKind::ExistingMixed : TextureFilamentKind::ExistingPhysical; + entry.dialog_index = (int)filament_entries.size(); + entry.project_config_index = i; + entry.color_hex = (colours_opt && i < colours_opt->values.size()) ? colours_opt->values[i] : "#808080"; + entry.type = (type_opt && i < type_opt->values.size()) ? type_opt->values[i] : ""; + + std::string name; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) + name = preset->label(false); + } + if (name.empty()) + name = "Filament " + std::to_string(i + 1); + entry.name = name; + + if (entry.kind == TextureFilamentKind::ExistingMixed) { + if (components_opt && i < components_opt->values.size()) + entry.mixed_components = Slic3r::parse_mixed_components(components_opt->values[i]); + std::vector ratios = Slic3r::parse_mixed_ratios( + ratios_opt && i < ratios_opt->values.size() ? ratios_opt->values[i] : "", + entry.mixed_components.size()); + entry.mixed_ratios.reserve(ratios.size()); + for (double ratio : ratios) + entry.mixed_ratios.push_back((int)std::lround(ratio * 100.0)); + } + filament_entries.push_back(std::move(entry)); + } + } + + TextureImportDialog dlg(q, *loaded_model.texture_mesh, filament_entries, + std::move(cancel_callback), std::move(progress_callback)); + if (dlg.ShowModal() != wxID_OK) { + if (dlg.was_skipped()) { + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: user skipped texture matching"; + result.skipped = true; + loaded_model.texture_mesh.reset(); + return true; + } + if (dlg.fallback_to_geometry_only()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: texture import failed, falling back to geometry-only import"; + result.fallback_to_geometry_only = true; + result.fallback_warning = fallback_warning; + loaded_model.texture_mesh.reset(); + return true; + } + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: user cancelled"; + loaded_model.texture_mesh.reset(); + return false; + } + + auto painted = dlg.get_painted_mesh(); + auto final_matches = dlg.get_matches(); + + if (painted.face_colors.empty() || final_matches.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: no painting result"; + result.fallback_to_geometry_only = true; + result.fallback_warning = fallback_warning; + loaded_model.texture_mesh.reset(); + return true; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: got " << painted.cluster_colors.size() + << " clusters, skipped=" << dlg.was_skipped(); + + result.painted = std::move(painted); + result.matches = std::move(final_matches); + result.new_filament_colors = dlg.get_new_filament_colors(); + result.new_filament_preset_names = dlg.get_new_filament_preset_names(); + result.new_mixed_filaments = dlg.get_new_mixed_filaments(); + result.filament_entries = dlg.get_filament_entries(); + result.existing_filament_count = dlg.get_existing_filament_count(); + result.skipped = dlg.was_skipped(); + return true; +} + +void Plater::priv::apply_textured_mesh_import_result(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + const TextureImportResult& result, + LoadProgressCallback progress_callback, bool update_scene) +{ + auto update_apply_progress = [&progress_callback](int percent, const wxString& message) { + return !progress_callback || progress_callback(std::clamp(percent, 0, 100), message); + }; + + const auto& painted = result.painted; + const auto& final_matches = result.matches; + + if (painted.face_colors.empty() || final_matches.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: no painting result"; + loaded_model.texture_mesh.reset(); + return; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: got " << painted.cluster_colors.size() + << " clusters, skipped=" << result.skipped; + if (!update_apply_progress(0, _L("Applying texture colors..."))) + return; + + auto collect_physical_color_strs = []() { + std::vector colors; + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* colours_opt = project_config.option("filament_colour"); + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + const size_t total = wxGetApp().preset_bundle->filament_presets.size(); + for (size_t i = 0; i < total; ++i) { + const bool is_mixed = is_mixed_opt && i < is_mixed_opt->values.size() && is_mixed_opt->values[i]; + if (!is_mixed) + colors.push_back(colours_opt && i < colours_opt->values.size() ? colours_opt->values[i] : "#808080"); + } + return colors; + }; + + const auto& entries = result.filament_entries; + std::vector filament_index_remap(entries.size(), -1); + size_t existing_physical_count = 0; + size_t new_physical_count = 0; + for (const auto& entry : entries) { + if (entry.kind == TextureFilamentKind::ExistingPhysical) + ++existing_physical_count; + else if (entry.kind == TextureFilamentKind::NewPhysical) + ++new_physical_count; + } + + for (const auto& entry : entries) { + if (entry.dialog_index < 0 || entry.dialog_index >= (int)filament_index_remap.size()) + continue; + if (entry.kind == TextureFilamentKind::ExistingPhysical) { + filament_index_remap[entry.dialog_index] = (int)entry.project_config_index; + } else if (entry.kind == TextureFilamentKind::ExistingMixed) { + filament_index_remap[entry.dialog_index] = (int)(entry.project_config_index + new_physical_count); + } + } + + size_t new_physical_order = 0; + for (const auto& entry : entries) { + if (entry.kind != TextureFilamentKind::NewPhysical) + continue; + wxColour new_col(entry.color_hex); + const size_t final_idx = existing_physical_count + new_physical_order; + sidebar->add_custom_filament(new_col, entry.preset_name); + if (entry.dialog_index >= 0 && entry.dialog_index < (int)filament_index_remap.size()) + filament_index_remap[entry.dialog_index] = (int)final_idx; + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: created pending physical filament dialog=" + << entry.dialog_index << " final=" << final_idx + << " color=" << entry.color_hex + << " preset=" << entry.preset_name; + ++new_physical_order; + } + + std::vector physical_colors_for_mixing = collect_physical_color_strs(); + for (const auto& mixed : result.new_mixed_filaments) { + MixedFilamentResult mixed_result; + mixed_result.ratios = mixed.ratios; + mixed_result.components.reserve(mixed.component_dialog_indices.size()); + bool valid_components = true; + for (int component_dialog_idx : mixed.component_dialog_indices) { + if (component_dialog_idx < 0 || component_dialog_idx >= (int)filament_index_remap.size() || + filament_index_remap[component_dialog_idx] < 0) { + valid_components = false; + break; + } + mixed_result.components.push_back((unsigned int)(filament_index_remap[component_dialog_idx] + 1)); + } + if (!valid_components || mixed_result.components.size() < 2 || + mixed_result.components.size() != mixed_result.ratios.size()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: invalid pending mixed filament dialog=" + << mixed.dialog_index; + continue; + } + + const int final_idx = (int)wxGetApp().preset_bundle->filament_presets.size(); + if (create_mixed_filament_from_result(sidebar, mixed_result, physical_colors_for_mixing)) { + if (mixed.dialog_index >= 0 && mixed.dialog_index < (int)filament_index_remap.size()) + filament_index_remap[mixed.dialog_index] = final_idx; + physical_colors_for_mixing = collect_physical_color_strs(); + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: created pending mixed filament dialog=" + << mixed.dialog_index << " final=" << final_idx; + } + } + + std::vector remapped_matches = final_matches; + for (auto& m : remapped_matches) { + if (m.filament_index < 0) + continue; + if (m.filament_index < (int)filament_index_remap.size() && filament_index_remap[m.filament_index] >= 0) { + m.filament_index = filament_index_remap[m.filament_index]; + } else { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: invalid filament index " + << m.filament_index << " in texture mapping"; + m.filament_index = -1; + } + } + + int min_used_filament_1based = -1; + { + std::map, int> color_to_filament; + for (const auto& m : remapped_matches) { + if (m.cluster_index >= 0 && m.cluster_index < (int)painted.cluster_colors.size() && m.filament_index >= 0) + color_to_filament[painted.cluster_colors[m.cluster_index]] = m.filament_index + 1; + } + for (const auto& face_color : painted.face_colors) { + auto it = color_to_filament.find(face_color); + if (it == color_to_filament.end()) + continue; + if (min_used_filament_1based < 0 || it->second < min_used_filament_1based) + min_used_filament_1based = it->second; + } + } + if (min_used_filament_1based < 0) + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: cannot determine base filament from painted faces"; + + if (!update_apply_progress(25, _L("Applying texture colors..."))) + return; + + for (size_t obj_order = 0; obj_order < obj_idxs.size(); ++obj_order) { + size_t idx = obj_idxs[obj_order]; + if (idx >= loaded_model.objects.size()) continue; + ModelObject* obj = loaded_model.objects[idx]; + if (!obj) continue; + + // painted is derived from the whole textured mesh and is meaningful + // only against a single MODEL_PART volume. Applying it to every + // volume of a multi-part / modifier object would overwrite each + // volume with the same painted geometry. Restrict to the first + // model_part and warn when the object holds more than one. + ModelVolume* target = nullptr; + int part_count = 0; + for (ModelVolume* vol : obj->volumes) { + if (vol && vol->is_model_part()) { + ++part_count; + if (!target) target = vol; + } + } + if (!target) continue; + if (part_count > 1) { + BOOST_LOG_TRIVIAL(warning) + << "handle_textured_mesh_import: object has " << part_count + << " model parts; painting only applied to the first part."; + } + if (Slic3r::apply_painted_mesh_to_volume(painted, remapped_matches, *target) + && min_used_filament_1based > 0) { + target->config.set("extruder", min_used_filament_1based); + obj->config.set("extruder", min_used_filament_1based); + if (update_scene) { + if (auto* obj_list = wxGetApp().obj_list()) { + obj_list->update_objects_list_filament_column(std::max( + wxGetApp().filaments_cnt(), (size_t)min_used_filament_1based)); + obj_list->update_info_items(idx); + } + } + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: set base filament to " + << min_used_filament_1based << " for object index " << idx + << ", object extruder=" << obj->config.extruder() + << ", volume extruder=" << target->config.extruder(); + } + // bbox invalidation is performed inside apply_painted_mesh_to_volume. + obj->ensure_on_bed(); + const int object_percent = 25 + (int)(60 * (obj_order + 1) / std::max(obj_idxs.size(), 1)); + if (!update_apply_progress(object_percent, _L("Applying texture colors..."))) + return; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: painting applied to model volumes"; + loaded_model.texture_mesh.reset(); + if (update_scene) { + if (!update_apply_progress(90, _L("Updating 3D view..."))) + return; + update(); + } + update_apply_progress(100, _L("Texture colors applied.")); +} + +void Plater::priv::handle_textured_mesh_import(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + std::function cancel_callback) +{ + TextureImportResult result; + if (!run_textured_mesh_import_dialog(loaded_model, result, std::move(cancel_callback))) + return; + if (!result.painted.face_colors.empty()) + apply_textured_mesh_import_result(loaded_model, obj_idxs, result); +} + Sidebar& Plater::sidebar() { return *p->sidebar; } const Model& Plater::model() const { return p->model; } Model& Plater::model() { return p->model; } diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 6a42f61fd5..49bc247c59 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -86,6 +86,10 @@ using t_optgroups = std::vector >; class Plater; enum class ActionButtonType : int; +// Sentinel filament id meaning "use the slot the sidebar context menu was opened on" +// (Sidebar::priv::m_menu_filament_id) rather than an explicit index. +inline constexpr int kSidebarContextMenuFilamentId = -2; + #define EVT_PUBLISHING_START 1 #define EVT_PUBLISHING_STOP 2 @@ -188,7 +192,7 @@ public: void delete_filament(size_t filament_id = size_t(-1), int replace_filament_id = -1); // 0 base, -1 means default void change_filament(size_t from_id, size_t to_id); // 0 base void edit_filament(); - void add_custom_filament(wxColour new_col); + void add_custom_filament(wxColour new_col, const std::string& preset_name = std::string(), bool skip_preset_validation = false); bool is_new_project_in_gcode3mf(); // BBS void on_bed_type_change(BedType bed_type); @@ -262,6 +266,20 @@ public: std::vector& combos_filament(); void clear_combos_filament_badge(); void udpate_combos_filament_badge(); + + // Mixed-color filament sidebar section + void add_mixed_filament(); + void edit_mixed_filament(size_t idx); + void delete_mixed_filament_at(size_t idx); + void decompose_filament_color(int filament_idx); + void recalc_filament_scroll_sizes(); + void update_mixed_filament_list(); + bool has_broken_mixed_filament() const; + bool has_broken_mixed_filament(const PartPlate* plate) const; + void collect_physical_filament_info(std::vector& color_strs, + std::vector& names, + std::vector& types, + std::vector* config_indices = nullptr); Search::OptionsSearcher& get_searcher(); std::string& get_search_line(); void update_printer_thumbnail(); @@ -313,6 +331,11 @@ public: const SLAPrint& sla_print() const; SLAPrint& sla_print(); + // Helper: returns config indices where filament_is_mixed == true + std::vector mixed_filament_config_indices() const; + // Helper: returns config indices where filament_is_mixed == false + std::vector physical_filament_config_indices() const; + int new_project(bool skip_confirm = false, bool silent = false, const wxString& project_name = wxString()); // BBS: save & backup void load_project(wxString const & filename = "", wxString const & originfile = "-"); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index bec3bed20b..42796dd3e5 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4,6 +4,7 @@ #include "PresetHints.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" @@ -3497,6 +3498,21 @@ void TabPrintModel::activate_selected_page(std::function throw_if_cancel f->set_value(boost::any(), false); } } + if (m_type == Preset::TYPE_PLATE) + static_cast(this)->update_mixed_filament_seq_state(); +} + +// A mixed-color slot resolves to a different physical filament per layer, so a +// user-defined filament print order cannot be honoured while one exists. +void TabPrintPlate::update_mixed_filament_seq_state() +{ + if (!m_active_page) return; + auto &proj_cfg = m_preset_bundle->project_config; + auto *opt = proj_cfg.option("filament_is_mixed"); + bool has_mixed = opt && has_any_mixed_filament(opt->values); + + toggle_option("first_layer_sequence_choice", !has_mixed); + toggle_option("other_layers_sequence_choice", !has_mixed); } void TabPrintModel::on_value_change(const std::string& opt_id, const boost::any& value) diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 19eb0b849d..4f9cbf7b72 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -545,6 +545,8 @@ public: void build() override; void reset_model_config() override; int show_spiral_mode_settings_dialog(bool is_object_config) { return m_config_manipulation.show_spiral_mode_settings_dialog(is_object_config); } + // Disables the user-defined filament print order while a mixed-color filament exists. + void update_mixed_filament_seq_state(); protected: virtual void on_value_change(const std::string& opt_key, const boost::any& value) override; diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp new file mode 100644 index 0000000000..c9e5fb2147 --- /dev/null +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -0,0 +1,4333 @@ +#include +#include "OpenGLManager.hpp" + +#include "TextureImportDialog.hpp" +#include "I18N.hpp" +#include "GUI_App.hpp" +#include "MsgDialog.hpp" +#include "ColorDecomposeDialog.hpp" +#include "ColorDecomposeSupport.hpp" +#include "Widgets/StateColor.hpp" +#include "Widgets/StaticLine.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" +#include "libslic3r/MeshBoolean.hpp" +#include "libslic3r/TriangleSelector.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE = "PLA Basic"; +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_SHORT_TYPE = "PLA"; +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_NAME = "Bambu PLA Basic"; + +static bool is_dark() { return Slic3r::GUI::wxGetApp().dark_mode(); } + +static wxColour dark_or(const wxColour& light, const wxColour& dark) +{ + return is_dark() ? dark : light; +} + +static wxColour texture_import_gray9000() +{ + return wxColour(38, 46, 48); +} + +static wxColour texture_import_text_colour() +{ + return StateColor::darkModeColorFor(texture_import_gray9000()); +} + +static wxColour texture_import_separator_colour() +{ + return StateColor::darkModeColorFor(wxColour("#CECECE")); +} + +static wxFont texture_import_section_title_font(wxWindow* win) +{ + wxFont font = win ? win->GetFont() : wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + font.MakeBold(); + return font; +} + +static wxSize gl_viewport_size(wxWindow* win, const wxSize& logical_size) +{ + wxSize viewport_size = logical_size; +#ifdef __APPLE__ + const double scale = win ? win->GetContentScaleFactor() : 1.0; + if (scale > 0.0) { + viewport_size.x = std::max(1, (int)std::round(viewport_size.x * scale)); + viewport_size.y = std::max(1, (int)std::round(viewport_size.y * scale)); + } +#else + (void)win; +#endif + return viewport_size; +} + +class ScopedInteractiveBusyCursorSuspender +{ +public: + ScopedInteractiveBusyCursorSuspender() + { +#if defined(__WXMSW__) || defined(__APPLE__) + while (wxIsBusy()) { + wxEndBusyCursor(); + ++m_suspended_count; + } +#endif + } + + ~ScopedInteractiveBusyCursorSuspender() + { +#if defined(__WXMSW__) || defined(__APPLE__) + for (int i = 0; i < m_suspended_count; ++i) + wxBeginBusyCursor(); +#endif + } + +private: + int m_suspended_count = 0; +}; + +static bool needs_filament_swatch_border(const wxColour& colour) +{ + if (is_dark()) + return colour.Red() < 45 && colour.Green() < 45 && colour.Blue() < 45; + return colour.Red() > 224 && colour.Green() > 224 && colour.Blue() > 224; +} + +static wxColour filament_swatch_border_colour() +{ + return is_dark() ? wxColour(207, 207, 207) : wxColour(130, 130, 128); +} + +static void draw_filament_swatch_border(wxDC& dc, const wxColour& colour, + int x, int y, int w, int h, int radius = 0) +{ + if (!needs_filament_swatch_border(colour)) + return; + + dc.SetPen(wxPen(filament_swatch_border_colour(), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + if (radius > 0) + dc.DrawRoundedRectangle(x, y, w, h, radius); + else + dc.DrawRectangle(x, y, w, h); +} + +static void draw_filament_swatch_ellipse_border(wxDC& dc, const wxColour& colour, + int x, int y, int w, int h) +{ + if (!needs_filament_swatch_border(colour)) + return; + + dc.SetPen(wxPen(filament_swatch_border_colour(), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawEllipse(x, y, w, h); +} + +static wxString ellipsize_text(wxDC& dc, wxString text, int max_width) +{ + if (max_width <= 0) + return wxEmptyString; + if (dc.GetTextExtent(text).x <= max_width) + return text; + + const wxString ellipsis = "..."; + while (!text.empty() && dc.GetTextExtent(text + ellipsis).x > max_width) + text.RemoveLast(); + if (text.empty() && dc.GetTextExtent(ellipsis).x > max_width) + return wxString(); + return text + ellipsis; +} + +static int draw_brand_icon_and_strip(wxDC& dc, wxWindow* win, wxString& name, int x, int cy) +{ + int icon_sz = win->FromDIP(16); + if (name.StartsWith("Bambu ")) { + name = name.Mid(6); + wxBitmap bmp = create_scaled_bitmap("BambuStudioBlack", win, 16); + if (bmp.IsOk()) + dc.DrawBitmap(bmp, x, cy - icon_sz / 2, true); + x += icon_sz + win->FromDIP(4); + } + return x; +} + +// ============================================================ +// GreenSlider — thin track + green triangle thumb +// ============================================================ + +class GreenSlider : public wxPanel { +public: + GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize); + int GetValue() const; + void SetValue(int val); + bool Enable(bool enable = true) override; +private: + void OnPaint(wxPaintEvent&); + void OnMouse(wxMouseEvent&); + int xFromValue() const; + int valueFromX(int x) const; + int m_value, m_min, m_max; + bool m_dragging = false; +}; + +GreenSlider::GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos, const wxSize& size) + : wxPanel(parent, wxID_ANY, pos, size.IsFullySpecified() ? size : wxSize(-1, parent->FromDIP(24)), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE) + , m_value(std::clamp(value, minVal, maxVal)), m_min(minVal), m_max(maxVal) +{ + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(wxSize(-1, FromDIP(24))); + + Bind(wxEVT_PAINT, &GreenSlider::OnPaint, this); + Bind(wxEVT_SIZE, [this](wxSizeEvent& evt) { + evt.Skip(); + Refresh(); + }); + Bind(wxEVT_LEFT_DOWN, &GreenSlider::OnMouse, this); + Bind(wxEVT_LEFT_UP, &GreenSlider::OnMouse, this); + Bind(wxEVT_MOTION, &GreenSlider::OnMouse, this); +} + +int GreenSlider::GetValue() const { return m_value; } + +void GreenSlider::SetValue(int val) +{ + val = std::clamp(val, m_min, m_max); + if (val != m_value) { m_value = val; Refresh(); } +} + +bool GreenSlider::Enable(bool enable) +{ + bool ok = wxPanel::Enable(enable); + Refresh(); + return ok; +} + +int GreenSlider::xFromValue() const +{ + wxSize sz = GetClientSize(); + int margin = FromDIP(6); + int track_w = sz.x - 2 * margin; + if (m_max <= m_min || track_w <= 0) return margin; + return margin + (m_value - m_min) * track_w / (m_max - m_min); +} + +int GreenSlider::valueFromX(int x) const +{ + wxSize sz = GetClientSize(); + int margin = FromDIP(6); + int track_w = sz.x - 2 * margin; + if (track_w <= 0 || m_max <= m_min) return m_min; + int val = m_min + (x - margin) * (m_max - m_min) / track_w; + return std::clamp(val, m_min, m_max); +} + +void GreenSlider::OnPaint(wxPaintEvent&) +{ + wxAutoBufferedPaintDC dc(this); + wxSize sz = GetClientSize(); + + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + + int margin = FromDIP(6); + int track_y = sz.y / 2; + int ts = FromDIP(8); + int pen_w = FromDIP(2); + + wxColour greenClr = IsEnabled() ? wxColour(0, 174, 66) + : dark_or(wxColour(180, 180, 180), wxColour(90, 90, 96)); + wxColour grayClr = IsEnabled() ? dark_or(wxColour(200, 200, 200), wxColour(90, 90, 96)) + : dark_or(wxColour(220, 220, 220), wxColour(70, 70, 76)); + + int tx = xFromValue(); + + dc.SetPen(wxPen(greenClr, pen_w)); + dc.DrawLine(margin, track_y, tx, track_y); + + dc.SetPen(wxPen(grayClr, pen_w)); + dc.DrawLine(tx, track_y, sz.x - margin, track_y); + + wxPoint tri[3] = { + {tx, track_y + FromDIP(1)}, + {tx - ts / 2, track_y + FromDIP(1) + ts}, + {tx + ts / 2, track_y + FromDIP(1) + ts} + }; + dc.SetBrush(wxBrush(greenClr)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawPolygon(3, tri); +} + +void GreenSlider::OnMouse(wxMouseEvent& evt) +{ + if (!IsEnabled()) return; + + auto update = [&](int x) { + int nv = valueFromX(x); + if (nv != m_value) { + m_value = nv; + Refresh(); + wxCommandEvent e(wxEVT_SLIDER, GetId()); + e.SetEventObject(this); + ProcessWindowEvent(e); + } + }; + + if (evt.LeftDown()) { + m_dragging = true; + CaptureMouse(); + update(evt.GetX()); + } else if (evt.LeftUp()) { + m_dragging = false; + if (HasCapture()) ReleaseMouse(); + } else if (evt.Dragging() && m_dragging) { + update(evt.GetX()); + } +} + +namespace Slic3r { namespace GUI { + +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_DONE, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_PROGRESS, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_ERROR, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_MESH_REPAIR_DECISION, wxCommandEvent); + +static std::array parse_color_string(const std::string& hex) +{ + std::array c = {1.f, 1.f, 1.f, 1.f}; + if (hex.size() >= 7 && hex[0] == '#') { + unsigned long val = std::strtoul(hex.c_str() + 1, nullptr, 16); + c[0] = ((val >> 16) & 0xFF) / 255.f; + c[1] = ((val >> 8) & 0xFF) / 255.f; + c[2] = ((val ) & 0xFF) / 255.f; + } + return c; +} + +static wxString rgb_to_hex(const std::array& c) +{ + return wxString::Format("#%02X%02X%02X", + (unsigned)c[0], (unsigned)c[1], (unsigned)c[2]); +} + +static wxString filament_name_to_wx_string(const std::string& name) +{ + wxString utf8_name = wxString::FromUTF8(name.c_str()); + if (!utf8_name.empty() || name.empty()) + return utf8_name; + return wxString(name); +} + +static std::string texture_normalize_color_hex(std::string hex) +{ + if (hex.empty()) + return "#808080"; + if (hex.front() != '#') + hex = "#" + hex; + return decompose_normalize_color_hex(std::move(hex)); +} + +static std::string texture_rgba_to_hex(const std::array& rgba) +{ + return wxString::Format("#%02X%02X%02X", + (unsigned char)std::clamp(rgba[0] * 255.f, 0.f, 255.f), + (unsigned char)std::clamp(rgba[1] * 255.f, 0.f, 255.f), + (unsigned char)std::clamp(rgba[2] * 255.f, 0.f, 255.f)).ToStdString(); +} + +static bool texture_entry_is_physical(TextureFilamentKind kind) +{ + return kind == TextureFilamentKind::ExistingPhysical || kind == TextureFilamentKind::NewPhysical; +} + +static bool texture_entry_is_mixed(TextureFilamentKind kind) +{ + return kind == TextureFilamentKind::ExistingMixed || kind == TextureFilamentKind::NewMixed; +} + +static bool texture_entry_is_pla_basic(const TextureFilamentEntry& entry) +{ + return entry.type == DEFAULT_VIRTUAL_FILAMENT_SHORT_TYPE || entry.type == DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE || + entry.name.find(DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE) != std::string::npos || + entry.preset_name.find(DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE) != std::string::npos; +} + +static bool texture_entry_official_basic(const TextureFilamentEntry& entry) +{ + if (!texture_entry_is_physical(entry.kind)) + return false; + // NewPhysical entries are created by add_virtual_filament with a fixed Bambu Basic name. + if (entry.kind == TextureFilamentKind::NewPhysical) + return !official_basic_type_from_preset_name(entry.name).empty(); + // ExistingPhysical: resolve the filament preset name from project_config_index. + auto& pb = *wxGetApp().preset_bundle; + const size_t cfg = entry.project_config_index; + if (cfg < pb.filament_presets.size()) + return !official_basic_type_from_preset_name(pb.filament_presets[cfg]).empty(); + return false; +} + +static Slic3r::ColorDecomposeRecipeMode texture_recipe_mode(TextureAutoMixMode mode) +{ + return mode == TextureAutoMixMode::CMYW ? Slic3r::ColorDecomposeRecipeMode::CMYW : + Slic3r::ColorDecomposeRecipeMode::RYBW; +} + +static bool starts_with_preset_name(const std::string& name, const char* prefix) +{ + const size_t prefix_len = std::strlen(prefix); + return name.size() >= prefix_len && name.compare(0, prefix_len, prefix) == 0; +} + +static std::string resolve_default_virtual_filament_preset_name() +{ + auto* preset_bundle = wxGetApp().preset_bundle; + if (!preset_bundle) + return {}; + + auto valid_preset_name = [preset_bundle](const std::string& name) -> bool { + return !name.empty() && preset_bundle->filaments.find_preset(name, false) != nullptr; + }; + + const auto* default_profiles = preset_bundle->printers.get_selected_preset() + .config.option("default_filament_profile"); + if (default_profiles) { + for (const std::string& name : default_profiles->values) { + if (starts_with_preset_name(name, DEFAULT_VIRTUAL_FILAMENT_NAME) && valid_preset_name(name)) + return name; + } + } + + for (const Preset& preset : preset_bundle->filaments.get_presets()) { + if (preset.is_system && preset.is_visible && preset.is_compatible && + starts_with_preset_name(preset.name, DEFAULT_VIRTUAL_FILAMENT_NAME)) { + return preset.name; + } + } + + for (const Preset& preset : preset_bundle->filaments.get_presets()) { + if (preset.is_visible && preset.is_compatible && + starts_with_preset_name(preset.name, DEFAULT_VIRTUAL_FILAMENT_NAME)) { + return preset.name; + } + } + + std::string selected = preset_bundle->filaments.get_selected_preset_name(); + return valid_preset_name(selected) ? selected : std::string(); +} + +static wxString auto_mix_mode_label(TextureAutoMixMode mode) +{ + return mode == TextureAutoMixMode::CMYW ? _L("One-click CMYW auto-mix") : + _L("One-click RYBW auto-mix"); +} + +static wxPoint constrained_dialog_position(wxWindow* anchor, const wxSize& dialog_size) +{ + if (!anchor) + return wxDefaultPosition; + + wxSize size = dialog_size; + if (size.x <= 0 || size.y <= 0) + size = wxSize(anchor->FromDIP(450), anchor->FromDIP(350)); + + wxPoint pos = anchor->ClientToScreen(wxPoint(0, anchor->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - size.x)); + pos.y = std::clamp(pos.y, display_rect.GetTop(), + std::max(display_rect.GetTop(), display_rect.GetBottom() - size.y)); + return pos; +} + +// ============================================================ +// FilamentSelectPopup +// ============================================================ + +class FilamentSelectPopup : public PopupWindow +{ +public: + FilamentSelectPopup(wxWindow* parent, + const std::vector& entries, + const std::vector>& colors_rgba, + const std::vector& names, + size_t existing_count, + int popup_width, + wxWindow* dialog_anchor, + std::function on_select, + std::function on_add_filament, + std::function on_decompose_color, + std::function can_add_filament, + std::function on_close) + : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) + , m_entries(entries) + , m_colors_rgba(colors_rgba) + , m_names(names) + , m_existing_count(existing_count) + , m_dialog_anchor(dialog_anchor) + , m_on_select(std::move(on_select)) + , m_on_add_filament(std::move(on_add_filament)) + , m_on_decompose_color(std::move(on_decompose_color)) + , m_can_add_filament(std::move(can_add_filament)) + , m_on_close(std::move(on_close)) + { + wxColour pop_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + SetBackgroundColour(pop_bg); + + m_content = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); + m_content->SetBackgroundColour(pop_bg); + m_content->SetScrollRate(0, FromDIP(5)); + auto* outer = new wxBoxSizer(wxVERTICAL); + + const int pop_w = std::max(FromDIP(213), popup_width); + const int row_h = FromDIP(32); + const int pad = FromDIP(8); + const int max_visible_rows = 10; + const wxColour header_clr = dark_or(wxColour(0xAC, 0xAC, 0xAC), wxColour(0x81, 0x81, 0x83)); + + auto add_section_header = [&](const wxString& label) { + auto* hdr = new wxStaticText(m_content, wxID_ANY, label); + wxFont hf = hdr->GetFont(); + hf.SetPointSize(9); + hdr->SetFont(hf); + hdr->SetForegroundColour(header_clr); + outer->Add(hdr, 0, wxLEFT | wxRIGHT | wxTOP, pad); + auto* line = new StaticLine(m_content); + line->SetLineColour(texture_import_separator_colour()); + outer->Add(line, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + }; + + auto add_section = [&](const wxString& label, TextureFilamentKind kind) { + bool has_any = false; + for (const auto& entry : m_entries) { + if (entry.kind == kind) { + has_any = true; + break; + } + } + if (!has_any) + return; + add_section_header(label); + for (const auto& entry : m_entries) { + if (entry.kind != kind) + continue; + wxPanel* row = texture_entry_is_mixed(entry.kind) ? create_mixed_item_row(entry, row_h) + : create_item_row((size_t)entry.dialog_index, row_h); + outer->Add(row, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + } + }; + + add_section(_L("Project Physical Filaments"), TextureFilamentKind::ExistingPhysical); + add_section(_L("Project Mixed Filaments"), TextureFilamentKind::ExistingMixed); + add_section(_L("New Physical Filaments"), TextureFilamentKind::NewPhysical); + add_section(_L("New Mixed Filaments"), TextureFilamentKind::NewMixed); + + auto* decompose_label = new wxStaticText(this, wxID_ANY, _L("Decompose Color")); + auto* add_label = new wxStaticText(this, wxID_ANY, _L("+ Add Material")); + wxFont af = add_label->GetFont(); + af.SetPointSize(10); + add_label->SetFont(af); + decompose_label->SetFont(af); + const bool add_enabled = !m_can_add_filament || m_can_add_filament(); + add_label->SetForegroundColour(add_enabled ? wxColour(0x00, 0xAE, 0x42) : header_clr); + decompose_label->SetForegroundColour(add_enabled ? wxColour(0x00, 0xAE, 0x42) : header_clr); + add_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); + decompose_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); + if (!add_enabled) + add_label->SetToolTip(wxString::Format( + _L("The project supports up to %d filaments. Extra filaments will be discarded."), + (int)EnforcerBlockerType::ExtruderMax)); + decompose_label->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_can_add_filament && !m_can_add_filament()) + return; + auto on_decompose_color = m_on_decompose_color; + m_closing_from_action = true; + Dismiss(); + if (on_decompose_color) + on_decompose_color(); + }); + add_label->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_can_add_filament && !m_can_add_filament()) { + return; + } + auto on_add_filament = m_on_add_filament; + wxWindow* popup_parent = GetParent(); + wxWindow* color_anchor = m_dialog_anchor ? m_dialog_anchor : popup_parent; + m_closing_from_action = true; + Dismiss(); + wxColourData cd; + cd.SetChooseFull(true); + wxColourDialog dlg(popup_parent, &cd); + auto move_color_dialog = [&dlg, color_anchor]() { + dlg.Move(constrained_dialog_position(color_anchor, dlg.GetBestSize())); + }; + dlg.Bind(wxEVT_SHOW, [move_color_dialog](wxShowEvent& e) mutable { + e.Skip(); + if (e.IsShown()) + move_color_dialog(); + }); + move_color_dialog(); + if (dlg.ShowModal() == wxID_OK) { + wxColour clr = dlg.GetColourData().GetColour(); + if (on_add_filament) on_add_filament(clr); + } + }); + + m_content->SetSizer(outer); + m_content->FitInside(); + + auto* top_sizer = new wxBoxSizer(wxVERTICAL); + int list_h = outer->GetMinSize().y; + if (m_colors_rgba.size() > max_visible_rows) + list_h -= ((int)m_colors_rgba.size() - max_visible_rows) * row_h; + m_content->SetMinSize(wxSize(pop_w, list_h)); + m_content->SetMaxSize(wxSize(pop_w, list_h)); + top_sizer->Add(m_content, 0, wxEXPAND); + + top_sizer->AddSpacer(FromDIP(4)); + auto* sep_line = new StaticLine(this); + sep_line->SetLineColour(texture_import_separator_colour()); + top_sizer->Add(sep_line, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + top_sizer->Add(decompose_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + auto* sep_line2 = new StaticLine(this); + sep_line2->SetLineColour(texture_import_separator_colour()); + top_sizer->Add(sep_line2, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + top_sizer->Add(add_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + SetSizerAndFit(top_sizer); + + SetSize(pop_w, top_sizer->GetMinSize().y); + } + +private: + void OnDismiss() override + { + restore_cursor_state(); + if (m_on_close) m_on_close(m_closing_from_action); + m_closing_from_action = false; + wxPopupTransientWindow::OnDismiss(); + schedule_destroy(); + } + + void restore_cursor_state() + { + SetCursor(wxNullCursor); + if (m_content) + m_content->SetCursor(wxNullCursor); + if (m_dialog_anchor) + m_dialog_anchor->SetCursor(wxCursor(wxCURSOR_HAND)); + wxSetCursor(wxNullCursor); + } + + void schedule_destroy() + { + if (m_destroy_scheduled) + return; + m_destroy_scheduled = true; + CallAfter([this]() { Destroy(); }); + } + + wxPanel* create_item_row(size_t idx, int row_h) + { + wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour name_fg = texture_import_text_colour(); + + wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + + const int sq = row->FromDIP(24); + const int sq_r = row->FromDIP(2); + const int sq_x = row->FromDIP(4); + const int gap1 = row->FromDIP(8); + + wxColour fil_clr = idx < m_colors_rgba.size() + ? wxColour((unsigned char)(m_colors_rgba[idx][0] * 255.f), + (unsigned char)(m_colors_rgba[idx][1] * 255.f), + (unsigned char)(m_colors_rgba[idx][2] * 255.f)) + : wxColour(128, 128, 128); + + wxString name_str = (idx < m_names.size()) ? filament_name_to_wx_string(m_names[idx]) + : wxString::Format("Filament %d", (int)(idx + 1)); + row->SetToolTip(name_str); + + row->Bind(wxEVT_PAINT, [this, idx, sq, sq_r, sq_x, gap1, fil_clr, name_str, row_bg, hover_bg, name_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + bool hovered = (m_hover_idx == (int)idx); + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + int sq_y = (sz.y - sq) / 2; + wxColour paint_clr = fil_clr; + if (idx < m_colors_rgba.size()) { + paint_clr = wxColour((unsigned char)(m_colors_rgba[idx][0] * 255.f), + (unsigned char)(m_colors_rgba[idx][1] * 255.f), + (unsigned char)(m_colors_rgba[idx][2] * 255.f)); + } + dc.SetBrush(wxBrush(paint_clr)); + dc.DrawRoundedRectangle(sq_x, sq_y, sq, sq, sq_r); + draw_filament_swatch_border(dc, paint_clr, sq_x, sq_y, sq, sq, sq_r); + + { + wxFont nf = p->GetFont(); + nf.SetPointSize(9); + dc.SetFont(nf); + dc.SetTextForeground(paint_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + wxString ns = wxString::Format("%d", (int)(idx + 1)); + wxSize tsz = dc.GetTextExtent(ns); + dc.DrawText(ns, sq_x + (sq - tsz.x) / 2, sq_y + (sq - tsz.y) / 2); + } + + // Brand icon + material name + { + wxFont mf = p->GetFont(); + mf.SetPointSize(10); + dc.SetFont(mf); + dc.SetTextForeground(name_fg); + wxString display = name_str; + int tx = draw_brand_icon_and_strip(dc, p, display, sq_x + sq + gap1, sz.y / 2); + display = ellipsize_text(dc, display, sz.x - tx - p->FromDIP(4)); + wxSize tsz = dc.GetTextExtent(display); + if (!display.empty()) + dc.DrawText(display, tx, (sz.y - tsz.y) / 2); + } + }); + + row->Bind(wxEVT_MOTION, [this, idx](wxMouseEvent& evt) { + if (m_hover_idx != (int)idx) { + m_hover_idx = (int)idx; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& evt) { + if (m_hover_idx != -1) { + m_hover_idx = -1; + m_content->Refresh(); + } + evt.Skip(); + }); + + row->Bind(wxEVT_LEFT_DOWN, [this, idx](wxMouseEvent&) { + if (m_on_select) m_on_select((int)idx); + m_closing_from_action = true; + Dismiss(); + }); + + return row; + } + + wxPanel* create_mixed_item_row(const TextureFilamentEntry& entry, int row_h) + { + wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour name_fg = texture_import_text_colour(); + wxColour plus_fg = dark_or(wxColour(38, 46, 48), wxColour(0xE6, 0xE6, 0xE8)); + const int idx = entry.dialog_index; + + wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + row->SetToolTip(entry.name.empty() ? wxString::Format("Filament %d", idx + 1) : filament_name_to_wx_string(entry.name)); + + row->Bind(wxEVT_PAINT, [this, entry, idx, row_bg, hover_bg, name_fg, plus_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + const bool hovered = (m_hover_idx == idx); + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxFont font = p->GetFont(); + font.SetPointSize(9); + dc.SetFont(font); + int x = p->FromDIP(2); + const int sw = p->FromDIP(22); + const int sw_r = p->FromDIP(2); + const int y = (sz.y - sw) / 2; + + for (size_t ci = 0; ci < entry.mixed_components.size() && ci < entry.mixed_ratios.size(); ++ci) { + if (ci > 0) { + dc.SetTextForeground(plus_fg); + wxString plus = "+"; + wxSize psz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + + const unsigned int comp_id = entry.mixed_components[ci]; + const int comp_dialog_idx = comp_id >= 1 ? (int)comp_id - 1 : -1; + wxColour comp_clr("#D9D9D9"); + if (comp_dialog_idx >= 0 && comp_dialog_idx < (int)m_colors_rgba.size()) { + const auto& c = m_colors_rgba[comp_dialog_idx]; + comp_clr = wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + + dc.SetBrush(wxBrush(comp_clr)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRoundedRectangle(x, y, sw, sw, sw_r); + draw_filament_swatch_border(dc, comp_clr, x, y, sw, sw, sw_r); + + wxString num = wxString::Format("%u", comp_id); + wxSize nsz = dc.GetTextExtent(num); + dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + dc.DrawText(num, x + (sw - nsz.x) / 2, y + (sw - nsz.y) / 2); + x += sw + p->FromDIP(4); + + dc.SetTextForeground(name_fg); + wxString pct = wxString::Format("%d%%", entry.mixed_ratios[ci]); + wxSize psz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + }); + + row->Bind(wxEVT_MOTION, [this, idx](wxMouseEvent& evt) { + if (m_hover_idx != idx) { + m_hover_idx = idx; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& evt) { + if (m_hover_idx != -1) { + m_hover_idx = -1; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEFT_DOWN, [this, idx](wxMouseEvent&) { + if (m_on_select) m_on_select(idx); + m_closing_from_action = true; + Dismiss(); + }); + + return row; + } + + wxScrolledWindow* m_content = nullptr; + std::vector m_entries; + std::vector> m_colors_rgba; + std::vector m_names; + size_t m_existing_count = 0; + wxWindow* m_dialog_anchor = nullptr; + std::function m_on_select; + std::function m_on_add_filament; + std::function m_on_decompose_color; + std::function m_can_add_filament; + std::function m_on_close; + int m_hover_idx = -1; + bool m_closing_from_action = false; + bool m_destroy_scheduled = false; +}; + +// ============================================================ +// AutoMixSelectPopup +// ============================================================ + +class AutoMixSelectPopup : public PopupWindow +{ +public: + AutoMixSelectPopup(wxWindow* parent, + TextureAutoMixMode current_mode, + int popup_width, + int font_point_size, + std::function on_select, + std::function on_close) + : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) + , m_current_mode(current_mode) + , m_font_point_size(font_point_size) + , m_on_select(std::move(on_select)) + , m_on_close(std::move(on_close)) + { + wxColour pop_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + SetBackgroundColour(pop_bg); + + auto* content = new wxPanel(this, wxID_ANY); + content->SetBackgroundColour(pop_bg); + content->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto* sizer = new wxBoxSizer(wxVERTICAL); + const int row_h = FromDIP(36); + const int pop_w = std::max(FromDIP(216), popup_width); + sizer->Add(create_item_row(content, TextureAutoMixMode::CMYW, row_h), 0, wxEXPAND); + sizer->Add(create_item_row(content, TextureAutoMixMode::RYBW, row_h), 0, wxEXPAND); + content->SetSizer(sizer); + content->SetMinSize(wxSize(pop_w, row_h * 2)); + + auto* top_sizer = new wxBoxSizer(wxVERTICAL); + top_sizer->Add(content, 0, wxEXPAND | wxALL, FromDIP(4)); + SetSizerAndFit(top_sizer); + SetSize(pop_w, top_sizer->GetMinSize().y); + } + +private: + void OnDismiss() override + { + if (m_on_close) + m_on_close(); + wxPopupTransientWindow::OnDismiss(); + CallAfter([this]() { Destroy(); }); + } + + wxPanel* create_item_row(wxWindow* parent, TextureAutoMixMode mode, int row_h) + { + wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour text_fg = texture_import_text_colour(); + wxColour green = wxColour(0, 174, 66); + + wxPanel* row = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + + const int row_idx = mode == TextureAutoMixMode::CMYW ? 0 : 1; + row->Bind(wxEVT_PAINT, [this, row_bg, hover_bg, text_fg, green, mode, row_idx](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + const bool hovered = (m_hover_idx == row_idx); + const bool selected = (m_current_mode == mode); + + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxFont font = p->GetFont(); + font.SetPointSize(m_font_point_size); + dc.SetFont(font); + dc.SetTextForeground(text_fg); + wxString label = auto_mix_mode_label(mode); + wxSize tsz = dc.GetTextExtent(label); + dc.DrawText(label, p->FromDIP(12), (sz.y - tsz.y) / 2); + + if (selected) { + wxFont check_font = p->GetFont(); + check_font.SetPointSize(12); + check_font.MakeBold(); + dc.SetFont(check_font); + dc.SetTextForeground(green); + wxString check = wxString::FromUTF8("✓"); + wxSize csz = dc.GetTextExtent(check); + dc.DrawText(check, sz.x - p->FromDIP(16) - csz.x, (sz.y - csz.y) / 2); + } + }); + + row->Bind(wxEVT_MOTION, [this, row, row_idx](wxMouseEvent& evt) { + if (m_hover_idx != row_idx) { + m_hover_idx = row_idx; + row->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this, row](wxMouseEvent& evt) { + m_hover_idx = -1; + row->Refresh(); + evt.Skip(); + }); + row->Bind(wxEVT_LEFT_DOWN, [this, mode](wxMouseEvent&) { + if (m_on_select) + m_on_select(mode); + Dismiss(); + }); + + return row; + } + + TextureAutoMixMode m_current_mode; + int m_font_point_size = 10; + int m_hover_idx = -1; + std::function m_on_select; + std::function m_on_close; +}; + +// ============================================================ +// TexturePreviewCanvas +// ============================================================ + +TexturePreviewCanvas::TexturePreviewCanvas(wxWindow* parent, const wxGLAttributes& attrs) + : wxGLCanvas(parent, attrs, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxFULL_REPAINT_ON_RESIZE) +{ + m_context = new wxGLContext(this); + + Bind(wxEVT_PAINT, &TexturePreviewCanvas::on_paint, this); + Bind(wxEVT_SIZE, &TexturePreviewCanvas::on_size, this); + Bind(wxEVT_MOUSEWHEEL, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_LEFT_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_LEFT_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MOTION, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_LEAVE_WINDOW, &TexturePreviewCanvas::on_mouse, this); +} + +TexturePreviewCanvas::~TexturePreviewCanvas() +{ + if (m_context) { + SetCurrent(*m_context); + if (m_tex_id) + glDeleteTextures(1, &m_tex_id); + for (unsigned int id : m_gl_tex_ids) + if (id) glDeleteTextures(1, &id); + for (unsigned int id : {m_reset_icon_tex, m_reset_icon_hover_tex, + m_reset_icon_dark_tex, m_reset_icon_dark_hover_tex}) + if (id) glDeleteTextures(1, &id); + delete m_context; + } +} + +void TexturePreviewCanvas::set_mesh_data( + const std::vector>& vertices, + const std::vector>& indices) +{ + m_vertices = vertices; + m_indices = indices; + update_bounding_box(); + compute_smooth_normals(); + Refresh(); +} + +void TexturePreviewCanvas::compute_smooth_normals() +{ + m_vertex_normals.clear(); + if (m_vertices.empty() || m_indices.empty()) return; + + m_vertex_normals.resize(m_vertices.size(), {0.f, 0.f, 0.f}); + + for (const auto& face : m_indices) { + int i0 = face[0], i1 = face[1], i2 = face[2]; + if (i0 < 0 || i0 >= (int)m_vertices.size() || + i1 < 0 || i1 >= (int)m_vertices.size() || + i2 < 0 || i2 >= (int)m_vertices.size()) + continue; + + const auto& v0 = m_vertices[i0]; + const auto& v1 = m_vertices[i1]; + const auto& v2 = m_vertices[i2]; + + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + + m_vertex_normals[i0][0] += nx; m_vertex_normals[i0][1] += ny; m_vertex_normals[i0][2] += nz; + m_vertex_normals[i1][0] += nx; m_vertex_normals[i1][1] += ny; m_vertex_normals[i1][2] += nz; + m_vertex_normals[i2][0] += nx; m_vertex_normals[i2][1] += ny; m_vertex_normals[i2][2] += nz; + } + + for (auto& n : m_vertex_normals) { + float len = std::sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]); + if (len > 1e-8f) { n[0] /= len; n[1] /= len; n[2] /= len; } + } +} + +void TexturePreviewCanvas::set_texture_data( + const std::vector>& uvs, + const unsigned char* tex_data, int tex_w, int tex_h, int tex_channels) +{ + m_uvs = uvs; + m_tex_w = tex_w; + m_tex_h = tex_h; + m_tex_channels = tex_channels; + m_tex_dirty = true; + + size_t sz = (size_t)tex_w * tex_h * tex_channels; + m_tex_data.assign(tex_data, tex_data + sz); + Refresh(); +} + +void TexturePreviewCanvas::set_texture_render_data( + const std::vector>& tex_pixels_rgb, + const std::vector& tex_widths, + const std::vector& tex_heights, + const std::vector, 3>>& face_uvs, + const std::vector& face_tex_ids) +{ + m_tex_pixels_rgb = tex_pixels_rgb; + m_tex_widths = tex_widths; + m_tex_heights = tex_heights; + m_face_uvs = face_uvs; + m_face_tex_ids = face_tex_ids; + m_multi_tex_dirty = true; + Refresh(); +} + +void TexturePreviewCanvas::upload_textures() +{ + if (!m_multi_tex_dirty) return; + m_multi_tex_dirty = false; + + for (unsigned int id : m_gl_tex_ids) + if (id) glDeleteTextures(1, &id); + m_gl_tex_ids.clear(); + + m_gl_tex_ids.resize(m_tex_pixels_rgb.size(), 0); + for (size_t i = 0; i < m_tex_pixels_rgb.size(); ++i) { + if (m_tex_pixels_rgb[i].empty() || m_tex_widths[i] <= 0 || m_tex_heights[i] <= 0) + continue; + GLuint tex_id = 0; + glGenTextures(1, &tex_id); + glBindTexture(GL_TEXTURE_2D, tex_id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, m_tex_widths[i], m_tex_heights[i], + 0, GL_RGB, GL_UNSIGNED_BYTE, m_tex_pixels_rgb[i].data()); + m_gl_tex_ids[i] = tex_id; + } + glBindTexture(GL_TEXTURE_2D, 0); +} + +void TexturePreviewCanvas::set_painted_mesh_data( + const std::vector>& vertices, + const std::vector>& indices) +{ + m_painted_vertices = vertices; + m_painted_indices = indices; + Refresh(); +} + +static void convert_face_colors(const std::vector>& src, + std::vector>& dst) +{ + dst.resize(src.size()); + for (size_t i = 0; i < src.size(); ++i) + dst[i] = { src[i][0] / 255.f, src[i][1] / 255.f, src[i][2] / 255.f }; +} + +void TexturePreviewCanvas::set_face_colors(const std::vector>& face_colors) +{ + convert_face_colors(face_colors, m_face_colors_rgb); + Refresh(); +} + +void TexturePreviewCanvas::set_original_face_colors(const std::vector>& face_colors) +{ + convert_face_colors(face_colors, m_original_face_colors_rgb); + Refresh(); +} + +void TexturePreviewCanvas::set_filament_color_map( + const std::map, std::array>& color_map) +{ + m_color_map = color_map; + m_filament_colors_rgb.resize(m_face_colors_rgb.size()); + for (size_t i = 0; i < m_face_colors_rgb.size(); ++i) { + std::array key = { + (std::size_t)(m_face_colors_rgb[i][0] * 255.f + 0.5f), + (std::size_t)(m_face_colors_rgb[i][1] * 255.f + 0.5f), + (std::size_t)(m_face_colors_rgb[i][2] * 255.f + 0.5f) + }; + auto it = color_map.find(key); + if (it != color_map.end()) + m_filament_colors_rgb[i] = it->second; + else + m_filament_colors_rgb[i] = m_face_colors_rgb[i]; + } + Refresh(); +} + +void TexturePreviewCanvas::set_render_mode(RenderMode mode) +{ + if (m_mode != mode) { + m_mode = mode; + Refresh(); + } +} + +void TexturePreviewCanvas::set_computing_overlay(bool /*show*/) +{ + Refresh(); +} + +void TexturePreviewCanvas::reset_view() +{ + m_zoom = 1.0f; + m_rot_x = -30.0f; + m_rot_y = 30.0f; + m_pan_x = 0.0f; + m_pan_y = 0.0f; + Refresh(); +} + +wxRect TexturePreviewCanvas::reset_overlay_rect() const +{ + wxSize sz = GetClientSize(); + const int button_size = FromDIP(40); + const int margin = FromDIP(20); + return wxRect( + std::max(margin, sz.x - button_size - margin), + std::max(margin, sz.y - button_size - margin), + button_size, + button_size); +} + +unsigned int TexturePreviewCanvas::upload_reset_icon_texture(const std::string& icon_name) +{ + wxBitmap bmp = create_scaled_bitmap(icon_name, this, 40); + if (!bmp.IsOk()) + return 0; + + wxImage image = bmp.ConvertToImage(); + if (!image.IsOk()) + return 0; + + const int w = image.GetWidth(); + const int h = image.GetHeight(); + const unsigned char* rgb = image.GetData(); + const unsigned char* alpha = image.HasAlpha() ? image.GetAlpha() : nullptr; + if (!rgb || w <= 0 || h <= 0) + return 0; + + std::vector rgba((size_t)w * h * 4); + for (int i = 0; i < w * h; ++i) { + rgba[(size_t)i * 4 + 0] = rgb[i * 3 + 0]; + rgba[(size_t)i * 4 + 1] = rgb[i * 3 + 1]; + rgba[(size_t)i * 4 + 2] = rgb[i * 3 + 2]; + rgba[(size_t)i * 4 + 3] = alpha ? alpha[i] : 255; + } + + GLuint tex_id = 0; + glGenTextures(1, &tex_id); + glBindTexture(GL_TEXTURE_2D, tex_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + return tex_id; +} + +void TexturePreviewCanvas::upload_reset_icon_textures() +{ + if (m_reset_icon_tex && m_reset_icon_hover_tex && m_reset_icon_dark_tex && m_reset_icon_dark_hover_tex) + return; + + if (!m_reset_icon_tex) + m_reset_icon_tex = upload_reset_icon_texture("fit_camera"); + if (!m_reset_icon_hover_tex) + m_reset_icon_hover_tex = upload_reset_icon_texture("fit_camera_hover"); + if (!m_reset_icon_dark_tex) + m_reset_icon_dark_tex = upload_reset_icon_texture("fit_camera_dark"); + if (!m_reset_icon_dark_hover_tex) + m_reset_icon_dark_hover_tex = upload_reset_icon_texture("fit_camera_dark_hover"); +} + +bool TexturePreviewCanvas::handle_reset_overlay_mouse(wxMouseEvent& evt) +{ + if (evt.Leaving()) { + if (m_reset_overlay_pressed) { + m_reset_overlay_hovered = false; + m_reset_overlay_pressed = false; + SetCursor(wxCursor(wxCURSOR_ARROW)); + if (HasCapture()) + ReleaseMouse(); + Refresh(); + return true; + } + if (m_reset_overlay_hovered) { + m_reset_overlay_hovered = false; + SetCursor(wxCursor(wxCURSOR_ARROW)); + Refresh(); + } + return false; + } + + const bool over = reset_overlay_rect().Contains(evt.GetPosition()); + if (over != m_reset_overlay_hovered) { + m_reset_overlay_hovered = over; + SetCursor(wxCursor(over ? wxCURSOR_HAND : wxCURSOR_ARROW)); + Refresh(); + } + + if (m_drag_mode != DragMode::None && !m_reset_overlay_pressed) + return false; + + if (evt.LeftDown() && over) { + m_reset_overlay_pressed = true; + if (!HasCapture()) + CaptureMouse(); + Refresh(); + return true; + } + + if (evt.LeftUp() && m_reset_overlay_pressed) { + const bool activate = over; + m_reset_overlay_pressed = false; + if (HasCapture()) + ReleaseMouse(); + if (activate) + reset_view(); + else + Refresh(); + return true; + } + + return over; +} + +void TexturePreviewCanvas::update_bounding_box() +{ + if (m_vertices.empty()) return; + std::array mn = m_vertices[0], mx = m_vertices[0]; + for (const auto& v : m_vertices) { + for (int i = 0; i < 3; ++i) { + mn[i] = std::min(mn[i], v[i]); + mx[i] = std::max(mx[i], v[i]); + } + } + m_center = { (mn[0]+mx[0])/2, (mn[1]+mx[1])/2, (mn[2]+mx[2])/2 }; + float dx = mx[0]-mn[0], dy = mx[1]-mn[1], dz = mx[2]-mn[2]; + m_radius = std::sqrt(dx*dx + dy*dy + dz*dz) / 2.0f; + if (m_radius < 1e-6f) m_radius = 1.0f; +} + +void TexturePreviewCanvas::ensure_gl_ready() +{ + if (m_gl_initialized) return; + + // BBS loads GL entry points here with GLEW. Orca uses glad and centralises loading in + // OpenGLManager, which has already run by the time any canvas is realized, so just + // verify the loader is up and drain any stale error state. + // glad leaves unresolved entry points as null pointers, so this is a cheap guard against + // painting before OpenGLManager::init_gl() has run. + if (glGetString == nullptr) { + BOOST_LOG_TRIVIAL(error) << "TexturePreviewCanvas: OpenGL functions are not loaded yet"; + return; + } + while (glGetError() != GL_NO_ERROR) {} + + m_gl_initialized = true; + + glEnable(GL_DEPTH_TEST); + glEnable(GL_LIGHTING); + glEnable(GL_LIGHT0); + glEnable(GL_COLOR_MATERIAL); + glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE); + + GLfloat light_pos[] = { 0.5f, 1.0f, 1.0f, 0.0f }; + GLfloat light_ambient[] = { 0.3f, 0.3f, 0.3f, 1.0f }; + GLfloat light_diffuse[] = { 0.8f, 0.8f, 0.8f, 1.0f }; + glLightfv(GL_LIGHT0, GL_POSITION, light_pos); + glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient); + glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse); +} + +void TexturePreviewCanvas::on_paint(wxPaintEvent&) +{ + wxPaintDC dc(this); + if (!m_context) return; + SetCurrent(*m_context); + ensure_gl_ready(); + render(); + SwapBuffers(); +} + +void TexturePreviewCanvas::on_size(wxSizeEvent&) +{ + Refresh(); +} + +void TexturePreviewCanvas::on_mouse(wxMouseEvent& evt) +{ + if (handle_reset_overlay_mouse(evt)) + return; + + if (evt.LeftDown()) { + m_drag_mode = DragMode::Rotate; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.LeftUp()) { + if (m_drag_mode == DragMode::Rotate) { + m_drag_mode = DragMode::None; + if (HasCapture()) ReleaseMouse(); + } + } + else if (evt.RightDown()) { + m_drag_mode = DragMode::Pan; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.MiddleDown()) { + m_drag_mode = DragMode::Pan; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.RightUp() || evt.MiddleUp()) { + if (m_drag_mode == DragMode::Pan) { + m_drag_mode = DragMode::None; + if (HasCapture()) ReleaseMouse(); + } + } + else if (evt.Dragging() && m_drag_mode != DragMode::None) { + wxPoint pos = evt.GetPosition(); + float dx = (float)(pos.x - m_last_mouse_pos.x); + float dy = (float)(pos.y - m_last_mouse_pos.y); + + if (m_drag_mode == DragMode::Rotate) { + m_rot_y += dx * 0.5f; + m_rot_x += dy * 0.5f; + m_rot_x = std::max(-89.0f, std::min(89.0f, m_rot_x)); + } else if (m_drag_mode == DragMode::Pan) { + wxSize sz = GetClientSize(); + if (sz.x > 0) + m_pan_x += dx / (float)sz.x * m_radius * 2.0f / m_zoom; + if (sz.y > 0) + m_pan_y -= dy / (float)sz.y * m_radius * 2.0f / m_zoom; + } + + m_last_mouse_pos = pos; + Refresh(); + } + else if (evt.GetWheelRotation() != 0) { + float delta = evt.GetWheelRotation() > 0 ? 1.1f : 0.9f; + m_zoom *= delta; + m_zoom = std::max(0.1f, std::min(20.0f, m_zoom)); + Refresh(); + } +} + +void TexturePreviewCanvas::render() +{ + wxSize sz = GetClientSize(); + if (sz.x <= 0 || sz.y <= 0) return; + + wxSize viewport_sz = gl_viewport_size(this, sz); + glViewport(0, 0, viewport_sz.x, viewport_sz.y); + if (is_dark()) + glClearColor(0.24f, 0.24f, 0.27f, 1.0f); + else + glClearColor(0.933f, 0.933f, 0.933f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + glMatrixMode(GL_PROJECTION); + glLoadIdentity(); + float aspect = (float)viewport_sz.x / (float)viewport_sz.y; + float dist = m_radius * 3.0f / m_zoom; + float near_plane = dist * 0.01f; + float far_plane = dist * 10.0f; + float fov_rad = 45.0f * static_cast(M_PI) / 180.0f; + float f = 1.0f / std::tan(fov_rad / 2.0f); + float proj[16] = {}; + proj[0] = f / aspect; + proj[5] = f; + proj[10] = (far_plane + near_plane) / (near_plane - far_plane); + proj[11] = -1.0f; + proj[14] = (2.0f * far_plane * near_plane) / (near_plane - far_plane); + glMultMatrixf(proj); + + glMatrixMode(GL_MODELVIEW); + glLoadIdentity(); + glTranslatef(0.0f, 0.0f, -dist); + glTranslatef(m_pan_x, m_pan_y, 0.0f); + glRotatef(m_rot_x, 1.0f, 0.0f, 0.0f); + glRotatef(m_rot_y, 0.0f, 1.0f, 0.0f); + glTranslatef(-m_center[0], -m_center[1], -m_center[2]); + + render_mesh(); + render_reset_overlay(sz, viewport_sz); +} + +void TexturePreviewCanvas::render_reset_overlay(const wxSize& logical_size, const wxSize& viewport_size) +{ + if (logical_size.x <= 0 || logical_size.y <= 0 || viewport_size.x <= 0 || viewport_size.y <= 0) + return; + + upload_reset_icon_textures(); + + const unsigned int tex_id = is_dark() + ? (m_reset_overlay_hovered ? m_reset_icon_dark_hover_tex : m_reset_icon_dark_tex) + : (m_reset_overlay_hovered ? m_reset_icon_hover_tex : m_reset_icon_tex); + if (!tex_id) + return; + + wxRect rc = reset_overlay_rect(); + const float sx = (float)viewport_size.x / (float)logical_size.x; + const float sy = (float)viewport_size.y / (float)logical_size.y; + const float x0 = rc.GetLeft() * sx; + const float y0 = rc.GetTop() * sy; + const float x1 = (rc.GetLeft() + rc.GetWidth()) * sx; + const float y1 = (rc.GetTop() + rc.GetHeight()) * sy; + const float alpha = m_reset_overlay_hovered ? 1.0f : 0.78f; + + glPushAttrib(GL_ENABLE_BIT | GL_COLOR_BUFFER_BIT | GL_TEXTURE_BIT | GL_DEPTH_BUFFER_BIT); + glDisable(GL_DEPTH_TEST); + glDisable(GL_LIGHTING); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glEnable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, tex_id); + glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE); + + glMatrixMode(GL_PROJECTION); + glPushMatrix(); + glLoadIdentity(); + glOrtho(0.0, viewport_size.x, viewport_size.y, 0.0, -1.0, 1.0); + + glMatrixMode(GL_MODELVIEW); + glPushMatrix(); + glLoadIdentity(); + + glColor4f(1.0f, 1.0f, 1.0f, alpha); + glBegin(GL_QUADS); + glTexCoord2f(0.0f, 0.0f); glVertex2f(x0, y0); + glTexCoord2f(1.0f, 0.0f); glVertex2f(x1, y0); + glTexCoord2f(1.0f, 1.0f); glVertex2f(x1, y1); + glTexCoord2f(0.0f, 1.0f); glVertex2f(x0, y1); + glEnd(); + + glPopMatrix(); + glMatrixMode(GL_PROJECTION); + glPopMatrix(); + glMatrixMode(GL_MODELVIEW); + + glBindTexture(GL_TEXTURE_2D, 0); + glPopAttrib(); +} + +void TexturePreviewCanvas::render_textured_original() +{ + if (m_vertices.empty() || m_indices.empty()) return; + if (m_face_uvs.empty() || m_face_tex_ids.empty()) return; + if (m_face_uvs.size() != m_indices.size()) return; + + upload_textures(); + + const bool has_smooth = (m_vertex_normals.size() == m_vertices.size()); + + // Group faces by texture id for batch rendering + std::map> tex_groups; + for (size_t fi = 0; fi < m_indices.size(); ++fi) { + int tid = (fi < m_face_tex_ids.size()) ? m_face_tex_ids[fi] : -1; + tex_groups[tid].push_back(fi); + } + + glEnable(GL_LIGHTING); + glColor3f(1.0f, 1.0f, 1.0f); + + for (const auto& [tid, face_list] : tex_groups) { + bool tex_bound = false; + if (tid >= 0 && tid < (int)m_gl_tex_ids.size() && m_gl_tex_ids[tid] != 0) { + glEnable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, m_gl_tex_ids[tid]); + tex_bound = true; + } else { + glDisable(GL_TEXTURE_2D); + } + + glBegin(GL_TRIANGLES); + for (size_t fi : face_list) { + const auto& face = m_indices[fi]; + const auto& uvs = m_face_uvs[fi]; + + if (!tex_bound) { + if (fi < m_original_face_colors_rgb.size()) + glColor3fv(m_original_face_colors_rgb[fi].data()); + else + glColor3f(0.7f, 0.7f, 0.7f); + } + + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx < 0 || idx >= (int)m_vertices.size()) continue; + + if (has_smooth) { + glNormal3fv(m_vertex_normals[idx].data()); + } else if (vi == 0) { + const auto& v0 = m_vertices[face[0]]; + const auto& v1 = m_vertices[face[1]]; + const auto& v2 = m_vertices[face[2]]; + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; } + glNormal3f(nx, ny, nz); + } + + if (tex_bound) + glTexCoord2fv(uvs[vi].data()); + glVertex3fv(m_vertices[idx].data()); + } + } + glEnd(); + } + + glDisable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void TexturePreviewCanvas::render_mesh() +{ + if (m_vertices.empty() || m_indices.empty()) return; + + // Original mode with texture data: use proper texture mapping + if (m_mode == RenderMode::Original && !m_face_uvs.empty()) { + render_textured_original(); + return; + } + + // For Multi-Color / FilamentMap, use the painted (remeshed) geometry if available; + // the face color arrays match the painted mesh, not the original mesh. + const bool use_painted = (m_mode != RenderMode::Original) + && !m_painted_vertices.empty() + && !m_painted_indices.empty(); + + const auto& verts = use_painted ? m_painted_vertices : m_vertices; + const auto& faces = use_painted ? m_painted_indices : m_indices; + + const std::vector>* colors_ptr = nullptr; + if (m_mode == RenderMode::Original && !m_original_face_colors_rgb.empty() + && m_original_face_colors_rgb.size() == m_indices.size()) { + colors_ptr = &m_original_face_colors_rgb; + } else if (m_mode == RenderMode::FilamentMap && !m_filament_colors_rgb.empty() + && m_filament_colors_rgb.size() == faces.size()) { + colors_ptr = &m_filament_colors_rgb; + } else if (!m_face_colors_rgb.empty() && m_face_colors_rgb.size() == faces.size()) { + colors_ptr = &m_face_colors_rgb; + } + + // Use smooth normals for the original mesh when available + const bool has_smooth = !use_painted + && (m_vertex_normals.size() == m_vertices.size()); + + glDisable(GL_TEXTURE_2D); + glEnable(GL_LIGHTING); + + glBegin(GL_TRIANGLES); + for (size_t fi = 0; fi < faces.size(); ++fi) { + if (colors_ptr) + glColor3fv((*colors_ptr)[fi].data()); + else + glColor3f(0.7f, 0.7f, 0.7f); + + const auto& face = faces[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx < 0 || idx >= (int)verts.size()) continue; + + if (has_smooth && idx < (int)m_vertex_normals.size()) { + glNormal3fv(m_vertex_normals[idx].data()); + } else if (vi == 0) { + const auto& v0 = verts[face[0]]; + const auto& v1 = verts[face[1]]; + const auto& v2 = verts[face[2]]; + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; } + glNormal3f(nx, ny, nz); + } + + glVertex3fv(verts[idx].data()); + } + } + glEnd(); +} + + +// ============================================================ +// TextureImportDialog +// ============================================================ + +wxBEGIN_EVENT_TABLE(TextureImportDialog, DPIDialog) + EVT_BUTTON(TextureImportDialog::ID_COLOR_4, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_8, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_16, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_AUTO, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_BTN_APPLY, TextureImportDialog::on_apply_clicked) + EVT_BUTTON(TextureImportDialog::ID_BTN_SKIP, TextureImportDialog::on_skip_clicked) + EVT_BUTTON(wxID_OK, TextureImportDialog::on_ok_clicked) +wxEND_EVENT_TABLE() + +TextureImportDialog::TextureImportDialog( + wxWindow* parent, + const Slic3r::TexturedMesh& textured_mesh, + const std::vector& filament_entries, + std::function initial_cancel_callback, + std::function initial_progress_callback) + : DPIDialog(parent, wxID_ANY, _L("Import Model"), + wxDefaultPosition, wxDefaultSize, + (wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) & ~(wxMINIMIZE_BOX | wxMAXIMIZE_BOX)) + , m_textured_mesh(textured_mesh) + , m_filament_entries(filament_entries) + , m_initial_cancel_callback(std::move(initial_cancel_callback)) + , m_initial_progress_callback(std::move(initial_progress_callback)) +{ + SetSize(wxSize(FromDIP(960), FromDIP(640))); + + m_filament_colors_rgba.reserve(m_filament_entries.size()); + m_filament_color_strs.reserve(m_filament_entries.size()); + m_filament_names.reserve(m_filament_entries.size()); + for (size_t i = 0; i < m_filament_entries.size(); ++i) { + auto& entry = m_filament_entries[i]; + entry.dialog_index = (int)i; + entry.color_hex = texture_normalize_color_hex(entry.color_hex); + if (entry.name.empty()) + entry.name = "Filament " + std::to_string(i + 1); + m_filament_color_strs.push_back(entry.color_hex); + m_filament_names.push_back(entry.name); + m_filament_colors_rgba.push_back(parse_color_string(entry.color_hex)); + } + + m_existing_filament_count = m_filament_colors_rgba.size(); + m_default_virtual_filament_preset_name = resolve_default_virtual_filament_preset_name(); + + Bind(EVT_TEXTURE_COMPUTE_DONE, &TextureImportDialog::on_computation_complete, this); + Bind(EVT_TEXTURE_COMPUTE_PROGRESS, &TextureImportDialog::on_computation_progress, this); + Bind(EVT_TEXTURE_COMPUTE_ERROR, &TextureImportDialog::on_computation_error, this); + Bind(EVT_TEXTURE_MESH_REPAIR_DECISION, &TextureImportDialog::on_mesh_repair_decision_required, this); + + build_ui(); + SetMinSize(wxSize(FromDIP(800), FromDIP(500))); + CenterOnParent(); + wxGetApp().UpdateDlgDarkUI(this); + + m_preview_canvas->set_mesh_data(m_textured_mesh.vertices, m_textured_mesh.indices); + + // Prepare texture rendering data for the Original tab + if (!m_textured_mesh.textures.empty()) { + std::vector> tex_pixels_rgb; + std::vector tex_widths, tex_heights; + tex_pixels_rgb.reserve(m_textured_mesh.textures.size()); + tex_widths.reserve(m_textured_mesh.textures.size()); + tex_heights.reserve(m_textured_mesh.textures.size()); + + for (const auto& ti : m_textured_mesh.textures) { + std::vector bgr_pixels; + int w = 0, h = 0; + if (Slic3r::decode_texture_to_pixels(ti, bgr_pixels, w, h) && !bgr_pixels.empty()) { + // Convert BGR to RGB for OpenGL + for (size_t p = 0; p < bgr_pixels.size(); p += 3) + std::swap(bgr_pixels[p], bgr_pixels[p + 2]); + tex_pixels_rgb.push_back(std::move(bgr_pixels)); + } else { + tex_pixels_rgb.push_back({}); + } + tex_widths.push_back(w); + tex_heights.push_back(h); + } + + const size_t nf = m_textured_mesh.indices.size(); + const bool has_mapping = !m_textured_mesh.material_texture_map.empty(); + + // Build per-face UV array + std::vector, 3>> face_uvs(nf); + for (size_t fi = 0; fi < nf; ++fi) { + if (m_textured_mesh.has_face_uvs()) { + const auto& ui = m_textured_mesh.uv_indices[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = ui[vi]; + if (idx >= 0 && static_cast(idx) < m_textured_mesh.uv_coords.size()) + face_uvs[fi][vi] = m_textured_mesh.uv_coords[idx]; + else + face_uvs[fi][vi] = {0.f, 0.f}; + } + } else if (!m_textured_mesh.uvs.empty()) { + const auto& face = m_textured_mesh.indices[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx >= 0 && static_cast(idx) < m_textured_mesh.uvs.size()) + face_uvs[fi][vi] = m_textured_mesh.uvs[idx]; + else + face_uvs[fi][vi] = {0.f, 0.f}; + } + } + } + + // Build per-face texture index + std::vector face_tex_ids(nf, 0); + for (size_t fi = 0; fi < nf; ++fi) { + int mat_idx = (fi < m_textured_mesh.material_ids.size()) + ? m_textured_mesh.material_ids[fi] : -1; + if (has_mapping && mat_idx >= 0 + && static_cast(mat_idx) < m_textured_mesh.material_texture_map.size()) + face_tex_ids[fi] = m_textured_mesh.material_texture_map[mat_idx]; + else if (!tex_pixels_rgb.empty()) + face_tex_ids[fi] = 0; + else + face_tex_ids[fi] = -1; + } + + m_preview_canvas->set_texture_render_data( + tex_pixels_rgb, tex_widths, tex_heights, face_uvs, face_tex_ids); + + // Still sample per-face colors as fallback + std::vector> orig_colors; + if (Slic3r::sample_original_face_colors(m_textured_mesh, orig_colors)) + m_preview_canvas->set_original_face_colors(orig_colors); + } + + set_state(TextureImportState::Idle); +} + +TextureImportDialog::~TextureImportDialog() +{ + dismiss_auto_mix_popup(); + dismiss_filament_popup(); + m_cancel_flag = true; + if (m_worker && m_worker->joinable()) + m_worker->join(); +} + +int TextureImportDialog::ShowModal() +{ + if (m_state == TextureImportState::Idle && m_painted.face_colors.empty()) { + start_computation(true, true); + + while (m_initial_computation_pending) { + if (auto* event_loop = wxEventLoopBase::GetActive()) + event_loop->Yield(); + else + wxYield(); + if (m_progress_dlg && m_progress_dlg->WasCancelled()) + m_cancel_flag = true; + if (m_initial_cancel_callback && m_initial_cancel_callback()) + m_cancel_flag = true; + wxMilliSleep(10); + } + + if (m_worker && m_worker->joinable()) + m_worker->join(); + m_worker.reset(); + + if (m_initial_computation_cancelled || m_initial_computation_failed) + return wxID_CANCEL; + } + + ScopedInteractiveBusyCursorSuspender busy_cursor_suspender; + return DPIDialog::ShowModal(); +} + +void TextureImportDialog::build_ui() +{ + const wxColour dialog_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + SetBackgroundColour(dialog_bg); + SetForegroundColour(dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0))); + + wxBoxSizer* root_sizer = new wxBoxSizer(wxVERTICAL); + + auto line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1)); + line_top->SetBackgroundColour(dark_or(wxColour(166, 169, 170), wxColour(80, 80, 86))); + root_sizer->Add(line_top, 0, wxEXPAND); + + wxBoxSizer* main_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxBoxSizer* left_sizer = new wxBoxSizer(wxVERTICAL); + build_preview_panel(this, left_sizer); + main_sizer->Add(left_sizer, 3, wxEXPAND | wxALL, FromDIP(8)); + + wxBoxSizer* right_sizer = new wxBoxSizer(wxVERTICAL); + build_params_panel(this, right_sizer); + build_mapping_panel(this, right_sizer); + build_bottom_buttons(right_sizer); + main_sizer->Add(right_sizer, 2, wxEXPAND | wxALL, FromDIP(8)); + + root_sizer->Add(main_sizer, 1, wxEXPAND); + + SetSizer(root_sizer); + Layout(); + Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + +#ifdef __WXMSW__ + wxPanel* size_grip_cover = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + size_grip_cover->SetBackgroundColour(dialog_bg); + size_grip_cover->SetBackgroundStyle(wxBG_STYLE_COLOUR); + + auto update_size_grip_cover = [this, size_grip_cover]() { + const int cover_size = FromDIP(20); + wxSize client_size = GetClientSize(); + size_grip_cover->SetSize(client_size.x - cover_size, client_size.y - cover_size, cover_size, cover_size); + size_grip_cover->Raise(); + }; + update_size_grip_cover(); + + Bind(wxEVT_SIZE, [update_size_grip_cover](wxSizeEvent& e) { + e.Skip(); + update_size_grip_cover(); + }); +#endif +} + +void TextureImportDialog::build_preview_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour preview_bg = dark_or(wxColour(238, 238, 238), wxColour(0x3E, 0x3E, 0x45)); + wxColour preview_bd = dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)); + + wxPanel* preview_container = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + preview_container->SetBackgroundColour(preview_bg); + preview_container->SetBackgroundStyle(wxBG_STYLE_PAINT); + preview_container->Bind(wxEVT_PAINT, [preview_bg, preview_bd](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + dc.SetBrush(wxBrush(preview_bg)); + dc.SetPen(wxPen(preview_bd, 1)); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, 4); + }); + + wxBoxSizer* container_sizer = new wxBoxSizer(wxVERTICAL); + + wxGLAttributes canvas_attrs; + canvas_attrs.PlatformDefaults().RGBA().DoubleBuffer().Depth(24).EndList(); + m_preview_canvas = new TexturePreviewCanvas(preview_container, canvas_attrs); + container_sizer->Add(m_preview_canvas, 1, wxEXPAND | wxALL, FromDIP(1)); + + preview_container->SetSizer(container_sizer); + sizer->Add(preview_container, 1, wxEXPAND); + + m_tab_panel = new wxPanel(preview_container, wxID_ANY); + m_tab_panel->SetBackgroundColour(preview_bg); + + m_btn_view_original = new Button(m_tab_panel, _L("Original")); + m_btn_view_original->SetId(ID_VIEW_ORIGINAL); + m_btn_view_multicolor = new Button(m_tab_panel, _L("Multi-Color")); + m_btn_view_multicolor->SetId(ID_VIEW_MULTICOLOR); + + const int view_button_height = FromDIP(27); + m_btn_view_original->SetCornerRadius(view_button_height / 2); + m_btn_view_original->SetMinSize(wxSize(FromDIP(57), view_button_height)); + m_btn_view_original->SetFont(m_btn_view_original->GetFont().Bold()); + m_btn_view_original->SetToolTip(_L("Your input texture model")); + m_btn_view_multicolor->SetCornerRadius(view_button_height / 2); + m_btn_view_multicolor->SetMinSize(wxSize(FromDIP(57), view_button_height)); + m_btn_view_multicolor->SetFont(m_btn_view_multicolor->GetFont().Bold()); + m_btn_view_multicolor->SetToolTip(_L("Processed multi-color model")); + + wxBoxSizer* tab_sizer = new wxBoxSizer(wxHORIZONTAL); + tab_sizer->Add(m_btn_view_original, 0, wxRIGHT, FromDIP(2)); + tab_sizer->Add(m_btn_view_multicolor, 0); + m_tab_panel->SetSizer(tab_sizer); + m_tab_panel->Fit(); + + m_btn_view_multicolor->Hide(); + + auto preview_original = [this](wxMouseEvent& e) { + if (m_preview_canvas) { + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::Original); + highlight_view_button(0); + } + e.Skip(); + }; + auto preview_multicolor = [this](wxMouseEvent& e) { + if (m_preview_canvas) { + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::MultiColor); + highlight_view_button(1); + } + e.Skip(); + }; + auto restore_filament_if_outside = [this](wxMouseEvent& e) { + if (m_preview_canvas && m_tab_panel) { + wxWindow* event_window = wxDynamicCast(e.GetEventObject(), wxWindow); + wxPoint screen_pos = event_window ? event_window->ClientToScreen(e.GetPosition()) : wxGetMousePosition(); + wxPoint panel_pos = m_tab_panel->ScreenToClient(screen_pos); + if (!m_tab_panel->GetClientRect().Contains(panel_pos)) { + const bool mapping_ready = (m_state == TextureImportState::Ready); + m_preview_canvas->set_render_mode(mapping_ready ? TexturePreviewCanvas::RenderMode::FilamentMap : + TexturePreviewCanvas::RenderMode::Original); + highlight_view_button(-1); + } + } + e.Skip(); + }; + + m_btn_view_original->Bind(wxEVT_ENTER_WINDOW, preview_original); + m_btn_view_multicolor->Bind(wxEVT_ENTER_WINDOW, preview_multicolor); + m_btn_view_original->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + m_btn_view_multicolor->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + m_tab_panel->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + + auto update_preview_overlay_buttons = [this]() { + if (m_tab_panel) { + m_tab_panel->Fit(); + m_tab_panel->SetPosition(wxPoint(FromDIP(8), FromDIP(8))); + m_tab_panel->Raise(); + } + }; + + preview_container->Bind(wxEVT_SIZE, [update_preview_overlay_buttons](wxSizeEvent& e) { + e.Skip(); + update_preview_overlay_buttons(); + }); + update_preview_overlay_buttons(); + + highlight_view_button(-1); +} + +void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour label_fg = dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0)); + + wxBoxSizer* color_header_sizer = new wxBoxSizer(wxHORIZONTAL); + wxStaticText* lbl_colors = new wxStaticText(parent, wxID_ANY, _L("Color Count")); + lbl_colors->SetForegroundColour(label_fg); + lbl_colors->SetFont(lbl_colors->GetFont().Bold()); + color_header_sizer->Add(lbl_colors, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + m_btn_color_4 = new Button(parent, "4"); + m_btn_color_4->SetId(ID_COLOR_4); + m_btn_color_8 = new Button(parent, "8"); + m_btn_color_8->SetId(ID_COLOR_8); + m_btn_color_16 = new Button(parent, "16"); + m_btn_color_16->SetId(ID_COLOR_16); + m_btn_color_auto = new Button(parent, _L("Auto")); + m_btn_color_auto->SetId(ID_COLOR_AUTO); + + { + StateColor preset_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed | StateColor::Checked), + std::pair(wxColour(61, 203, 115), StateColor::Hovered | StateColor::Checked), + std::pair(wxColour(0, 174, 66), StateColor::Checked), + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), + std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + StateColor preset_bd( + std::pair(wxColour(0, 174, 66), StateColor::Checked), + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor preset_text( + std::pair(wxColour(255, 255, 255), StateColor::Checked), + std::pair(dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0)), StateColor::Normal)); + + for (auto* btn : {m_btn_color_4, m_btn_color_8, m_btn_color_16}) { + btn->SetCornerRadius(FromDIP(12)); + btn->SetMinSize(wxSize(FromDIP(28), FromDIP(28))); + btn->SetBackgroundColor(preset_bg); + btn->SetBorderColor(preset_bd); + btn->SetTextColor(preset_text); + } + } + + update_color_count_preset_buttons(); + + color_header_sizer->Add(m_btn_color_4, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + color_header_sizer->Add(m_btn_color_8, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + color_header_sizer->Add(m_btn_color_16, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(color_header_sizer, 0, wxBOTTOM, FromDIP(4)); + + wxBoxSizer* color_slider_sizer = new wxBoxSizer(wxHORIZONTAL); + m_color_slider = new GreenSlider(parent, m_param_color_count, 1, (int)max_filament_count()); + m_color_spin = new SpinInput(parent, wxString::Format("%d", m_param_color_count), + wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(60), FromDIP(28)), + wxTE_PROCESS_ENTER, 1, (int)max_filament_count(), m_param_color_count); + + m_color_slider->Bind(wxEVT_SLIDER, &TextureImportDialog::on_color_slider_changed, this); + m_color_spin->Bind(wxEVT_SPINCTRL, &TextureImportDialog::on_color_spin_changed, this); + m_color_spin->Bind(EVT_SPINCTRL_TEXT, &TextureImportDialog::on_color_spin_text_changed, this); + + color_slider_sizer->Add(m_color_slider, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + color_slider_sizer->Add(m_color_spin, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(color_slider_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + wxStaticText* lbl_smooth = new wxStaticText(parent, wxID_ANY, _L("Smooth Level")); + lbl_smooth->SetForegroundColour(label_fg); + lbl_smooth->SetFont(lbl_smooth->GetFont().Bold()); + sizer->Add(lbl_smooth, 0, wxBOTTOM, FromDIP(4)); + + wxBoxSizer* smooth_sizer = new wxBoxSizer(wxHORIZONTAL); + m_smooth_slider = new GreenSlider(parent, m_param_smooth, 0, 10); + m_smooth_spin = new SpinInput(parent, wxString::Format("%d", m_param_smooth), + wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(60), FromDIP(28)), + wxTE_PROCESS_ENTER, 0, 10, m_param_smooth); + + m_smooth_slider->Bind(wxEVT_SLIDER, &TextureImportDialog::on_smooth_slider_changed, this); + m_smooth_spin->Bind(wxEVT_SPINCTRL, &TextureImportDialog::on_smooth_spin_changed, this); + m_smooth_spin->Bind(EVT_SPINCTRL_TEXT, &TextureImportDialog::on_smooth_spin_text_changed, this); + + smooth_sizer->Add(m_smooth_slider, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + smooth_sizer->Add(m_smooth_spin, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(smooth_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + m_btn_apply = new Button(parent, _L("Apply")); + m_btn_apply->SetId(ID_BTN_APPLY); + + { + StateColor btn_bg_white( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), + std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + StateColor btn_bd_green( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor btn_text_green( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + + m_btn_color_auto->SetCornerRadius(FromDIP(12)); + m_btn_color_auto->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + m_btn_color_auto->SetBackgroundColor(btn_bg_white); + m_btn_color_auto->SetBorderColor(btn_bd_green); + m_btn_color_auto->SetTextColor(btn_text_green); + + m_btn_apply->SetCornerRadius(FromDIP(12)); + m_btn_apply->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + m_btn_apply->SetBackgroundColor(btn_bg_white); + m_btn_apply->SetBorderColor(btn_bd_green); + m_btn_apply->SetTextColor(btn_text_green); + } + + // Defer attaching the Auto/Apply tooltips until the dialog has actually + // been shown. On macOS, AppKit creates NSTrackingArea and dispatches a + // synthetic mouseEntered: as soon as the window first becomes visible, + // which would otherwise pop the native tooltip without the user actually + // hovering when the cursor happens to land on these buttons as the dialog + // appears. + Bind(wxEVT_SHOW, [this](wxShowEvent& e) { + e.Skip(); + if (!e.IsShown() || m_initial_tooltips_set) + return; + m_initial_tooltips_set = true; + CallAfter([this]() { + if (m_btn_color_auto) + m_btn_color_auto->SetToolTip(_L("Automatically determine the optimal color count only and recompute filament mapping")); + if (m_btn_apply) + m_btn_apply->SetToolTip(_L("Convert texture to painting using the specified color count and smooth level")); + }); + }); + + wxBoxSizer* apply_sizer = new wxBoxSizer(wxHORIZONTAL); + apply_sizer->Add(m_btn_color_auto, 0, wxRIGHT, FromDIP(4)); + apply_sizer->Add(m_btn_apply, 0); + sizer->Add(apply_sizer, 0, wxALIGN_RIGHT | wxBOTTOM, FromDIP(8)); + + m_hint_label = new wxStaticText(parent, wxID_ANY, + _L("Reminder: parameters changed, click Apply to take effect")); + m_hint_label->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_hint_label->SetFont(texture_import_section_title_font(parent)); + m_hint_label->Hide(); + sizer->Add(m_hint_label, 0, wxBOTTOM, FromDIP(4)); + + auto* mapping_separator = new StaticLine(parent); + mapping_separator->SetLineColour(texture_import_separator_colour()); + sizer->Add(mapping_separator, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); +} + +void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour secondary_fg = dark_or(wxColour(107, 107, 107), wxColour(0x81, 0x81, 0x83)); + + wxBoxSizer* header_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxStaticText* lbl_mapping = new wxStaticText(parent, wxID_ANY, _L("Filament Mapping")); + lbl_mapping->SetForegroundColour(secondary_fg); + lbl_mapping->SetFont(texture_import_section_title_font(parent)); + m_auto_mix_font_point_size = lbl_mapping->GetFont().GetPointSize(); + header_sizer->Add(lbl_mapping, 0, wxALIGN_CENTER_VERTICAL); + + m_btn_mix_reset = new Button(parent, "", "revert_btn", wxBORDER_NONE, 16); + m_btn_mix_reset->SetCanFocus(false); + m_btn_mix_reset->SetPaddingSize(wxSize(FromDIP(2), FromDIP(2))); + { + StateColor reset_bg( + std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)), StateColor::Pressed), + std::pair(dark_or(wxColour(248, 248, 248), wxColour(0x35, 0x35, 0x3A)), StateColor::Hovered), + std::pair(dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + m_btn_mix_reset->SetBackgroundColor(reset_bg); + m_btn_mix_reset->SetBorderColor(StateColor()); + } + m_btn_mix_reset->SetToolTip(_L("Reset filament mapping to the state before one-click mixing")); + m_btn_mix_reset->Bind(wxEVT_BUTTON, [this](wxCommandEvent& evt) { + reset_auto_mix(); + evt.Skip(); + }); + m_btn_mix_reset->Hide(); + header_sizer->Add(m_btn_mix_reset, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(6)); + + header_sizer->AddStretchSpacer(); + + m_btn_auto_mix = new Button(parent, auto_mix_mode_label(m_auto_mix_mode)); + { + wxFont btn_font = m_btn_auto_mix->GetFont(); + btn_font.SetPointSize(m_auto_mix_font_point_size); + m_btn_auto_mix->SetFont(btn_font); + } + m_btn_auto_mix->SetCornerRadius(FromDIP(14)); + m_btn_auto_mix->SetMinSize(wxSize(FromDIP(178), FromDIP(28))); + { + StateColor btn_bg( + std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)), StateColor::Pressed), + std::pair(dark_or(wxColour(248, 248, 248), wxColour(0x35, 0x35, 0x3A)), StateColor::Hovered), + std::pair(dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + StateColor btn_bd( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor btn_text( + std::pair(texture_import_text_colour(), StateColor::Normal)); + m_btn_auto_mix->SetBackgroundColor(btn_bg); + m_btn_auto_mix->SetBorderColor(btn_bd); + m_btn_auto_mix->SetTextColor(btn_text); + } + m_btn_auto_mix->SetToolTip(_L("Choose the one-click auto-mix mode for texture color import")); + m_btn_auto_mix->Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& evt) { + show_auto_mix_popup(); + evt.Skip(); + }); + m_btn_auto_mix->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + show_auto_mix_popup(); + evt.Skip(); + }); + header_sizer->Add(m_btn_auto_mix, 0, wxALIGN_CENTER_VERTICAL); + + sizer->Add(header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + wxBoxSizer* merge_sizer = new wxBoxSizer(wxHORIZONTAL); + m_auto_merge_cb = new wxCheckBox(parent, wxID_ANY, _L("Auto-merge same filament")); + m_auto_merge_cb->SetToolTip(_L("Automatically merge identical filaments into existing filaments in the project")); + m_auto_merge_cb->SetForegroundColour(secondary_fg); + m_auto_merge_cb->SetValue(true); + m_auto_merge_cb->Bind(wxEVT_CHECKBOX, &TextureImportDialog::on_auto_merge_toggled, this); + merge_sizer->Add(m_auto_merge_cb, 0, wxALIGN_CENTER_VERTICAL); + + sizer->Add(merge_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + m_mapping_scroll = new wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, + wxSize(-1, FromDIP(300))); + m_mapping_scroll->SetScrollRate(0, FromDIP(10)); + m_mapping_scroll->SetBackgroundColour(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31))); + m_mapping_scroll->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + m_mapping_sizer = new wxBoxSizer(wxVERTICAL); + m_mapping_scroll->SetSizer(m_mapping_sizer); + + sizer->Add(m_mapping_scroll, 1, wxEXPAND | wxBOTTOM, FromDIP(8)); +} + +void TextureImportDialog::build_bottom_buttons(wxSizer* sizer) +{ + m_drop_warning_label = new wxStaticText(this, wxID_ANY, + wxString::Format( + _L("The project supports up to %d filaments. Extra filaments will be discarded."), + (int)max_filament_count())); + m_drop_warning_label->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_drop_warning_label->SetFont(texture_import_section_title_font(this)); + m_drop_warning_label->Hide(); + sizer->Add(m_drop_warning_label, 0, wxALIGN_LEFT | wxBOTTOM, FromDIP(4)); + + wxBoxSizer* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + m_btn_skip = new Button(this, _L("Skip Matching")); + m_btn_skip->SetId(ID_BTN_SKIP); + m_btn_skip->SetToolTip(_L("Skip filament mapping and import as a single-color model")); + m_btn_skip->SetCornerRadius(FromDIP(20)); + m_btn_skip->SetMinSize(wxSize(FromDIP(136), FromDIP(40))); + { + StateColor skip_bg( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), + std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + StateColor skip_bd( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor skip_text( + std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); + m_btn_skip->SetBackgroundColor(skip_bg); + m_btn_skip->SetBorderColor(skip_bd); + m_btn_skip->SetTextColor(skip_text); + } + + m_btn_ok = new Button(this, _L("Confirm")); + m_btn_ok->SetId(wxID_OK); + m_btn_ok->SetCornerRadius(FromDIP(20)); + m_btn_ok->SetMinSize(wxSize(FromDIP(156), FromDIP(40))); + { + StateColor ok_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed), + std::pair(wxColour(61, 203, 115), StateColor::Hovered), + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_bd( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_text( + std::pair(wxColour("#FFFFFE"), StateColor::Normal)); + m_btn_ok->SetBackgroundColor(ok_bg); + m_btn_ok->SetBorderColor(ok_bd); + m_btn_ok->SetTextColor(ok_text); + } + + btn_sizer->AddStretchSpacer(); + btn_sizer->Add(m_btn_skip, 0, wxRIGHT, FromDIP(16)); + btn_sizer->Add(m_btn_ok, 0); + + sizer->Add(btn_sizer, 0, wxEXPAND | wxTOP, FromDIP(8)); +} + +// ---- State machine ---- + +void TextureImportDialog::set_state(TextureImportState new_state) +{ + m_state = new_state; + update_ui_for_state(); +} + +void TextureImportDialog::update_ui_for_state() +{ + bool computing = (m_state == TextureImportState::Computing); + bool ready = (m_state == TextureImportState::Ready); + bool idle = (m_state == TextureImportState::Idle); + bool valid = has_valid_result(); + + m_color_slider->Enable(!computing); + m_color_spin->Enable(!computing); + m_smooth_slider->Enable(!computing); + m_smooth_spin->Enable(!computing); + m_btn_apply->Enable(!computing); + m_btn_color_4->Enable(!computing); + m_btn_color_8->Enable(!computing); + m_btn_color_16->Enable(!computing); + m_btn_color_auto->Enable(!computing); + if (m_btn_auto_mix) + m_btn_auto_mix->Enable(!computing); + if (m_btn_mix_reset) + m_btn_mix_reset->Enable(!computing); + if (computing) + dismiss_auto_mix_popup(); + + m_btn_ok->Enable(ready && valid); + m_btn_skip->Enable(ready || idle); + + m_auto_merge_cb->Enable(!computing); + + m_preview_canvas->set_computing_overlay(computing); + + if (ready && valid && is_params_dirty()) { + m_btn_ok->Enable(true); + StateColor gray_bg( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor gray_bd( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor gray_text( + std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); + m_btn_ok->SetBackgroundColor(gray_bg); + m_btn_ok->SetBorderColor(gray_bd); + m_btn_ok->SetTextColor(gray_text); + m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); + if (m_hint_label) m_hint_label->Show(); + } else if (ready && valid) { + StateColor ok_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed), + std::pair(wxColour(61, 203, 115), StateColor::Hovered), + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_bd( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_text( + std::pair(wxColour("#FFFFFE"), StateColor::Normal)); + m_btn_ok->SetBackgroundColor(ok_bg); + m_btn_ok->SetBorderColor(ok_bd); + m_btn_ok->SetTextColor(ok_text); + m_btn_ok->UnsetToolTip(); + if (m_hint_label) m_hint_label->Hide(); + } else { + if (m_hint_label) m_hint_label->Hide(); + } + + m_btn_ok->Refresh(); + Layout(); +} + +// ---- Async computation ---- + +void TextureImportDialog::start_computation(bool auto_color, bool initial) +{ + cancel_computation(); + + m_cancel_flag = false; + m_current_computation_initial = initial; + m_current_computation_auto_color = auto_color; + if (initial) { + m_initial_computation_pending = true; + m_initial_computation_cancelled = false; + m_initial_computation_failed = false; + } + set_state(TextureImportState::Computing); + + bool silent_initial = initial && static_cast(m_initial_cancel_callback); + if (!silent_initial) { + m_progress_dlg = new ProgressDialog( + _L("Processing"), _L("Computing texture colors..."), + 100, initial ? GetParent() : this, wxPD_APP_MODAL | wxPD_CAN_ABORT | wxPD_AUTO_HIDE); + } + + Slic3r::TexturePaintingSettings settings; + settings.target_colors_num = auto_color ? 0 : (size_t)m_param_color_count; + settings.smooth_weight = m_param_smooth / 10.0; + settings.mesh_repair_decision = m_mesh_repair_decision; + // BBS repairs the mesh through the Windows 3D SDK, which only exists on Windows and only + // when the SDK is present at build time. Orca already ships a CGAL-based repair + // (MeshBoolean::cgal::repair) that works on all three platforms, so use that instead — + // this makes the repair path available on Linux and macOS too. + settings.mesh_repair_callback = [](const indexed_triangle_set& mesh, + indexed_triangle_set& repaired_mesh, + std::function progress_callback, + std::function cancel_callback, + std::string* error_message) -> bool { + if (cancel_callback && cancel_callback()) + return false; + if (progress_callback) + progress_callback(_u8L("Repairing mesh").c_str(), 0); + + TriangleMesh tm(mesh); + if (!MeshBoolean::cgal::repair(tm, nullptr, error_message)) + return false; + + if (cancel_callback && cancel_callback()) + return false; + repaired_mesh = tm.its; + if (progress_callback) + progress_callback(_u8L("Repairing mesh").c_str(), 100); + return true; + }; + + Slic3r::TexturedMesh mesh_copy = m_textured_mesh; + wxEvtHandler* handler = this; + + m_worker = std::make_unique([this, settings, mesh_copy, handler]() { + Slic3r::PaintedMesh result; + + auto progress_cb = [handler](int percent, const char*) { + auto* evt = new wxCommandEvent(EVT_TEXTURE_COMPUTE_PROGRESS); + evt->SetInt(percent); + wxQueueEvent(handler, evt); + }; + + auto cancel_cb = [this]() -> bool { + return m_cancel_flag.load(); + }; + + auto worker_settings = settings; + bool mesh_repair_decision_required = false; + worker_settings.mesh_repair_decision_required = &mesh_repair_decision_required; + bool ok = Slic3r::texture_to_painting(mesh_copy, result, worker_settings, progress_cb, cancel_cb); + + if (m_cancel_flag.load()) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_ERROR)); + return; + } + + if (!ok && mesh_repair_decision_required) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_MESH_REPAIR_DECISION)); + return; + } + + { + std::lock_guard lock(m_result_mutex); + m_pending_result = std::move(result); + } + + if (ok) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_DONE)); + } else { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_ERROR)); + } + }); +} + +void TextureImportDialog::cancel_computation() +{ + m_cancel_flag = true; + if (m_worker && m_worker->joinable()) + m_worker->join(); + m_worker.reset(); + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + if (m_current_computation_initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + } +} + +void TextureImportDialog::on_computation_progress(wxCommandEvent& evt) +{ + if (m_progress_dlg) { + if (!m_progress_dlg->Update(evt.GetInt())) + m_cancel_flag = true; + } else if (m_current_computation_initial && m_initial_progress_callback) { + if (!m_initial_progress_callback(evt.GetInt())) + m_cancel_flag = true; + } +} + +void TextureImportDialog::on_computation_complete(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + { + std::lock_guard lock(m_result_mutex); + m_painted = std::move(m_pending_result); + } + + int actual_colors = (int)m_painted.cluster_colors.size(); + if (actual_colors >= 2 && actual_colors <= (int)max_filament_count()) { + set_color_count_value(actual_colors, true); + } + + m_preview_canvas->set_painted_mesh_data(m_painted.vertices, m_painted.indices); + m_preview_canvas->set_face_colors(m_painted.face_colors); + + // A fresh texture computation replaces m_painted, so virtual filaments from + // the previous computation must not consume capacity when deciding whether + // this run drops extra colors. Rebuild virtual filaments from this result. + m_current_matches.clear(); + if (m_filament_colors_rgba.size() > m_existing_filament_count) + m_filament_colors_rgba.resize(m_existing_filament_count); + if (m_filament_color_strs.size() > m_existing_filament_count) + m_filament_color_strs.resize(m_existing_filament_count); + if (m_filament_names.size() > m_existing_filament_count) + m_filament_names.resize(m_existing_filament_count); + if (m_filament_entries.size() > m_existing_filament_count) + m_filament_entries.resize(m_existing_filament_count); + while (m_filament_entries.size() < m_existing_filament_count) { + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::ExistingPhysical; + entry.dialog_index = (int)m_filament_entries.size(); + entry.project_config_index = m_filament_entries.size(); + m_filament_entries.push_back(entry); + } + for (size_t i = 0; i < m_filament_entries.size(); ++i) { + m_filament_entries[i].dialog_index = (int)i; + m_filament_entries[i].color_hex = i < m_filament_color_strs.size() ? + texture_normalize_color_hex(m_filament_color_strs[i]) : "#808080"; + m_filament_entries[i].name = i < m_filament_names.size() ? + m_filament_names[i] : "Filament " + std::to_string(i + 1); + } + m_new_filament_colors.clear(); + m_new_filament_preset_names.clear(); + m_new_mixed_filaments.clear(); + + do_auto_match(); + compact_used_virtual_filaments(); + sort_current_matches_by_filament_index(); + update_filament_color_map(); + rebuild_mapping_rows(); + + m_applied_color_count = m_param_color_count; + m_applied_smooth = m_param_smooth; + + set_state(TextureImportState::Ready); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); + + m_btn_view_multicolor->Show(); + if (m_tab_panel) { + m_tab_panel->GetSizer()->Layout(); + m_tab_panel->Fit(); + m_tab_panel->SetPosition(wxPoint(FromDIP(8), FromDIP(8))); + } + GetSizer()->Layout(); + + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::FilamentMap); + highlight_view_button(-1); + + if (initial) { + m_initial_computation_pending = false; + m_current_computation_initial = false; + } +} + +void TextureImportDialog::on_computation_error(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + if (m_cancel_flag.load()) { + if (initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + return; + } + if (has_valid_result()) { + if (m_applied_color_count >= 0) { + m_param_color_count = m_applied_color_count; + m_color_slider->SetValue(m_param_color_count); + m_color_spin->SetValue(m_param_color_count); + update_color_count_preset_buttons(); + } + if (m_applied_smooth >= 0) { + m_param_smooth = m_applied_smooth; + m_smooth_slider->SetValue(m_param_smooth); + m_smooth_spin->SetValue(m_param_smooth); + } + set_state(TextureImportState::Ready); + return; + } + set_state(TextureImportState::Idle); + return; + } + + if (initial) { + m_initial_computation_failed = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + m_fallback_to_geometry_only = true; + return; + } + + set_state(TextureImportState::Error); + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("Computation failed. Please adjust parameters and retry."), + _L("Error"), wxOK | wxICON_ERROR); + dlg.ShowModal(); +} + +void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + bool auto_color = m_current_computation_auto_color; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + +#ifdef HAS_WIN10SDK + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it with Windows 3D repair service before importing."), + _L("Mesh repair"), wxYES_NO | wxICON_WARNING | wxYES_DEFAULT); + dlg.SetButtonLabel(wxID_YES, _L("Import without repair")); + dlg.SetButtonLabel(wxID_NO, _L("Repair and import"), true); + StateColor primary_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed), + std::pair(wxColour(61, 203, 115), StateColor::Hovered), + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor primary_bd( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor primary_text( + std::pair(wxColour("#FFFFFE"), StateColor::Normal)); + StateColor secondary_bg( + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + StateColor secondary_bd( + std::pair(texture_import_gray9000(), StateColor::Normal)); + StateColor secondary_text( + std::pair(texture_import_gray9000(), StateColor::Normal)); + if (auto* yes_btn = dynamic_cast(dlg.FindWindow(wxID_YES))) { + yes_btn->SetMinSize(wxSize(FromDIP(180), FromDIP(24))); + yes_btn->SetBackgroundColor(secondary_bg); + yes_btn->SetBorderColor(secondary_bd); + yes_btn->SetTextColor(secondary_text); + } + if (auto* no_btn = dynamic_cast(dlg.FindWindow(wxID_NO))) { + no_btn->SetMinSize(wxSize(FromDIP(160), FromDIP(24))); + no_btn->SetBackgroundColor(primary_bg); + no_btn->SetBorderColor(primary_bd); + no_btn->SetTextColor(primary_text); + } + dlg.Layout(); + dlg.Fit(); + dlg.CenterOnParent(); + int ret = dlg.ShowModal(); + m_mesh_repair_decision = (ret == wxID_NO) + ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport + : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; +#else + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("Please note that the mesh has non-manifold geometry or open boundaries."), + _L("Mesh issue"), wxOK | wxCANCEL | wxICON_WARNING | wxOK_DEFAULT); + dlg.SetButtonLabel(wxID_OK, _L("Continue"), true); + dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); + int ret = dlg.ShowModal(); + if (ret != wxID_OK) { + m_cancel_flag = true; + if (initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + } else if (has_valid_result()) { + set_state(TextureImportState::Ready); + } else { + set_state(TextureImportState::Idle); + } + return; + } + m_mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; +#endif + + start_computation(auto_color, initial); +} + +// ---- Mapping ---- + +void TextureImportDialog::update_filament_color_map() +{ + std::map, std::array> color_map; + for (const auto& m : m_current_matches) { + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + color_map[m.cluster_color] = { + m_filament_colors_rgba[m.filament_index][0], + m_filament_colors_rgba[m.filament_index][1], + m_filament_colors_rgba[m.filament_index][2] + }; + } + } + m_preview_canvas->set_filament_color_map(color_map); +} + +// Canonical ordering used on the very first display after a computation: +// sort ascending by filament_index, and push unmapped (filament_index < 0) +// entries to the end. This gives the user a stable, predictable mapping +// layout regardless of the cluster discovery order. +void TextureImportDialog::sort_current_matches_by_filament_index() +{ + std::stable_sort(m_current_matches.begin(), m_current_matches.end(), + [](const auto& lhs, const auto& rhs) { + const bool lhs_valid = lhs.filament_index >= 0; + const bool rhs_valid = rhs.filament_index >= 0; + + if (lhs_valid != rhs_valid) + return lhs_valid; + if (!lhs_valid) + return false; + + return lhs.filament_index < rhs.filament_index; + }); +} + +// Preserve the row order the user is currently looking at across a +// re-computation (e.g. when auto-merge is toggled). We key on cluster_index +// because it survives compact_used_virtual_filaments() and filament-index +// renumbering, whereas filament_index does not. +// +// Behaviour: +// * Entries whose cluster_index appeared in `previous_matches` keep their +// previous relative order. +// * Entries whose cluster_index is new (not in `previous_matches`) are +// appended at the end, in their current relative order. +// +// Assumption: each cluster_index appears at most once in both vectors. This +// is currently guaranteed by do_auto_match(), which emits exactly one match +// per cluster. If that invariant ever changes, the std::map::emplace below +// silently keeps only the first occurrence and the order will be wrong. +void TextureImportDialog::restore_current_match_order(const std::vector& previous_matches) +{ + if (previous_matches.empty() || m_current_matches.size() < 2) + return; + + std::map previous_order_by_cluster; + for (size_t i = 0; i < previous_matches.size(); ++i) { + if (previous_matches[i].cluster_index >= 0) + previous_order_by_cluster.emplace(previous_matches[i].cluster_index, i); + } + + std::stable_sort(m_current_matches.begin(), m_current_matches.end(), + [&previous_order_by_cluster](const auto& lhs, const auto& rhs) { + const auto lhs_it = previous_order_by_cluster.find(lhs.cluster_index); + const auto rhs_it = previous_order_by_cluster.find(rhs.cluster_index); + const bool lhs_known = lhs_it != previous_order_by_cluster.end(); + const bool rhs_known = rhs_it != previous_order_by_cluster.end(); + + if (lhs_known != rhs_known) + return lhs_known; + if (!lhs_known) + return false; + + return lhs_it->second < rhs_it->second; + }); +} + +size_t TextureImportDialog::max_filament_count() const +{ + return static_cast(EnforcerBlockerType::ExtruderMax); +} + +bool TextureImportDialog::can_add_virtual_filament() const +{ + return m_filament_colors_rgba.size() < max_filament_count(); +} + +int TextureImportDialog::find_closest_filament_index(const std::array& color) const +{ + int best_idx = -1; + double best_delta = std::numeric_limits::max(); + const size_t filament_count = std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (size_t i = 0; i < filament_count; ++i) { + const double delta = Slic3r::compute_delta_e(color, m_filament_colors_rgba[i]); + if (delta < best_delta) { + best_delta = delta; + best_idx = (int)i; + } + } + return best_idx; +} + +int TextureImportDialog::add_virtual_filament(const std::array& rgba, const std::string& hex, + const std::string& preset_name) +{ + if (m_filament_color_strs.size() != m_filament_colors_rgba.size() || + m_filament_names.size() != m_filament_colors_rgba.size() || + m_filament_entries.size() != m_filament_colors_rgba.size()) { + return -1; + } + if (!can_add_virtual_filament()) { + // Mark that this do_auto_match() run hit the filament cap and had to + // drop at least one cluster. The mapping itself still falls back via + // find_closest_filament_index() below; this flag only drives the + // inline orange warning above the bottom buttons. + // Note: only the false -> true transition happens here; the flag is + // cleared exclusively at the entry of do_auto_match() so it always + // reflects the most recent match, never an accumulated history. + m_filaments_dropped = true; + return -1; + } + + const int new_idx = (int)m_filament_colors_rgba.size(); + m_filament_colors_rgba.push_back(rgba); + m_filament_color_strs.push_back(hex); + m_filament_names.push_back(DEFAULT_VIRTUAL_FILAMENT_NAME); + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::NewPhysical; + entry.dialog_index = new_idx; + entry.project_config_index = size_t(-1); + entry.color_hex = texture_normalize_color_hex(hex); + entry.name = DEFAULT_VIRTUAL_FILAMENT_NAME; + entry.preset_name = preset_name.empty() ? m_default_virtual_filament_preset_name : preset_name; + m_filament_entries.push_back(entry); + m_new_filament_colors.push_back(rgba); + m_new_filament_preset_names.push_back(preset_name.empty() ? m_default_virtual_filament_preset_name : preset_name); + return new_idx; +} + +int TextureImportDialog::add_virtual_mixed_filament(const std::string& color_hex, + const std::vector& component_dialog_indices, + const std::vector& ratios) +{ + if (m_filament_color_strs.size() != m_filament_colors_rgba.size() || + m_filament_names.size() != m_filament_colors_rgba.size() || + m_filament_entries.size() != m_filament_colors_rgba.size()) { + return -1; + } + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + return -1; + for (int idx : component_dialog_indices) { + if (idx < 0 || idx >= (int)m_filament_entries.size() || + !texture_entry_is_physical(m_filament_entries[idx].kind)) { + return -1; + } + } + if (!can_add_virtual_filament()) { + m_filaments_dropped = true; + return -1; + } + + const int new_idx = (int)m_filament_colors_rgba.size(); + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::NewMixed; + entry.dialog_index = new_idx; + entry.project_config_index = size_t(-1); + entry.color_hex = texture_normalize_color_hex(color_hex); + entry.name = DEFAULT_VIRTUAL_FILAMENT_NAME; + entry.mixed_ratios = ratios; + for (int idx : component_dialog_indices) + entry.mixed_components.push_back((unsigned int)(idx + 1)); + + TextureNewMixedFilament mixed; + mixed.dialog_index = entry.dialog_index; + mixed.color_hex = entry.color_hex; + mixed.component_dialog_indices = component_dialog_indices; + mixed.ratios = ratios; + + m_filament_entries.push_back(entry); + m_filament_color_strs.push_back(entry.color_hex); + m_filament_names.push_back(entry.name); + m_filament_colors_rgba.push_back(parse_color_string(entry.color_hex)); + m_new_mixed_filaments.push_back(mixed); + return entry.dialog_index; +} + +void TextureImportDialog::compact_used_virtual_filaments() +{ + if (m_current_matches.empty()) + return; + + const std::vector> old_colors = m_filament_colors_rgba; + const std::vector old_color_strs = m_filament_color_strs; + const std::vector old_names = m_filament_names; + const std::vector old_entries = m_filament_entries; + + auto old_new_mixed_has_valid_components = [&old_entries, &old_colors](const TextureFilamentEntry& entry) { + if (entry.kind != TextureFilamentKind::NewMixed) + return true; + if (entry.mixed_components.size() < 2 || entry.mixed_components.size() != entry.mixed_ratios.size()) + return false; + for (unsigned int comp : entry.mixed_components) { + int comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (comp_idx < 0 || comp_idx >= (int)old_entries.size() || comp_idx >= (int)old_colors.size() || + !texture_entry_is_physical(old_entries[comp_idx].kind)) { + return false; + } + } + return true; + }; + + std::set used_virtual_indices; + for (const auto& m : m_current_matches) { + if (m.filament_index >= (int)m_existing_filament_count && + m.filament_index < (int)old_colors.size()) { + if (m.filament_index < (int)old_entries.size() && + old_entries[m.filament_index].kind == TextureFilamentKind::NewMixed && + !old_new_mixed_has_valid_components(old_entries[m.filament_index])) { + continue; + } + used_virtual_indices.insert(m.filament_index); + } + } + bool added_dependency = true; + while (added_dependency) { + added_dependency = false; + std::vector current_used(used_virtual_indices.begin(), used_virtual_indices.end()); + for (int used_idx : current_used) { + if (used_idx < 0 || used_idx >= (int)old_entries.size() || + old_entries[used_idx].kind != TextureFilamentKind::NewMixed || + !old_new_mixed_has_valid_components(old_entries[used_idx])) + continue; + for (unsigned int comp : old_entries[used_idx].mixed_components) { + int comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (comp_idx >= (int)m_existing_filament_count && comp_idx < (int)old_entries.size() && + used_virtual_indices.insert(comp_idx).second) { + added_dependency = true; + } + } + } + } + + std::vector> compact_colors; + std::vector compact_color_strs; + std::vector compact_names; + std::vector compact_entries; + compact_colors.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_color_strs.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_names.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_entries.reserve(m_existing_filament_count + used_virtual_indices.size()); + + const size_t existing_count = std::min(m_existing_filament_count, old_colors.size()); + for (size_t i = 0; i < existing_count; ++i) { + compact_colors.push_back(old_colors[i]); + compact_color_strs.push_back(i < old_color_strs.size() ? old_color_strs[i] : ""); + compact_names.push_back(i < old_names.size() ? old_names[i] : "Filament " + std::to_string(i + 1)); + TextureFilamentEntry entry = i < old_entries.size() ? old_entries[i] : TextureFilamentEntry{}; + entry.dialog_index = (int)i; + entry.color_hex = texture_normalize_color_hex(compact_color_strs.back()); + entry.name = compact_names.back(); + compact_entries.push_back(entry); + } + + std::map old_to_new; + std::vector> compact_new_colors; + std::vector compact_new_preset_names; + compact_new_colors.reserve(used_virtual_indices.size()); + compact_new_preset_names.reserve(used_virtual_indices.size()); + + for (int old_idx : used_virtual_indices) { + old_to_new[old_idx] = (int)compact_colors.size(); + compact_colors.push_back(old_colors[old_idx]); + compact_color_strs.push_back(old_idx < (int)old_color_strs.size() ? old_color_strs[old_idx] : ""); + compact_names.push_back(old_idx < (int)old_names.size() ? old_names[old_idx] : DEFAULT_VIRTUAL_FILAMENT_NAME); + TextureFilamentEntry entry = old_idx < (int)old_entries.size() ? old_entries[old_idx] : TextureFilamentEntry{}; + entry.dialog_index = (int)compact_entries.size(); + entry.color_hex = texture_normalize_color_hex(compact_color_strs.back()); + entry.name = compact_names.back(); + if (entry.kind == TextureFilamentKind::NewPhysical) { + compact_new_colors.push_back(old_colors[old_idx]); + compact_new_preset_names.push_back(entry.preset_name.empty() ? m_default_virtual_filament_preset_name : entry.preset_name); + } + compact_entries.push_back(entry); + } + + m_filament_colors_rgba = std::move(compact_colors); + m_filament_color_strs = std::move(compact_color_strs); + m_filament_names = std::move(compact_names); + m_filament_entries = std::move(compact_entries); + m_new_filament_colors = std::move(compact_new_colors); + m_new_filament_preset_names = std::move(compact_new_preset_names); + m_new_mixed_filaments.clear(); + std::set invalid_compacted_mixed_indices; + for (auto& entry : m_filament_entries) { + if (entry.kind != TextureFilamentKind::NewMixed) + continue; + TextureNewMixedFilament mixed; + mixed.dialog_index = entry.dialog_index; + mixed.color_hex = entry.color_hex; + mixed.ratios = entry.mixed_ratios; + mixed.component_dialog_indices.reserve(entry.mixed_components.size()); + bool valid_components = entry.mixed_components.size() >= 2 && + entry.mixed_components.size() == entry.mixed_ratios.size(); + for (unsigned int comp : entry.mixed_components) { + int old_comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (old_comp_idx < 0) { + valid_components = false; + break; + } + auto remap_it = old_to_new.find(old_comp_idx); + int new_comp_idx = remap_it != old_to_new.end() ? remap_it->second : old_comp_idx; + if (new_comp_idx < 0 || new_comp_idx >= (int)m_filament_entries.size() || + !texture_entry_is_physical(m_filament_entries[new_comp_idx].kind)) { + valid_components = false; + break; + } + mixed.component_dialog_indices.push_back(new_comp_idx); + } + if (!valid_components) { + invalid_compacted_mixed_indices.insert(entry.dialog_index); + continue; + } + entry.mixed_components.clear(); + for (int comp_idx : mixed.component_dialog_indices) + entry.mixed_components.push_back((unsigned int)(comp_idx + 1)); + m_new_mixed_filaments.push_back(mixed); + } + + auto find_closest_physical_filament_index = [this](const std::array& color) { + int best_idx = -1; + double best_delta = std::numeric_limits::max(); + const size_t filament_count = std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (size_t i = 0; i < filament_count && i < m_filament_entries.size(); ++i) { + if (!texture_entry_is_physical(m_filament_entries[i].kind)) + continue; + const double delta = Slic3r::compute_delta_e(color, m_filament_colors_rgba[i]); + if (delta < best_delta) { + best_delta = delta; + best_idx = (int)i; + } + } + return best_idx; + }; + + for (auto& m : m_current_matches) { + auto it = old_to_new.find(m.filament_index); + if (it != old_to_new.end()) { + m.filament_index = it->second; + } else if (m.filament_index >= (int)m_existing_filament_count) { + m.filament_index = find_closest_filament_index(m.cluster_color); + } + if (invalid_compacted_mixed_indices.count(m.filament_index) > 0) { + int fallback_idx = find_closest_physical_filament_index(m.cluster_color); + m.filament_index = fallback_idx >= 0 ? fallback_idx : find_closest_filament_index(m.cluster_color); + } + + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + m.filament_color = m_filament_colors_rgba[m.filament_index]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + if (m.filament_index >= (int)m_existing_filament_count) + m.delta_e = 0.0; + } + } +} + +void TextureImportDialog::dismiss_filament_popup() +{ + if (!m_filament_popup) { + m_filament_popup_row = -1; + return; + } + + FilamentSelectPopup* popup = m_filament_popup; + m_filament_popup = nullptr; + m_filament_popup_row = -1; + if (popup->IsShown()) + popup->Dismiss(); + else + popup->Destroy(); +} + +void TextureImportDialog::show_auto_mix_popup() +{ + if (!m_btn_auto_mix || !m_btn_auto_mix->IsEnabled()) + return; + + if (m_auto_mix_popup && m_auto_mix_popup->IsShown()) + return; + dismiss_auto_mix_popup(); + + auto on_select = [this](TextureAutoMixMode mode) { + set_auto_mix_mode(mode); + }; + auto on_close = [this]() { + m_auto_mix_popup = nullptr; + }; + + auto* popup = new AutoMixSelectPopup(this, m_auto_mix_mode, m_btn_auto_mix->GetSize().x, + m_auto_mix_font_point_size, + on_select, on_close); + wxPoint pos = m_btn_auto_mix->ClientToScreen(wxPoint(0, m_btn_auto_mix->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - popup->GetSize().x)); + popup->Position(pos, wxSize(0, 0)); + popup->Bind(wxEVT_DESTROY, [this, popup](wxWindowDestroyEvent& e) { + e.Skip(); + if (m_auto_mix_popup == popup) + m_auto_mix_popup = nullptr; + }); + m_auto_mix_popup = popup; + popup->Popup(); +} + +void TextureImportDialog::dismiss_auto_mix_popup() +{ + if (!m_auto_mix_popup) + return; + + AutoMixSelectPopup* popup = m_auto_mix_popup; + m_auto_mix_popup = nullptr; + if (popup->IsShown()) + popup->Dismiss(); + else + popup->Destroy(); +} + +void TextureImportDialog::set_auto_mix_mode(TextureAutoMixMode mode) +{ + m_auto_mix_mode = mode; + if (m_btn_auto_mix) { + m_btn_auto_mix->SetLabel(auto_mix_mode_label(mode)); + m_btn_auto_mix->Refresh(); + } + apply_auto_standard_mix(mode); +} + +void TextureImportDialog::apply_auto_standard_mix(TextureAutoMixMode mode) +{ + if (m_mapping_rows.empty()) + return; + m_filaments_dropped = false; + + auto find_or_add_base_physical = [this](const std::string& color_hex) -> int { + const std::string normalized = texture_normalize_color_hex(color_hex); + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != normalized) + continue; + if (texture_entry_is_pla_basic(entry)) + return entry.dialog_index; + } + + std::array rgba = parse_color_string(normalized); + int idx = add_virtual_filament(rgba, normalized, m_default_virtual_filament_preset_name); + if (idx >= 0 && idx < (int)m_filament_entries.size()) { + m_filament_entries[idx].type = DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE; + m_filament_entries[idx].name = DEFAULT_VIRTUAL_FILAMENT_NAME; + } + return idx; + }; + + auto find_existing_mixed = [this](const std::vector& component_indices, const std::vector& ratios) -> int { + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_mixed(entry.kind) || entry.mixed_components.size() != component_indices.size() || + entry.mixed_ratios.size() != ratios.size()) + continue; + bool same = true; + for (size_t i = 0; i < component_indices.size(); ++i) { + if (entry.mixed_components[i] != (unsigned int)(component_indices[i] + 1) || + entry.mixed_ratios[i] != ratios[i]) { + same = false; + break; + } + } + if (same) + return entry.dialog_index; + } + return -1; + }; + + bool changed = false; + const auto recipe_mode = texture_recipe_mode(mode); + for (size_t row_index = 0; row_index < m_mapping_rows.size(); ++row_index) { + Slic3r::ColorDecomposeRgb target_rgb; + if (!Slic3r::color_decompose_hex_to_rgb(m_mapping_rows[row_index].source_hex, target_rgb)) + continue; + + auto recipe = Slic3r::lookup_standard_recipe(target_rgb, recipe_mode, DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE); + if (!recipe.valid || recipe.components.size() < 2) + continue; + + std::vector component_dialog_indices; + std::vector ratios; + for (const auto& comp : recipe.components) { + int component_idx = find_or_add_base_physical(comp.color_hex); + if (component_idx < 0) { + component_dialog_indices.clear(); + break; + } + component_dialog_indices.push_back(component_idx); + ratios.push_back(comp.ratio); + } + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + continue; + + int mixed_idx = find_existing_mixed(component_dialog_indices, ratios); + if (mixed_idx < 0) + mixed_idx = add_virtual_mixed_filament(recipe.matched_color_hex, component_dialog_indices, ratios); + if (mixed_idx < 0) + continue; + + m_mapping_rows[row_index].target_filament_idx = mixed_idx; + if (row_index < m_current_matches.size()) { + m_current_matches[row_index].filament_index = mixed_idx; + m_current_matches[row_index].filament_color = m_filament_colors_rgba[mixed_idx]; + m_current_matches[row_index].delta_e = Slic3r::compute_delta_e( + m_current_matches[row_index].cluster_color, m_current_matches[row_index].filament_color); + if (mixed_idx >= (int)m_existing_filament_count) + m_current_matches[row_index].delta_e = 0.0; + } + changed = true; + } + + if (!changed) + return; + + m_auto_mix_applied = true; + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); +} + +void TextureImportDialog::reset_auto_mix() +{ + if (m_state != TextureImportState::Ready || !m_auto_mix_applied) + return; + + dismiss_auto_mix_popup(); + + // Clear mixed filament references so the compact inside do_auto_match() + // removes them (and their exclusively-owned base physicals) from the + // filament arrays, giving the baseline matching a clean starting state. + for (auto& m : m_current_matches) { + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_entries.size() && + texture_entry_is_mixed(m_filament_entries[m.filament_index].kind)) { + m.filament_index = -1; + } + } + + // Re-run the baseline auto-match (same flow as the auto-merge toggle) so the + // mapping reverts to the pre-mix state: every colour matches an existing + // physical filament or a virtual physical filament, with no mixed filaments. + const auto previous_matches = m_current_matches; + do_auto_match(); + restore_current_match_order(previous_matches); + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); +} + +void TextureImportDialog::update_auto_mix_reset_visibility() +{ + if (!m_btn_mix_reset) + return; + if (m_btn_mix_reset->Show(m_auto_mix_applied)) { + if (wxWindow* parent = m_btn_mix_reset->GetParent()) + parent->Layout(); + } +} + +bool TextureImportDialog::add_decomposed_mixed_filament(size_t row_index) +{ + if (row_index >= m_mapping_rows.size()) + return false; + + std::vector physical_colors; + std::vector physical_names; + std::vector physical_types; + std::vector physical_dialog_indices; + std::vector physical_config_indices; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (const auto& entry : m_filament_entries) { + if (entry.kind != TextureFilamentKind::ExistingPhysical) + continue; + physical_colors.push_back(entry.color_hex); + physical_names.push_back(entry.name); + const size_t cfg_idx = entry.project_config_index; + Preset* preset = nullptr; + if (cfg_idx < preset_bundle.filament_presets.size()) + preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]); + physical_types.push_back(filament_type_for_color_decompose(preset)); + physical_dialog_indices.push_back(entry.dialog_index); + physical_config_indices.push_back(cfg_idx); + } + if (physical_colors.empty()) + return false; + + wxColour target(m_mapping_rows[row_index].source_hex); + ColorDecomposeDialog dlg(this, -1, target, physical_colors, physical_names, physical_types, + m_filament_entries.size(), max_filament_count(), + std::move(physical_config_indices)); + // Count "new physical filaments" with the exact reuse rule of the write-back + // loop below: a base color is only new if no existing OR virtual official + // Bambu Basic filament already carries that color. This keeps the dialog's + // filament-limit pre-check consistent with what add_decomposed_mixed_filament + // will actually create, so already-present virtual base colors are not + // double counted (which previously could wrongly disable OK). + dlg.set_missing_physical_calculator([this](const ColorDecomposeResult& result) -> size_t { + size_t missing = 0; + for (const DecomposeComponent& comp : result.components) { + if (comp.filament_index > 0) + continue; // reuses a physical slot passed to the dialog, no new filament + const std::string comp_hex = texture_normalize_color_hex( + comp.colour.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + bool found = false; + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != comp_hex) + continue; + if (texture_entry_official_basic(entry)) { + found = true; + break; + } + } + if (!found) + ++missing; + } + return missing; + }); + if (dlg.ShowModal() != wxID_OK) + return false; + + ColorDecomposeResult result = dlg.get_result(); + std::vector component_dialog_indices; + std::vector ratios; + for (const DecomposeComponent& comp : result.components) { + ratios.push_back(comp.ratio); + if (comp.filament_index > 0) { + const size_t physical_idx = (size_t)(comp.filament_index - 1); + if (physical_idx >= physical_dialog_indices.size()) + return false; + component_dialog_indices.push_back(physical_dialog_indices[physical_idx]); + continue; + } + + const std::string comp_hex = texture_normalize_color_hex(comp.colour.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + int existing_idx = -1; + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != comp_hex) + continue; + if (texture_entry_official_basic(entry)) { + existing_idx = entry.dialog_index; + break; + } + } + if (existing_idx < 0) { + std::array rgba = parse_color_string(comp_hex); + existing_idx = add_virtual_filament(rgba, comp_hex); + if (existing_idx < 0) + return false; + } + component_dialog_indices.push_back(existing_idx); + } + + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + return false; + + const std::string mixed_hex = texture_normalize_color_hex( + result.matched_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + int mixed_idx = add_virtual_mixed_filament(mixed_hex, component_dialog_indices, ratios); + if (mixed_idx < 0) + return false; + + m_mapping_rows[row_index].target_filament_idx = mixed_idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = mixed_idx; + rebuild_mapping_rows(); + update_filament_color_map(); + return true; +} + +void TextureImportDialog::dismiss_filament_popup_on_wheel(wxMouseEvent& evt) +{ + dismiss_filament_popup(); + dismiss_auto_mix_popup(); + evt.Skip(); +} + +void TextureImportDialog::show_filament_popup(size_t row_index) +{ + if (row_index >= m_mapping_rows.size()) return; + + if (m_skip_next_filament_popup_row == (int)row_index) { + m_skip_next_filament_popup_row = -1; + return; + } + + if (m_filament_popup && m_filament_popup->IsShown()) { + if (m_filament_popup_row == (int)row_index) { + dismiss_filament_popup(); + return; + } + dismiss_filament_popup(); + } + + auto on_select = [this, row_index](int idx) { + if (row_index >= m_mapping_rows.size()) return; + m_mapping_rows[row_index].target_filament_idx = idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = idx; + if (m_mapping_rows[row_index].target_panel) { + wxString label = (idx >= 0 && idx < (int)m_filament_names.size()) + ? filament_name_to_wx_string(m_filament_names[idx]) + : wxString::Format("Filament %d", idx + 1); + m_mapping_rows[row_index].target_panel->SetToolTip(label); + m_mapping_rows[row_index].target_panel->Refresh(); + } + update_filament_color_map(); + }; + + auto on_add_filament = [this, row_index](wxColour clr) { + std::array rgba = {clr.Red() / 255.f, clr.Green() / 255.f, + clr.Blue() / 255.f, 1.0f}; + std::string hex = wxString::Format("#%02X%02X%02X", + clr.Red(), clr.Green(), clr.Blue()).ToStdString(); + int new_idx = add_virtual_filament(rgba, hex); + if (new_idx < 0) + return; + + if (row_index < m_mapping_rows.size()) { + m_mapping_rows[row_index].target_filament_idx = new_idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = new_idx; + } + rebuild_mapping_rows(); + update_filament_color_map(); + }; + + auto on_decompose_color = [this, row_index]() { + CallAfter([this, row_index]() { + add_decomposed_mixed_filament(row_index); + }); + }; + + wxPanel* tp = m_mapping_rows[row_index].target_panel; + if (!tp) return; + + auto on_close = [this, row_index](bool closed_by_action) { + if (m_filament_popup_row == (int)row_index) { + m_filament_popup = nullptr; + m_filament_popup_row = -1; + } + if (!closed_by_action) { + m_skip_next_filament_popup_row = (int)row_index; + CallAfter([this, row_index]() { + if (m_skip_next_filament_popup_row == (int)row_index) + m_skip_next_filament_popup_row = -1; + }); + } + }; + + auto* popup = new FilamentSelectPopup( + this, m_filament_entries, m_filament_colors_rgba, m_filament_names, + m_existing_filament_count, tp->GetSize().x, tp, on_select, on_add_filament, + on_decompose_color, + [this]() { return can_add_virtual_filament(); }, + on_close); + + wxPoint pos = tp->ClientToScreen(wxPoint(0, tp->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - popup->GetSize().x)); + popup->Position(pos, wxSize(0, 0)); + popup->Bind(wxEVT_DESTROY, [this, popup](wxWindowDestroyEvent& e) { + e.Skip(); + if (m_filament_popup == popup) { + m_filament_popup = nullptr; + m_filament_popup_row = -1; + } + }); + m_filament_popup = popup; + m_filament_popup_row = (int)row_index; + popup->Popup(); +} + +void TextureImportDialog::do_auto_match() +{ + if (m_painted.cluster_colors.empty()) return; + + // do_auto_match() always rebuilds the baseline mapping without any mixed + // filaments, so it is the common entry for every "revert one-click mix" + // path. Clear the applied flag here; callers refresh the reset button. + m_auto_mix_applied = false; + + // Reset the "filaments were dropped" flag at the start of every run, so it + // strictly reflects what happens during *this* match (no historical + // accumulation). add_virtual_filament() will flip it back to true if and + // only if it hits the global filament cap below. + m_filaments_dropped = false; + + // Drop any virtual filaments left over from previous match runs that the + // current m_current_matches no longer references. Without this, the + // residual virtual filaments inflate m_filament_colors_rgba.size() at the + // entry of this match, which can make add_virtual_filament() fail (and + // wrongly flip m_filaments_dropped to true) even when the *real* count + // of needed virtual filaments for this run is well below the global cap. + // This is purely a state cleanup; it does not change any mapping rule. + compact_used_virtual_filaments(); + + const auto previous_matches = m_current_matches; + + std::map, int> previous_virtual_by_cluster; + for (const auto& match : previous_matches) { + if (match.filament_index >= (int)m_existing_filament_count && + match.filament_index < (int)m_filament_entries.size() && + texture_entry_is_physical(m_filament_entries[match.filament_index].kind)) { + previous_virtual_by_cluster[match.cluster_color] = match.filament_index; + } + } + + auto find_virtual_filament_by_color = [this](const std::array& color) -> int { + std::string hex = rgb_to_hex(color).ToStdString(); + for (size_t i = m_existing_filament_count; i < m_filament_color_strs.size(); ++i) { + if (m_filament_color_strs[i] == hex && + i < m_filament_entries.size() && texture_entry_is_physical(m_filament_entries[i].kind)) + return (int)i; + } + return -1; + }; + + auto get_or_add_virtual_filament = [this, &previous_virtual_by_cluster, &find_virtual_filament_by_color]( + const std::array& color) -> int { + auto previous_it = previous_virtual_by_cluster.find(color); + if (previous_it != previous_virtual_by_cluster.end() && + previous_it->second >= (int)m_existing_filament_count && + previous_it->second < (int)m_filament_colors_rgba.size()) { + return previous_it->second; + } + + int existing_idx = find_virtual_filament_by_color(color); + if (existing_idx >= 0) + return existing_idx; + + std::array rgba = { + color[0] / 255.f, + color[1] / 255.f, + color[2] / 255.f, + 1.f + }; + return add_virtual_filament(rgba, rgb_to_hex(color).ToStdString()); + }; + + if (m_auto_merge_cb && m_auto_merge_cb->GetValue()) { + // Match clusters to closest existing filaments + std::vector names; + for (size_t i = 0; i < m_existing_filament_count; ++i) + names.push_back(m_filament_names.size() > i ? m_filament_names[i] : "Filament " + std::to_string(i + 1)); + + std::vector> existing_filament_colors( + m_filament_colors_rgba.begin(), + m_filament_colors_rgba.begin() + std::min(m_existing_filament_count, m_filament_colors_rgba.size())); + + m_current_matches = Slic3r::match_clusters_to_filaments( + m_painted.cluster_colors, existing_filament_colors, names); + + // For clusters with poor match (CIEDE2000 ΔE > 5), create virtual filaments. + constexpr double NEW_FILAMENT_THRESHOLD = 5.0; + std::map, int> virtual_color_index; + + for (auto& m : m_current_matches) { + if (m.delta_e <= NEW_FILAMENT_THRESHOLD) + continue; + + auto it = virtual_color_index.find(m.cluster_color); + if (it != virtual_color_index.end()) { + m.filament_index = it->second; + } else { + int new_idx = get_or_add_virtual_filament(m.cluster_color); + if (new_idx >= 0) + virtual_color_index[m.cluster_color] = new_idx; + m.filament_index = new_idx >= 0 ? new_idx : find_closest_filament_index(m.cluster_color); + } + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + m.filament_color = m_filament_colors_rgba[m.filament_index]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + if (m.filament_index >= (int)m_existing_filament_count) + m.delta_e = 0.0; + } + } + } else { + // Keep all virtual filaments in this dialog; unused ones are pruned only on OK. + m_current_matches.clear(); + std::map, int> virtual_map; + + for (size_t i = 0; i < m_painted.cluster_colors.size(); ++i) { + const auto& cc = m_painted.cluster_colors[i]; + Slic3r::FilamentMatch fm; + fm.cluster_index = (int)i; + fm.cluster_color = cc; + + auto it = virtual_map.find(cc); + if (it != virtual_map.end()) { + fm.filament_index = it->second; + } else { + int idx = get_or_add_virtual_filament(cc); + if (idx >= 0) + virtual_map[cc] = idx; + fm.filament_index = idx >= 0 ? idx : find_closest_filament_index(cc); + } + if (fm.filament_index >= 0 && fm.filament_index < (int)m_filament_colors_rgba.size()) { + fm.filament_color = m_filament_colors_rgba[fm.filament_index]; + fm.delta_e = Slic3r::compute_delta_e(fm.cluster_color, fm.filament_color); + if (fm.filament_index >= (int)m_existing_filament_count) + fm.delta_e = 0.0; + } + m_current_matches.push_back(fm); + } + } + + update_filament_color_map(); +} + +void TextureImportDialog::rebuild_mapping_rows() +{ + m_mapping_scroll->Freeze(); + m_mapping_sizer->Clear(true); + m_mapping_rows.clear(); + + if (m_current_matches.empty()) { + m_mapping_scroll->FitInside(); + m_mapping_scroll->Thaw(); + return; + } + + auto get_target_wxcolor = [this](int idx) -> wxColour { + if (idx >= 0 && idx < (int)m_filament_colors_rgba.size()) { + const auto& c = m_filament_colors_rgba[idx]; + return wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + return wxColour(128, 128, 128); + }; + + auto get_filament_label = [this](int idx) -> wxString { + if (idx >= 0 && idx < (int)m_filament_names.size()) + return filament_name_to_wx_string(m_filament_names[idx]); + return wxString::Format("Filament %d", idx + 1); + }; + + const wxColour dash_clr = dark_or(wxColour(179, 179, 179), wxColour(100, 100, 106)); + const wxColour hex_fg = texture_import_text_colour(); + const wxColour card_bg = dark_or(wxColour(235, 235, 235), wxColour(0x3C, 0x3C, 0x42)); + const wxColour card_bd = dark_or(wxColour(224, 224, 224), wxColour(0x46, 0x46, 0x4C)); + const wxColour name_fg = texture_import_text_colour(); + const wxColour chev_clr = dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)); + + m_mapping_rows.resize(m_current_matches.size()); + for (size_t ci = 0; ci < m_current_matches.size(); ++ci) { + auto& row = m_mapping_rows[ci]; + row.cluster_id = m_current_matches[ci].cluster_index; + row.source_color = m_current_matches[ci].cluster_color; + row.source_hex = rgb_to_hex(row.source_color).ToStdString(); + row.target_filament_idx = m_current_matches[ci].filament_index; + + wxColour src_wx_color( + (unsigned char)row.source_color[0], + (unsigned char)row.source_color[1], + (unsigned char)row.source_color[2]); + + // --- Row container --- + wxPanel* row_panel = new wxPanel(m_mapping_scroll, wxID_ANY); + row_panel->SetBackgroundColour(m_mapping_scroll->GetBackgroundColour()); + row_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + wxBoxSizer* row_sizer = new wxBoxSizer(wxHORIZONTAL); + + // --- Source card (dashed border, circle + hex) --- + const int src_w = FromDIP(138); + const int target_min_w = FromDIP(239); + const int row_h = FromDIP(44); + row.source_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(src_w, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row.source_panel->SetMinSize(wxSize(src_w, row_h)); + row.source_panel->SetMaxSize(wxSize(src_w, row_h)); + row.source_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + row.source_panel->Bind(wxEVT_PAINT, [this, ci, src_wx_color, dash_clr, hex_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxPen dash_pen(dash_clr, 1, wxPENSTYLE_SHORT_DASH); + dc.SetPen(dash_pen); + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + int r = p->FromDIP(8); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, r); + + // Color circle 24px + int cd = p->FromDIP(24); + int cx = p->FromDIP(10); + int cy = (sz.y - cd) / 2; + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(src_wx_color)); + dc.DrawEllipse(cx, cy, cd, cd); + draw_filament_swatch_ellipse_border(dc, src_wx_color, cx, cy, cd, cd); + + if (ci < m_mapping_rows.size()) { + wxFont hex_font = p->GetFont(); + hex_font.SetPointSize(9); + dc.SetFont(hex_font); + dc.SetTextForeground(hex_fg); + wxString hex_str = wxString::Format("# %s", m_mapping_rows[ci].source_hex.substr(1)); + wxSize tsz = dc.GetTextExtent(hex_str); + dc.DrawText(hex_str, cx + cd + p->FromDIP(6), (sz.y - tsz.y) / 2); + } + }); + row.source_panel->Bind(wxEVT_SIZE, [](wxSizeEvent& e) { + e.Skip(); + static_cast(e.GetEventObject())->Refresh(); + }); + row.source_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + row_sizer->Add(row.source_panel, 0, wxEXPAND); + + // --- Arrow panel (dashed arrow) --- + const int arrow_w = FromDIP(24); + wxPanel* arrow_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(arrow_w, row_h)); + arrow_panel->SetMinSize(wxSize(arrow_w, row_h)); + arrow_panel->SetMaxSize(wxSize(arrow_w, row_h)); + arrow_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + arrow_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + arrow_panel->Bind(wxEVT_PAINT, [dash_clr](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + int mid_y = sz.y / 2; + int margin = p->FromDIP(2); + int arrow_tip = sz.x - margin; + int arrow_start = margin; + + wxPen dash_pen(dash_clr, p->FromDIP(1), wxPENSTYLE_SHORT_DASH); + dc.SetPen(dash_pen); + dc.DrawLine(arrow_start, mid_y, arrow_tip - p->FromDIP(4), mid_y); + + int ah = p->FromDIP(4); + wxPoint tri[3] = { + {arrow_tip, mid_y}, + {arrow_tip - ah, mid_y - ah / 2}, + {arrow_tip - ah, mid_y + ah / 2} + }; + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(dash_clr)); + dc.DrawPolygon(3, tri); + }); + + row_sizer->Add(arrow_panel, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(4)); + + // --- Target card (numbered square + material name + chevron) --- + row.target_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row.target_panel->SetMinSize(wxSize(target_min_w, row_h)); + row.target_panel->SetToolTip(get_filament_label(row.target_filament_idx)); + row.target_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + row.target_panel->SetCursor(wxCursor(wxCURSOR_HAND)); + + row.target_panel->Bind(wxEVT_PAINT, [this, ci, get_target_wxcolor, get_filament_label, + card_bg, card_bd, name_fg, chev_clr](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + if (ci >= m_mapping_rows.size()) return; + int fil_idx = m_mapping_rows[ci].target_filament_idx; + + int r = p->FromDIP(8); + dc.SetBrush(wxBrush(card_bg)); + dc.SetPen(wxPen(card_bd, 1)); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, r); + + if (fil_idx >= 0 && fil_idx < (int)m_filament_entries.size() && + texture_entry_is_mixed(m_filament_entries[fil_idx].kind)) { + const TextureFilamentEntry& entry = m_filament_entries[fil_idx]; + wxFont mixed_font = p->GetFont(); + mixed_font.SetPointSize(10); + dc.SetFont(mixed_font); + + int x = p->FromDIP(10); + const int sw = p->FromDIP(28); + const int sw_r = p->FromDIP(6); + const int sw_y = (sz.y - sw) / 2; + for (size_t mi = 0; mi < entry.mixed_components.size() && mi < entry.mixed_ratios.size(); ++mi) { + if (mi > 0) { + dc.SetTextForeground(name_fg); + wxString plus = "+"; + wxSize psz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + + const unsigned int comp_id = entry.mixed_components[mi]; + const int comp_idx = comp_id >= 1 ? (int)comp_id - 1 : -1; + wxColour comp_clr("#D9D9D9"); + if (comp_idx >= 0 && comp_idx < (int)m_filament_colors_rgba.size()) { + const auto& c = m_filament_colors_rgba[comp_idx]; + comp_clr = wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(comp_clr)); + dc.DrawRoundedRectangle(x, sw_y, sw, sw, sw_r); + draw_filament_swatch_border(dc, comp_clr, x, sw_y, sw, sw, sw_r); + + wxString num_str = wxString::Format("%u", comp_id); + wxSize nsz = dc.GetTextExtent(num_str); + dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + dc.DrawText(num_str, x + (sw - nsz.x) / 2, sw_y + (sw - nsz.y) / 2); + x += sw + p->FromDIP(5); + + dc.SetTextForeground(name_fg); + wxString pct = wxString::Format("%d%%", entry.mixed_ratios[mi]); + wxSize pct_sz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, (sz.y - pct_sz.y) / 2); + x += pct_sz.x + p->FromDIP(5); + if (x > sz.x - p->FromDIP(34)) + break; + } + + int chev_cx = sz.x - p->FromDIP(14); + int chev_cy = sz.y / 2; + int hw = p->FromDIP(3); + int hh = p->FromDIP(2); + dc.SetPen(wxPen(chev_clr, p->FromDIP(1) > 0 ? p->FromDIP(1) : 1)); + dc.DrawLine(chev_cx - hw, chev_cy - hh, chev_cx, chev_cy + hh); + dc.DrawLine(chev_cx, chev_cy + hh, chev_cx + hw, chev_cy - hh); + return; + } + + // Numbered color square 32x32, rounded 6px + int sq = p->FromDIP(32); + int sq_x = p->FromDIP(6); + int sq_y = (sz.y - sq) / 2; + int sq_r = p->FromDIP(6); + wxColour fil_clr = get_target_wxcolor(fil_idx); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(fil_clr)); + dc.DrawRoundedRectangle(sq_x, sq_y, sq, sq, sq_r); + draw_filament_swatch_border(dc, fil_clr, sq_x, sq_y, sq, sq, sq_r); + + { + wxFont num_font = p->GetFont(); + num_font.SetPointSize(10); + dc.SetFont(num_font); + dc.SetTextForeground(fil_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + wxString num_str = wxString::Format("%d", fil_idx + 1); + wxSize nsz = dc.GetTextExtent(num_str); + dc.DrawText(num_str, sq_x + (sq - nsz.x) / 2, sq_y + (sq - nsz.y) / 2); + } + + // Brand icon + material name + { + wxFont name_font = p->GetFont(); + name_font.SetPointSize(9); + dc.SetFont(name_font); + dc.SetTextForeground(name_fg); + wxString name_str = get_filament_label(fil_idx); + int text_x = draw_brand_icon_and_strip(dc, p, name_str, sq_x + sq + p->FromDIP(8), sz.y / 2); + int max_text_w = sz.x - text_x - p->FromDIP(24); + if (max_text_w > 0) { + name_str = ellipsize_text(dc, name_str, max_text_w); + wxSize tsz = dc.GetTextExtent(name_str); + dc.DrawText(name_str, text_x, (sz.y - tsz.y) / 2); + } + } + + // Dropdown chevron at right edge + { + int chev_cx = sz.x - p->FromDIP(14); + int chev_cy = sz.y / 2; + int hw = p->FromDIP(3); + int hh = p->FromDIP(2); + dc.SetPen(wxPen(chev_clr, p->FromDIP(1) > 0 ? p->FromDIP(1) : 1)); + dc.DrawLine(chev_cx - hw, chev_cy - hh, chev_cx, chev_cy + hh); + dc.DrawLine(chev_cx, chev_cy + hh, chev_cx + hw, chev_cy - hh); + } + }); + + row.target_panel->Bind(wxEVT_LEFT_DOWN, [this, ci](wxMouseEvent&) { + show_filament_popup(ci); + }); + row.target_panel->Bind(wxEVT_SIZE, [](wxSizeEvent& e) { + e.Skip(); + static_cast(e.GetEventObject())->Refresh(); + }); + row.target_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + row_sizer->Add(row.target_panel, 1, wxEXPAND); + + row_panel->SetSizer(row_sizer); + m_mapping_sizer->Add(row_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(12)); + } + + m_mapping_scroll->FitInside(); + m_mapping_scroll->Layout(); + m_mapping_scroll->Thaw(); +} + +std::vector TextureImportDialog::build_matches_from_rows() const +{ + std::vector matches(m_mapping_rows.size()); + for (size_t i = 0; i < m_mapping_rows.size(); ++i) { + auto& m = matches[i]; + m.cluster_index = m_mapping_rows[i].cluster_id; + m.cluster_color = m_mapping_rows[i].source_color; + + int sel = m_mapping_rows[i].target_filament_idx; + if (sel >= 0 && sel < (int)m_filament_colors_rgba.size()) { + m.filament_index = sel; + m.filament_color = m_filament_colors_rgba[sel]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + } + } + return matches; +} + +// ---- Event handlers ---- + +void TextureImportDialog::update_color_count_preset_buttons() +{ + if (m_btn_color_4) m_btn_color_4->SetValue(m_param_color_count == 4); + if (m_btn_color_8) m_btn_color_8->SetValue(m_param_color_count == 8); + if (m_btn_color_16) m_btn_color_16->SetValue(m_param_color_count == 16); +} + +void TextureImportDialog::set_color_count_value(int value, bool update_spin) +{ + m_param_color_count = std::clamp(value, 1, (int)max_filament_count()); + m_color_slider->SetValue(m_param_color_count); + if (update_spin) + m_color_spin->SetValue(m_param_color_count); + update_color_count_preset_buttons(); + update_confirm_button_state(); +} + +void TextureImportDialog::set_smooth_value(int value, bool update_spin) +{ + m_param_smooth = std::clamp(value, 0, 10); + m_smooth_slider->SetValue(m_param_smooth); + if (update_spin) + m_smooth_spin->SetValue(m_param_smooth); + update_confirm_button_state(); +} + +void TextureImportDialog::preview_spin_text_value(SpinInput* spin, GreenSlider* slider, int& param, + int min_value, int max_value, const wxString& text, + std::function on_value_changed) +{ + long value; + if (!text.ToLong(&value)) + return; + + wxTextCtrl* tc = spin->GetTextCtrl(); + long parsed = value; + value = std::clamp((int)parsed, min_value, max_value); + + wxString normalized = text; + if (parsed > max_value || (text.length() > 1 && text[0] == '0')) + normalized = wxString::Format("%ld", value); + + if (normalized != text) { + long pos = tc->GetInsertionPoint(); + tc->ChangeValue(normalized); + if (parsed > max_value) + tc->SetInsertionPointEnd(); + else + tc->SetInsertionPoint(std::min(normalized.length(), std::max(0L, pos - 1))); + } + + param = (int)value; + slider->SetValue(param); + if (on_value_changed) + on_value_changed(); + update_confirm_button_state(); +} + +void TextureImportDialog::on_color_preset_clicked(wxCommandEvent& evt) +{ + int id = evt.GetId(); + int color_count = m_param_color_count; + if (id == ID_COLOR_4) { color_count = 4; } + if (id == ID_COLOR_8) { color_count = 8; } + if (id == ID_COLOR_16) { color_count = 16; } + + if (id == ID_COLOR_AUTO) { + start_computation(true); + return; + } + + set_color_count_value(color_count, true); +} + +void TextureImportDialog::on_color_slider_changed(wxCommandEvent&) +{ + set_color_count_value(m_color_slider->GetValue(), true); +} + +void TextureImportDialog::on_color_spin_changed(wxCommandEvent&) +{ + set_color_count_value(m_color_spin->GetValue(), true); +} + +void TextureImportDialog::on_color_spin_text_changed(wxCommandEvent& evt) +{ + preview_spin_text_value(m_color_spin, m_color_slider, m_param_color_count, + 1, (int)max_filament_count(), evt.GetString(), + [this]() { update_color_count_preset_buttons(); }); +} + +void TextureImportDialog::on_smooth_slider_changed(wxCommandEvent&) +{ + set_smooth_value(m_smooth_slider->GetValue(), true); +} + +void TextureImportDialog::on_smooth_spin_changed(wxCommandEvent&) +{ + set_smooth_value(m_smooth_spin->GetValue(), true); +} + +void TextureImportDialog::on_smooth_spin_text_changed(wxCommandEvent& evt) +{ + preview_spin_text_value(m_smooth_spin, m_smooth_slider, m_param_smooth, + 0, 10, evt.GetString()); +} + +void TextureImportDialog::on_apply_clicked(wxCommandEvent&) +{ + start_computation(); +} + +void TextureImportDialog::on_auto_merge_toggled(wxCommandEvent&) +{ + bool auto_merge_enabled = !m_auto_merge_cb || m_auto_merge_cb->GetValue(); + m_auto_merge_enabled = auto_merge_enabled; + + if (m_state == TextureImportState::Ready) { + const auto previous_matches = m_current_matches; + do_auto_match(); + restore_current_match_order(previous_matches); + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); + } +} + +void TextureImportDialog::highlight_view_button(int view_index) +{ + Button* btns[] = { m_btn_view_original, m_btn_view_multicolor }; + + StateColor active_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed), + std::pair(wxColour(61, 203, 115), StateColor::Hovered), + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor active_bd( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor active_text( + std::pair(wxColour(255, 255, 255), StateColor::Normal)); + + StateColor inactive_bg( + std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x5C, 0x5C, 0x64)), StateColor::Pressed), + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x66, 0x66, 0x6E)), StateColor::Hovered), + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor inactive_bd( + std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor inactive_text( + std::pair(dark_or(wxColour(104, 104, 104), wxColour(0xD0, 0xD0, 0xD2)), StateColor::Normal)); + + for (int i = 0; i < 2; ++i) { + if (!btns[i]) continue; + if (i == view_index) { + btns[i]->SetBackgroundColor(active_bg); + btns[i]->SetBorderColor(active_bd); + btns[i]->SetTextColor(active_text); + } else { + btns[i]->SetBackgroundColor(inactive_bg); + btns[i]->SetBorderColor(inactive_bd); + btns[i]->SetTextColor(inactive_text); + } + btns[i]->Refresh(); + } +} + +void TextureImportDialog::on_skip_clicked(wxCommandEvent&) +{ + m_skipped = true; + m_new_filament_colors.clear(); + m_new_filament_preset_names.clear(); + m_new_mixed_filaments.clear(); + m_current_matches.clear(); + cancel_computation(); + EndModal(wxID_CANCEL); +} + +bool TextureImportDialog::has_valid_result() const +{ + if (m_painted.face_colors.empty() || m_current_matches.empty() || m_mapping_rows.empty()) + return false; + + if (m_mapping_rows.size() != m_current_matches.size()) + return false; + + const int filament_count = (int)std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (const auto& row : m_mapping_rows) { + if (row.target_filament_idx < 0 || row.target_filament_idx >= filament_count) + return false; + } + return true; +} + +bool TextureImportDialog::is_params_dirty() const +{ + if (m_applied_color_count < 0) + return false; + return m_param_color_count != m_applied_color_count + || m_param_smooth != m_applied_smooth; +} + +void TextureImportDialog::update_drop_warning_visibility() +{ + if (!m_drop_warning_label) return; + // Show only when the most recent do_auto_match() ran into the filament + // cap AND we are in the Ready state. The flag is reset at every + // do_auto_match() entry, so any "clean" re-run automatically hides the + // warning even if a previous run had dropped clusters. + const bool show = (m_state == TextureImportState::Ready) && m_filaments_dropped; + if (m_drop_warning_label->IsShown() == show) return; + m_drop_warning_label->Show(show); + Layout(); +} + +void TextureImportDialog::update_confirm_button_state() +{ + if (m_state != TextureImportState::Ready) + return; + + if (!has_valid_result()) { + m_btn_ok->Enable(false); + if (m_hint_label) m_hint_label->Hide(); + m_btn_ok->Refresh(); + Layout(); + return; + } + + bool dirty = is_params_dirty(); + + m_btn_ok->Enable(true); + + if (dirty) { + StateColor gray_bg( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor gray_bd( + std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + StateColor gray_text( + std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); + m_btn_ok->SetBackgroundColor(gray_bg); + m_btn_ok->SetBorderColor(gray_bd); + m_btn_ok->SetTextColor(gray_text); + m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); + if (m_hint_label) m_hint_label->Show(); + } else { + StateColor ok_bg( + std::pair(wxColour(27, 136, 68), StateColor::Pressed), + std::pair(wxColour(61, 203, 115), StateColor::Hovered), + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_bd( + std::pair(wxColour(0, 174, 66), StateColor::Normal)); + StateColor ok_text( + std::pair(wxColour(255, 255, 255), StateColor::Normal)); + m_btn_ok->SetBackgroundColor(ok_bg); + m_btn_ok->SetBorderColor(ok_bd); + m_btn_ok->SetTextColor(ok_text); + m_btn_ok->UnsetToolTip(); + if (m_hint_label) m_hint_label->Hide(); + } + + m_btn_ok->Refresh(); + Layout(); +} + +void TextureImportDialog::on_ok_clicked(wxCommandEvent&) +{ + if (m_state != TextureImportState::Ready || !has_valid_result() || is_params_dirty()) + return; + + m_current_matches = build_matches_from_rows(); + if (m_current_matches.empty()) + return; + + compact_used_virtual_filaments(); + + EndModal(wxID_OK); +} + +// ---- Result accessors ---- + +Slic3r::PaintedMesh TextureImportDialog::get_painted_mesh() const +{ + return m_painted; +} + +std::vector TextureImportDialog::get_matches() const +{ + if (!m_current_matches.empty()) + return m_current_matches; + return build_matches_from_rows(); +} + +void TextureImportDialog::on_dpi_changed(const wxRect&) +{ + // All control sizes below are baked into persistent properties (min size, + // corner radius, fixed wxSize) using FromDIP() at build time. The base + // DPIAware::rescale() only rescales fonts; it does not recompute these + // stored pixel values. Re-apply them here so the layout stays consistent + // when the dialog is dragged to a screen with a different DPI. + SetMinSize(wxSize(FromDIP(800), FromDIP(500))); + + const int view_button_height = FromDIP(27); + for (Button* btn : {m_btn_view_original, m_btn_view_multicolor}) { + if (btn) { + btn->SetCornerRadius(view_button_height / 2); + btn->SetMinSize(wxSize(FromDIP(57), view_button_height)); + } + } + + for (Button* btn : {m_btn_color_4, m_btn_color_8, m_btn_color_16}) { + if (btn) { + btn->SetCornerRadius(FromDIP(12)); + btn->SetMinSize(wxSize(FromDIP(28), FromDIP(28))); + } + } + + if (m_btn_color_auto) { + m_btn_color_auto->SetCornerRadius(FromDIP(12)); + m_btn_color_auto->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + } + if (m_btn_apply) { + m_btn_apply->SetCornerRadius(FromDIP(12)); + m_btn_apply->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + } + if (m_btn_auto_mix) { + m_btn_auto_mix->SetCornerRadius(FromDIP(14)); + m_btn_auto_mix->SetMinSize(wxSize(FromDIP(178), FromDIP(28))); + } + if (m_btn_mix_reset) + m_btn_mix_reset->SetPaddingSize(wxSize(FromDIP(2), FromDIP(2))); + + if (m_color_spin) + m_color_spin->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + if (m_smooth_spin) + m_smooth_spin->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + + if (m_mapping_scroll) { + m_mapping_scroll->SetMinSize(wxSize(-1, FromDIP(300))); + m_mapping_scroll->SetScrollRate(0, FromDIP(10)); + } + + if (m_btn_skip) { + m_btn_skip->SetCornerRadius(FromDIP(20)); + m_btn_skip->SetMinSize(wxSize(FromDIP(136), FromDIP(40))); + } + if (m_btn_ok) { + m_btn_ok->SetCornerRadius(FromDIP(20)); + m_btn_ok->SetMinSize(wxSize(FromDIP(156), FromDIP(40))); + } + + // Mapping rows store their panel sizes (source/target/arrow/row height) + // as fixed FromDIP min/max sizes, so rebuild them to pick up the new DPI. + rebuild_mapping_rows(); + + if (wxSizer* sizer = GetSizer()) + sizer->Layout(); + Layout(); + Refresh(); + wxGetApp().UpdateDlgDarkUI(this); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureImportDialog.hpp b/src/slic3r/GUI/TextureImportDialog.hpp new file mode 100644 index 0000000000..63e30dfc5b --- /dev/null +++ b/src/slic3r/GUI/TextureImportDialog.hpp @@ -0,0 +1,398 @@ +#pragma once + +#include "GUI_Utils.hpp" +#include "Widgets/ProgressDialog.hpp" +#include "libslic3r/TexturePainting.hpp" + +#include +#include +#include "Widgets/PopupWindow.hpp" +#include +#include +#include +#include "Widgets/SpinInput.hpp" +#include +#include +#include "Widgets/Button.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class GreenSlider; + +namespace Slic3r { namespace GUI { + +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_DONE, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_PROGRESS, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_ERROR, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_MESH_REPAIR_DECISION, wxCommandEvent); + +enum class TextureImportState { + Idle, + Computing, + Ready, + Error +}; + +enum class TextureAutoMixMode { + CMYW, + RYBW +}; + +enum class TextureFilamentKind { + ExistingPhysical, + ExistingMixed, + NewPhysical, + NewMixed +}; + +struct TextureFilamentEntry { + TextureFilamentKind kind{TextureFilamentKind::ExistingPhysical}; + int dialog_index{-1}; + size_t project_config_index{size_t(-1)}; + std::string color_hex; + std::string name; + std::string type; + std::string preset_name; + std::vector mixed_components; + std::vector mixed_ratios; +}; + +struct TextureNewMixedFilament { + int dialog_index{-1}; + std::string color_hex; + std::vector component_dialog_indices; + std::vector ratios; +}; + +struct FilamentMappingRow { + int cluster_id = -1; + std::array source_color = {0, 0, 0}; + std::string source_hex; + int target_filament_idx = 0; + wxPanel* source_panel = nullptr; + wxPanel* target_panel = nullptr; +}; + +class FilamentSelectPopup; +class AutoMixSelectPopup; +// Lightweight 3D preview panel using wxGLCanvas. +// Renders: original textured, multi-color, or filament-mapped. +class TexturePreviewCanvas : public wxGLCanvas +{ +public: + enum class RenderMode { Original, MultiColor, FilamentMap }; + + TexturePreviewCanvas(wxWindow* parent, const wxGLAttributes& attrs); + ~TexturePreviewCanvas(); + + void set_mesh_data( + const std::vector>& vertices, + const std::vector>& indices); + + void set_texture_data( + const std::vector>& uvs, + const unsigned char* tex_data, int tex_w, int tex_h, int tex_channels); + + void set_texture_render_data( + const std::vector>& tex_pixels_rgb, + const std::vector& tex_widths, + const std::vector& tex_heights, + const std::vector, 3>>& face_uvs, + const std::vector& face_tex_ids); + + void set_painted_mesh_data( + const std::vector>& vertices, + const std::vector>& indices); + void set_face_colors(const std::vector>& face_colors); + void set_original_face_colors(const std::vector>& face_colors); + void set_filament_color_map(const std::map, std::array>& color_map); + + void set_render_mode(RenderMode mode); + RenderMode get_render_mode() const { return m_mode; } + void set_computing_overlay(bool show); + void reset_view(); + +private: + void on_paint(wxPaintEvent& evt); + void on_size(wxSizeEvent& evt); + void on_mouse(wxMouseEvent& evt); + void ensure_gl_ready(); + void render(); + void render_mesh(); + void render_textured_original(); + void render_reset_overlay(const wxSize& logical_size, const wxSize& viewport_size); + void upload_reset_icon_textures(); + unsigned int upload_reset_icon_texture(const std::string& icon_name); + wxRect reset_overlay_rect() const; + bool handle_reset_overlay_mouse(wxMouseEvent& evt); + void upload_textures(); + void compute_smooth_normals(); + void update_bounding_box(); + + wxGLContext* m_context = nullptr; + bool m_gl_initialized = false; + RenderMode m_mode = RenderMode::Original; + + float m_zoom = 1.0f; + float m_rot_x = -30.0f; + float m_rot_y = 30.0f; + float m_pan_x = 0.0f; + float m_pan_y = 0.0f; + wxPoint m_last_mouse_pos; + enum class DragMode { None, Rotate, Pan }; + DragMode m_drag_mode = DragMode::None; + + std::vector> m_vertices; + std::vector> m_indices; + std::vector> m_uvs; + std::vector> m_painted_vertices; + std::vector> m_painted_indices; + std::vector> m_face_colors_rgb; + std::vector> m_original_face_colors_rgb; + std::vector> m_filament_colors_rgb; + std::map, std::array> m_color_map; + + unsigned int m_tex_id = 0; + int m_tex_w = 0; + int m_tex_h = 0; + int m_tex_channels = 3; + bool m_tex_dirty = false; + std::vector m_tex_data; + + std::vector m_gl_tex_ids; + std::vector> m_tex_pixels_rgb; + std::vector m_tex_widths; + std::vector m_tex_heights; + std::vector, 3>> m_face_uvs; + std::vector m_face_tex_ids; + bool m_multi_tex_dirty = false; + + std::vector> m_vertex_normals; + + std::array m_center = {0, 0, 0}; + float m_radius = 1.0f; + + unsigned int m_reset_icon_tex = 0; + unsigned int m_reset_icon_hover_tex = 0; + unsigned int m_reset_icon_dark_tex = 0; + unsigned int m_reset_icon_dark_hover_tex = 0; + bool m_reset_overlay_hovered = false; + bool m_reset_overlay_pressed = false; +}; + + +class TextureImportDialog : public DPIDialog +{ +public: + TextureImportDialog(wxWindow* parent, + const Slic3r::TexturedMesh& textured_mesh, + const std::vector& filament_entries, + std::function initial_cancel_callback = {}, + std::function initial_progress_callback = {}); + ~TextureImportDialog(); + + int ShowModal() override; + void on_dpi_changed(const wxRect& suggested_rect) override; + + Slic3r::PaintedMesh get_painted_mesh() const; + std::vector get_matches() const; + bool was_skipped() const { return m_skipped; } + bool fallback_to_geometry_only() const { return m_fallback_to_geometry_only; } + // Colors of virtual filaments that need to be created after dialog confirmation. + // Index i corresponds to filament index (m_existing_filament_count + i). + const std::vector>& get_new_filament_colors() const { return m_new_filament_colors; } + const std::vector& get_new_filament_preset_names() const { return m_new_filament_preset_names; } + const std::vector& get_new_mixed_filaments() const { return m_new_mixed_filaments; } + const std::vector& get_filament_entries() const { return m_filament_entries; } + size_t get_existing_filament_count() const { return m_existing_filament_count; } + +private: + void build_ui(); + void build_preview_panel(wxWindow* parent, wxSizer* sizer); + void build_params_panel(wxWindow* parent, wxSizer* sizer); + void build_mapping_panel(wxWindow* parent, wxSizer* sizer); + void build_bottom_buttons(wxSizer* sizer); + + void set_state(TextureImportState new_state); + void update_ui_for_state(); + + void start_computation(bool auto_color = false, bool initial = false); + void cancel_computation(); + void on_computation_complete(wxCommandEvent& evt); + void on_computation_progress(wxCommandEvent& evt); + void on_computation_error(wxCommandEvent& evt); + void on_mesh_repair_decision_required(wxCommandEvent& evt); + + void rebuild_mapping_rows(); + void do_auto_match(); + // Reorder m_current_matches into a canonical, predictable order (ascending + // filament_index, with unmapped entries pushed to the end). Used right + // after the initial computation so the first view the user sees has a + // stable, intuitive layout. + void sort_current_matches_by_filament_index(); + // Reorder m_current_matches so they appear in the same order as + // `previous_matches` (keyed by cluster_index). Entries whose cluster_index + // was not present before are appended at the end, preserving their current + // relative order. Used when the user toggles auto-merge so the rows do not + // visually jump around. Assumes each cluster_index appears at most once in + // both vectors (this invariant is currently guaranteed by do_auto_match, + // which produces one match per cluster). + void restore_current_match_order(const std::vector& previous_matches); + std::vector build_matches_from_rows() const; + void update_filament_color_map(); + void show_filament_popup(size_t row_index); + void dismiss_filament_popup(); + void dismiss_filament_popup_on_wheel(wxMouseEvent& evt); + void show_auto_mix_popup(); + void dismiss_auto_mix_popup(); + void set_auto_mix_mode(TextureAutoMixMode mode); + void apply_auto_standard_mix(TextureAutoMixMode mode); + void reset_auto_mix(); + void update_auto_mix_reset_visibility(); + bool add_decomposed_mixed_filament(size_t row_index); + int add_virtual_filament(const std::array& rgba, const std::string& hex, + const std::string& preset_name = std::string()); + int add_virtual_mixed_filament(const std::string& color_hex, + const std::vector& component_dialog_indices, + const std::vector& ratios); + size_t max_filament_count() const; + bool can_add_virtual_filament() const; + // Recomputes m_drop_warning_label visibility from m_filaments_dropped and + // m_state. Safe to call whether or not the label has been created yet. + // Visibility reflects ONLY the result of the most recent do_auto_match(): + // if the latest match did not drop any cluster, the label is hidden even + // if a previous match had dropped (no historical accumulation). + void update_drop_warning_visibility(); + void compact_used_virtual_filaments(); + int find_closest_filament_index(const std::array& color) const; + + void on_color_preset_clicked(wxCommandEvent& evt); + void on_color_slider_changed(wxCommandEvent& evt); + void on_color_spin_changed(wxCommandEvent& evt); + void on_color_spin_text_changed(wxCommandEvent& evt); + void on_smooth_slider_changed(wxCommandEvent& evt); + void on_smooth_spin_changed(wxCommandEvent& evt); + void on_smooth_spin_text_changed(wxCommandEvent& evt); + void on_apply_clicked(wxCommandEvent& evt); + void on_auto_merge_toggled(wxCommandEvent& evt); + void highlight_view_button(int view_index); + void on_skip_clicked(wxCommandEvent& evt); + void on_ok_clicked(wxCommandEvent& evt); + + void set_color_count_value(int value, bool update_spin); + void set_smooth_value(int value, bool update_spin); + void preview_spin_text_value(SpinInput* spin, GreenSlider* slider, int& param, + int min_value, int max_value, const wxString& text, + std::function on_value_changed = {}); + void update_color_count_preset_buttons(); + + bool has_valid_result() const; + bool is_params_dirty() const; + void update_confirm_button_state(); + + Slic3r::TexturedMesh m_textured_mesh; + std::vector m_filament_color_strs; // existing + virtual + std::vector m_filament_names; // existing + virtual + std::vector> m_filament_colors_rgba; // existing + virtual + std::vector m_filament_entries; // aligned with m_filament_colors_rgba + size_t m_existing_filament_count = 0; + std::vector> m_new_filament_colors; // only virtual (to be created) + std::vector m_new_filament_preset_names; // only virtual, aligned with m_new_filament_colors + std::vector m_new_mixed_filaments; + std::string m_default_virtual_filament_preset_name; + + TextureImportState m_state = TextureImportState::Idle; + bool m_skipped = false; + bool m_fallback_to_geometry_only = false; + // True iff *the most recent* do_auto_match() ran into the global filament + // limit and had to drop one or more clusters. Reset to false on every + // do_auto_match() entry so it never accumulates across runs: a run that + // does not drop anything must observe false here, regardless of whether + // previous runs dropped. Drives the inline orange warning above the + // bottom buttons; never affects the mapping itself. + bool m_filaments_dropped = false; + bool m_auto_merge_enabled = true; + TextureAutoMixMode m_auto_mix_mode = TextureAutoMixMode::CMYW; + int m_auto_mix_font_point_size = 10; + + Slic3r::PaintedMesh m_painted; + std::vector m_current_matches; + + std::unique_ptr m_worker; + std::atomic m_cancel_flag{false}; + std::mutex m_result_mutex; + Slic3r::PaintedMesh m_pending_result; + std::function m_initial_cancel_callback; + std::function m_initial_progress_callback; + bool m_current_computation_initial = false; + bool m_initial_computation_pending = false; + bool m_initial_computation_cancelled = false; + bool m_initial_computation_failed = false; + bool m_initial_tooltips_set = false; + bool m_current_computation_auto_color = false; + Slic3r::TexturePaintingSettings::MeshRepairDecision m_mesh_repair_decision = + Slic3r::TexturePaintingSettings::MeshRepairDecision::Ask; + + Button* m_btn_color_4 = nullptr; + Button* m_btn_color_8 = nullptr; + Button* m_btn_color_16 = nullptr; + Button* m_btn_color_auto = nullptr; + GreenSlider* m_color_slider = nullptr; + SpinInput* m_color_spin = nullptr; + GreenSlider* m_smooth_slider = nullptr; + SpinInput* m_smooth_spin = nullptr; + Button* m_btn_apply = nullptr; + + wxCheckBox* m_auto_merge_cb = nullptr; + Button* m_btn_auto_mix = nullptr; + Button* m_btn_mix_reset = nullptr; + bool m_auto_mix_applied = false; + AutoMixSelectPopup* m_auto_mix_popup = nullptr; + wxScrolledWindow* m_mapping_scroll = nullptr; + wxBoxSizer* m_mapping_sizer = nullptr; + std::vector m_mapping_rows; + FilamentSelectPopup* m_filament_popup = nullptr; + int m_filament_popup_row = -1; + int m_skip_next_filament_popup_row = -1; + + TexturePreviewCanvas* m_preview_canvas = nullptr; + wxPanel* m_tab_panel = nullptr; + Button* m_btn_view_original = nullptr; + Button* m_btn_view_multicolor = nullptr; + + ProgressDialog* m_progress_dlg = nullptr; + + Button* m_btn_skip = nullptr; + Button* m_btn_ok = nullptr; + wxStaticText* m_drop_warning_label = nullptr; + + int m_param_color_count = 4; + int m_param_smooth = 5; + + int m_applied_color_count = -1; + int m_applied_smooth = -1; + wxStaticText* m_hint_label = nullptr; + + static const int ID_COLOR_4 = wxID_HIGHEST + 200; + static const int ID_COLOR_8 = wxID_HIGHEST + 201; + static const int ID_COLOR_16 = wxID_HIGHEST + 202; + static const int ID_COLOR_AUTO = wxID_HIGHEST + 203; + static const int ID_BTN_APPLY = wxID_HIGHEST + 204; + static const int ID_BTN_SKIP = wxID_HIGHEST + 205; + static const int ID_VIEW_ORIGINAL = wxID_HIGHEST + 206; + static const int ID_VIEW_MULTICOLOR = wxID_HIGHEST + 207; + + wxDECLARE_EVENT_TABLE(); +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 783e1caadf..b6f6d42450 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -87,10 +87,18 @@ void ComboBox::SetSelection(int n) return; drop.SetSelection(n); SetLabel(drop.GetValue()); - if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) - SetIcon(items[drop.selection].icon_textctrl); - else + if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) { + if (m_keep_drop_arrow) { + SetIcon("drop_down"); + SetIcon_1(items[drop.selection].icon_textctrl); + } else { + SetIcon(items[drop.selection].icon_textctrl); + } + } else { SetIcon("drop_down"); + if (m_keep_drop_arrow) + SetIcon_1(wxNullBitmap); + } if (drop.selection >= 0) { SetStaticTips(items[drop.selection].text_static_tips, wxNullBitmap); @@ -120,10 +128,18 @@ void ComboBox::SetValue(const wxString &value) { drop.SetValue(value); SetLabel(value); - if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) - SetIcon(items[drop.selection].icon_textctrl); - else + if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) { + if (m_keep_drop_arrow) { + SetIcon("drop_down"); + SetIcon_1(items[drop.selection].icon_textctrl); + } else { + SetIcon(items[drop.selection].icon_textctrl); + } + } else { SetIcon("drop_down"); + if (m_keep_drop_arrow) + SetIcon_1(wxNullBitmap); + } if (drop.selection >= 0) { SetStaticTips(items[drop.selection].text_static_tips, wxNullBitmap); diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 552909b477..91c34d53aa 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -16,6 +16,7 @@ class ComboBox : public wxWindowWithItems bool drop_down = false; bool text_off = false; bool is_replace_text_to_image = false; + bool m_keep_drop_arrow = false; // When true, item icon goes to icon_1, keeping drop_down arrow wxString replace_text; wxString image_for_text; @@ -31,6 +32,11 @@ public: DropDown & GetDropDown() { return drop; } + // When true, item icon is shown as icon_1 (secondary), preserving drop_down arrow. + // Note: item bitmaps are set via raw wxBitmap (not ScalableBitmap), so they won't + // auto-rescale on DPI change. Caller should recreate items after DPI change. + void SetKeepDropArrow(bool keep) { m_keep_drop_arrow = keep; } + virtual bool SetFont(wxFont const & font) override; public: diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 973113d0ac..a44303169a 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -360,6 +360,9 @@ void DropDown::render(wxDC &dc) for (int i = 0; i < items.size(); ++i) { auto &item = items[i]; int states2 = states; + // Dimmed items stay selectable but render greyed out (used by the mixed-filament + // dialog to show components that are already consumed by another mix). + bool is_dimmed = (item.style & DD_ITEM_STYLE_DIMMED) != 0; if ((item.style & DD_ITEM_STYLE_DISABLED) != 0) states2 &= ~StateColor::Enabled; // Skip by group @@ -427,7 +430,7 @@ void DropDown::render(wxDC &dc) } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); - dc.SetTextForeground(text_color.colorForStates(states2)); + dc.SetTextForeground(is_dimmed ? wxColour(0xCE, 0xCE, 0xCE) : text_color.colorForStates(states2)); dc.DrawText(text, pt); if (group.IsEmpty() && !item.group_key.IsEmpty()) { auto szBmp = arrow_bitmap.GetBmpSize(); diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 09041e3dc0..bcd0a58c41 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -13,6 +13,7 @@ #define DD_ITEM_STYLE_SPLIT_ITEM 0x0001 // ----text----, text with horizontal line arounds #define DD_ITEM_STYLE_DISABLED 0x0002 // ----text----, text with horizontal line arounds +#define DD_ITEM_STYLE_DIMMED 0x0004 // gray text, but still selectable wxDECLARE_EVENT(EVT_DISMISS, wxCommandEvent); diff --git a/src/slic3r/GUI/Widgets/SpinInput.cpp b/src/slic3r/GUI/Widgets/SpinInput.cpp index fba5a45233..538f010383 100644 --- a/src/slic3r/GUI/Widgets/SpinInput.cpp +++ b/src/slic3r/GUI/Widgets/SpinInput.cpp @@ -9,6 +9,8 @@ #include "../GUI_Utils.hpp" #endif +wxDEFINE_EVENT(EVT_SPINCTRL_TEXT, wxCommandEvent); + BEGIN_EVENT_TABLE(SpinInput, StaticBox) EVT_KEY_DOWN(SpinInput::keyPressed) @@ -74,6 +76,7 @@ void SpinInput::Create(wxWindow *parent, state_handler.attach_child(text_ctrl); text_ctrl->Bind(wxEVT_KILL_FOCUS, &SpinInput::onTextLostFocus, this); text_ctrl->Bind(wxEVT_TEXT_ENTER, &SpinInput::onTextEnter, this); + text_ctrl->Bind(wxEVT_TEXT, &SpinInput::onTextChanged, this); text_ctrl->Bind(wxEVT_KEY_DOWN, &SpinInput::keyPressed, this); text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu button_inc = createButton(true); @@ -300,6 +303,19 @@ void SpinInput::onTextEnter(wxCommandEvent &event) ProcessEventLocally(event); } +void SpinInput::onTextChanged(wxCommandEvent &event) +{ + long value; + if (text_ctrl->GetValue().ToLong(&value)) { + wxCommandEvent e(EVT_SPINCTRL_TEXT, GetId()); + e.SetEventObject(this); + e.SetInt((int) value); + e.SetString(text_ctrl->GetValue()); + GetEventHandler()->ProcessEvent(e); + } + event.Skip(); +} + void SpinInput::mouseWheelMoved(wxMouseEvent &event) { auto delta = event.GetWheelRotation() < 0 ? 1 : -1; diff --git a/src/slic3r/GUI/Widgets/SpinInput.hpp b/src/slic3r/GUI/Widgets/SpinInput.hpp index 275d42a95d..caf2bf3843 100644 --- a/src/slic3r/GUI/Widgets/SpinInput.hpp +++ b/src/slic3r/GUI/Widgets/SpinInput.hpp @@ -9,6 +9,10 @@ class Button; +// Fired on every keystroke that leaves a parseable integer in the field, so callers can +// react live rather than only on commit (wxEVT_SPINCTRL) or Enter. Ported from BambuStudio. +wxDECLARE_EVENT(EVT_SPINCTRL_TEXT, wxCommandEvent); + class SpinInput : public wxNavigationEnabled { wxSize labelSize; @@ -98,6 +102,7 @@ private: void keyPressed(wxKeyEvent& event); void onTimer(wxTimerEvent &evnet); void onTextLostFocus(wxEvent &event); + void onTextChanged(wxCommandEvent &event); void onTextEnter(wxCommandEvent &event); void sendSpinEvent(); diff --git a/src/slic3r/GUI/Widgets/TextInput.cpp b/src/slic3r/GUI/Widgets/TextInput.cpp index 49605e048d..b6e60f60d7 100644 --- a/src/slic3r/GUI/Widgets/TextInput.cpp +++ b/src/slic3r/GUI/Widgets/TextInput.cpp @@ -139,6 +139,15 @@ void TextInput::SetIcon_1(const wxString &icon) { Rescale(); } +// Set icon_1 from a raw bitmap. Note: won't auto-rescale on DPI change +// since ScalableBitmap::name() will be empty. Caller should re-set after DPI change. +void TextInput::SetIcon_1(const wxBitmap &icon) { + this->icon_1 = ScalableBitmap(); + if (icon.IsOk()) + this->icon_1.bmp() = icon; + Rescale(); +} + void TextInput::SetLabelColor(StateColor const &color) { label_color = color; diff --git a/src/slic3r/GUI/Widgets/TextInput.hpp b/src/slic3r/GUI/Widgets/TextInput.hpp index 9aca7037c4..44562a895d 100644 --- a/src/slic3r/GUI/Widgets/TextInput.hpp +++ b/src/slic3r/GUI/Widgets/TextInput.hpp @@ -54,6 +54,7 @@ public: void SetIcon(const wxString & icon); void SetIcon_1(const wxString &icon); + void SetIcon_1(const wxBitmap &icon); void SetLabelColor(StateColor const &color); diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index abf8baf086..f1d0946bf5 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -256,6 +256,41 @@ static std::vector MatrixFlatten(const WipingDialog::VolumeMatrix& matrix return vec; } +// Mixed-color slots are virtual: they are never loaded into a tray and so have no flushing +// volumes of their own. The dialog therefore shows only the physical filaments, which means +// converting between the full config matrix (indexed by config slot) and a dense physical +// sub-matrix (indexed by row/column in the table). +static std::vector extract_physical_sub_matrix( + const std::vector& full_matrix, size_t full_n, + const std::vector& indices) +{ + size_t p = indices.size(); + std::vector sub(p * p, 0.0); + if (full_matrix.size() < full_n * full_n) + return sub; + for (size_t pi = 0; pi < p; ++pi) + for (size_t pj = 0; pj < p; ++pj) + sub[pi * p + pj] = full_matrix[indices[pi] * full_n + indices[pj]]; + return sub; +} + +// Write the edited physical sub-matrix back into a copy of the full matrix, leaving the +// entries that belong to mixed slots untouched. +static std::vector expand_physical_to_full_matrix( + const std::vector& sub_matrix, + const std::vector& indices, size_t full_n, + const std::vector& original_matrix) +{ + std::vector full = original_matrix; + if (full.size() < full_n * full_n) + return full; + size_t p = indices.size(); + for (size_t pi = 0; pi < p; ++pi) + for (size_t pj = 0; pj < p; ++pj) + full[indices[pi] * full_n + indices[pj]] = sub_matrix[pi * p + pj]; + return full; +} + wxString WipingDialog::BuildTableObjStr() { auto full_config = wxGetApp().preset_bundle->full_config(); @@ -265,9 +300,22 @@ wxString WipingDialog::BuildTableObjStr() auto raw_matrix_data = full_config.option("flush_volumes_matrix")->values; auto nozzle_flush_dataset = full_config.option("nozzle_flush_dataset")->values; + // Restrict the table to physical filaments; mixed slots have no flushing volumes. + m_physical_indices = wxGetApp().preset_bundle->physical_filament_config_indices(); + const size_t full_n = filament_colors.size(); + { + std::vector physical_colors; + physical_colors.reserve(m_physical_indices.size()); + for (size_t i : m_physical_indices) + if (i < filament_colors.size()) + physical_colors.push_back(filament_colors[i]); + filament_colors = std::move(physical_colors); + } + std::vector> flush_matrixs; for (int idx = 0; idx < nozzle_num; ++idx) { - flush_matrixs.emplace_back(get_flush_volumes_matrix(raw_matrix_data, idx, nozzle_num)); + auto fm = get_flush_volumes_matrix(raw_matrix_data, idx, nozzle_num); + flush_matrixs.emplace_back(extract_physical_sub_matrix(fm, full_n, m_physical_indices)); } flush_multiplier.resize(nozzle_num, 1); @@ -372,7 +420,7 @@ WipingDialog::WipingDialog(wxWindow* parent, const int max_flush_volume) : wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); this->SetSizer(main_sizer); this->SetBackgroundColour(*wxWHITE); - auto filament_count = wxGetApp().preset_bundle->project_config.option("filament_colour")->values.size(); + auto filament_count = wxGetApp().preset_bundle->physical_filament_config_indices().size(); // Estimate table scroll area size based on filament count // Each table cell is ~60x25 DIP, plus headers and borders @@ -592,11 +640,29 @@ void WipingDialog::StoreFlushData(int extruder_num, const std::vector WipingDialog::ExpandToFullMatrix(const std::vector& sub_matrix, int nozzle_idx) const +{ + const auto& project_config = wxGetApp().preset_bundle->project_config; + const size_t full_n = project_config.option("filament_colour")->values.size(); + if (m_physical_indices.size() == full_n) + return sub_matrix; // no mixed slots: sub-matrix already is the full matrix + + auto raw = project_config.option("flush_volumes_matrix")->values; + int nozzle_num = (int)wxGetApp().preset_bundle->project_config.option("flush_multiplier")->values.size(); + if (nozzle_num < 1) nozzle_num = 1; + auto original = get_flush_volumes_matrix(raw, nozzle_idx, nozzle_num); + return expand_physical_to_full_matrix(sub_matrix, m_physical_indices, full_n, original); +} + std::vector WipingDialog::GetFlattenMatrix()const { std::vector ret; - for (auto& matrix : m_raw_matrixs) { - ret.insert(ret.end(), matrix.begin(), matrix.end()); + for (size_t idx = 0; idx < m_raw_matrixs.size(); ++idx) { + auto full = ExpandToFullMatrix(m_raw_matrixs[idx], (int)idx); + ret.insert(ret.end(), full.begin(), full.end()); } return ret; } diff --git a/src/slic3r/GUI/WipeTowerDialog.hpp b/src/slic3r/GUI/WipeTowerDialog.hpp index 64e5758534..91944cfc78 100644 --- a/src/slic3r/GUI/WipeTowerDialog.hpp +++ b/src/slic3r/GUI/WipeTowerDialog.hpp @@ -58,12 +58,16 @@ private: wxString BuildTableObjStr(); wxString BuildTextObjStr(bool multi_language = true); void StoreFlushData(int extruder_num, const std::vector>& flush_volume_vecs, const std::vector& flush_multipliers); + // Maps the physical-only matrix shown in the table back onto the full config-indexed matrix. + std::vector ExpandToFullMatrix(const std::vector& sub_matrix, int nozzle_idx) const; wxWebView* m_webview; int m_max_flush_volume; VolumeMatrix m_raw_matrixs; std::vector m_flush_multipliers; + // Config indices of the physical (non-mixed) filaments, in table order. + std::vector m_physical_indices; bool m_submit_flag{ false }; }; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 28c39c2d6a..2d575ab989 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(${_TEST_NAME}_tests test_vendor_cache.cpp test_elephant_foot_compensation.cpp test_fill_corner_smoothing.cpp + test_filament_mixer.cpp test_fill_plane_path.cpp test_geometry.cpp test_multimaterial_segmentation.cpp diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp new file mode 100644 index 0000000000..7b5842334f --- /dev/null +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -0,0 +1,182 @@ +#include + +#include "libslic3r/FilamentMixer.hpp" + +using namespace Slic3r; + +TEST_CASE("parse_mixed_components reads 1-based component ids", "[FilamentMixer]") +{ + REQUIRE(parse_mixed_components("1,3") == std::vector{1, 3}); + REQUIRE(parse_mixed_components("2, 4 ,5") == std::vector{2, 4, 5}); + + SECTION("Malformed input yields no components") { + REQUIRE(parse_mixed_components("").empty()); + REQUIRE(parse_mixed_components("abc").empty()); + } +} + +TEST_CASE("parse_mixed_ratios normalizes to sum 1.0", "[FilamentMixer]") +{ + auto r = parse_mixed_ratios("0.7,0.3", 2); + REQUIRE(r.size() == 2); + REQUIRE_THAT(r[0], Catch::Matchers::WithinAbs(0.7, 1e-9)); + REQUIRE_THAT(r[1], Catch::Matchers::WithinAbs(0.3, 1e-9)); + + SECTION("Unnormalized input is rescaled") { + auto v = parse_mixed_ratios("2,2", 2); + REQUIRE_THAT(v[0], Catch::Matchers::WithinAbs(0.5, 1e-9)); + REQUIRE_THAT(v[1], Catch::Matchers::WithinAbs(0.5, 1e-9)); + } + + SECTION("Empty or mismatched input falls back to equal shares") { + auto v = parse_mixed_ratios("", 3); + REQUIRE(v.size() == 3); + for (double x : v) + REQUIRE_THAT(x, Catch::Matchers::WithinAbs(1.0 / 3.0, 1e-9)); + } +} + +TEST_CASE("has_any_mixed_filament detects mixed slots", "[FilamentMixer]") +{ + REQUIRE_FALSE(has_any_mixed_filament({})); + REQUIRE_FALSE(has_any_mixed_filament({0, 0, 0})); + REQUIRE(has_any_mixed_filament({0, 1, 0})); +} + +TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "[FilamentMixer]") +{ + // Slot 2 (0-based) is a mix of physical filaments 1 and 2 (1-based) => 0 and 1 (0-based). + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + + REQUIRE(expand_mixed_filaments({2}, is_mixed, comp_strs) == std::vector{0, 1}); + + SECTION("Non-mixed entries pass through, result is sorted and deduplicated") { + REQUIRE(expand_mixed_filaments({2, 0}, is_mixed, comp_strs) == std::vector{0, 1}); + } +} + +TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 1}; + + SECTION("All components resolve") { + REQUIRE(check_mixed_filament_integrity(is_mixed, {"", "", "1,2"}, 2).empty()); + } + + SECTION("A component past the physical filament count is broken") { + auto broken = check_mixed_filament_integrity(is_mixed, {"", "", "1,9"}, 2); + REQUIRE(broken == std::vector{2}); + } +} + +TEST_CASE("remap_mixed_components_on_delete rewrites ids around the deleted slot", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 0, 1}; + std::vector comps = {"", "", "", "1,3"}; + + SECTION("Deleting a filament below the references shifts them down") { + remap_mixed_components_on_delete(is_mixed, comps, 2); + REQUIRE(comps[3] == "1,2"); + } + + SECTION("Deleting a referenced filament zeroes that component") { + remap_mixed_components_on_delete(is_mixed, comps, 1); + // 1 -> 0 (deleted sentinel), 3 -> 2 + REQUIRE(comps[3] == "0,2"); + } +} + +TEST_CASE("check_mixed_filament_type_consistency flags mismatched component types", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + + REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA"}).empty()); + + auto bad = check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PETG"}); + REQUIRE(bad == std::vector{2}); +} + +TEST_CASE("gradient curves round-trip and sample monotonically", "[FilamentMixer]") +{ + SECTION("Empty input yields an empty curve") { + REQUIRE(parse_gradient_curve("").empty()); + REQUIRE(serialize_gradient_curve(GradientCurve{}).empty()); + } + + SECTION("Legacy 2-field anchors survive a parse/serialize round trip") { + GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85"); + REQUIRE(c.points.size() == 3); + + // Anchors with no tangent override serialize back to the 2-field legacy form + // (canonical fixed-precision, so compare by re-parsing rather than by string). + const std::string round_tripped = serialize_gradient_curve(c); + REQUIRE(round_tripped.find(",nan") == std::string::npos); + + GradientCurve c2 = parse_gradient_curve(round_tripped); + REQUIRE(c2.points.size() == c.points.size()); + for (size_t i = 0; i < c.points.size(); ++i) { + REQUIRE_THAT(c2.points[i].x, Catch::Matchers::WithinAbs(c.points[i].x, 1e-4)); + REQUIRE_THAT(c2.points[i].y, Catch::Matchers::WithinAbs(c.points[i].y, 1e-4)); + } + } + + SECTION("Sampling is clamped at the ends and monotone in between") { + GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85"); + REQUIRE_THAT(sample_gradient_curve(c, 0.0), Catch::Matchers::WithinAbs(0.15, 1e-9)); + REQUIRE_THAT(sample_gradient_curve(c, 1.0), Catch::Matchers::WithinAbs(0.85, 1e-9)); + // Outside the control point range the end values are held. + REQUIRE_THAT(sample_gradient_curve(c, -1.0), Catch::Matchers::WithinAbs(0.15, 1e-9)); + REQUIRE_THAT(sample_gradient_curve(c, 2.0), Catch::Matchers::WithinAbs(0.85, 1e-9)); + + double prev = sample_gradient_curve(c, 0.0); + for (int i = 1; i <= 20; ++i) { + double v = sample_gradient_curve(c, i / 20.0); + REQUIRE(v >= prev - 1e-9); + prev = v; + } + } + + SECTION("A curve with fewer than two points falls back to 0.5") { + GradientCurve c = parse_gradient_curve("0.5,0.7"); + REQUIRE_THAT(sample_gradient_curve(c, 0.3), Catch::Matchers::WithinAbs(0.5, 1e-9)); + } +} + +TEST_CASE("blend_color mixes two hex colors", "[FilamentMixer]") +{ + // ratio 0 keeps the first color, ratio 1 the second. + REQUIRE(blend_color("#FF0000", "#0000FF", 0.0f) == "#FF0000"); + REQUIRE(blend_color("#FF0000", "#0000FF", 1.0f) == "#0000FF"); + + SECTION("Blue and yellow make green, not grey (pigment mixing)") { + // The polynomial model approximates subtractive pigment behaviour. + std::string mixed = blend_color("#0021D0", "#FCD300", 0.5f); + REQUIRE(mixed.size() == 7); + REQUIRE(mixed[0] == '#'); + auto comp = [&](int i) { return std::stoi(mixed.substr(1 + 2 * i, 2), nullptr, 16); }; + // Green channel should dominate red and blue. + REQUIRE(comp(1) > comp(0)); + REQUIRE(comp(1) > comp(2)); + } +} + +TEST_CASE("blend_color_multi weights components", "[FilamentMixer]") +{ + SECTION("A single component is returned unchanged") { + REQUIRE(blend_color_multi({"#FF0000"}, {1}) == "#FF0000"); + } + + SECTION("Mixing a color with itself stays close to that color") { + // The mixer is a degree-4 polynomial fit of pigment behaviour, so a round trip through + // it is near-identity rather than exact (the model documents a mean Delta-E around 2). + std::string mixed = blend_color_multi({"#123456", "#123456"}, {1, 1}); + REQUIRE(mixed.size() == 7); + auto comp = [](const std::string &hex, int i) { + return std::stoi(hex.substr(1 + 2 * i, 2), nullptr, 16); + }; + for (int i = 0; i < 3; ++i) + REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8); + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 037a76a805..5d9e60d6ef 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -566,3 +566,46 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } + +// 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(); + if (const auto *s = cfg.option(key)) + return s->values.size(); + return size_t(-1); // key missing entirely + }; + + PresetBundle bundle; + + const unsigned int n = GENERATE(2u, 4u, 8u); + bundle.set_num_filaments(n, std::string("#FF0000")); + + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == n); + for (const char *key : kMixedKeys) { + DYNAMIC_SECTION("grown: " << key) { + CHECK(mixed_array_size(bundle.project_config, key) == n); + } + } + + SECTION("shrinking keeps them in step too") { + bundle.set_num_filaments(1, std::string("#00FF00")); + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 1); + for (const char *key : kMixedKeys) + CHECK(mixed_array_size(bundle.project_config, key) == 1); + } +} From 8b20a4b0665ccfe842e9767c08776e7c483cfd0f Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 17:50:16 -0300 Subject: [PATCH 077/138] Using resolve mixed --- src/libslic3r/GCode/ToolOrdering.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index 19f8fddb93..9d2cc11d21 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -91,22 +91,30 @@ bool check_filament_printable_after_group(const std::vector &used_ } // Return a zero based extruder from the region, or extruder_override if overriden. +// The region accessors below resolve mixed-color slots to the physical filament chosen for +// this layer. Without sub-layer splitting a mixed slot is realized by alternating whole layers +// (deficit round-robin, see resolve_mixed_filaments), so a region asking "which filament?" must +// get the resolved physical one, not the virtual slot id. resolve_mixed() is identity when the +// slot is not mixed, so this is a no-op for every non-mixed setup. unsigned int LayerTools::wall_extruder_id(const PrintRegion ®ion) const { assert(region.config().outer_wall_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().outer_wall_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().outer_wall_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } unsigned int LayerTools::sparse_infill_filament_id(const PrintRegion ®ion) const { assert(region.config().sparse_infill_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().sparse_infill_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().sparse_infill_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } unsigned int LayerTools::internal_solid_filament_id(const PrintRegion ®ion) const { assert(region.config().internal_solid_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().internal_solid_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().internal_solid_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } // Returns a zero based extruder this eec should be printed with, according to PrintRegion config or extruder_override if overriden. @@ -142,7 +150,8 @@ unsigned int LayerTools::extruder(const ExtrusionEntityCollection &extrusions, c } else extruder = this->extruder_override; - return (extruder == 0) ? 0 : extruder - 1; + unsigned int result = (extruder == 0) ? 0 : extruder - 1; + return resolve_mixed(result); } static double calc_max_layer_height(const PrintConfig &config, double max_object_layer_height) From 51bc06a68aecba1da00736360cf48739a441f97d Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 17:53:21 -0300 Subject: [PATCH 078/138] USe is_mixed_slot --- src/libslic3r/GCode.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index ba26f7f0da..2f8f0f90fd 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6004,9 +6004,16 @@ LayerResult GCode::process_layer( const WipingExtrusions::ExtruderPerCopy *entity_overrides = nullptr; if (! layer_tools.has_extruder(correct_extruder_id)) { - // this entity is not overridden, but its extruder is not in layer_tools - we'll print it - // by last extruder on this layer (could happen e.g. when a wiping object is taller than others - dontcare extruders are eradicated from layer_tools) - correct_extruder_id = layer_tools.extruders.back(); + // A mixed-color slot is absent from layer_tools.extruders by design: + // resolve_mixed_filaments() replaced it with its physical components, + // and the sublayer block emits its geometry separately. Reassigning it + // to the last extruder here would print it in the wrong colour, so only + // fall back for genuinely stale (dontcare) extruders. + if (!layer_tools.is_mixed_slot(correct_extruder_id)) { + // this entity is not overridden, but its extruder is not in layer_tools - we'll print it + // by last extruder on this layer (could happen e.g. when a wiping object is taller than others - dontcare extruders are eradicated from layer_tools) + correct_extruder_id = layer_tools.extruders.back(); + } } printing_extruders.clear(); if (is_anything_overridden && use_overrides) { From 76f23396ea495290a701eb0d5555640be56adc04 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 17:57:22 -0300 Subject: [PATCH 079/138] Use expand_mixed_slots_in_unprintables --- src/libslic3r/GCode/ToolOrdering.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index 9d2cc11d21..ef5495a29e 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -2698,6 +2698,17 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first std::vector used_filaments = collect_sorted_used_filaments(layer_filaments); std::vector>geometric_unprintables = m_print->get_geometric_unprintable_filaments(); + + // Unprintable sets are keyed by filament id, but a mixed-color slot is virtual: what actually + // reaches the nozzle are its components. Expand the slot to those components so a geometric + // restriction is applied to the filaments really being printed. No-op without mixed filaments. + { + const auto &is_mixed = m_print->config().filament_is_mixed.values; + const auto &comp_strs = m_print->config().filament_mixed_components.values; + if (has_any_mixed_filament(is_mixed)) + expand_mixed_slots_in_unprintables(geometric_unprintables, is_mixed, comp_strs); + } + std::vector>physical_unprintables = m_print->get_physical_unprintable_filaments(used_filaments); auto filament_unprintable_volumes = m_print->get_filament_unprintable_flow(used_filaments); From ccd34ab03ae13f2915cc2e3a3a196c866c9b8c79 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 18:24:32 -0300 Subject: [PATCH 080/138] sublayers and more --- src/libslic3r/PresetBundle.cpp | 85 +++++++++ src/slic3r/GUI/GLCanvas3D.cpp | 19 +- src/slic3r/GUI/GUI_Factories.cpp | 9 +- .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 21 +++ .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 9 + src/slic3r/GUI/PlateSettingsDialog.cpp | 26 +++ src/slic3r/GUI/PlateSettingsDialog.hpp | 3 + src/slic3r/GUI/Plater.cpp | 166 +++++++++++++++++- src/slic3r/GUI/Plater.hpp | 11 +- src/slic3r/GUI/SyncAmsInfoDialog.cpp | 12 ++ src/slic3r/GUI/Tab.cpp | 1 + 11 files changed, 355 insertions(+), 7 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 961ed59d2b..eeb56d6e11 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -3575,6 +3575,63 @@ unsigned int PresetBundle::sync_ams_list(std::vector("filament_colour_type"); ConfigOptionInts * filament_map = project_config.option("filament_map"); ConfigOptionInts * filament_volume_map = project_config.option("filament_volume_map"); + + // Snapshot and temporarily strip mixed filament slots so AMS sync operates on physical + // filaments only. A mixed slot is virtual and has no tray to sync against; leaving it in + // would let AMS mapping overwrite it and would break the physical-first slot ordering the + // rest of the feature relies on. The slots are re-appended verbatim after the sync. + struct MixedSlotSnapshot { + std::string preset; + std::string color; + std::string color_type; + std::string mixed_components; + std::string mixed_sublayer_ratios; + bool mixed_gradient = false; + std::string mixed_gradient_range; + std::string mixed_gradient_curve; + bool mixed_gradient_per_part = false; + }; + std::vector mixed_snapshots; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* mixed_comp_opt = project_config.option("filament_mixed_components"); + auto* mixed_ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + auto* mixed_gradient_opt = project_config.option("filament_mixed_gradient"); + auto* mixed_grad_range_opt = project_config.option("filament_mixed_gradient_range"); + auto* mixed_grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + auto* mixed_per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + if (is_mixed_opt) { + for (size_t i = 0; i < is_mixed_opt->values.size() && i < this->filament_presets.size(); ++i) { + if (!is_mixed_opt->values[i]) + continue; + MixedSlotSnapshot snap; + snap.preset = this->filament_presets[i]; + snap.color = (i < filament_color->values.size()) ? filament_color->values[i] : ""; + snap.color_type = (i < filament_color_type->values.size()) ? filament_color_type->values[i] : ""; + if (mixed_comp_opt && i < mixed_comp_opt->values.size()) snap.mixed_components = mixed_comp_opt->values[i]; + if (mixed_ratios_opt && i < mixed_ratios_opt->values.size()) snap.mixed_sublayer_ratios = mixed_ratios_opt->values[i]; + if (mixed_gradient_opt && i < mixed_gradient_opt->values.size()) snap.mixed_gradient = mixed_gradient_opt->values[i]; + if (mixed_grad_range_opt && i < mixed_grad_range_opt->values.size()) snap.mixed_gradient_range = mixed_grad_range_opt->values[i]; + if (mixed_grad_curve_opt && i < mixed_grad_curve_opt->values.size()) snap.mixed_gradient_curve = mixed_grad_curve_opt->values[i]; + if (mixed_per_part_opt && i < mixed_per_part_opt->values.size()) snap.mixed_gradient_per_part = mixed_per_part_opt->values[i]; + mixed_snapshots.push_back(snap); + } + if (!mixed_snapshots.empty()) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": stripping " << mixed_snapshots.size() << " mixed filament slot(s) before AMS sync"; + size_t phys_count = this->filament_presets.size() - mixed_snapshots.size(); + this->filament_presets.resize(phys_count); + filament_color->values.resize(phys_count); + filament_color_type->values.resize(phys_count); + filament_map->values.resize(phys_count, 1); + is_mixed_opt->values.resize(phys_count); + if (mixed_comp_opt) mixed_comp_opt->values.resize(phys_count); + if (mixed_ratios_opt) mixed_ratios_opt->values.resize(phys_count); + if (mixed_gradient_opt) mixed_gradient_opt->values.resize(phys_count); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.resize(phys_count); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.resize(phys_count); + if (mixed_per_part_opt) mixed_per_part_opt->values.resize(phys_count); + } + } + if (color_only) { auto get_map_index = [&ams_infos](const std::vector &infos, const AMSMapInfo &temp) { for (int i = 0; i < infos.size(); i++) { @@ -3830,6 +3887,34 @@ unsigned int PresetBundle::sync_ams_list(std::vectorvalue > filament_color_type->values.size()) support_interface_filament_opt->value = 0; } + // Re-append mixed filament slots that were stripped before AMS sync + if (!mixed_snapshots.empty()) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": re-appending " << mixed_snapshots.size() << " mixed filament slot(s) after AMS sync"; + size_t new_phys_count = this->filament_presets.size(); + if (is_mixed_opt) is_mixed_opt->values.resize(new_phys_count, (unsigned char)false); + if (mixed_comp_opt) mixed_comp_opt->values.resize(new_phys_count); + if (mixed_ratios_opt) mixed_ratios_opt->values.resize(new_phys_count); + if (mixed_gradient_opt) mixed_gradient_opt->values.resize(new_phys_count, (unsigned char)false); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.resize(new_phys_count); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.resize(new_phys_count); + if (mixed_per_part_opt) mixed_per_part_opt->values.resize(new_phys_count, (unsigned char)false); + + for (auto& snap : mixed_snapshots) { + this->filament_presets.push_back(snap.preset); + filament_color->values.push_back(snap.color); + filament_color_type->values.push_back(snap.color_type); + ams_multi_color_filment.push_back({snap.color}); + filament_map->values.push_back(1); + if (is_mixed_opt) is_mixed_opt->values.push_back((unsigned char)true); + if (mixed_comp_opt) mixed_comp_opt->values.push_back(snap.mixed_components); + if (mixed_ratios_opt) mixed_ratios_opt->values.push_back(snap.mixed_sublayer_ratios); + if (mixed_gradient_opt) mixed_gradient_opt->values.push_back((unsigned char)snap.mixed_gradient); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.push_back(snap.mixed_gradient_range); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.push_back(snap.mixed_gradient_curve); + if (mixed_per_part_opt) mixed_per_part_opt->values.push_back((unsigned char)snap.mixed_gradient_per_part); + } + } + // Update ams_multi_color_filment update_filament_multi_color(); update_multi_material_filament_presets(); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 967e02a907..a29ff1a9d0 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -8911,7 +8911,10 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar() m_sel_plate_toolbar.m_items[i]->slice_state = IMToolbarItem::SliceState::SLICE_FAILED; } else { - if ((!is_empty && !can_slice) || (plate_list.get_plate(i)->has_printable_instances() && !plate_list.get_plate(i)->can_slice())) + // A plate using a mixed filament whose components are broken cannot be sliced, + // so surface that on the plate toolbar the same way an unsliceable plate is. + if ((!is_empty && !can_slice) || (plate_list.get_plate(i)->has_printable_instances() && !plate_list.get_plate(i)->can_slice()) + || wxGetApp().plater()->sidebar().has_broken_mixed_filament(plate_list.get_plate(i))) m_sel_plate_toolbar.m_items[i]->slice_state = IMToolbarItem::SliceState::SLICE_FAILED; else { if (plate_list.get_plate(i)->get_slicing_percent() < 0.0f) @@ -9707,6 +9710,10 @@ void GLCanvas3D::_render_paint_toolbar() const bool disabled = !wxGetApp().plater()->can_fillcolor(); ColorRGBA rgba; + // Gradient mixed filaments fade between two colours over Z, so their swatch is drawn as a + // two-tone fade rather than the single blended colour in `colors`. + auto gradient_info = wxGetApp().plater()->get_filament_gradient_info(); + for (int i = 0; i < extruder_num; i++) { if (i > 0) ImGui::SameLine(); @@ -9720,6 +9727,16 @@ void GLCanvas3D::_render_paint_toolbar() const if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) wxPostEvent(m_canvas, IntEvent(EVT_GLTOOLBAR_FILLCOLOR, i + 1)); } + if (i < (int) gradient_info.size() && gradient_info[i].is_gradient) { + auto to_imu32 = [](const std::array &c) -> ImU32 { + return IM_COL32(uint8_t(c[0]*255.f), uint8_t(c[1]*255.f), uint8_t(c[2]*255.f), uint8_t(c[3]*255.f)); + }; + ImVec2 r_min = ImGui::GetItemRectMin(); + ImVec2 r_max = ImGui::GetItemRectMax(); + ImU32 col_from = to_imu32(gradient_info[i].color_from); + ImU32 col_to = to_imu32(gradient_info[i].color_to); + ImGui::GetWindowDrawList()->AddRectFilledMultiColor(r_min, r_max, col_from, col_to, col_to, col_from); + } if (ImGui::IsItemHovered() && i < 9) { if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 20.0f * f_scale, 10.0f * f_scale }); diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 5254492e5d..9ee74d742f 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1684,11 +1684,18 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men append_submenu(menu, sub_menu, wxID_ANY, _L("Merge with"), "", "", [filaments_cnt]() { return filaments_cnt > 1; }, m_parent); + // Decompose a target colour into a printable mix of the loaded filaments. Placed before the + // Delete entry below so Orca's "delete last" ordering is preserved (BBS appends it after). + append_menu_item( + menu, wxID_ANY, _L("Decompose Color"), "", [](wxCommandEvent&) { + plater()->sidebar().decompose_filament_color(kSidebarContextMenuFilamentId); }, "", nullptr, + []() { return plater()->sidebar().combos_filament().size() >= 2; }, m_parent); + // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS const int delete_id = menu->FindItem(_L("Delete")); if (delete_id != wxNOT_FOUND) menu->Destroy(delete_id); - + append_menu_item( menu, wxID_ANY, _L("Delete"), _L("Delete this filament"), [](wxCommandEvent&) { plater()->sidebar().delete_filament(-2); }, "", nullptr, diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 0ee0230ea9..5ac8cb814c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -78,6 +78,14 @@ void GLGizmoMmuSegmentation::init_extruders_data() m_extruders_colors = wxGetApp().plater()->get_extruders_colors(); m_selected_extruder_idx = 0; + auto plater_grad = wxGetApp().plater()->get_filament_gradient_info(); + m_gradient_info.resize(m_extruders_colors.size()); + for (size_t i = 0; i < m_gradient_info.size() && i < plater_grad.size(); ++i) { + m_gradient_info[i].is_gradient = plater_grad[i].is_gradient; + m_gradient_info[i].color_from = plater_grad[i].color_from; + m_gradient_info[i].color_to = plater_grad[i].color_to; + } + // keep remap table consistent with current extruder count m_extruder_remap.resize(m_extruders_colors.size()); for (size_t i = 0; i < m_extruder_remap.size(); ++i) @@ -433,6 +441,19 @@ void GLGizmoMmuSegmentation::on_render_input_window(float x, float y, float bott m_selected_extruder_idx = extruder_idx; } + // Overlay a two-tone fade for gradient mixed filaments; a single flat colour would + // misrepresent a slot that fades between two filaments over Z. + if (extruder_idx < (int) m_gradient_info.size() && m_gradient_info[extruder_idx].is_gradient) { + auto to_imu32 = [](const std::array &c) -> ImU32 { + return IM_COL32(uint8_t(c[0]*255.f), uint8_t(c[1]*255.f), uint8_t(c[2]*255.f), uint8_t(c[3]*255.f)); + }; + ImVec2 r_min = ImGui::GetItemRectMin(); + ImVec2 r_max = ImGui::GetItemRectMax(); + ImU32 col_from = to_imu32(m_gradient_info[extruder_idx].color_from); + ImU32 col_to = to_imu32(m_gradient_info[extruder_idx].color_to); + ImGui::GetWindowDrawList()->AddRectFilledMultiColor(r_min, r_max, col_from, col_to, col_to, col_from); + } + if (extruder_idx < 16 && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); } // ORCA: Remap filaments section (Border only, Title in border). diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index 55308bffa9..e5448c2dcb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -79,6 +79,14 @@ public: // will be also extended to support additional states, requiring at least one state to remain free out of 19 states. static const constexpr size_t EXTRUDERS_LIMIT = 16; + // Endpoint colours for gradient mixed filaments, mirrored from Plater so the extruder + // swatches below can be drawn as a two-tone fade instead of a single blended colour. + struct GradientInfo { + bool is_gradient = false; + std::array color_from = {0.5f, 0.5f, 0.5f, 1.0f}; + std::array color_to = {0.5f, 0.5f, 0.5f, 1.0f}; + }; + const float get_cursor_radius_min() const override { return CursorRadiusMin; } // BBS @@ -116,6 +124,7 @@ protected: // Filament remap feature std::vector m_extruder_remap; // index → target extruder index + std::vector m_gradient_info; // per-slot gradient endpoints, empty entries for plain filaments // ORCA: Cache used filaments to filter UI std::set m_used_filaments; // Set of used filament indices (cached) diff --git a/src/slic3r/GUI/PlateSettingsDialog.cpp b/src/slic3r/GUI/PlateSettingsDialog.cpp index ea24d646c3..e7f1d926d9 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.cpp +++ b/src/slic3r/GUI/PlateSettingsDialog.cpp @@ -472,6 +472,32 @@ PlateSettingsDialog::PlateSettingsDialog(wxWindow* parent, const wxString& title m_sizer_main->AddSpacer(FromDIP(5)); m_sizer_main->Add(m_other_layers_seq_panel, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(30)); + // A mixed-color slot resolves to a different physical filament per layer, so a user-defined + // filament order cannot be honoured. Disable the choice and say why. BBS puts this warning + // inside its button sizer; Orca builds the buttons with DialogButtons, so it gets its own row. + { + auto &proj_cfg = wxGetApp().preset_bundle->project_config; + auto *is_mixed_opt = proj_cfg.option("filament_is_mixed"); + if (is_mixed_opt && Slic3r::has_any_mixed_filament(is_mixed_opt->values)) { + m_first_layer_print_seq_choice->Enable(false); + m_other_layers_seq_panel->enable_seq_choice(false); + + auto *warn_sizer = new wxBoxSizer(wxHORIZONTAL); + auto *warn_icon = new wxStaticBitmap(this, wxID_ANY, create_scaled_bitmap("warning", this, 16), + wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); + auto *warn_text = new wxStaticText(this, wxID_ANY, + _L("The filament list contains mixed filaments. Custom filament sequence will not take effect.")); + warn_text->SetForegroundColour(wxColour(255, 111, 0)); + warn_text->SetFont(Label::Body_12); + warn_text->Wrap(FromDIP(300)); + + warn_sizer->Add(warn_icon, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + warn_sizer->Add(warn_text, 1, wxALIGN_CENTER_VERTICAL, 0); + m_sizer_main->AddSpacer(FromDIP(5)); + m_sizer_main->Add(warn_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(30)); + } + } + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](auto& e) { diff --git a/src/slic3r/GUI/PlateSettingsDialog.hpp b/src/slic3r/GUI/PlateSettingsDialog.hpp index 1e61b0a708..b94739348f 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.hpp +++ b/src/slic3r/GUI/PlateSettingsDialog.hpp @@ -62,6 +62,9 @@ public: int get_layers_print_seq_choice() { return m_other_layer_print_seq_choice->GetSelection(); }; std::vector get_layers_print_seq_infos() { return m_layer_seq_infos; } + // Lets callers grey out the sequence choice (e.g. when a mixed filament makes a + // user-defined filament order impossible). + void enable_seq_choice(bool enable) { m_other_layer_print_seq_choice->Enable(enable); } protected: void append_layer(const LayerSeqInfo* layer_info = nullptr); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 676d29b961..2fc8ca482d 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5355,9 +5355,16 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { p->editing_filament = -1; } + // update_num_filaments() shrinks filament_is_mixed along with the other per-filament arrays, + // so snapshot it first — the paint cleanup below needs to know which slots were mixed + // *before* the delete to avoid discarding assignments to still-valid mixed slots. + std::vector is_mixed_snapshot; + if (auto* opt = wxGetApp().preset_bundle->project_config.option("filament_is_mixed")) + is_mixed_snapshot = opt->values; + wxGetApp().preset_bundle->update_num_filaments(filament_id); wxGetApp().plater()->get_partplate_list().on_filament_deleted(filament_count, filament_id); - wxGetApp().plater()->on_filaments_delete(filament_count, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id); + wxGetApp().plater()->on_filaments_delete(filament_count, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id, is_mixed_snapshot); wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); @@ -5374,6 +5381,36 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { void Sidebar::change_filament(size_t from_id, size_t to_id) { + // Merging a physical filament into a mixed one that lists it as a component would delete + // the very filament the mix depends on, leaving it broken. Warn before doing so. + auto& pb = *wxGetApp().preset_bundle; + bool from_is_physical = !pb.is_mixed_filament(from_id); + bool to_is_mixed = pb.is_mixed_filament(to_id); + + if (from_is_physical && to_is_mixed) { + auto* comp_opt = pb.project_config.option("filament_mixed_components"); + if (comp_opt && to_id < comp_opt->values.size()) { + auto comps = Slic3r::parse_mixed_components(comp_opt->values[to_id]); + unsigned int from_1based = (unsigned int)from_id + 1; + bool target_uses_source = false; + for (unsigned int c : comps) { + if (c == from_1based) { + target_uses_source = true; + break; + } + } + if (target_uses_source) { + int ret = wxMessageBox( + _L("The target mixed filament uses this physical filament as a component. " + "Merging will remove this physical filament and may invalidate the mixed filament. Continue?"), + _L("Warning"), + wxOK | wxCANCEL | wxICON_WARNING); + if (ret != wxOK) + return; + } + } + } + delete_filament(from_id, int(to_id)); } @@ -9744,7 +9781,11 @@ void Plater::priv::object_list_changed() // BBS //sidebar->enable_buttons(!model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances()); - bool can_slice = !model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances(); + // A mixed filament with deleted or type-mismatched components cannot be resolved at slicing + // time, so block the slice buttons the same way MainFrame::get_enable_slice_status() does. + bool mixed_broken = sidebar->has_broken_mixed_filament(); + bool can_slice = !model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances() + && !mixed_broken; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": can_slice %1%, model_fits= %2%, export_in_progress %3%, has_printable_instances %4% ")%can_slice %model_fits %export_in_progress %part_plate->has_printable_instances(); main_frame->update_slice_print_status(MainFrame::eEventObjectUpdate, can_slice); @@ -13963,6 +14004,25 @@ bool Plater::priv::can_layers_editing() const void Plater::priv::on_action_layersediting(SimpleEvent&) { + // Sub-layer splitting divides each layer by the mix ratio, so an adaptive layer profile makes + // those sub-layer heights vary and degrades the blend. ConfigManipulation warns when the + // option is switched on with a variable profile already present; this is the other direction, + // warning when variable layer editing is switched on while the option is active. Both honour + // the same do-not-show-again flag. + if (!view3D->is_layers_editing_enabled()) { + const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (print_config.opt_bool("enable_mixed_color_sublayer")) { + if (wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + MessageDialog dlg(q, + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + _L("Warning"), wxICON_WARNING | wxOK); + dlg.show_dsa_button(); + dlg.ShowModal(); + if (dlg.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + } + } + } view3D->enable_layers_editing(!view3D->is_layers_editing_enabled()); notification_manager->set_move_from_overlay(view3D->is_layers_editing_enabled()); } @@ -19385,7 +19445,7 @@ void Plater::on_filament_count_change(size_t num_filaments) } } -void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int replace_filament_id) +void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int replace_filament_id, const std::vector& is_mixed_before_delete) { // only update elements in plater update_filament_colors_in_full_config(); @@ -19399,9 +19459,15 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r }*/ // update mmu info + // A volume assigned to a mixed slot legitimately sits past the physical filament count, so + // the paint cleanup must know which slots were mixed. Callers that already shrank the arrays + // pass the pre-delete flags; otherwise read the current ones. + const auto &is_mixed = is_mixed_before_delete.empty() + ? wxGetApp().preset_bundle->project_config.option("filament_is_mixed")->values + : is_mixed_before_delete; for (ModelObject *mo : wxGetApp().model().objects) { for (ModelVolume *mv : mo->volumes) { - mv->update_extruder_count_when_delete_filament(num_filaments, filament_id + 1, replace_filament_id + 1); // this function is 1 base + mv->update_extruder_count_when_delete_filament(num_filaments, filament_id + 1, replace_filament_id + 1, is_mixed); // this function is 1 base } } @@ -19740,6 +19806,63 @@ std::vector Plater::get_extruder_colors_from_plater_config(const GC } } +namespace { + +// A gradient mixed filament fades between its two components over Z, so the UI shows it as a +// two-tone swatch rather than one blended colour. Resolve each slot to its from/to endpoint +// colours; non-gradient slots are left untouched. +struct MixedGradientSlot { + bool is_gradient = false; + std::string color_from; + std::string color_to; +}; + +std::vector parse_mixed_gradient_slots(const Slic3r::DynamicPrintConfig& config, size_t slot_count) +{ + std::vector result(slot_count); + const auto* is_mixed = config.option("filament_is_mixed"); + const auto* mixed_grad = config.option("filament_mixed_gradient"); + const auto* mixed_comp = config.option("filament_mixed_components"); + const auto* grad_range = config.option("filament_mixed_gradient_range"); + const auto* fil_colour = config.option("filament_colour"); + if (!is_mixed || !mixed_grad || !mixed_comp || !fil_colour) return result; + + for (size_t i = 0; i < slot_count && i < is_mixed->values.size(); ++i) { + if (!is_mixed->values[i]) continue; + if (i >= mixed_grad->values.size() || !mixed_grad->values[i]) continue; + if (i >= mixed_comp->values.size()) continue; + + std::vector comp_ids; + std::istringstream iss(mixed_comp->values[i]); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + comp_ids.push_back(v); + } + if (comp_ids.size() != 2) continue; + + int direction = 0; + if (grad_range && i < grad_range->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range->values[i].c_str(), "%f,%f", &v0, &v1) == 2) + direction = (v0 > v1) ? 0 : 1; + } + + unsigned int from_id = (direction == 0) ? comp_ids[0] : comp_ids[1]; + unsigned int to_id = (direction == 0) ? comp_ids[1] : comp_ids[0]; + result[i].is_gradient = true; + result[i].color_from = (from_id >= 1 && from_id <= fil_colour->values.size()) + ? fil_colour->values[from_id - 1] : "#D9D9D9"; + result[i].color_to = (to_id >= 1 && to_id <= fil_colour->values.size()) + ? fil_colour->values[to_id - 1] : "#D9D9D9"; + } + return result; +} + +} // anonymous namespace + std::vector Plater::get_filament_colors_render_info() const { const Slic3r::DynamicPrintConfig* config = &wxGetApp().preset_bundle->project_config; @@ -19747,6 +19870,13 @@ std::vector Plater::get_filament_colors_render_info() const if (!config->has("filament_multi_colour")) return color_packs; color_packs = (config->option("filament_multi_colour"))->values; + + auto slots = parse_mixed_gradient_slots(*config, color_packs.size()); + for (size_t i = 0; i < color_packs.size(); ++i) { + if (slots[i].is_gradient) + color_packs[i] = slots[i].color_from + " " + slots[i].color_to; + } + return color_packs; } @@ -19757,9 +19887,37 @@ std::vector Plater::get_filament_color_render_type() const if (!config->has("filament_colour_type")) return ctype; ctype = (config->option("filament_colour_type"))->values; + + auto slots = parse_mixed_gradient_slots(*config, ctype.size()); + while (ctype.size() < slots.size()) ctype.push_back("1"); + for (size_t i = 0; i < ctype.size() && i < slots.size(); ++i) { + if (slots[i].is_gradient) + ctype[i] = "0"; + } + return ctype; } +std::vector Plater::get_filament_gradient_info() const +{ + const Slic3r::DynamicPrintConfig* config = &wxGetApp().preset_bundle->project_config; + size_t n = get_extruder_colors_from_plater_config().size(); + std::vector info(n); + + auto slots = parse_mixed_gradient_slots(*config, n); + unsigned char rgba[4] = {}; + for (size_t i = 0; i < n; ++i) { + if (!slots[i].is_gradient) continue; + info[i].is_gradient = true; + Slic3r::GUI::BitmapCache::parse_color4(slots[i].color_from, rgba); + info[i].color_from = {rgba[0] / 255.f, rgba[1] / 255.f, rgba[2] / 255.f, rgba[3] / 255.f}; + Slic3r::GUI::BitmapCache::parse_color4(slots[i].color_to, rgba); + info[i].color_to = {rgba[0] / 255.f, rgba[1] / 255.f, rgba[2] / 255.f, rgba[3] / 255.f}; + } + + return info; +} + /* Get vector of colors used for rendering of a Preview scene in "Color print" mode * It consists of extruder colors and colors, saved in model.custom_gcode_per_print_z */ diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 49bc247c59..147f61bed6 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -591,7 +591,7 @@ public: void on_filament_change(size_t filament_idx); void on_filament_count_change(size_t extruders_count); - void on_filaments_delete(size_t extruders_count, size_t filament_id, int replace_filament_id = -1); + void on_filaments_delete(size_t extruders_count, size_t filament_id, int replace_filament_id = -1, const std::vector& is_mixed_before_delete = {}); std::vector get_extruders_colors(); // BBS void on_bed_type_change(BedType bed_type); @@ -606,6 +606,15 @@ public: std::vector get_extruder_colors_from_plater_config(const GCodeProcessorResult* const result = nullptr) const; std::vector get_filament_colors_render_info() const; std::vector get_filament_color_render_type() const; + + // Endpoint colours for gradient mixed filaments, so the 3D scene and the paint gizmo can + // draw a two-tone swatch. is_gradient is false for every ordinary filament slot. + struct FilamentGradientInfo { + bool is_gradient = false; + std::array color_from = {0.5f, 0.5f, 0.5f, 1.0f}; + std::array color_to = {0.5f, 0.5f, 0.5f, 1.0f}; + }; + std::vector get_filament_gradient_info() const; std::vector get_colors_for_color_print(const GCodeProcessorResult* const result = nullptr) const; void set_global_filament_map_mode(FilamentMapMode mode); diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index ea67ad5b31..3524d89c74 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -2575,6 +2575,10 @@ void SyncAmsInfoDialog::reset_and_sync_ams_list() m_materialList.clear(); m_filaments.clear(); + // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as + // AMS sync targets. Look the flags up once and skip those slots in the loop below. + auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); + bool use_double_extruder = get_is_double_extruder(); if (use_double_extruder) { const auto &project_config = preset_bundle->project_config; @@ -2592,6 +2596,8 @@ void SyncAmsInfoDialog::reset_and_sync_ams_list() auto colour_rgb = wxColour((int) rgb[0], (int) rgb[1], (int) rgb[2], (int) rgb[3]); if (extruder >= materials.size() || extruder < 0 || extruder >= display_materials.size()) continue; + if (is_mixed_opt && extruder < (int) is_mixed_opt->values.size() && is_mixed_opt->values[extruder]) + continue; if (contronal_index % SYNC_FLEX_GRID_COL == 0) { wxBoxSizer *ams_tip_sizer = new wxBoxSizer(wxVERTICAL); @@ -2793,6 +2799,10 @@ void SyncAmsInfoDialog::generate_override_fix_ams_list() m_fix_materialList.clear(); m_fix_filaments.clear(); + // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as + // AMS sync targets. Look the flags up once and skip those slots in the loop below. + auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); + bool use_double_extruder = get_is_double_extruder(); if (use_double_extruder) { const auto &project_config = preset_bundle->project_config; @@ -2810,6 +2820,8 @@ void SyncAmsInfoDialog::generate_override_fix_ams_list() auto colour_rgb = wxColour((int) rgb[0], (int) rgb[1], (int) rgb[2], (int) rgb[3]); if (extruder >= extruders.size() || extruder < 0 || extruder >= m_ams_combo_info.ams_filament_colors.size()) continue; + if (is_mixed_opt && extruder < (int) is_mixed_opt->values.size() && is_mixed_opt->values[extruder]) + continue; if (contronal_index % SYNC_FLEX_GRID_COL == 0) { wxBoxSizer *ams_tip_sizer = new wxBoxSizer(wxVERTICAL); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 42796dd3e5..045ce9c43c 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2630,6 +2630,7 @@ void TabPrint::build() auto optgroup = page->new_optgroup(L("Layer height"), L"param_layer_height"); optgroup->append_single_option_line("layer_height","quality_settings_layer_height"); optgroup->append_single_option_line("initial_layer_print_height","quality_settings_layer_height"); + optgroup->append_single_option_line("enable_mixed_color_sublayer"); optgroup = page->new_optgroup(L("Line width"), L"param_line_width"); optgroup->append_single_option_line("line_width","quality_settings_line_width"); From 72a68e9a0f2f11e448d317fe002b1f887595ed0e Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 19:17:00 -0300 Subject: [PATCH 081/138] assimp --- deps/Assimp/Assimp.cmake | 40 ++++ deps/CMakeLists.txt | 4 + src/libslic3r/CMakeLists.txt | 4 + src/libslic3r/Format/AssimpImport.cpp | 327 ++++++++++++++++++++++++++ src/libslic3r/Format/AssimpImport.hpp | 11 + src/libslic3r/Model.cpp | 39 +++ src/slic3r/GUI/GUI_App.cpp | 4 +- 7 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 deps/Assimp/Assimp.cmake create mode 100644 src/libslic3r/Format/AssimpImport.cpp create mode 100644 src/libslic3r/Format/AssimpImport.hpp diff --git a/deps/Assimp/Assimp.cmake b/deps/Assimp/Assimp.cmake new file mode 100644 index 0000000000..8b4de03b09 --- /dev/null +++ b/deps/Assimp/Assimp.cmake @@ -0,0 +1,40 @@ +if(CMAKE_VERSION VERSION_LESS 3.22) + set(_assimp_url "https://github.com/assimp/assimp/archive/refs/tags/v5.3.1.tar.gz") + set(_assimp_hash "SHA256=a07666be71afe1ad4bc008c2336b7c688aca391271188eb9108d0c6db1be53f1") +else() + set(_assimp_url "https://github.com/assimp/assimp/archive/refs/tags/v5.4.3.tar.gz") + set(_assimp_hash "SHA256=66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb") +endif() + +# Assimp's bundled zlib (contrib/zlib) is too old to compile against the modern +# macOS SDK: its zutil.h takes the classic-Mac branch under TARGET_OS_MAC and +# does `#define fdopen(fd,mode) NULL`, which then clobbers the SDK's real +# `fdopen` prototype in and breaks the build. On macOS use the system +# zlib (already found by find_package(ZLIB) in deps-unix-common) instead. +if(APPLE) + set(_assimp_build_zlib "-DASSIMP_BUILD_ZLIB=OFF") +else() + set(_assimp_build_zlib "-DASSIMP_BUILD_ZLIB=ON") +endif() + +orcaslicer_add_cmake_project(Assimp + URL ${_assimp_url} + URL_HASH ${_assimp_hash} + CMAKE_ARGS + -DASSIMP_BUILD_TESTS=OFF + -DASSIMP_BUILD_SAMPLES=OFF + -DASSIMP_BUILD_ASSIMP_TOOLS=OFF + -DASSIMP_INSTALL_PDB=OFF + -DASSIMP_NO_EXPORT=ON + -DASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT=OFF + -DASSIMP_BUILD_GLTF_IMPORTER=ON + -DASSIMP_BUILD_OBJ_IMPORTER=ON + -DASSIMP_BUILD_FBX_IMPORTER=ON + ${_assimp_build_zlib} + -DASSIMP_WARNINGS_AS_ERRORS=OFF + -DBUILD_WITH_STATIC_CRT=OFF +) + +if (MSVC) + add_debug_dep(dep_Assimp) +endif () diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 39cc5de182..8f4bc2a215 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -367,6 +367,9 @@ include(libnoise/libnoise.cmake) include(Draco/Draco.cmake) +# Assimp: glTF/GLB/FBX import for the texture-to-color feature. +include(Assimp/Assimp.cmake) + # I *think* 1.1 is used for *just* md5 hashing? # 3.1 has everything in the right place, but the md5 funcs used are deprecated @@ -448,6 +451,7 @@ set(_dep_list dep_libnoise dep_python3 dep_wxInspector + dep_Assimp ) if (MSVC) diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index b8e537c5aa..2880a3cc6b 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -205,6 +205,8 @@ set(lisbslic3r_sources format.hpp Format/OBJ.cpp Format/OBJ.hpp + Format/AssimpImport.hpp + Format/AssimpImport.cpp Format/ResourcePathUtils.hpp Format/objparser.cpp Format/objparser.hpp @@ -521,6 +523,7 @@ cmake_policy(SET CMP0011 NEW) set(CMAKE_POLICY_DEFAULT_CMP0167 NEW) find_package(CGAL REQUIRED) find_package(OpenCV REQUIRED core) +find_package(assimp REQUIRED) unset(CMAKE_POLICY_DEFAULT_CMP0167) cmake_policy(POP) @@ -609,6 +612,7 @@ target_link_libraries(libslic3r libnest2d miniz opencv_world + assimp::assimp PRIVATE ${CMAKE_DL_LIBS} ${EXPAT_LIBRARIES} diff --git a/src/libslic3r/Format/AssimpImport.cpp b/src/libslic3r/Format/AssimpImport.cpp new file mode 100644 index 0000000000..f0ae99506a --- /dev/null +++ b/src/libslic3r/Format/AssimpImport.cpp @@ -0,0 +1,327 @@ +#include "AssimpImport.hpp" + +#include "../TexturePainting.hpp" +#include "ResourcePathUtils.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace { + +void clear_textured_mesh(TexturedMesh& out) +{ + out.vertices.clear(); + out.indices.clear(); + out.uvs.clear(); + out.uv_coords.clear(); + out.uv_indices.clear(); + out.textures.clear(); + out.material_ids.clear(); + out.material_texture_map.clear(); + out.material_colors.clear(); +} + +void set_error_message(std::string* error_message, const std::string& message) +{ + if (error_message) + *error_message = message; +} + +bool is_fbx_path(const std::string& path) +{ + return boost::algorithm::iends_with(path, ".fbx"); +} + +bool should_flip_uvs(const std::string& path) +{ + return boost::algorithm::iends_with(path, ".fbx") || + boost::algorithm::iends_with(path, ".glb"); +} + +unsigned int assimp_import_flags(const std::string& path) +{ + unsigned int flags = aiProcess_Triangulate + | aiProcess_GenNormals + | aiProcess_PreTransformVertices + | aiProcess_SortByPType; + if (should_flip_uvs(path)) + flags |= aiProcess_FlipUVs; + return flags; +} + +void configure_importer(Assimp::Importer& importer, const std::string& path, unsigned int flags) +{ + importer.SetPropertyInteger(AI_CONFIG_PP_SBP_REMOVE, + aiPrimitiveType_POINT | aiPrimitiveType_LINE); + + if (flags & aiProcess_PreTransformVertices) + importer.SetPropertyBool(AI_CONFIG_PP_PTV_KEEP_HIERARCHY, true); + + if (is_fbx_path(path)) { + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_ALL_GEOMETRY_LAYERS, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_MATERIALS, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_TEXTURES, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_ANIMATIONS, false); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_LIGHTS, false); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_CAMERAS, false); + } +} + +bool read_external_texture_file(const boost::filesystem::path& path, TextureImage& out) +{ + boost::nowide::ifstream file(path.string(), std::ios::binary | std::ios::ate); + if (!file.is_open()) + return false; + + const std::streamoff size = file.tellg(); + if (size <= 0) + return false; + if (static_cast(size) > static_cast(std::numeric_limits::max())) + return false; + + file.seekg(0); + out.width = -1; + out.height = -1; + out.channels = 0; + out.data.resize(static_cast(size)); + file.read(reinterpret_cast(out.data.data()), size); + if (!file && !file.eof()) { + out.data.clear(); + return false; + } + return true; +} + +bool read_embedded_texture(const aiTexture& texture, TextureImage& out) +{ + out.data.clear(); + if (texture.mHeight == 0) { + if (texture.mWidth == 0) + return false; + out.width = -1; + out.height = -1; + out.channels = 0; + out.data.assign( + reinterpret_cast(texture.pcData), + reinterpret_cast(texture.pcData) + texture.mWidth); + return !out.data.empty(); + } + + if (texture.mWidth == 0 || texture.mHeight == 0) + return false; + if (texture.mWidth > static_cast(std::numeric_limits::max()) || + texture.mHeight > static_cast(std::numeric_limits::max())) { + return false; + } + const size_t width = static_cast(texture.mWidth); + const size_t height = static_cast(texture.mHeight); + if (width > std::numeric_limits::max() / height || + width * height > std::numeric_limits::max() / 4) { + return false; + } + + out.width = static_cast(texture.mWidth); + out.height = static_cast(texture.mHeight); + out.channels = 4; + const size_t pixel_count = width * height; + out.data.resize(pixel_count * 4); + for (size_t i = 0; i < pixel_count; ++i) { + const aiTexel& texel = texture.pcData[i]; + out.data[i * 4 + 0] = texel.r; + out.data[i * 4 + 1] = texel.g; + out.data[i * 4 + 2] = texel.b; + out.data[i * 4 + 3] = texel.a; + } + return !out.data.empty(); +} + +bool get_material_texture(const aiMaterial& material, aiString& texture_path) +{ + if (material.GetTextureCount(aiTextureType_DIFFUSE) > 0 && + material.GetTexture(aiTextureType_DIFFUSE, 0, &texture_path) == AI_SUCCESS) { + return true; + } + + if (material.GetTextureCount(aiTextureType_BASE_COLOR) > 0 && + material.GetTexture(aiTextureType_BASE_COLOR, 0, &texture_path) == AI_SUCCESS) { + return true; + } + + return false; +} + +std::array get_material_color(const aiMaterial& material) +{ + aiColor4D color(1.f, 1.f, 1.f, 1.f); + if (material.Get(AI_MATKEY_BASE_COLOR, color) == AI_SUCCESS) + return {color.r, color.g, color.b, color.a}; + if (material.Get(AI_MATKEY_COLOR_DIFFUSE, color) == AI_SUCCESS) + return {color.r, color.g, color.b, color.a}; + return {1.f, 1.f, 1.f, 1.f}; +} + +bool collect_mesh(const aiMesh& mesh, size_t& vertex_offset, TexturedMesh& out, std::string& error) +{ + if (mesh.mNumVertices > static_cast(std::numeric_limits::max()) - vertex_offset) { + error = "Assimp mesh has too many vertices for TexturedMesh indices"; + return false; + } + + for (unsigned int i = 0; i < mesh.mNumVertices; ++i) { + const aiVector3D& v = mesh.mVertices[i]; + out.vertices.push_back({v.x, v.y, v.z}); + + if (mesh.HasTextureCoords(0)) { + const aiVector3D& uv = mesh.mTextureCoords[0][i]; + out.uvs.push_back({uv.x, uv.y}); + } else { + out.uvs.push_back({0.f, 0.f}); + } + } + + const int material_index = static_cast(mesh.mMaterialIndex); + for (unsigned int i = 0; i < mesh.mNumFaces; ++i) { + const aiFace& face = mesh.mFaces[i]; + if (face.mNumIndices != 3) + continue; + if (face.mIndices[0] >= mesh.mNumVertices || + face.mIndices[1] >= mesh.mNumVertices || + face.mIndices[2] >= mesh.mNumVertices) { + error = "Assimp mesh face index is out of bounds"; + return false; + } + out.indices.push_back({ + static_cast(static_cast(face.mIndices[0]) + vertex_offset), + static_cast(static_cast(face.mIndices[1]) + vertex_offset), + static_cast(static_cast(face.mIndices[2]) + vertex_offset)}); + out.material_ids.push_back(material_index); + } + + vertex_offset += mesh.mNumVertices; + return true; +} + +void collect_materials(const aiScene& scene, const boost::filesystem::path& base_dir, TexturedMesh& out) +{ + out.material_texture_map.assign(scene.mNumMaterials, -1); + out.material_colors.assign(scene.mNumMaterials, {1.f, 1.f, 1.f, 1.f}); + + for (unsigned int material_index = 0; material_index < scene.mNumMaterials; ++material_index) { + const aiMaterial* material = scene.mMaterials[material_index]; + if (!material) + continue; + + out.material_colors[material_index] = get_material_color(*material); + + aiString texture_path; + if (!get_material_texture(*material, texture_path)) + continue; + + TextureImage image; + const aiTexture* embedded_texture = scene.GetEmbeddedTexture(texture_path.C_Str()); + if (embedded_texture) { + if (!read_embedded_texture(*embedded_texture, image)) + continue; + } else { + const boost::filesystem::path resolved = resource_path::resolve_external_resource_path( + base_dir, texture_path.C_Str(), "Assimp texture"); + if (resolved.empty()) { + BOOST_LOG_TRIVIAL(warning) << "AssimpImport: texture file not found: " + << texture_path.C_Str(); + continue; + } + if (!read_external_texture_file(resolved, image)) { + BOOST_LOG_TRIVIAL(warning) << "AssimpImport: failed to read texture: " + << resolved; + continue; + } + } + + out.material_texture_map[material_index] = static_cast(out.textures.size()); + out.textures.push_back(std::move(image)); + } +} + +std::string scene_failure_summary(const std::string& path, const char* assimp_error) +{ + std::ostringstream ss; + ss << "Assimp failed to import " << path; + if (assimp_error && assimp_error[0] != '\0') + ss << ": " << assimp_error; + return ss.str(); +} + +} // namespace + +bool load_assimp_textured_model(const std::string& path, TexturedMesh& out, std::string* error_message) +{ + clear_textured_mesh(out); + + Assimp::Importer importer; + const unsigned int flags = assimp_import_flags(path); + configure_importer(importer, path, flags); + + const aiScene* scene = importer.ReadFile(path, flags); + if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { + const std::string message = scene_failure_summary(path, importer.GetErrorString()); + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + return false; + } + + if (scene->mNumMeshes == 0) { + const std::string message = "Assimp scene has no meshes: " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + return false; + } + + size_t vertex_offset = 0; + for (unsigned int mesh_index = 0; mesh_index < scene->mNumMeshes; ++mesh_index) { + const aiMesh* mesh = scene->mMeshes[mesh_index]; + if (!mesh || !mesh->HasPositions()) + continue; + std::string mesh_error; + if (!collect_mesh(*mesh, vertex_offset, out, mesh_error)) { + const std::string message = mesh_error + ": " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + clear_textured_mesh(out); + return false; + } + } + + if (out.vertices.empty() || out.indices.empty()) { + const std::string message = "Assimp extracted no valid triangles: " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + clear_textured_mesh(out); + return false; + } + + collect_materials(*scene, boost::filesystem::path(path).parent_path(), out); + + BOOST_LOG_TRIVIAL(info) << "AssimpImport: loaded " << out.vertices.size() + << " vertices, " << out.indices.size() + << " triangles, " << out.textures.size() + << " textures from " << path; + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/Format/AssimpImport.hpp b/src/libslic3r/Format/AssimpImport.hpp new file mode 100644 index 0000000000..80c3e2dc91 --- /dev/null +++ b/src/libslic3r/Format/AssimpImport.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Slic3r { + +struct TexturedMesh; + +bool load_assimp_textured_model(const std::string& path, TexturedMesh& out, std::string* error_message = nullptr); + +} // namespace Slic3r diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 3617e85991..c9322eff3c 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -2,6 +2,7 @@ #include "libslic3r.h" #include "BuildVolume.hpp" #include "TexturePainting.hpp" +#include "Format/AssimpImport.hpp" #include "ClipperUtils.hpp" #include "Exception.hpp" #include "Model.hpp" @@ -242,6 +243,27 @@ _finished: // BBS: add part plate related logic // BBS: backup & restore // Loading model from a file, it may be a simple geometry file as STL or OBJ, however it may be a project file as well. +// Build a plain geometry ModelObject from a textured mesh. The texture itself is carried +// separately on Model::texture_mesh and consumed by the texture import dialog. +static void add_textured_mesh_to_model(Model& model, const TexturedMesh& tex_mesh, const std::string& input_file) +{ + std::string object_name = boost::filesystem::path(input_file).filename().string(); + + indexed_triangle_set its; + its.vertices.resize(tex_mesh.vertices.size()); + for (size_t i = 0; i < tex_mesh.vertices.size(); ++i) + its.vertices[i] = Vec3f(tex_mesh.vertices[i][0], tex_mesh.vertices[i][1], tex_mesh.vertices[i][2]); + its.indices.resize(tex_mesh.indices.size()); + for (size_t i = 0; i < tex_mesh.indices.size(); ++i) + its.indices[i] = Vec3i32(tex_mesh.indices[i][0], tex_mesh.indices[i][1], tex_mesh.indices[i][2]); + + its_merge_vertices(its); + its_remove_degenerate_faces(its); + its_compactify_vertices(its); + + model.add_object(object_name.c_str(), input_file.c_str(), std::move(TriangleMesh(std::move(its)))); +} + Model Model::read_from_file(const std::string& input_file, DynamicPrintConfig* config, ConfigSubstitutionContext* config_substitutions, @@ -325,6 +347,23 @@ Model Model::read_from_file(const std::string& }*/ } } + else if (boost::algorithm::iends_with(input_file, ".glb") || + boost::algorithm::iends_with(input_file, ".gltf") || + boost::algorithm::iends_with(input_file, ".fbx")) { + // These formats always carry material/texture data, so they go through the textured + // import path: the geometry becomes a normal object and the texture is handed to the + // texture-to-color dialog via Model::texture_mesh. + auto tex_mesh = std::make_shared(); + result = load_assimp_textured_model(input_file, *tex_mesh, &message); + if (result) { + model.texture_mesh = tex_mesh; + add_textured_mesh_to_model(model, *tex_mesh, input_file); + } else if (!message.empty()) { + BOOST_LOG_TRIVIAL(error) << "Assimp: failed to load model: " << message + << ", path=" << input_file; + message = _L("The file format is incompatible and cannot be parsed."); + } + } else if (boost::algorithm::iends_with(input_file, ".svg")) result = load_svg(input_file.c_str(), &model, message); //BBS: remove the old .amf.xml files diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 6028ada640..12e8500dbc 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -521,10 +521,10 @@ static const FileWildcards file_wildcards_by_type[FT_SIZE] = { /* FT_GCODE */ { L("G-code files"), { ".gcode"sv} }, #ifdef __APPLE__ /* FT_MODEL */ - {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".usd"sv, ".usda"sv, ".usdc"sv, ".usdz"sv, ".abc"sv, ".ply"sv, ".drc"sv}}, + {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".gltf"sv, ".glb"sv, ".fbx"sv, ".usd"sv, ".usda"sv, ".usdc"sv, ".usdz"sv, ".abc"sv, ".ply"sv, ".drc"sv}}, #else /* FT_MODEL */ - {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".drc"sv}}, + {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".gltf"sv, ".glb"sv, ".fbx"sv, ".drc"sv}}, #endif /* FT_ZIP */ { L("ZIP files"), { ".zip"sv } }, /* FT_PROJECT */ { L("Project files"), { ".3mf"sv} }, From 86a7e93a48caaaf897249c1296bef2392da0218c Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 12 Aug 2026 20:46:10 -0300 Subject: [PATCH 082/138] Layer subdivision fix --- src/libslic3r/GCode.cpp | 30 +++++- tests/fff_print/CMakeLists.txt | 1 + tests/fff_print/test_mixed_filament.cpp | 137 ++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/fff_print/test_mixed_filament.cpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 2f8f0f90fd..f42591b6be 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6101,7 +6101,17 @@ LayerResult GCode::process_layer( const bool island_level_ordering = print.config().print_sequence != PrintSequence::ByObject && single_object_instance_idx == size_t(-1) && print.config().print_order != PrintOrder::AsObjectList; - for (unsigned int filament_id : layer_tools.extruders) { + // A mixed-color slot is absent from layer_tools.extruders by design: resolve_mixed_filaments() + // replaced it with its physical components. Its geometry is still keyed under the slot in + // by_extruder though, and the sublayer emitter looks the plan up by slot id, so append the + // slots here. Appended (not merged) so the existing order is untouched, and empty for every + // configuration without sublayer splitting. + std::vector plan_filaments = layer_tools.extruders; + for (const auto &grp : layer_tools.mixed_sub_layer_groups) + if (std::find(plan_filaments.begin(), plan_filaments.end(), grp.mixed_slot_0based) == plan_filaments.end()) + plan_filaments.push_back(grp.mixed_slot_0based); + + for (unsigned int filament_id : plan_filaments) { auto objects_by_extruder_it = by_extruder.find(filament_id); if (objects_by_extruder_it == by_extruder.end()) continue; @@ -6282,8 +6292,22 @@ LayerResult GCode::process_layer( } if (print.config().print_sequence == PrintSequence::ByLayer && m_enable_exclude_object && print.config().support_object_skip_flush.value) { - std::vector filament_instances_id; - for (InstanceToPrint &instance : filament_to_print_instances[extruder_id].first) filament_instances_id.emplace_back(instance.label_object_id); + std::set all_label_ids; + for (InstanceToPrint &instance : filament_to_print_instances[extruder_id].first) + all_label_ids.insert(instance.label_object_id); + // This extruder may also be printing sub-layers on behalf of a mixed slot, whose + // instances live under the slot id. Their labels belong in the same skip set, or + // exclude-object would not skip that geometry. + for (const auto &grp : layer_tools.mixed_sub_layer_groups) + for (unsigned int comp : grp.components_0based) + if (comp == extruder_id) { + auto mit = filament_to_print_instances.find(grp.mixed_slot_0based); + if (mit != filament_to_print_instances.end()) + for (const InstanceToPrint &inst : mit->second.first) + all_label_ids.insert(inst.label_object_id); + break; + } + std::vector filament_instances_id(all_label_ids.begin(), all_label_ids.end()); m_filament_instances_code = _encode_label_ids_to_base64(filament_instances_id); } diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 43afd4281d..fc46bb8fdc 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -15,6 +15,7 @@ add_executable(${_TEST_NAME}_tests test_perimeters.cpp test_print.cpp test_printobject.cpp + test_mixed_filament.cpp test_skirt_brim.cpp test_slicing_pipeline_hook.cpp test_support_material.cpp diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp new file mode 100644 index 0000000000..4f4d914cc2 --- /dev/null +++ b/tests/fff_print/test_mixed_filament.cpp @@ -0,0 +1,137 @@ +#include + +#include "libslic3r/GCode/ToolOrdering.hpp" +#include "libslic3r/Print.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +namespace { + +// Two physical filaments plus one mixed slot (config index 2, 1-based id 3) blending them 60/40. +// The mixed arrays are parallel to filament_colour and must be sized to the filament count. +// Note ConfigOptionBools deserializes on ',' while ConfigOptionStrings uses ';'. +DynamicPrintConfig mixed_config(bool sublayer_on, const char *ratios = "0.6,0.4") +{ + DynamicPrintConfig config = multifilament_config(3); + config.set_deserialize_strict({ + {"filament_is_mixed", "0,0,1"}, + {"filament_mixed_components", ";;1,2"}, + {"filament_mixed_sublayer_ratios", std::string(";;") + ratios}, + {"filament_mixed_gradient", "0,0,0"}, + {"filament_mixed_gradient_range", ";;"}, + {"filament_mixed_gradient_curve", ";;"}, + {"filament_mixed_gradient_per_part","0,0,0"}, + {"enable_mixed_color_sublayer", sublayer_on ? "1" : "0"}, + // Assign every region role to the mixed slot so it actually participates in slicing. + {"outer_wall_filament_id", "3"}, + {"inner_wall_filament_id", "3"}, + {"sparse_infill_filament_id", "3"}, + {"internal_solid_filament_id", "3"}, + {"top_surface_filament_id", "3"}, + {"bottom_surface_filament_id", "3"}, + }); + return config; +} + +// Total sub-layer groups and per-layer DRR resolutions across the whole tool ordering. +void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions) +{ + groups = resolutions = 0; + for (const LayerTools < : to.layer_tools()) { + groups += lt.mixed_sub_layer_groups.size(); + resolutions += lt.mixed_filament_resolution.size(); + } +} + +} // namespace + +TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(true)); + + // The option lives in PrintConfig; if it did not survive Print::apply the slicer would + // silently fall back to the whole-layer path. + CHECK(print.config().enable_mixed_color_sublayer.value == true); + REQUIRE(print.config().filament_is_mixed.values.size() == 3); + CHECK(print.config().filament_is_mixed.values[2] == true); + REQUIRE(print.config().filament_mixed_components.values.size() == 3); + CHECK(print.config().filament_mixed_components.values[2] == "1,2"); +} + +TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(true)); + print.process(); + + ToolOrdering &to = const_cast(print.tool_ordering()); + REQUIRE(!to.layer_tools().empty()); + + size_t groups = 0, resolutions = 0; + count_mixed(to, groups, resolutions); + + INFO("layers=" << to.layer_tools().size() << " groups=" << groups); + CHECK(groups > 0); +} + +TEST_CASE("Mixed filament alternates whole layers when the option is off", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(false)); + print.process(); + + ToolOrdering &to = const_cast(print.tool_ordering()); + REQUIRE(!to.layer_tools().empty()); + + size_t groups = 0, resolutions = 0; + count_mixed(to, groups, resolutions); + + // With splitting off the slot is realized by the deficit round-robin scheduler instead: + // no sub-layer groups, but a per-layer resolution to one physical component. + INFO("layers=" << to.layer_tools().size() << " resolutions=" << resolutions); + CHECK(groups == 0); + CHECK(resolutions > 0); +} + +TEST_CASE("Sub-layer splitting emits the scaled sub-heights into G-code", "[MixedFilament]") +{ + // layer_height 0.2 split 60/40 gives sub-layers of 0.12 and 0.08. The emitter reports the + // sub-height (not the nominal layer height) in the HEIGHT tag and scales flow to match. + DynamicPrintConfig config = mixed_config(true); + config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}}); + + Print print; + Model model; + init_print({cube(20)}, print, model, config); + print.process(); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + INFO("gcode bytes=" << gc.size()); + CHECK(gc.find(";HEIGHT:0.12") != std::string::npos); + CHECK(gc.find(";HEIGHT:0.08") != std::string::npos); +} + +TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilament]") +{ + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}}); + + Print print; + Model model; + init_print({cube(20)}, print, model, config); + print.process(); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + // No sub-layer split, so the 60/40 sub-heights must never appear. + CHECK(gc.find(";HEIGHT:0.12") == std::string::npos); + CHECK(gc.find(";HEIGHT:0.08") == std::string::npos); +} From 42f708bbb67f70d63efc495fba09db61d5cb565b Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Thu, 13 Aug 2026 10:21:58 -0300 Subject: [PATCH 083/138] Fixes from Full spectrum port https://github.com/OrcaSlicer/OrcaSlicer/pull/14383 --- src/libslic3r/PresetBundle.cpp | 2 +- src/libslic3r/PrintApply.cpp | 3 +- src/libslic3r/TriangleSelector.cpp | 50 +++++-- src/libslic3r/TriangleSelector.hpp | 22 +++- src/libslic3r/libslic3r.h | 6 + src/slic3r/GUI/3DScene.cpp | 18 ++- src/slic3r/GUI/ConfigManipulation.cpp | 60 +++++---- src/slic3r/GUI/GLCanvas3D.cpp | 8 ++ .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 2 +- .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 9 +- src/slic3r/GUI/Gizmos/GLGizmosManager.cpp | 46 +++++-- src/slic3r/GUI/Gizmos/GLGizmosManager.hpp | 2 + tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_triangle_selector.cpp | 124 ++++++++++++++++++ 14 files changed, 288 insertions(+), 65 deletions(-) create mode 100644 tests/libslic3r/test_triangle_selector.cpp diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index eeb56d6e11..a71b5a9d18 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -3795,7 +3795,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector= size_t(EnforcerBlockerType::ExtruderMax)){ + if (exist_filament_presets.size() >= MAXIMUM_AMS_SYNC_FILAMENT_NUMBER){ break; } auto idx = get_idx_in_array(exist_filament_presets, exist_colors, need_append_colors[i].filament_preset, need_append_colors[i].filament_color); diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index bb9da850ca..7eb40946a4 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1931,7 +1931,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ for (const ModelVolume *volume : volumes) { const std::vector &volume_used_facet_states = volume->mmu_segmentation_facets.get_data().used_states; - assert(volume_used_facet_states.size() == used_facet_states.size()); + // Sizes may legitimately differ: paint data stored before the state range was + // extended carries a shorter used_states vector. Merge over the common prefix. for (size_t state_idx = 0; state_idx < std::min(volume_used_facet_states.size(), used_facet_states.size()); ++state_idx) used_facet_states[state_idx] |= volume_used_facet_states[state_idx]; } diff --git a/src/libslic3r/TriangleSelector.cpp b/src/libslic3r/TriangleSelector.cpp index 12f314a799..b47004fca5 100644 --- a/src/libslic3r/TriangleSelector.cpp +++ b/src/libslic3r/TriangleSelector.cpp @@ -1736,13 +1736,22 @@ TriangleSelector::TriangleSplittingData TriangleSelector::serialize() const { data.used_states[n] = true; if (n >= 3) { - assert(n <= 16); - if (n <= 16) { - // Store "11" plus 4 bits of (n-3). - data.bitstream.insert(data.bitstream.end(), { true, true }); - n -= 3; + assert(n <= int(EnforcerBlockerType::ExtruderMax)); + // Store "11" plus 4 bits of (n-3), which covers states 3..17. State 18 and + // above set that nibble to 0b1111 and store (n-18) in a second nibble. This is + // the encoding the CONST_FILAMENTS table in Model.cpp already writes for + // colored mesh imports. + data.bitstream.insert(data.bitstream.end(), { true, true }); + auto &bitstream = data.bitstream; + auto push_nibble = [&bitstream](int value) { for (size_t bit_idx = 0; bit_idx < 4; ++bit_idx) - data.bitstream.push_back(n & (uint64_t(0b0001) << bit_idx)); + bitstream.push_back(value & (uint64_t(0b0001) << bit_idx)); + }; + if (n <= 17) { + push_nibble(n - 3); + } else { + push_nibble(0b1111); + push_nibble(n - 18); } } else { // Simple case, compatible with PrusaSlicer 2.3.1 and older for storing paint on supports and seams. @@ -1810,6 +1819,12 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data, n |= data.bitstream[ibit ++] << i; return n; }; + // Decode a leaf state stored behind the "11" prefix: one nibble of (state-3) for states + // 3..17, or 0b1111 followed by a nibble of (state-18) above that. + auto decode_leaf_state = [&next_nibble]() { + const int nibble = next_nibble(); + return EnforcerBlockerType(nibble == 0b1111 ? next_nibble() + 18 : nibble + 3); + }; parents.clear(); while (true) { @@ -1818,8 +1833,8 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data, int num_of_split_sides = code & 0b11; int num_of_children = num_of_split_sides == 0 ? 0 : num_of_split_sides + 1; bool is_split = num_of_children != 0; - // Only valid if not is_split. Value of the second nibble was subtracted by 3, so it is added back. - auto state = is_split ? EnforcerBlockerType::NONE : EnforcerBlockerType((code & 0b1100) == 0b1100 ? next_nibble() + 3 : code >> 2); + // Only valid if not is_split. + auto state = is_split ? EnforcerBlockerType::NONE : ((code & 0b1100) == 0b1100 ? decode_leaf_state() : EnforcerBlockerType(code >> 2)); // BBS if (state == to_delete_filament) @@ -1916,7 +1931,14 @@ void TriangleSelector::TriangleSplittingData::update_used_states(const size_t bi if (const bool is_split = (code & 0b11) != 0; is_split) continue; - const uint8_t facet_state = (code & 0b1100) == 0b1100 ? read_next_nibble() + 3 : code >> 2; + uint8_t facet_state; + if ((code & 0b1100) == 0b1100) { + // Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18). + const uint8_t nibble = read_next_nibble(); + facet_state = nibble == 0b1111 ? uint8_t(read_next_nibble() + 18) : uint8_t(nibble + 3); + } else { + facet_state = code >> 2; + } assert(facet_state < this->used_states.size()); if (facet_state >= this->used_states.size()) continue; @@ -1946,9 +1968,13 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor auto num_children_or_state = [&next_nibble]() -> int { int code = next_nibble(); int num_of_split_sides = code & 0b11; - return num_of_split_sides == 0 ? - ((code & 0b1100) == 0b1100 ? next_nibble() + 3 : code >> 2) : - - num_of_split_sides - 1; + if (num_of_split_sides != 0) + return - num_of_split_sides - 1; + if ((code & 0b1100) != 0b1100) + return code >> 2; + // Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18). + const int nibble = next_nibble(); + return nibble == 0b1111 ? next_nibble() + 18 : nibble + 3; }; int state = num_children_or_state(); diff --git a/src/libslic3r/TriangleSelector.hpp b/src/libslic3r/TriangleSelector.hpp index 41d189cdd1..594f710e45 100644 --- a/src/libslic3r/TriangleSelector.hpp +++ b/src/libslic3r/TriangleSelector.hpp @@ -17,7 +17,9 @@ enum class EnforcerBlockerType : int8_t { BLOCKER = 2, // For the fuzzy skin, we use just two values (NONE and FUZZY_SKIN). FUZZY_SKIN = ENFORCER, - // Maximum is 15. The value is serialized in TriangleSelector into 6 bits using a 2 bit prefix code. + // States 3..17 are serialized into 6 bits using a 2 bit prefix code; states 18 and above use + // one additional nibble (see TriangleSelector::serialize). ExtruderMax matches the last entry + // of CONST_FILAMENTS in Model.cpp, which encodes the same range for colored mesh imports. Extruder1 = ENFORCER, Extruder2 = BLOCKER, Extruder3, @@ -34,7 +36,23 @@ enum class EnforcerBlockerType : int8_t { Extruder14, Extruder15, Extruder16, - ExtruderMax = Extruder16 + Extruder17, + Extruder18, + Extruder19, + Extruder20, + Extruder21, + Extruder22, + Extruder23, + Extruder24, + Extruder25, + Extruder26, + Extruder27, + Extruder28, + Extruder29, + Extruder30, + Extruder31, + Extruder32, + ExtruderMax = Extruder32 }; // Type alias for the state mapping array to improve code readability diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index f4291d36df..dee0a93087 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -64,6 +64,12 @@ static constexpr double LARGE_BED_THRESHOLD = 2147; // Orca: maximum number of extruders is 64. For SEMM printers, it defines maximum filament number. static constexpr size_t MAXIMUM_EXTRUDER_NUMBER = 64; +// Orca: how many filament slots syncing an AMS setup may create. This used to follow +// EnforcerBlockerType::ExtruderMax, which capped it at the number of paintable filaments; that +// limit has since been raised to 32, so the value is pinned here to keep AMS sync behaving as +// before for projects that use no mixed-colour filaments. +static constexpr size_t MAXIMUM_AMS_SYNC_FILAMENT_NUMBER = 16; + // Orca: maximum line width is 5 times the nozzle diameter static constexpr float MAX_LINE_WIDTH_MULTIPLIER = 5; diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index bf5d1f2421..f65c0e3532 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -682,13 +682,19 @@ void GLVolume::simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_obj if (shader) { if (idx == 0) { int extruder_id = model_volume->extruder_id(); - //to make black not too hard too see - ColorRGBA new_color = adjust_color_for_rendering(extruder_colors[extruder_id - 1]); - if (ban_light) { - new_color[3] = (255 - (extruder_id - 1))/255.0f; + // ORCA: extruder_id may be 0 (unset) or point past the colour list after a + // filament is deleted/remapped, so clamp the index instead of reading out of + // bounds. + if (!extruder_colors.empty()) { + int color_idx = std::clamp(extruder_id - 1, 0, int(extruder_colors.size()) - 1); + //to make black not too hard too see + ColorRGBA new_color = adjust_color_for_rendering(extruder_colors[color_idx]); + if (ban_light) { + new_color[3] = (255 - color_idx)/255.0f; + } + m.set_color(new_color); + // shader->set_uniform("uniform_color", new_color); } - m.set_color(new_color); - // shader->set_uniform("uniform_color", new_color); } else { if (idx <= extruder_colors.size()) { diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 519e75d9d2..1d44d1b356 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -577,36 +577,40 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con } // BBS - // A per-role filament override must name a real, physical filament. Out-of-range values are - // stale; a mixed-color slot is virtual and cannot be driven directly by a role override, so - // both are reset to 0 ("inherit the object's filament"). The object's own extruder assignment - // is what legitimately carries a mixed slot. Orca splits BBS's wall/solid_infill roles into - // six keys, so all of them are checked here. - static const char* keys[] = { "support_filament", "support_interface_filament", - "outer_wall_filament_id", "inner_wall_filament_id", - "sparse_infill_filament_id", "internal_solid_filament_id", - "top_surface_filament_id", "bottom_surface_filament_id" }; - for (int i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { - std::string key = std::string(keys[i]); + // A filament override naming a slot that no longer exists is stale and falls back to the + // plater's value. Support is additionally restricted to physical filaments: the support paths + // (ToolOrdering::collect_extruders, Print::validate) consume support_filament directly, with + // no per-layer mixed resolution, so a virtual slot there would reach the G-code unresolved. + // The per-feature keys have no such restriction — LayerTools::extruder() and its siblings + // resolve a mixed slot to the physical filament chosen for each layer. + static const char* support_keys[] = { "support_filament", "support_interface_filament" }; + static const char* feature_keys[] = { "outer_wall_filament_id", "inner_wall_filament_id", + "sparse_infill_filament_id", "internal_solid_filament_id", + "top_surface_filament_id", "bottom_surface_filament_id" }; + auto reset_invalid_filament = [this, config, filament_cnt](const char* key, bool allow_mixed) { auto* opt = dynamic_cast(config->option(key, false)); - if (opt != nullptr) { - int val = opt->getInt(); - bool out_of_range = val > filament_cnt; - bool is_mixed = (val > 0 && val <= filament_cnt && - wxGetApp().preset_bundle->is_mixed_filament(val - 1)); - if (out_of_range || is_mixed) { - DynamicPrintConfig new_conf = *config; - int new_value = 0; - if (out_of_range) { - const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); - if (conf_temp != nullptr && conf_temp->has(key)) - new_value = conf_temp->opt_int(key); - } - new_conf.set_key_value(key, new ConfigOptionInt(new_value)); - apply(config, &new_conf); - } + if (opt == nullptr) + return; + const int val = opt->getInt(); + const bool out_of_range = val > filament_cnt; + const bool is_mixed = !allow_mixed && val > 0 && val <= filament_cnt && + wxGetApp().preset_bundle->is_mixed_filament(val - 1); + if (!out_of_range && !is_mixed) + return; + DynamicPrintConfig new_conf = *config; + int new_value = 0; + if (out_of_range) { + const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); + if (conf_temp != nullptr && conf_temp->has(key)) + new_value = conf_temp->opt_int(key); } - } + new_conf.set_key_value(key, new ConfigOptionInt(new_value)); + apply(config, &new_conf); + }; + for (const char* key : support_keys) + reset_invalid_filament(key, false); + for (const char* key : feature_keys) + reset_invalid_filament(key, true); // Sub-layer splitting divides each layer by the mix ratio; an adaptive layer profile makes // those sub-layer heights vary per layer, which degrades the blend. Warn once per enable. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index a29ff1a9d0..3a919c800d 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -9681,6 +9681,14 @@ void GLCanvas3D::_render_paint_toolbar() const } } } + // ORCA: the loop above only produces a label for a slot whose preset is found in the preset + // collection, while the render loop below iterates extruder_num (= colour count). Pad the + // label arrays so a slot without a matching preset cannot index past them — reading a garbage + // std::string here crashes in ImGui::CalcTextSize (strlen). + while (int(filament_text_first_line.size()) < extruder_num) { + filament_text_first_line.emplace_back(); + filament_text_second_line.emplace_back(); + } ImGuiWrapper& imgui = *wxGetApp().imgui(); const float canvas_w = float(get_canvas_size().get_width()); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 5ac8cb814c..77b58d3bb5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -454,7 +454,7 @@ void GLGizmoMmuSegmentation::on_render_input_window(float x, float y, float bott ImGui::GetWindowDrawList()->AddRectFilledMultiColor(r_min, r_max, col_from, col_to, col_to, col_from); } - if (extruder_idx < 16 && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); + if (extruder_idx < int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT) && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); } // ORCA: Remap filaments section (Border only, Title in border). // Styled as a panel for visual grouping. diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index e5448c2dcb..b244a68860 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -73,11 +73,10 @@ public: void data_changed(bool is_serializing) override; - // TriangleSelector::serialization/deserialization has a limit to store 19 different states. - // EXTRUDER_LIMIT + 1 states are used to storing the painting because also uncolored triangles are stored. - // When increasing EXTRUDER_LIMIT, it needs to ensure that TriangleSelector::serialization/deserialization - // will be also extended to support additional states, requiring at least one state to remain free out of 19 states. - static const constexpr size_t EXTRUDERS_LIMIT = 16; + // The paint material limit follows EnforcerBlockerType::ExtruderMax: TriangleSelector + // serialization covers the extended (17..32) range through an escape nibble. Mixed-color + // filaments occupy ordinary slots, so they draw from the same budget as physical ones. + static const constexpr size_t EXTRUDERS_LIMIT = static_cast(EnforcerBlockerType::ExtruderMax); // Endpoint colours for gradient mixed filaments, mirrored from Plater so the extruder // swatches below can be drawn as a two-tone fade instead of a single blended colour. diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 94c76d896b..92c691f696 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -998,16 +998,40 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) keyCode = keyCode- WXK_NUMPAD0+'0'; } if (keyCode >= '0' && keyCode <= '9') { - if (keyCode == '1' && !m_timer_set_color.IsRunning()) { + // The paint palette now reaches EXTRUDERS_LIMIT (mixed-color filaments share + // the same slots), so any leading digit that can start a valid two-digit + // number waits briefly for a second one. + const int digit = keyCode - '0'; + const int shortcut_max = int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT); + auto can_start_two_digit = [shortcut_max](int d) { return d > 0 && d * 10 <= shortcut_max; }; + auto select = [mmu_seg](int number) { return number > 0 && mmu_seg->on_number_key_down(number); }; + + if (m_timer_set_color.IsRunning() && m_pending_color_shortcut_tens > 0) { + const int two_digit = m_pending_color_shortcut_tens * 10 + digit; + const int pending = m_pending_color_shortcut_tens; + m_pending_color_shortcut_tens = 0; + m_timer_set_color.Stop(); + if (two_digit <= shortcut_max) { + processed = select(two_digit); + } else { + // Out of range: commit the pending digit, then treat this one as new input. + processed = select(pending); + if (can_start_two_digit(digit)) { + m_pending_color_shortcut_tens = digit; + m_timer_set_color.StartOnce(500); + processed = true; + } else { + processed = select(digit) || processed; + } + } + } + else if (can_start_two_digit(digit)) { + m_pending_color_shortcut_tens = digit; m_timer_set_color.StartOnce(500); processed = true; } - else if (keyCode < '7' && m_timer_set_color.IsRunning()) { - processed = mmu_seg->on_number_key_down(keyCode - '0'+10); - m_timer_set_color.Stop(); - } else { - processed = mmu_seg->on_number_key_down(keyCode - '0'); + processed = select(digit); } } else if (keyCode == 'F' || keyCode == 'T' || keyCode == 'S' || keyCode == 'C' || keyCode == 'H' || keyCode == 'G') { @@ -1054,11 +1078,15 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) void GLGizmosManager::on_set_color_timer(wxTimerEvent& evt) { - if (m_current == MmSegmentation) { + // No second digit arrived in time: commit the pending leading digit on its own. + if (m_current == MmSegmentation && m_pending_color_shortcut_tens > 0) { GLGizmoMmuSegmentation* mmu_seg = dynamic_cast(get_current()); - mmu_seg->on_number_key_down(1); - m_parent.set_as_dirty(); + if (mmu_seg != nullptr) { + mmu_seg->on_number_key_down(m_pending_color_shortcut_tens); + m_parent.set_as_dirty(); + } } + m_pending_color_shortcut_tens = 0; } void GLGizmosManager::update_after_undo_redo(const UndoRedo::Snapshot& snapshot) diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp index 01814521aa..157eb43dc7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp @@ -144,6 +144,8 @@ private: //When there are more than 9 colors, shortcut key coloring wxTimer m_timer_set_color; + // Leading digit of a two-digit color shortcut still waiting for its second digit. + int m_pending_color_shortcut_tens = 0; void on_set_color_timer(wxTimerEvent& evt); // key MENU_ICON_NAME, value = ImtextureID diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 2d575ab989..ef42a5e897 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests test_mutable_priority_queue.cpp test_nozzle_volume_type.cpp test_stl.cpp + test_triangle_selector.cpp test_meshboolean.cpp test_marchingsquares.cpp test_model.cpp diff --git a/tests/libslic3r/test_triangle_selector.cpp b/tests/libslic3r/test_triangle_selector.cpp new file mode 100644 index 0000000000..dfeae477b9 --- /dev/null +++ b/tests/libslic3r/test_triangle_selector.cpp @@ -0,0 +1,124 @@ +#include + +#include "libslic3r/TriangleSelector.hpp" +#include "libslic3r/TriangleMesh.hpp" + +using namespace Slic3r; + +// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned +// to a facet of its own without any splitting getting in the way. +static TriangleMesh test_mesh() { return make_sphere(5., 2 * PI / 24); } + +// Read the nibble_idx-th 4-bit group of a serialized bitstream, least significant bit first. +static int nibble_at(const std::vector &bitstream, size_t nibble_idx) +{ + int n = 0; + for (size_t bit = 0; bit < 4; ++bit) + n |= int(bitstream[nibble_idx * 4 + bit]) << bit; + return n; +} + +TEST_CASE("Every extruder state survives a serialize/deserialize round trip", "[TriangleSelector]") +{ + const TriangleMesh mesh = test_mesh(); + const int max_state = int(EnforcerBlockerType::ExtruderMax); + REQUIRE(int(mesh.its.indices.size()) >= max_state); + + TriangleSelector selector(mesh); + for (int state = 1; state <= max_state; ++state) + selector.set_facet(state - 1, EnforcerBlockerType(state)); + + TriangleSelector restored(mesh); + restored.deserialize(selector.serialize()); + + for (int state = 1; state <= max_state; ++state) { + INFO("Extruder " << state); + REQUIRE(restored.has_facets(EnforcerBlockerType(state))); + REQUIRE(restored.num_facets(EnforcerBlockerType(state)) == 1); + } +} + +TEST_CASE("Serialized data reports the extruder states it uses", "[TriangleSelector]") +{ + const TriangleMesh mesh = test_mesh(); + TriangleSelector selector(mesh); + selector.set_facet(0, EnforcerBlockerType::Extruder16); + selector.set_facet(1, EnforcerBlockerType::Extruder32); + + const TriangleSelector::TriangleSplittingData data = selector.serialize(); + + REQUIRE(data.used_states.size() == size_t(EnforcerBlockerType::ExtruderMax) + 1); + REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder16)]); + REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder32)]); + REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder17)]); + + SECTION("used_states recomputed from the bitstream agrees") { + TriangleSelector::TriangleSplittingData recomputed = data; + recomputed.reset_used_states(); + recomputed.update_used_states(0); + REQUIRE(recomputed.used_states == data.used_states); + } + + SECTION("has_facets on the raw data agrees") { + REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder32)); + REQUIRE_FALSE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder17)); + } +} + +// States 3..17 must keep the pre-existing encoding ("11" prefix plus one nibble of state-3) so +// projects written by older builds stay readable and newly written ones stay readable by them. +TEST_CASE("Extruder states up to 17 keep the single-nibble encoding", "[TriangleSelector]") +{ + const int state = GENERATE(3, 8, 16, 17); + + TriangleSelector selector(test_mesh()); + selector.set_facet(0, EnforcerBlockerType(state)); + const std::vector bitstream = selector.serialize().bitstream; + + INFO("Extruder " << state); + // Two nibbles: the "11"-prefixed leaf code, then the state itself. + REQUIRE(bitstream.size() == 8); + REQUIRE(nibble_at(bitstream, 0) == 0b1100); + REQUIRE(nibble_at(bitstream, 1) == state - 3); +} + +// States 18 and above set the state nibble to 0b1111 and carry (state-18) in one more nibble. +TEST_CASE("Extruder states above 17 are encoded in a second nibble", "[TriangleSelector]") +{ + const int state = GENERATE(18, 25, 32); + + TriangleSelector selector(test_mesh()); + selector.set_facet(0, EnforcerBlockerType(state)); + const std::vector bitstream = selector.serialize().bitstream; + + INFO("Extruder " << state); + REQUIRE(bitstream.size() == 12); + REQUIRE(nibble_at(bitstream, 0) == 0b1100); + REQUIRE(nibble_at(bitstream, 1) == 0b1111); + REQUIRE(nibble_at(bitstream, 2) == state - 18); +} + +// Model.cpp writes these hex strings into the 3MF for colored mesh imports; the selector must +// decode exactly the states that table assigns to them. +TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSelector]") +{ + struct Case { const char *hex; int state; }; + const auto c = GENERATE(values({ + {"8", 2}, {"0C", 3}, {"DC", 16}, {"EC", 17}, {"0FC", 18}, {"EFC", 32}, + })); + + // get_triangle_as_string emits the nibbles most significant first, so read the hex backwards. + std::vector bitstream; + for (auto it = std::string(c.hex).rbegin(); it != std::string(c.hex).rend(); ++it) { + const int nibble = *it >= 'A' ? (*it - 'A' + 10) : (*it - '0'); + for (int bit = 0; bit < 4; ++bit) + bitstream.push_back((nibble >> bit) & 1); + } + + TriangleSelector::TriangleSplittingData data; + data.triangles_to_split.emplace_back(0, 0); + data.bitstream = bitstream; + + INFO("Hex " << c.hex << " -> extruder " << c.state); + REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state))); +} From 3c37bf9ca48957152816bb8dc407cbafaf3765d1 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Fri, 14 Aug 2026 11:52:47 -0300 Subject: [PATCH 084/138] 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); + } +} From 9d733e50f9bbcbee78180f65c8dda00e7703a441 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 12:55:14 +0800 Subject: [PATCH 085/138] Fix prime tower and by-object brim with mixed filaments --- src/libslic3r/Print.cpp | 24 +++++++++++++++++++----- src/libslic3r/PrintConfig.cpp | 11 ++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 33389d27c6..2bc4e9ee2a 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2585,6 +2585,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) std::vector::const_iterator print_object_instance_sequential_active; std::vector>> layers_to_print = GCode::collect_layers_to_print(*this); std::vector printExtruders; + // Per-object first-layer mixed-slot resolutions for the by-object remap below + // (BBS reads them from m_sequential_print_data->object_tool_ordering_map). + std::map> seq_mixed_resolution; // Cleared on every process so a print-sequence or selector-mode change can never leave // stale object pointers behind; repopulated below only by the sequential selector path. m_sequential_dynamic_orderings.clear(); @@ -2687,6 +2690,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } else { tool_ordering = ToolOrdering(*print_object, initial_extruder_id); tool_ordering.sort_and_build_data(*print_object, initial_extruder_id); + if (!tool_ordering.layer_tools().empty()) + seq_mixed_resolution[print_object->id()] = tool_ordering.layer_tools().front().mixed_filament_resolution; } if ((initial_extruder_id = tool_ordering.first_extruder()) != static_cast(-1)) { append(printExtruders, tool_ordering.tools_for_layer(layers_to_print.front().first).extruders); @@ -2722,14 +2727,23 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) // Resolve mixed filament virtual slots to physical components so brim // extruder matching works correctly (mixed slot IDs are not present // in printExtruders after ToolOrdering::resolve_mixed_filaments). - if (m_config.print_sequence != PrintSequence::ByObject && !tool_ordering.layer_tools().empty()) { - const LayerTools &first_lt = tool_ordering.layer_tools().front(); + { + const LayerTools *first_lt = nullptr; + if (m_config.print_sequence != PrintSequence::ByObject && !tool_ordering.layer_tools().empty()) + first_lt = &tool_ordering.layer_tools().front(); for (auto &[obj_id, ext_1based] : objectExtruderMap) { if (ext_1based == 0) continue; - auto it = first_lt.mixed_filament_resolution.find(ext_1based - 1); - if (it != first_lt.mixed_filament_resolution.end()) - ext_1based = it->second + 1; + const std::map *resolution = nullptr; + if (first_lt) + resolution = &first_lt->mixed_filament_resolution; + else if (auto obj_it = seq_mixed_resolution.find(obj_id); obj_it != seq_mixed_resolution.end()) + resolution = &obj_it->second; + if (resolution) { + auto it = resolution->find(ext_1based - 1); + if (it != resolution->end()) + ext_1based = it->second + 1; + } } } std::vector> objPrintVec; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 43559a3120..f2072c7860 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -2,6 +2,7 @@ #include "PrintConfigConstants.hpp" #include "ClipperUtils.hpp" #include "Config.hpp" +#include "FilamentMixer.hpp" #include "MaterialType.hpp" #include "I18N.hpp" #include "format.hpp" @@ -9669,7 +9670,15 @@ t_config_option_keys DynamicPrintConfig::normalize_fdm_2(int num_objects, int us ConfigOptionBool *enable_wrapping_opt = this->option("enable_wrapping_detection"); bool enable_wrapping = enable_wrapping_opt != nullptr && enable_wrapping_opt->value; - if (!is_smooth_timelapse && !enable_wrapping && (used_filaments == 1 || (ps_opt->value == PrintSequence::ByObject && num_objects > 1))) { + bool has_mixed_filament = false; + { + auto *mixed_opt = this->option("filament_is_mixed"); + if (mixed_opt) + has_mixed_filament = has_any_mixed_filament(mixed_opt->values); + } + if (!is_smooth_timelapse && !enable_wrapping + && ( (used_filaments == 1 && !has_mixed_filament) + || (ps_opt->value == PrintSequence::ByObject && num_objects > 1))) { if (ept_opt->value) { ept_opt->value = false; changed_keys.push_back("enable_prime_tower"); From fcdfcae427692ad2d00b1c1048acb3001a90b554 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 13:05:36 +0800 Subject: [PATCH 086/138] Port mixed filament engine fixes from BambuStudio --- src/libslic3r/FilamentMixer.cpp | 275 +++++++++++++++++++++++++ src/libslic3r/FilamentMixer.hpp | 20 ++ src/libslic3r/Format/bbs_3mf.cpp | 42 ++++ src/libslic3r/Format/bbs_3mf.hpp | 14 ++ src/libslic3r/GCode.cpp | 6 +- src/libslic3r/GCode/GCodeProcessor.cpp | 1 + src/libslic3r/GCode/GCodeProcessor.hpp | 4 + src/libslic3r/GCode/ToolOrdering.cpp | 92 ++++++++- src/libslic3r/GCode/ToolOrdering.hpp | 4 + src/libslic3r/PresetBundle.cpp | 3 + src/libslic3r/Print.cpp | 67 +++--- src/libslic3r/Print.hpp | 6 + src/libslic3r/PrintConfig.cpp | 17 ++ src/slic3r/GUI/PartPlate.cpp | 32 +++ 14 files changed, 552 insertions(+), 31 deletions(-) diff --git a/src/libslic3r/FilamentMixer.cpp b/src/libslic3r/FilamentMixer.cpp index 6514e6d3d2..66640498e6 100644 --- a/src/libslic3r/FilamentMixer.cpp +++ b/src/libslic3r/FilamentMixer.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -551,4 +552,278 @@ void expand_mixed_slots_in_unprintables( } } +void sanitize_mixed_gradient_curve_array(std::vector& vals) +{ + for (size_t i = 0; i < vals.size(); ++i) { + if (vals[i].empty()) + continue; + // parse_gradient_curve returns empty for both "empty input" and "<2 valid points"; + // we already skipped empty, so an empty result means a corrupted single-point slot. + if (parse_gradient_curve(vals[i]).empty()) { + BOOST_LOG_TRIVIAL(warning) << "sanitize_mixed_gradient_curve_array: slot " + << i << " curve \"" << vals[i] + << "\" has fewer than 2 valid points; clearing to linear"; + vals[i].clear(); + } + } +} + +bool try_parse_mixed_components_strict(const std::string &str, + std::vector &components, + std::string &err) +{ + components.clear(); + if (str.empty()) { + err = "empty component list"; + return false; + } + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + if (token.empty()) { + err = "empty component index"; + return false; + } + try { + const long val = std::stol(token); + if (val < 1) { + err = "component index must be >= 1 (got " + token + ")"; + return false; + } + components.push_back(static_cast(val)); + } catch (...) { + err = "invalid component index \"" + token + "\""; + return false; + } + } + if (components.size() < 2) { + err = "at least 2 components required (got " + std::to_string(components.size()) + ")"; + return false; + } + std::set seen; + for (unsigned int c : components) { + if (!seen.insert(c).second) { + err = "duplicate component index " + std::to_string(c); + return false; + } + } + return true; +} + +bool try_parse_mixed_ratios_strict(const std::string &str, + size_t n_components, + std::string &err) +{ + if (str.empty()) + return true; + + CNumericLocalesSetter c_locale_setter; + std::vector ratios; + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + if (token.empty()) { + err = "empty ratio value"; + return false; + } + try { + const double val = std::stod(token); + if (!(val > 0.0)) { + err = "ratio must be positive (got " + token + ")"; + return false; + } + ratios.push_back(val); + } catch (...) { + err = "invalid ratio \"" + token + "\""; + return false; + } + } + if (ratios.size() != n_components) { + err = "expected " + std::to_string(n_components) + " ratio(s), got " + + std::to_string(ratios.size()); + return false; + } + return true; +} + +bool validate_gradient_range_strict(const std::string &str, std::string &err) +{ + if (str.empty()) + return true; + + CNumericLocalesSetter c_locale_setter; + float v0 = 0.f, v1 = 0.f; + if (std::sscanf(str.c_str(), "%f,%f", &v0, &v1) != 2) { + err = "expected two comma-separated floats, e.g. \"0.10,0.90\""; + return false; + } + if (!(v0 > 0.f && v0 < 1.f && v1 > 0.f && v1 < 1.f)) { + err = "start and end ratios must be in (0, 1)"; + return false; + } + return true; +} + +static void append_error(std::map &errors, + const std::string &key, + const std::string &msg) +{ + auto it = errors.find(key); + if (it == errors.end()) + errors.emplace(key, msg); + else + it->second += "; " + msg; +} + +static bool has_mixed_sub_params_specified( + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags) +{ + for (const std::string &s : comp_strs) + if (!s.empty()) return true; + for (const std::string &s : ratio_strs) + if (!s.empty()) return true; + for (unsigned char g : gradient_flags) + if (g) return true; + return false; +} + +static bool mixed_string_array_was_specified(const std::vector &vals) +{ + for (const std::string &s : vals) + if (!s.empty()) + return true; + return false; +} + +static bool mixed_bool_array_was_specified(const std::vector &vals) +{ + for (unsigned char v : vals) + if (v) + return true; + return false; +} + +static void check_mixed_array_size_required(std::map &errors, + const std::string &opt_key, + size_t actual_size, + size_t expected_size) +{ + if (actual_size != expected_size) { + append_error(errors, opt_key, + "array size " + std::to_string(actual_size) + + " does not match filament slot count " + std::to_string(expected_size)); + } +} + +std::map validate_mixed_filament_params( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags, + const std::vector &gradient_range_strs, + const std::vector &gradient_curve_strs) +{ + std::map errors; + + if (has_mixed_sub_params_specified(comp_strs, ratio_strs, gradient_flags) + && !has_any_mixed_filament(is_mixed)) { + append_error(errors, "filament_is_mixed", + "must be set when mixed filament parameters are specified"); + return errors; + } + + if (!has_any_mixed_filament(is_mixed)) + return errors; + + const size_t slot_count = is_mixed.size(); + + // Rule 1: mixed filament model → components & ratios arrays must cover every slot. + check_mixed_array_size_required(errors, "filament_mixed_components", comp_strs.size(), slot_count); + check_mixed_array_size_required(errors, "filament_mixed_sublayer_ratios", ratio_strs.size(), slot_count); + + // Rule 2: gradient passed (any slot true) → gradient & range arrays must cover every slot. + const bool gradient_specified = mixed_bool_array_was_specified(gradient_flags); + if (gradient_specified) { + check_mixed_array_size_required(errors, "filament_mixed_gradient", gradient_flags.size(), slot_count); + check_mixed_array_size_required(errors, "filament_mixed_gradient_range", gradient_range_strs.size(), slot_count); + } + + // Rule 3: curve passed (any non-empty entry) → curve array must cover every slot. + const bool curve_specified = mixed_string_array_was_specified(gradient_curve_strs); + if (curve_specified) + check_mixed_array_size_required(errors, "filament_mixed_gradient_curve", gradient_curve_strs.size(), slot_count); + + size_t num_physical = 0; + for (unsigned char v : is_mixed) + if (!v) ++num_physical; + + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + + const std::string slot = "slot " + std::to_string(i + 1); + const std::string comp_str = i < comp_strs.size() ? comp_strs[i] : ""; + + std::vector components; + std::string comp_err; + if (!try_parse_mixed_components_strict(comp_str, components, comp_err)) { + append_error(errors, "filament_mixed_components", slot + ": " + comp_err); + continue; + } + + for (unsigned int c : components) { + if (c > num_physical) { + append_error(errors, "filament_mixed_components", + slot + ": component " + std::to_string(c) + + " out of range (max physical filament index is " + + std::to_string(num_physical) + ")"); + break; + } + if (c == i + 1) { + append_error(errors, "filament_mixed_components", + slot + ": cannot reference itself as a component"); + break; + } + const size_t idx0 = static_cast(c - 1); + if (idx0 < is_mixed.size() && is_mixed[idx0]) { + append_error(errors, "filament_mixed_components", + slot + ": component " + std::to_string(c) + + " references a mixed filament slot"); + break; + } + } + + std::string ratio_err; + const std::string ratio_str = i < ratio_strs.size() ? ratio_strs[i] : ""; + if (!try_parse_mixed_ratios_strict(ratio_str, components.size(), ratio_err)) + append_error(errors, "filament_mixed_sublayer_ratios", slot + ": " + ratio_err); + + const bool gradient_on = i < gradient_flags.size() && gradient_flags[i]; + if (gradient_on) { + if (components.size() != 2) { + append_error(errors, "filament_mixed_gradient", + slot + ": gradient requires exactly 2 components"); + } + + if (gradient_specified) { + std::string range_err; + const std::string range_str = i < gradient_range_strs.size() ? gradient_range_strs[i] : ""; + if (!validate_gradient_range_strict(range_str, range_err)) + append_error(errors, "filament_mixed_gradient_range", slot + ": " + range_err); + } + + if (curve_specified) { + const std::string curve_str = i < gradient_curve_strs.size() ? gradient_curve_strs[i] : ""; + if (!curve_str.empty() && parse_gradient_curve(curve_str).empty()) + append_error(errors, "filament_mixed_gradient_curve", + slot + ": invalid curve (need at least 2 valid control points)"); + } + } + } + + return errors; +} + } // namespace Slic3r diff --git a/src/libslic3r/FilamentMixer.hpp b/src/libslic3r/FilamentMixer.hpp index 2ca4182066..81ddd29e46 100644 --- a/src/libslic3r/FilamentMixer.hpp +++ b/src/libslic3r/FilamentMixer.hpp @@ -2,6 +2,7 @@ #define SLIC3R_FILAMENT_MIXER_HPP #include +#include #include #include #include @@ -139,6 +140,25 @@ void expand_mixed_slots_in_unprintables( const std::vector &is_mixed, const std::vector &comp_strs); +// Clear any non-empty gradient-curve slot that parses to fewer than 2 control points. +// Heals per-slot arrays corrupted by the legacy "|" separator collision between +// PresetBundle::export_selections / load_selections (which used "|" as the inter-slot +// delimiter) and serialize_gradient_curve / parse_gradient_curve (which use "|" as the +// intra-slot control-point delimiter). Such a round-trip splits a multi-point curve +// across adjacent slots, leaving single-point entries that fail MakerWorld's strict +// "curve needs >= 2 points" check. Clearing them falls back to the linear range. +void sanitize_mixed_gradient_curve_array(std::vector& vals); + +// Validate mixed-color (混色) parameters. Returns error messages keyed by option name. +// Slot details are included in the message text (1-based slot index). +std::map validate_mixed_filament_params( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags, + const std::vector &gradient_range_strs, + const std::vector &gradient_curve_strs); + } // namespace Slic3r #endif // SLIC3R_FILAMENT_MIXER_HPP diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 3000adb441..5391f9ba3d 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -4,6 +4,7 @@ #include "../Preset.hpp" #include "../Utils.hpp" #include "../LocalesUtils.hpp" +#include "../FilamentMixer.hpp" #include "../GCode.hpp" #include "../Geometry.hpp" #include "../GCode/ThumbnailData.hpp" @@ -246,6 +247,8 @@ static constexpr const char* BUILD_TAG = "build"; static constexpr const char* ITEM_TAG = "item"; static constexpr const char* METADATA_TAG = "metadata"; static constexpr const char* FILAMENT_TAG = "filament"; +static constexpr const char* MIXED_FILAMENT_TAG = "mixed_filament"; +static constexpr const char* MIXED_FILAMENT_COMPONENTS_TAG = "components"; static constexpr const char* SLICE_WARNING_TAG = "warning"; static constexpr const char* WARNING_MSG_TAG = "msg"; static constexpr const char *FILAMENT_ID_TAG = "id"; @@ -1315,6 +1318,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool _handle_end_config_metadata(); bool _handle_start_config_filament(const char** attributes, unsigned int num_attributes); + bool _handle_start_config_mixed_filament(const char** attributes, unsigned int num_attributes); bool _handle_end_config_filament(); bool _handle_start_config_warning(const char** attributes, unsigned int num_attributes); @@ -2694,6 +2698,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return; } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", load project config file successfully from %1%\n") %dest_file; + + // Heal any gradient-curve slots corrupted by the legacy "|" separator collision + // (see FilamentMixer::sanitize_mixed_gradient_curve_array). The 3MF JSON itself + // is safe (";" + C-style escape), but older projects saved through the buggy + // export_selections/load_selections path may already carry single-point entries + // that fail MakerWorld's "curve needs >= 2 points" check. + if (auto* curve_opt = config.option("filament_mixed_gradient_curve")) + Slic3r::sanitize_mixed_gradient_curve_array(curve_opt->values); } } @@ -3511,6 +3523,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) res = _handle_start_config_plater_instance(attributes, num_attributes); else if (::strcmp(FILAMENT_TAG, name) == 0) res = _handle_start_config_filament(attributes, num_attributes); + else if (::strcmp(MIXED_FILAMENT_TAG, name) == 0) + res = _handle_start_config_mixed_filament(attributes, num_attributes); else if (::strcmp(SLICE_WARNING_TAG, name) == 0) res = _handle_start_config_warning(attributes, num_attributes); else if (::strcmp(NOZZLE_TAG, name) == 0) @@ -4684,6 +4698,23 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return true; } + bool _BBS_3MF_Importer::_handle_start_config_mixed_filament(const char** attributes, unsigned int num_attributes) + { + if (m_curr_plater) { + std::string id = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_ID_TAG); + std::string type = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_TYPE_TAG); + std::string color = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_COLOR_TAG); + std::string components = bbs_get_attribute_value_string(attributes, num_attributes, MIXED_FILAMENT_COMPONENTS_TAG); + PlateMixedFilamentInfo mixed_info; + mixed_info.id = atoi(id.c_str()); + mixed_info.type = type; + mixed_info.color = color; + mixed_info.components = components; + m_curr_plater->mixed_filaments_info.push_back(mixed_info); + } + return true; + } + bool _BBS_3MF_Importer::_handle_end_config_filament() { // do nothing @@ -8488,6 +8519,17 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) << FILAMENT_USED_FOR_SUPPORT << "=\"" << std::boolalpha << it->used_for_support << "\"/>\n"; } + // Mixed (virtual) filaments used by this plate. These are resolved to physical + // components before g-code statistics, so they are not present in the + // list above and are recorded separately here. + for (auto it = plate_data->mixed_filaments_info.begin(); it != plate_data->mixed_filaments_info.end(); it++) + { + stream << " <" << MIXED_FILAMENT_TAG << " " << FILAMENT_ID_TAG << "=\"" << std::to_string(it->id) << "\" " + << FILAMENT_TYPE_TAG << "=\"" << it->type << "\" " + << FILAMENT_COLOR_TAG << "=\"" << it->color << "\" " + << MIXED_FILAMENT_COMPONENTS_TAG << "=\"" << it->components << "\"/>\n"; + } + for (auto it = plate_data->warnings.begin(); it != plate_data->warnings.end(); it++) { stream << " <" << SLICE_WARNING_TAG << " msg=\"" << it->msg << "\" level=\"" << std::to_string(it->level) << "\" error_code =\"" << it->error_code << "\" />\n"; } diff --git a/src/libslic3r/Format/bbs_3mf.hpp b/src/libslic3r/Format/bbs_3mf.hpp index 9c697a14fc..7f5bb8c78d 100644 --- a/src/libslic3r/Format/bbs_3mf.hpp +++ b/src/libslic3r/Format/bbs_3mf.hpp @@ -48,6 +48,18 @@ public: }; +// Mixed (virtual) filament used by a plate. Mixed filaments are virtual slots that get +// resolved to their physical components before g-code statistics, so they never appear in +// slice_filaments_info. They are recorded here separately so a plate's mixed-color usage +// can be recovered from slice_info. +struct PlateMixedFilamentInfo +{ + int id{0}; // 1-based virtual filament slot id + std::string type; + std::string color; // blended display color, "#RRGGBB" + std::string components; // 1-based physical component ids, comma separated, e.g. "1,3" +}; + //BBS: define plate data list related structures struct PlateData { @@ -89,6 +101,8 @@ struct PlateData std::string first_layer_time; std::string plate_name; std::vector slice_filaments_info; + // Mixed (virtual) filaments used by this plate; empty when no mixed filament is used. + std::vector mixed_filaments_info; std::vector skipped_objects; DynamicPrintConfig config; bool is_support_used {false}; diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index f42591b6be..a98a991f03 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -4195,6 +4195,8 @@ void GCode::export_layer_filaments(GCodeProcessorResult* result) } } + result->used_mixed_filaments = m_print->get_slice_used_mixed_filaments(); + result->optimal_assignment.clear(); result->optimal_assignment.reserve(filament_map.size()); for (int nozzle_id : filament_map) @@ -6859,7 +6861,7 @@ LayerResult GCode::process_layer( if (instance_to_print.object_by_extruder.support && !instance_to_print.object_by_extruder.support->empty()) { if (use_per_volume) { m_nominal_z = obj_sub_z; - gcode += m_writer.travel_to_z(obj_sub_z, "restore Z for support"); + m_need_change_layer_lift_z = true; } ExtrusionRole support_role = instance_to_print.object_by_extruder.support_extrusion_role; gcode += this->extrude_support(*instance_to_print.object_by_extruder.support, support_role); @@ -6897,7 +6899,7 @@ LayerResult GCode::process_layer( if (!layer_tools.mixed_sub_layer_groups.empty()) { m_writer.add_object_end_labels(gcode); m_nominal_z = print_z; - gcode += m_writer.travel_to_z(print_z, "restore Z after sublayers"); + m_need_change_layer_lift_z = true; } } diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index b13273d696..4f3f95f297 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2543,6 +2543,7 @@ void GCodeProcessorResult::reset() { spiral_vase_mode = false; layer_filaments.clear(); filament_change_sequence.clear(); + used_mixed_filaments.clear(); nozzle_change_sequence.clear(); optimal_assignment.clear(); filament_change_count_map.clear(); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 505f7c06a0..0f211f133e 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -306,6 +306,9 @@ class Print; std::unordered_map, std::vector>,FilamentSequenceHash> layer_filaments; std::vector nozzle_change_sequence; std::vector filament_change_sequence; + // 0-based mixed (virtual) filament slots actually used on this plate. + // Recorded before resolve_mixed_filaments expands them to physical components. + std::vector used_mixed_filaments; std::vector optimal_assignment; // first key stores `from` filament, second keys stores the `to` filament std::map, int > filament_change_count_map; @@ -357,6 +360,7 @@ class Print; printer_extruder_id = other.printer_extruder_id; layer_filaments = other.layer_filaments; filament_change_sequence = other.filament_change_sequence; + used_mixed_filaments = other.used_mixed_filaments; nozzle_change_sequence = other.nozzle_change_sequence; optimal_assignment = other.optimal_assignment; filament_change_count_map = other.filament_change_count_map; diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index ef5495a29e..e59a607d7d 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -1015,7 +1015,7 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ //FIXME this is a hack to get the ball rolling. for (LayerTools < : m_layer_tools) - lt.has_wipe_tower |= (lt.has_object && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) + lt.has_wipe_tower |= ((lt.has_object || lt.has_support) && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) || lt.print_z < object_bottom_z + EPSILON; // Test for a raft, insert additional wipe tower layer to fill in the raft separation gap. @@ -1056,6 +1056,84 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ } } + // Ensure wipe tower vertical continuity: + // + // (1) Any existing LayerTools sandwiched between two has_wipe_tower layers must itself be a + // wipe-tower layer. The LayerTools entry already exists, but it has neither object nor + // support geometry (has_object == false && has_support == false), so the marking pass + // above leaves has_wipe_tower == false. Happens e.g. when one object is fully floating + // above another and the support_top_z_distance / support_bottom_z_distance gap leaves an + // interior layer with no object and no support (e.g. B top z=20.4, A first layer z=20.8, + // the z=20.6 LayerTools entry exists but stays unmarked). + // + // (2) When two adjacent has_wipe_tower layers are farther apart than max_layer_height and no + // LayerTools entry exists between them, insert virtual wipe-tower-only layers to bridge + // the gap. Happens with raft: BambuStudio's raft contact layer can be thicker than + // max_layer_height (e.g. raft base top z=0.2, raft contact top z=0.5 — gap 0.3 > 0.28), + // and there is no LayerTools entry between those two z values. + // + // wipe_tower_partitions has already been max-propagated downward above, so partition counts + // on the filled-in / inserted layers stay consistent. + { + int first_wt_idx = -1; + int last_wt_idx = -1; + for (int i = 0; i < (int)m_layer_tools.size(); ++i) + if (m_layer_tools[i].has_wipe_tower) { + if (first_wt_idx < 0) first_wt_idx = i; + last_wt_idx = i; + } + for (int i = first_wt_idx + 1; i < last_wt_idx; ++i) { + LayerTools < = m_layer_tools[i]; + lt.has_wipe_tower = true; + // GCode::process_layer emits wipe-tower G-code inside `for (extruder_id : layer_tools.extruders)`. + // An empty extruders vector here would silently skip wipe tower output, leaving the tower + // physically floating. Seed from the nearest non-empty neighbor so the loop actually runs. + if (lt.extruders.empty()) { + unsigned int seed_extruder = 0; + bool found_seed = false; + for (int j = i - 1; j >= 0; --j) + if (!m_layer_tools[j].extruders.empty()) { + seed_extruder = m_layer_tools[j].extruders.back(); + found_seed = true; + break; + } + if (!found_seed) + for (int j = i + 1; j < (int)m_layer_tools.size(); ++j) + if (!m_layer_tools[j].extruders.empty()) { + seed_extruder = m_layer_tools[j].extruders.front(); + found_seed = true; + break; + } + if (found_seed) + lt.extruders.push_back(seed_extruder); + } + } + + // Walk adjacent has_wipe_tower pairs and split oversized gaps. Re-evaluate the same i + // after each insertion so very large gaps get split into multiple layers. + for (int i = 0; i + 1 < (int)m_layer_tools.size(); ) { + LayerTools < = m_layer_tools[i]; + LayerTools <_next = m_layer_tools[i + 1]; + if (!lt.has_wipe_tower || !lt_next.has_wipe_tower) { + ++i; + continue; + } + coordf_t gap = lt_next.print_z - lt.print_z; + if (gap <= max_layer_height + EPSILON) { + ++i; + continue; + } + LayerTools lt_new(0.5 * (lt.print_z + lt_next.print_z)); + lt_new.has_wipe_tower = true; + if (!lt_next.extruders.empty()) + lt_new.extruders.push_back(lt_next.extruders.front()); + else if (!lt.extruders.empty()) + lt_new.extruders.push_back(lt.extruders.back()); + lt_new.wipe_tower_partitions = lt_next.wipe_tower_partitions; + m_layer_tools.insert(m_layer_tools.begin() + i + 1, lt_new); + } + } + // If the model contains empty layers (such as https://github.com/prusa3d/Slic3r/issues/1266), there might be layers // that were not marked as has_wipe_tower, even when they should have been. This produces a crash with soluble supports // and maybe other problems. We will therefore go through layer_tools and detect and fix this. @@ -2081,6 +2159,18 @@ void ToolOrdering::resolve_mixed_filaments(const PrintConfig &config) const auto &comp_strs = config.filament_mixed_components.values; const auto &ratio_strs = config.filament_mixed_sublayer_ratios.values; + // Capture mixed slots that actually appear on layers before they are expanded to + // physical components. Assigned-but-unused mixed slots never enter layer_tools. + m_used_mixed_filaments.clear(); + if (has_any_mixed_filament(is_mixed)) { + std::set used; + for (const LayerTools < : m_layer_tools) + for (unsigned int ext : lt.extruders) + if (ext < is_mixed.size() && is_mixed[ext]) + used.insert(ext); + m_used_mixed_filaments.assign(used.begin(), used.end()); + } + if (!has_any_mixed_filament(is_mixed)) return; diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index 699afa7091..4dc08c0e8b 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -290,6 +290,9 @@ public: // For a multi-material print, the printing extruders are ordered in the order they shall be primed. const std::vector& all_extruders() const { return m_all_printing_extruders; } + // 0-based mixed (virtual) slots that appeared on layers before resolve_mixed_filaments + // expanded them to physical components. + const std::vector& used_mixed_filaments() const { return m_used_mixed_filaments; } // Find LayerTools with the closest print_z. const LayerTools& tools_for_layer(coordf_t print_z) const; @@ -376,6 +379,7 @@ private: unsigned int m_last_printing_extruder = (unsigned int)-1; // All extruders, which extrude some material over m_layer_tools. std::vector m_all_printing_extruders; + std::vector m_used_mixed_filaments; const DynamicPrintConfig* m_print_full_config = nullptr; const PrintConfig* m_print_config_ptr = nullptr; diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 3e23107102..244a9e6607 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -2760,6 +2760,9 @@ static void load_mixed_filament_settings(DynamicPrintConfig &project_config, con vals = std::move(curves); } vals.resize(n_filaments, std::string{}); + // Heal legacy corruption: clear any non-empty slot that ended up with < 2 points + // (e.g. a curve split across slots by the old "|" delimiter). Falls back to linear. + Slic3r::sanitize_mixed_gradient_curve_array(vals); } } diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 2bc4e9ee2a..b50a6b9d43 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1388,6 +1388,13 @@ StringObjectException Print::validate(std::vector *warnin // #4043 if (total_copies_count > 1 && m_config.print_sequence != PrintSequence::ByObject) return {L("Please select \"By object\" print sequence to print multiple objects in spiral vase mode."), nullptr, "spiral_mode"}; + // A mixed (virtual) filament always resolves to multiple physical components, which + // spiral vase cannot print. + const auto &is_mixed = m_config.filament_is_mixed.values; + for (const PrintObject *object : m_objects) + for (unsigned int ext : object->object_extruders()) + if (ext < is_mixed.size() && is_mixed[ext]) + return {L("Spiral (vase) mode does not work when an object contains more than one material."), nullptr, "spiral_mode"}; assert(m_objects.size() == 1); const auto all_regions = m_objects.front()->all_regions(); if (all_regions.size() > 1) { @@ -2595,6 +2602,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) // Order object instances for sequential print. print_object_instances_ordering = sort_object_instances_by_model_order(*this); std::vector first_layer_used_filaments; + std::vector used_mixed_filaments; std::vector> all_filaments; for (print_object_instance_sequential_active = print_object_instances_ordering.begin(); print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); @@ -2604,10 +2612,14 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (idx == 0) first_layer_used_filaments.insert(first_layer_used_filaments.end(), layer_filament.begin(), layer_filament.end()); } + used_mixed_filaments.insert(used_mixed_filaments.end(), + tool_ordering.used_mixed_filaments().begin(), tool_ordering.used_mixed_filaments().end()); } sort_remove_duplicates(first_layer_used_filaments); + sort_remove_duplicates(used_mixed_filaments); auto used_filaments = collect_sorted_used_filaments(all_filaments); this->set_slice_used_filaments(first_layer_used_filaments,used_filaments); + this->set_slice_used_mixed_filaments(used_mixed_filaments); auto physical_unprintables = this->get_physical_unprintable_filaments(used_filaments); auto geometric_unprintables = this->get_geometric_unprintable_filaments(); @@ -2717,6 +2729,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) first_layer_used_filaments = tool_ordering.layer_tools().front().extruders; this->set_slice_used_filaments(first_layer_used_filaments, tool_ordering.all_extruders()); + this->set_slice_used_mixed_filaments(tool_ordering.used_mixed_filaments()); has_wipe_tower = this->has_wipe_tower() && tool_ordering.has_wipe_tower(); initial_extruder_id = tool_ordering.first_extruder(); print_object_instances_ordering = chain_print_object_instances(*this); @@ -4034,38 +4047,36 @@ void Print::_make_wipe_tower() return; // Check whether there are any layers in m_tool_ordering, which are marked with has_wipe_tower, - // they print neither object, nor support. These layers are above the raft and below the object, and they - // shall be added to the support layers to be printed. - // see https://github.com/prusa3d/PrusaSlicer/issues/607 + // they print neither object, nor support. Each such layer needs a virtual support layer + // counterpart in m_objects.front() so that GCode::collect_layers_to_print picks it up and the + // wipe tower G-code is actually emitted for that z. Such layers appear in two scenarios: + // - above the raft, between raft top and the first real object layer + // (see https://github.com/prusa3d/PrusaSlicer/issues/607); + // - between two real wipe-tower layers, when one object is fully floating above another and + // the support_top_z_distance / support_bottom_z_distance gap leaves interior z values with + // neither object nor support (continuity fill in ToolOrdering::fill_wipe_tower_partitions). + // The previous implementation only handled the first contiguous run starting at the first + // virtual layer, which made the second scenario silently produce empty wipe-tower layers. { - size_t idx_begin = size_t(-1); - size_t idx_end = m_wipe_tower_data.tool_ordering.layer_tools().size(); - // Find the first wipe tower layer, which does not have a counterpart in an object or a support layer. + auto &support_layers = m_objects.front()->support_layers(); + auto it_layer = support_layers.begin(); + const size_t idx_end = m_wipe_tower_data.tool_ordering.layer_tools().size(); for (size_t i = 0; i < idx_end; ++ i) { - const LayerTools < = m_wipe_tower_data.tool_ordering.layer_tools()[i]; - if (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support) { - idx_begin = i; - break; - } - } - if (idx_begin != size_t(-1)) { - // Find the position in m_objects.first()->support_layers to insert these new support layers. - double wipe_tower_new_layer_print_z_first = m_wipe_tower_data.tool_ordering.layer_tools()[idx_begin].print_z; - auto it_layer = m_objects.front()->support_layers().begin(); - auto it_end = m_objects.front()->support_layers().end(); - for (; it_layer != it_end && (*it_layer)->print_z - EPSILON < wipe_tower_new_layer_print_z_first; ++ it_layer); - // Find the stopper of the sequence of wipe tower layers, which do not have a counterpart in an object or a support layer. - for (size_t i = idx_begin; i < idx_end; ++ i) { - LayerTools < = const_cast(m_wipe_tower_data.tool_ordering.layer_tools()[i]); - if (! (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support)) - break; - lt.has_support = true; - // Insert the new support layer. - double height = lt.print_z - (i == 0 ? 0. : m_wipe_tower_data.tool_ordering.layer_tools()[i-1].print_z); - //FIXME the support layer ID is set to -1, as Vojtech hopes it is not being used anyway. - it_layer = m_objects.front()->insert_support_layer(it_layer, -1, 0, height, lt.print_z, lt.print_z - 0.5 * height); + LayerTools < = const_cast(m_wipe_tower_data.tool_ordering.layer_tools()[i]); + if (! (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support)) + continue; + while (it_layer != support_layers.end() && (*it_layer)->print_z + EPSILON < lt.print_z) ++ it_layer; + if (it_layer != support_layers.end() && std::abs((*it_layer)->print_z - lt.print_z) < EPSILON) { + lt.has_support = true; + ++ it_layer; + continue; } + lt.has_support = true; + double height = lt.print_z - (i == 0 ? 0. : m_wipe_tower_data.tool_ordering.layer_tools()[i-1].print_z); + //FIXME the support layer ID is set to -1, as Vojtech hopes it is not being used anyway. + it_layer = m_objects.front()->insert_support_layer(it_layer, -1, 0, height, lt.print_z, lt.print_z - 0.5 * height); + ++ it_layer; } } this->throw_if_canceled(); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index efee489c57..b1d38a3ed3 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -1088,6 +1088,10 @@ public: m_slice_used_filaments = used_filaments; } std::vector get_slice_used_filaments(bool first_layer) const { return first_layer ? m_slice_used_filaments_first_layer : m_slice_used_filaments;} + void set_slice_used_mixed_filaments(const std::vector &used_mixed_filaments) { + m_slice_used_mixed_filaments = used_mixed_filaments; + } + const std::vector& get_slice_used_mixed_filaments() const { return m_slice_used_mixed_filaments; } /** * @brief Determines the unprintable filaments for each extruder based on its physical attributes @@ -1355,6 +1359,8 @@ private: std::vector m_slice_used_filaments; std::vector m_slice_used_filaments_first_layer; + // 0-based mixed (virtual) filament slots actually used on this plate. + std::vector m_slice_used_mixed_filaments; //BBS: plate's origin Vec3d m_origin {0, 0, 0}; diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index f2072c7860..2a4eb8d7a7 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -11826,6 +11826,23 @@ std::map validate(const FullPrintConfig &cfg, bool und } } + // Mixed-color (混色) parameter validation. + { + const auto &is_mixed = cfg.filament_is_mixed.values; + const auto &comp_strs = cfg.filament_mixed_components.values; + const auto &ratio_strs = cfg.filament_mixed_sublayer_ratios.values; + const auto &gradient_flags = cfg.filament_mixed_gradient.values; + const auto &range_strs = cfg.filament_mixed_gradient_range.values; + const auto &curve_strs = cfg.filament_mixed_gradient_curve.values; + + std::map mixed_errors = validate_mixed_filament_params( + is_mixed, comp_strs, ratio_strs, gradient_flags, + range_strs, curve_strs); + for (const auto &kv : mixed_errors) + if (error_message.find(kv.first) == error_message.end()) + error_message.emplace(kv.first, kv.second); + } + // The configuration is valid. return error_message; } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 1bbe1fc034..74f13d94df 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -6492,6 +6492,31 @@ int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool w } //parse filament info plate_data_item->parse_filament_info(m_plate_list[i]->get_slice_result()); + + // Record mixed (virtual) filaments actually used on this plate. + // Source is ToolOrdering::used_mixed_filaments (slots that appeared in + // layer tools before resolve), persisted on GCodeProcessorResult / Print — + // not print->extruders() which only reflects assignment. + { + std::vector used_mixed; + if (auto *slice_result = m_plate_list[i]->get_slice_result()) + used_mixed = slice_result->used_mixed_filaments; + if (used_mixed.empty() && print) + used_mixed = print->get_slice_used_mixed_filaments(); + if (!used_mixed.empty() && print) { + const auto &fila_types = print->config().filament_type.values; + const auto &fila_colors = print->config().filament_colour.values; + const auto &fila_comps = print->config().filament_mixed_components.values; + for (unsigned int fid : used_mixed) { + PlateMixedFilamentInfo mixed_info; + mixed_info.id = (int) fid + 1; + if (fid < fila_types.size()) mixed_info.type = fila_types[fid]; + if (fid < fila_colors.size()) mixed_info.color = fila_colors[fid]; + if (fid < fila_comps.size()) mixed_info.components = fila_comps[fid]; + plate_data_item->mixed_filaments_info.push_back(mixed_info); + } + } + } } else { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "slice result = " << m_plate_list[i]->get_slice_result() << ", result valid = " << m_plate_list[i]->is_slice_result_valid(); @@ -6558,6 +6583,13 @@ int PartPlateList::load_from_3mf_structure(PlateDataPtrs& plate_data_list, int f m_plate_list[index]->slice_filaments_info = plate_data_list[i]->slice_filaments_info; gcode_result->warnings = plate_data_list[i]->warnings; gcode_result->filament_maps = plate_data_list[i]->filament_maps; + gcode_result->used_mixed_filaments.clear(); + for (const auto &mixed_info : plate_data_list[i]->mixed_filaments_info) { + if (mixed_info.id > 0) + gcode_result->used_mixed_filaments.push_back(static_cast(mixed_info.id - 1)); + } + if (Print *print = dynamic_cast(fff_print)) + print->set_slice_used_mixed_filaments(gcode_result->used_mixed_filaments); // Reconstruct the device-side nozzle grouping from the loaded 3mf so // the monitor/preview can map filaments to physical nozzles. From 94a1cd6c932cc87bcd26479cad56f3c39af23644 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 13:06:36 +0800 Subject: [PATCH 087/138] Port color decompose recipe data and interpolation from BambuStudio --- .../standard_color_recipes.json | 14820 ++++++++-------- src/libslic3r/ColorDecomposeRecipe.cpp | 207 +- src/slic3r/GUI/ColorDecomposeDialog.cpp | 8 +- src/slic3r/GUI/Plater.cpp | 27 +- 4 files changed, 7625 insertions(+), 7437 deletions(-) diff --git a/resources/filament_mixing/standard_color_recipes.json b/resources/filament_mixing/standard_color_recipes.json index 280be054b2..df03b49ac3 100644 --- a/resources/filament_mixing/standard_color_recipes.json +++ b/resources/filament_mixing/standard_color_recipes.json @@ -19,11 +19,11 @@ 80 ], "measured_lab": [ - 48.12, - 36.72, - -25.69 + 48.14, + 33.87, + -25.42 ], - "measured_rgb": "#9A5B9E", + "measured_rgb": "#965E9E", "source": "measured" }, { @@ -44,12 +44,12 @@ 75 ], "measured_lab": [ - 48.038, - 34.252, - -26.762 + 48.2, + 33.11, + -25.85 ], - "measured_rgb": "#955DA0", - "source": "interpolated" + "measured_rgb": "#955F9E", + "source": "measured" }, { "mode": "CMYW", @@ -69,12 +69,12 @@ 70 ], "measured_lab": [ - 47.957, - 31.783, - -27.833 + 48.06, + 29.44, + -27.62 ], - "measured_rgb": "#905FA1", - "source": "interpolated" + "measured_rgb": "#8D61A1", + "source": "measured" }, { "mode": "CMYW", @@ -94,12 +94,12 @@ 65 ], "measured_lab": [ - 47.875, - 29.315, - -28.905 + 47.82, + 28.45, + -28.19 ], - "measured_rgb": "#8B61A3", - "source": "interpolated" + "measured_rgb": "#8A62A1", + "source": "measured" }, { "mode": "CMYW", @@ -119,12 +119,12 @@ 60 ], "measured_lab": [ - 47.793, - 26.847, - -29.977 + 47.94, + 22.08, + -30.15 ], - "measured_rgb": "#8563A4", - "source": "interpolated" + "measured_rgb": "#7D67A5", + "source": "measured" }, { "mode": "CMYW", @@ -144,12 +144,12 @@ 55 ], "measured_lab": [ - 47.712, - 24.378, - -31.048 + 47.64, + 24.63, + -30.31 ], - "measured_rgb": "#8065A6", - "source": "interpolated" + "measured_rgb": "#8164A4", + "source": "measured" }, { "mode": "CMYW", @@ -169,11 +169,11 @@ 50 ], "measured_lab": [ - 47.63, - 21.91, - -32.12 + 48.28, + 17.77, + -31.76 ], - "measured_rgb": "#7967A7", + "measured_rgb": "#746BA8", "source": "measured" }, { @@ -194,12 +194,12 @@ 45 ], "measured_lab": [ - 48.247, - 19.212, - -32.842 + 48.15, + 16.73, + -32.42 ], - "measured_rgb": "#756AAA", - "source": "interpolated" + "measured_rgb": "#706BA9", + "source": "measured" }, { "mode": "CMYW", @@ -219,12 +219,12 @@ 40 ], "measured_lab": [ - 48.863, - 16.513, - -33.563 + 48.57, + 17.11, + -32.94 ], - "measured_rgb": "#706DAD", - "source": "interpolated" + "measured_rgb": "#716CAB", + "source": "measured" }, { "mode": "CMYW", @@ -244,12 +244,12 @@ 35 ], "measured_lab": [ - 49.48, - 13.815, - -34.285 + 48.95, + 13.88, + -33.83 ], - "measured_rgb": "#6B71B0", - "source": "interpolated" + "measured_rgb": "#6A6FAE", + "source": "measured" }, { "mode": "CMYW", @@ -269,12 +269,12 @@ 30 ], "measured_lab": [ - 50.097, - 11.117, - -35.007 + 49.1, + 13.76, + -34.39 ], - "measured_rgb": "#6574B3", - "source": "interpolated" + "measured_rgb": "#6A70AF", + "source": "measured" }, { "mode": "CMYW", @@ -294,12 +294,12 @@ 25 ], "measured_lab": [ - 50.713, - 8.418, - -35.728 + 49.58, + 11.74, + -35.13 ], - "measured_rgb": "#5F77B6", - "source": "interpolated" + "measured_rgb": "#6572B1", + "source": "measured" }, { "mode": "CMYW", @@ -319,11 +319,11 @@ 20 ], "measured_lab": [ - 51.33, - 5.72, - -36.45 + 50.88, + 5.61, + -36.4 ], - "measured_rgb": "#587AB8", + "measured_rgb": "#5679B7", "source": "measured" }, { @@ -344,11 +344,11 @@ 80 ], "measured_lab": [ - 72.05, - -32.59, - 59.71 + 70.0, + -35.0, + 56.27 ], - "measured_rgb": "#96BE39", + "measured_rgb": "#8ABA3C", "source": "measured" }, { @@ -369,12 +369,12 @@ 75 ], "measured_lab": [ - 70.497, - -34.185, - 54.833 + 69.5, + -36.46, + 55.41 ], - "measured_rgb": "#8CBB41", - "source": "interpolated" + "measured_rgb": "#85B93C", + "source": "measured" }, { "mode": "CMYW", @@ -394,12 +394,12 @@ 70 ], "measured_lab": [ - 68.943, - -35.78, - 49.957 + 67.44, + -38.62, + 50.18 ], - "measured_rgb": "#82B748", - "source": "interpolated" + "measured_rgb": "#78B443", + "source": "measured" }, { "mode": "CMYW", @@ -419,12 +419,12 @@ 65 ], "measured_lab": [ - 67.39, - -37.375, - 45.08 + 66.34, + -39.72, + 47.6 ], - "measured_rgb": "#78B44E", - "source": "interpolated" + "measured_rgb": "#71B246", + "source": "measured" }, { "mode": "CMYW", @@ -444,12 +444,12 @@ 60 ], "measured_lab": [ - 65.837, - -38.97, - 40.203 + 65.4, + -40.42, + 42.8 ], - "measured_rgb": "#6DB054", - "source": "interpolated" + "measured_rgb": "#6AB04E", + "source": "measured" }, { "mode": "CMYW", @@ -469,12 +469,12 @@ 55 ], "measured_lab": [ - 64.283, - -40.565, - 35.327 + 63.51, + -42.24, + 38.44 ], - "measured_rgb": "#61AD5A", - "source": "interpolated" + "measured_rgb": "#5DAB52", + "source": "measured" }, { "mode": "CMYW", @@ -494,11 +494,11 @@ 50 ], "measured_lab": [ - 62.73, - -42.16, - 30.45 + 63.04, + -42.17, + 35.8 ], - "measured_rgb": "#53AA5F", + "measured_rgb": "#59AA56", "source": "measured" }, { @@ -519,12 +519,12 @@ 45 ], "measured_lab": [ - 62.037, - -42.202, - 26.398 + 62.21, + -43.03, + 32.64 ], - "measured_rgb": "#4DA865", - "source": "interpolated" + "measured_rgb": "#51A85A", + "source": "measured" }, { "mode": "CMYW", @@ -544,12 +544,12 @@ 40 ], "measured_lab": [ - 61.343, - -42.243, - 22.347 + 60.68, + -43.94, + 27.49 ], - "measured_rgb": "#45A66B", - "source": "interpolated" + "measured_rgb": "#44A560", + "source": "measured" }, { "mode": "CMYW", @@ -569,12 +569,12 @@ 35 ], "measured_lab": [ - 60.65, - -42.285, - 18.295 + 60.36, + -43.76, + 23.75 ], - "measured_rgb": "#3CA471", - "source": "interpolated" + "measured_rgb": "#3FA466", + "source": "measured" }, { "mode": "CMYW", @@ -594,12 +594,12 @@ 30 ], "measured_lab": [ - 59.957, - -42.327, - 14.243 + 59.06, + -44.3, + 17.42 ], - "measured_rgb": "#32A376", - "source": "interpolated" + "measured_rgb": "#2CA16E", + "source": "measured" }, { "mode": "CMYW", @@ -619,670 +619,20 @@ 25 ], "measured_lab": [ - 59.263, - -42.368, - 10.192 - ], - "measured_rgb": "#23A17B", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 58.57, - -42.41, - 6.14 - ], - "measured_rgb": "#089F81", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 76.96, - -13.8, - -20.22 - ], - "measured_rgb": "#86C7E3", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 75.9, - -13.61, - -21.202 - ], - "measured_rgb": "#81C4E1", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 74.84, - -13.42, - -22.183 - ], - "measured_rgb": "#7DC1E0", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 73.78, - -13.23, - -23.165 - ], - "measured_rgb": "#79BEDF", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 72.72, - -13.04, - -24.147 - ], - "measured_rgb": "#75BBDE", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 71.66, - -12.85, - -25.128 - ], - "measured_rgb": "#70B8DD", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 70.6, - -12.66, - -26.11 - ], - "measured_rgb": "#6CB6DB", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 67.377, - -9.625, - -27.845 - ], - "measured_rgb": "#69ABD6", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 64.153, - -6.59, - -29.58 - ], - "measured_rgb": "#65A1D0", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 60.93, - -3.555, - -31.315 - ], - "measured_rgb": "#6298CA", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 57.707, - -0.52, - -33.05 - ], - "measured_rgb": "#5F8EC4", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 54.483, - 2.515, - -34.785 - ], - "measured_rgb": "#5B84BE", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 51.26, - 5.55, - -36.52 - ], - "measured_rgb": "#577AB8", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 68.59, - 15.52, - 56.99 - ], - "measured_rgb": "#DB9B3C", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 66.767, - 18.537, - 52.082 - ], - "measured_rgb": "#D99443", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 64.943, - 21.553, - 47.173 - ], - "measured_rgb": "#D78D48", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 63.12, - 24.57, - 42.265 - ], - "measured_rgb": "#D4864E", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 61.297, - 27.587, - 37.357 - ], - "measured_rgb": "#D28053", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 59.473, - 30.603, - 32.448 - ], - "measured_rgb": "#CF7958", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 57.65, - 33.62, - 27.54 - ], - "measured_rgb": "#CC725C", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 57.01, - 35.452, - 24.22 - ], - "measured_rgb": "#CC6E61", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 56.37, - 37.283, - 20.9 - ], - "measured_rgb": "#CB6B65", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 55.73, - 39.115, - 17.58 - ], - "measured_rgb": "#CB6869", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 55.09, - 40.947, + 58.47, + -44.1, 14.26 ], - "measured_rgb": "#CA656D", - "source": "interpolated" + "measured_rgb": "#219F72", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 54.45, - 42.778, - 10.94 - ], - "measured_rgb": "#CA6271", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "Yellow", @@ -1294,11 +644,11 @@ 20 ], "measured_lab": [ - 53.81, - 44.61, - 7.62 + 58.09, + -43.7, + 10.53 ], - "measured_rgb": "#C95E75", + "measured_rgb": "#139E78", "source": "measured" }, { @@ -1306,8 +656,8 @@ "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1319,11 +669,11 @@ 80 ], "measured_lab": [ - 72.63, - 29.26, - -12.43 + 73.74, + -14.74, + -21.41 ], - "measured_rgb": "#DDA0CA", + "measured_rgb": "#78BFDC", "source": "measured" }, { @@ -1331,8 +681,8 @@ "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1344,20 +694,20 @@ 75 ], "measured_lab": [ - 71.607, - 29.98, - -12.407 + 71.76, + -15.4, + -24.59 ], - "measured_rgb": "#DB9CC7", - "source": "interpolated" + "measured_rgb": "#69BADC", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1369,20 +719,20 @@ 70 ], "measured_lab": [ - 70.583, - 30.7, - -12.383 + 70.11, + -15.73, + -25.86 ], - "measured_rgb": "#DA99C4", - "source": "interpolated" + "measured_rgb": "#60B6DA", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1394,20 +744,20 @@ 65 ], "measured_lab": [ - 69.56, - 31.42, - -12.36 + 68.1, + -16.03, + -28.26 ], - "measured_rgb": "#D896C1", - "source": "interpolated" + "measured_rgb": "#53B1D8", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1419,20 +769,20 @@ 60 ], "measured_lab": [ - 68.537, - 32.14, - -12.337 + 67.93, + -15.44, + -28.32 ], - "measured_rgb": "#D692BE", - "source": "interpolated" + "measured_rgb": "#55B0D8", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1444,20 +794,20 @@ 55 ], "measured_lab": [ - 67.513, - 32.86, - -12.313 + 66.5, + -15.79, + -29.78 ], - "measured_rgb": "#D48FBB", - "source": "interpolated" + "measured_rgb": "#4AACD6", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1469,11 +819,11 @@ 50 ], "measured_lab": [ - 66.49, - 33.58, - -12.29 + 65.75, + -15.69, + -30.79 ], - "measured_rgb": "#D38CB9", + "measured_rgb": "#44AAD6", "source": "measured" }, { @@ -1481,8 +831,8 @@ "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1494,20 +844,20 @@ 45 ], "measured_lab": [ - 64.777, - 36.348, - -12.727 + 64.67, + -15.68, + -31.78 ], - "measured_rgb": "#D285B5", - "source": "interpolated" + "measured_rgb": "#3DA8D5", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1519,20 +869,20 @@ 40 ], "measured_lab": [ - 63.063, - 39.117, - -13.163 + 62.88, + -16.04, + -34.06 ], - "measured_rgb": "#D17EB1", - "source": "interpolated" + "measured_rgb": "#27A3D4", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1544,20 +894,20 @@ 35 ], "measured_lab": [ - 61.35, - 41.885, - -13.6 + 62.86, + -15.71, + -34.68 ], - "measured_rgb": "#D077AD", - "source": "interpolated" + "measured_rgb": "#26A3D5", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1569,20 +919,20 @@ 30 ], "measured_lab": [ - 59.637, - 44.653, - -14.037 + 62.09, + -15.68, + -35.76 ], - "measured_rgb": "#CF70A9", - "source": "interpolated" + "measured_rgb": "#19A1D5", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1594,20 +944,20 @@ 25 ], "measured_lab": [ - 57.923, - 47.422, - -14.473 + 60.73, + -15.52, + -36.7 ], - "measured_rgb": "#CE69A6", - "source": "interpolated" + "measured_rgb": "#009DD3", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Magenta", - "rgb": "#EC008C" + "key": "Cyan", + "rgb": "#0086D6" }, { "key": "White", @@ -1619,11 +969,11 @@ 20 ], "measured_lab": [ - 56.21, - 50.19, - -14.91 + 60.03, + -15.72, + -37.19 ], - "measured_rgb": "#CD61A2", + "measured_rgb": "#009CD1", "source": "measured" }, { @@ -1631,12 +981,12 @@ "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1644,11 +994,11 @@ 80 ], "measured_lab": [ - 89.77, - -11.57, - 41.5 + 65.7, + 19.56, + 48.15 ], - "measured_rgb": "#E8E691", + "measured_rgb": "#D69148", "source": "measured" }, { @@ -1656,12 +1006,12 @@ "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1669,136 +1019,11 @@ 75 ], "measured_lab": [ - 89.41, - -11.307, - 43.647 + 63.26, + 25.13, + 42.51 ], - "measured_rgb": "#E8E58C", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 89.05, - -11.043, - 45.793 - ], - "measured_rgb": "#E9E487", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 88.69, - -10.78, - 47.94 - ], - "measured_rgb": "#E9E281", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 88.33, - -10.517, - 50.087 - ], - "measured_rgb": "#EAE17C", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 87.97, - -10.253, - 52.233 - ], - "measured_rgb": "#EAE077", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 87.61, - -9.99, - 54.38 - ], - "measured_rgb": "#EADF71", + "measured_rgb": "#D5864E", "source": "measured" }, { @@ -1806,12 +1031,137 @@ "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 60.24, + 30.06, + 32.69 + ], + "measured_rgb": "#D17B59", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 60.41, + 29.22, + 34.96 + ], + "measured_rgb": "#D17C55", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 57.97, + 35.04, + 26.17 + ], + "measured_rgb": "#CF7160", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 57.16, + 35.32, + 24.58 + ], + "measured_rgb": "#CC6F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 57.46, + 36.08, + 25.44 + ], + "measured_rgb": "#CE6F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1819,24 +1169,24 @@ 45 ], "measured_lab": [ - 87.398, - -9.86, - 57.192 + 56.02, + 38.35, + 20.53 ], - "measured_rgb": "#EBDE6B", - "source": "interpolated" + "measured_rgb": "#CC6965", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1844,24 +1194,24 @@ 40 ], "measured_lab": [ - 87.187, - -9.73, - 60.003 + 55.1, + 39.69, + 16.16 ], - "measured_rgb": "#ECDD64", - "source": "interpolated" + "measured_rgb": "#C9666A", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1869,24 +1219,24 @@ 35 ], "measured_lab": [ - 86.975, - -9.6, - 62.815 + 54.88, + 41.31, + 15.93 ], - "measured_rgb": "#ECDC5D", - "source": "interpolated" + "measured_rgb": "#CB646A", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1894,24 +1244,24 @@ 30 ], "measured_lab": [ - 86.763, - -9.47, - 65.627 + 53.45, + 45.1, + 7.0 ], - "measured_rgb": "#EDDB56", - "source": "interpolated" + "measured_rgb": "#C85D76", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ @@ -1919,1357 +1269,2007 @@ 25 ], "measured_lab": [ - 86.552, - -9.34, - 68.438 + 53.23, + 44.29, + 7.96 ], - "measured_rgb": "#EDDB4E", - "source": "interpolated" + "measured_rgb": "#C75D73", + "source": "measured" }, { "mode": "CMYW", "material": "PLA Basic", "components": [ { - "key": "Yellow", - "rgb": "#F4EE2A" + "key": "Magenta", + "rgb": "#EC008C" }, { - "key": "White", - "rgb": "#FFFFFF" + "key": "Yellow", + "rgb": "#F4EE2A" } ], "ratios": [ 80, 20 ], - "measured_lab": [ - 86.34, - -9.21, - 71.25 - ], - "measured_rgb": "#EDDA46", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 20, - 60 - ], - "measured_lab": [ - 58.92, - -7.07, - 33.53 - ], - "measured_rgb": "#969052", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 25, - 55 - ], - "measured_lab": [ - 56.835, - -4.933, - 27.242 - ], - "measured_rgb": "#918A59", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 30, - 50 - ], - "measured_lab": [ - 55.45, - -1.15, - 24.56 - ], - "measured_rgb": "#92845A", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 35, - 45 - ], - "measured_lab": [ - 54.072, - 1.273, - 19.464 - ], - "measured_rgb": "#907F60", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 40, - 40 - ], - "measured_lab": [ - 53.38, - 4.68, - 18.05 - ], - "measured_rgb": "#937C61", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 45, - 35 - ], - "measured_lab": [ - 52.131, - 7.12, - 11.6 - ], - "measured_rgb": "#907869", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 50, - 30 - ], - "measured_lab": [ - 51.41, - 10.52, - 8.37 - ], - "measured_rgb": "#92746D", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 55, - 25 - ], - "measured_lab": [ - 50.768, - 12.107, - 5.298 - ], - "measured_rgb": "#917170", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 60, - 20 - ], - "measured_lab": [ - 49.97, - 15.33, - 2.21 - ], - "measured_rgb": "#926E74", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 20, - 55 - ], - "measured_lab": [ - 57.345, - -9.33, - 27.302 - ], - "measured_rgb": "#8B8D59", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 25, - 50 - ], - "measured_lab": [ - 56.135, - -6.58, - 23.635 - ], - "measured_rgb": "#8A895D", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 30, - 45 - ], - "measured_lab": [ - 54.463, - -2.851, - 18.678 - ], - "measured_rgb": "#8A8362", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 35, - 40 - ], - "measured_lab": [ - 53.385, - 0.29, - 15.782 - ], - "measured_rgb": "#8A7E65", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 40, - 35 - ], - "measured_lab": [ - 52.613, - 3.31, - 13.936 - ], - "measured_rgb": "#8C7B66", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 45, - 30 - ], - "measured_lab": [ - 51.603, - 6.16, - 8.38 - ], - "measured_rgb": "#8B776D", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 50, - 25 - ], - "measured_lab": [ - 51.038, - 8.243, - 5.013 - ], - "measured_rgb": "#8B7571", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 55, - 20 - ], - "measured_lab": [ - 50.674, - 10.886, - 3.9 - ], - "measured_rgb": "#8E7272", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 20, - 50 - ], - "measured_lab": [ - 56.98, - -14.34, - 24.74 - ], - "measured_rgb": "#7F8F5D", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 25, - 45 - ], - "measured_lab": [ - 55.123, - -8.998, - 18.633 - ], - "measured_rgb": "#818863", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 30, - 40 - ], - "measured_lab": [ - 53.19, - -3.76, - 11.71 - ], - "measured_rgb": "#81806B", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 35, - 35 - ], - "measured_lab": [ - 52.698, - -0.693, - 12.101 - ], - "measured_rgb": "#857D69", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 40, - 30 - ], - "measured_lab": [ - 51.52, - 1.39, - 8.81 - ], - "measured_rgb": "#83796C", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 45, - 25 - ], - "measured_lab": [ - 51.074, - 5.2, - 5.16 - ], - "measured_rgb": "#867671", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 50, - 20 - ], - "measured_lab": [ - 50.1, - 8.05, - -1.71 - ], - "measured_rgb": "#84737A", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 20, - 45 - ], - "measured_lab": [ - 55.514, - -13.663, - 18.858 - ], - "measured_rgb": "#798B64", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 25, - 40 - ], - "measured_lab": [ - 54.392, - -10.32, - 15.045 - ], - "measured_rgb": "#7A8768", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 30, - 35 - ], - "measured_lab": [ - 52.967, - -5.5, - 10.567 - ], - "measured_rgb": "#7C816C", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 35, - 30 - ], - "measured_lab": [ - 51.84, - -2.31, - 6.725 - ], - "measured_rgb": "#7C7C70", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 40, - 25 - ], - "measured_lab": [ - 51.083, - 0.928, - 4.271 - ], - "measured_rgb": "#7E7972", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 45, - 20 - ], - "measured_lab": [ - 50.849, - 3.236, - 3.172 - ], - "measured_rgb": "#817774", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 20, - 40 - ], - "measured_lab": [ - 55.17, - -16.33, - 16.79 - ], - "measured_rgb": "#728B66", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 25, - 35 - ], - "measured_lab": [ - 53.931, - -11.167, - 12.925 - ], - "measured_rgb": "#76866A", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 30, - 30 - ], - "measured_lab": [ - 52.23, - -6.85, - 6.94 - ], - "measured_rgb": "#758071", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 35, - 25 - ], - "measured_lab": [ - 51.497, - -3.06, - 4.368 - ], - "measured_rgb": "#797C73", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 40, - 20 - ], - "measured_lab": [ - 50.42, - -0.02, - -0.56 - ], - "measured_rgb": "#777879", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 20, - 35 - ], - "measured_lab": [ - 54.193, - -15.753, - 11.043 - ], - "measured_rgb": "#6C896E", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 25, - 30 - ], - "measured_lab": [ - 53.26, - -12.21, - 7.178 - ], - "measured_rgb": "#6E8573", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 30, - 25 - ], - "measured_lab": [ - 52.327, - -8.667, - 3.312 - ], - "measured_rgb": "#6F8177", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 35, - 20 - ], - "measured_lab": [ - 51.389, - -4.229, - 1.238 - ], - "measured_rgb": "#747D78", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 50, - 20, - 30 - ], - "measured_lab": [ - 54.15, - -18.72, - 9.16 - ], - "measured_rgb": "#648A71", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 50, - 25, - 25 - ], - "measured_lab": [ - 52.967, - -12.623, - 4.053 - ], - "measured_rgb": "#698577", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 50, - 30, - 20 - ], - "measured_lab": [ - 51.49, - -6.94, - -4.18 - ], - "measured_rgb": "#697F82", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 55, - 20, - 25 - ], - "measured_lab": [ - 53.675, - -16.828, - 2.661 - ], - "measured_rgb": "#61887B", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 55, - 25, - 20 - ], "measured_lab": [ 53.17, - -14.343, - 1.343 + 45.44, + 5.5 ], - "measured_rgb": "#63867C", - "source": "interpolated" + "measured_rgb": "#C75C77", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 65.05, + 41.46, + -15.23 + ], + "measured_rgb": "#D981BA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 64.76, + 41.42, + -14.99 + ], + "measured_rgb": "#D881B9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 61.49, + 47.18, + -15.56 + ], + "measured_rgb": "#D773B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 62.17, + 44.07, + -15.56 + ], + "measured_rgb": "#D577B3", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 61.67, + 45.03, + -15.24 + ], + "measured_rgb": "#D575B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 60.38, + 47.58, + -15.12 + ], + "measured_rgb": "#D56FAD", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 57.67, + 51.19, + -15.52 + ], + "measured_rgb": "#D264A7", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 57.19, + 52.57, + -15.0 + ], + "measured_rgb": "#D361A5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 57.75, + 52.16, + -14.62 + ], + "measured_rgb": "#D463A6", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 57.2, + 51.19, + -14.81 + ], + "measured_rgb": "#D163A4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 55.57, + 53.66, + -14.82 + ], + "measured_rgb": "#D05BA0", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 55.51, + 53.08, + -14.46 + ], + "measured_rgb": "#CF5C9F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 54.62, + 54.16, + -14.51 + ], + "measured_rgb": "#CE589D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 87.72, + -15.64, + 55.43 + ], + "measured_rgb": "#E1E26F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 87.51, + -15.48, + 58.85 + ], + "measured_rgb": "#E2E167", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 87.35, + -15.37, + 61.45 + ], + "measured_rgb": "#E3E161", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 87.0, + -14.73, + 63.73 + ], + "measured_rgb": "#E4DF5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 86.76, + -14.25, + 65.47 + ], + "measured_rgb": "#E4DE56", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 86.6, + -13.9, + 67.58 + ], + "measured_rgb": "#E5DD50", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 86.23, + -14.03, + 72.5 + ], + "measured_rgb": "#E5DC42", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 86.91, + -12.84, + 74.11 + ], + "measured_rgb": "#EADE3F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 86.24, + -13.03, + 75.23 + ], + "measured_rgb": "#E8DC3A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 86.01, + -12.73, + 76.77 + ], + "measured_rgb": "#E8DB34", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 85.85, + -12.44, + 78.22 + ], + "measured_rgb": "#E8DA2E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 85.71, + -12.39, + 81.24 + ], + "measured_rgb": "#E9DA21", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 87.04, + -10.65, + 83.79 + ], + "measured_rgb": "#F0DC1A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 57.59, + -7.31, + 27.59 + ], + "measured_rgb": "#8F8D5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 56.47, + -5.15, + 29.22 + ], + "measured_rgb": "#908954", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 53.76, + 1.47, + 16.52 + ], + "measured_rgb": "#8E7F64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 53.89, + 1.26, + 22.63 + ], + "measured_rgb": "#917F5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 53.08, + 4.21, + 20.07 + ], + "measured_rgb": "#927B5D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 51.5, + 7.8, + 12.87 + ], + "measured_rgb": "#907565", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 50.11, + 12.7, + 3.91 + ], + "measured_rgb": "#8F6F71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 49.99, + 13.42, + 6.09 + ], + "measured_rgb": "#916F6D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 49.64, + 14.45, + 6.31 + ], + "measured_rgb": "#926D6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 57.53, + -11.27, + 27.15 + ], + "measured_rgb": "#888F5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 54.99, + -7.03, + 22.56 + ], + "measured_rgb": "#86865C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 54.25, + -4.47, + 21.25 + ], + "measured_rgb": "#88835D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 53.04, + -2.53, + 17.95 + ], + "measured_rgb": "#867F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 51.71, + 2.72, + 11.95 + ], + "measured_rgb": "#887967", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 50.85, + 5.89, + 10.93 + ], + "measured_rgb": "#8A7567", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 49.74, + 8.68, + 4.66 + ], + "measured_rgb": "#88716F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 49.3, + 9.76, + 2.9 + ], + "measured_rgb": "#886F71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 55.45, + -14.4, + 18.45 + ], + "measured_rgb": "#788B64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 55.07, + -10.24, + 24.11 + ], + "measured_rgb": "#82885A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 53.44, + -6.68, + 18.83 + ], + "measured_rgb": "#81825F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 52.37, + -3.05, + 13.89 + ], + "measured_rgb": "#817E65", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 51.03, + 0.35, + 11.39 + ], + "measured_rgb": "#827966", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 50.12, + 4.03, + 7.42 + ], + "measured_rgb": "#83756B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 49.36, + 7.35, + 3.42 + ], + "measured_rgb": "#847170", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 55.52, + -16.34, + 20.5 + ], + "measured_rgb": "#758C61", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 53.19, + -11.0, + 14.49 + ], + "measured_rgb": "#768466", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.8, + -8.48, + 16.8 + ], + "measured_rgb": "#7D8463", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 51.71, + -4.82, + 12.37 + ], + "measured_rgb": "#7C7D66", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 49.92, + 7.98, + 2.05 + ], + "measured_rgb": "#867274", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 48.86, + 10.46, + 0.0 + ], + "measured_rgb": "#866E74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 54.4, + -10.2, + 19.17 + ], + "measured_rgb": "#7D8661", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 52.52, + -4.36, + 12.99 + ], + "measured_rgb": "#7F7F67", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 51.21, + -1.28, + 6.49 + ], + "measured_rgb": "#7D7A6F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 50.18, + 4.0, + 4.31 + ], + "measured_rgb": "#817570", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 48.97, + 7.15, + 1.93 + ], + "measured_rgb": "#827071", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 52.71, + -11.69, + 14.25 + ], + "measured_rgb": "#738365", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 51.33, + -3.15, + 5.87 + ], + "measured_rgb": "#797C70", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 49.87, + -1.14, + 1.5 + ], + "measured_rgb": "#767774", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 50.92, + -2.35, + 7.7 + ], + "measured_rgb": "#7B7A6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 52.42, + -12.64, + 11.74 + ], + "measured_rgb": "#6E8369", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 50.67, + -4.67, + -2.82 + ], + "measured_rgb": "#6D7B7D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 49.65, + -0.75, + -0.75 + ], + "measured_rgb": "#747677", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 52.24, + -12.06, + 7.79 + ], + "measured_rgb": "#6C826F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 50.78, + -8.82, + 4.42 + ], + "measured_rgb": "#6C7D71", + "source": "measured" }, { "mode": "CMYW", @@ -3294,11 +3294,11 @@ 20 ], "measured_lab": [ - 53.47, - -16.97, - -4.04 + 52.04, + -14.16, + -0.63 ], - "measured_rgb": "#578886", + "measured_rgb": "#5F837D", "source": "measured" }, { @@ -4164,12 +4164,12 @@ 25 ], "measured_lab": [ - 51.78, - 19.563, - -31.891 + 50.49, + 23.51, + -31.23 ], - "measured_rgb": "#8072B2", - "source": "interpolated" + "measured_rgb": "#856CAE", + "source": "measured" }, { "mode": "CMYW", @@ -4194,12 +4194,12 @@ 20 ], "measured_lab": [ - 52.036, - 21.038, - -30.619 + 49.65, + 26.8, + -30.56 ], - "measured_rgb": "#8572B1", - "source": "interpolated" + "measured_rgb": "#8A68AA", + "source": "measured" }, { "mode": "CMYW", @@ -4224,11 +4224,11 @@ 40 ], "measured_lab": [ - 55.95, - 8.13, - -33.38 + 55.49, + 12.01, + -31.53 ], - "measured_rgb": "#7084C0", + "measured_rgb": "#7B81BB", "source": "measured" }, { @@ -4254,12 +4254,12 @@ 35 ], "measured_lab": [ - 54.999, - 11.835, - -32.553 + 54.0, + 18.26, + -30.55 ], - "measured_rgb": "#7880BC", - "source": "interpolated" + "measured_rgb": "#8579B6", + "source": "measured" }, { "mode": "CMYW", @@ -4284,11 +4284,11 @@ 30 ], "measured_lab": [ - 52.66, - 15.1, - -33.36 + 52.11, + 16.18, + -32.9 ], - "measured_rgb": "#7778B7", + "measured_rgb": "#7976B5", "source": "measured" }, { @@ -4314,12 +4314,12 @@ 25 ], "measured_lab": [ - 51.95, - 17.29, - -32.751 + 50.58, + 22.22, + -31.81 ], - "measured_rgb": "#7B74B4", - "source": "interpolated" + "measured_rgb": "#826EAF", + "source": "measured" }, { "mode": "CMYW", @@ -4344,11 +4344,11 @@ 20 ], "measured_lab": [ - 50.73, - 18.36, - -32.85 + 49.52, + 22.45, + -31.95 ], - "measured_rgb": "#7A71B1", + "measured_rgb": "#806BAC", "source": "measured" }, { @@ -4374,12 +4374,12 @@ 35 ], "measured_lab": [ - 56.176, - 7.293, - -32.738 + 55.21, + 10.3, + -31.74 ], - "measured_rgb": "#7085BF", - "source": "interpolated" + "measured_rgb": "#7681BB", + "source": "measured" }, { "mode": "CMYW", @@ -4404,12 +4404,12 @@ 30 ], "measured_lab": [ - 54.497, - 10.53, - -33.105 + 53.01, + 17.69, + -31.28 ], - "measured_rgb": "#727FBB", - "source": "interpolated" + "measured_rgb": "#8077B4", + "source": "measured" }, { "mode": "CMYW", @@ -4434,12 +4434,12 @@ 25 ], "measured_lab": [ - 52.207, - 15.028, - -33.565 + 51.1, + 17.85, + -33.01 ], - "measured_rgb": "#7677B6", - "source": "interpolated" + "measured_rgb": "#7972B2", + "source": "measured" }, { "mode": "CMYW", @@ -4464,12 +4464,12 @@ 20 ], "measured_lab": [ - 51.837, - 15.861, - -33.386 + 50.31, + 22.05, + -31.57 ], - "measured_rgb": "#7775B5", - "source": "interpolated" + "measured_rgb": "#816DAE", + "source": "measured" }, { "mode": "CMYW", @@ -4494,11 +4494,11 @@ 30 ], "measured_lab": [ - 58.08, - 3.22, - -31.73 + 53.39, + 8.16, + -34.46 ], - "measured_rgb": "#6C8CC3", + "measured_rgb": "#687EBB", "source": "measured" }, { @@ -4524,12 +4524,12 @@ 25 ], "measured_lab": [ - 54.626, - 9.807, - -32.928 + 52.44, + 17.84, + -31.9 ], - "measured_rgb": "#7180BB", - "source": "interpolated" + "measured_rgb": "#7E75B4", + "source": "measured" }, { "mode": "CMYW", @@ -4554,11 +4554,11 @@ 20 ], "measured_lab": [ - 51.3, - 15.67, - -33.95 + 50.6, + 16.89, + -33.72 ], - "measured_rgb": "#7474B4", + "measured_rgb": "#7571B2", "source": "measured" }, { @@ -4584,12 +4584,12 @@ 25 ], "measured_lab": [ - 56.067, - 5.637, - -32.746 + 53.48, + 9.63, + -33.29 ], - "measured_rgb": "#6B86BF", - "source": "interpolated" + "measured_rgb": "#6D7DB9", + "source": "measured" }, { "mode": "CMYW", @@ -4614,12 +4614,12 @@ 20 ], "measured_lab": [ - 54.872, - 8.214, - -33.064 + 51.33, + 13.07, + -34.41 ], - "measured_rgb": "#6E82BC", - "source": "interpolated" + "measured_rgb": "#6E76B5", + "source": "measured" }, { "mode": "CMYW", @@ -4644,11 +4644,11 @@ 20 ], "measured_lab": [ - 55.02, - 5.77, - -33.45 + 52.25, + 10.16, + -35.14 ], - "measured_rgb": "#6883BD", + "measured_rgb": "#687AB9", "source": "measured" }, { @@ -4674,11 +4674,11 @@ 60 ], "measured_lab": [ - 72.25, - -37.06, - 24.67 + 74.3, + -35.02, + 32.23 ], - "measured_rgb": "#76C283", + "measured_rgb": "#87C77A", "source": "measured" }, { @@ -4704,12 +4704,12 @@ 55 ], "measured_lab": [ - 71.136, - -38.009, - 27.818 + 73.72, + -36.25, + 35.28 ], - "measured_rgb": "#73BF7A", - "source": "interpolated" + "measured_rgb": "#85C572", + "source": "measured" }, { "mode": "CMYW", @@ -4734,11 +4734,11 @@ 50 ], "measured_lab": [ - 71.21, - -38.72, - 34.12 + 73.49, + -37.39, + 44.85 ], - "measured_rgb": "#77BF6E", + "measured_rgb": "#88C55E", "source": "measured" }, { @@ -4764,12 +4764,12 @@ 45 ], "measured_lab": [ - 70.097, - -39.222, - 34.128 + 73.18, + -37.11, + 44.81 ], - "measured_rgb": "#73BD6B", - "source": "interpolated" + "measured_rgb": "#88C45E", + "source": "measured" }, { "mode": "CMYW", @@ -4794,11 +4794,11 @@ 40 ], "measured_lab": [ - 69.96, - -39.61, - 37.44 + 73.27, + -36.92, + 47.03 ], - "measured_rgb": "#74BC64", + "measured_rgb": "#8AC459", "source": "measured" }, { @@ -4824,12 +4824,12 @@ 35 ], "measured_lab": [ - 69.533, - -39.725, - 38.622 + 72.96, + -36.08, + 48.85 ], - "measured_rgb": "#74BB61", - "source": "interpolated" + "measured_rgb": "#8CC355", + "source": "measured" }, { "mode": "CMYW", @@ -4854,11 +4854,11 @@ 30 ], "measured_lab": [ - 70.07, - -39.32, - 41.87 + 72.86, + -36.19, + 52.04 ], - "measured_rgb": "#78BC5C", + "measured_rgb": "#8DC24D", "source": "measured" }, { @@ -4884,12 +4884,12 @@ 25 ], "measured_lab": [ - 69.807, - -38.943, - 42.092 + 72.69, + -36.31, + 54.47 ], - "measured_rgb": "#79BB5A", - "source": "interpolated" + "measured_rgb": "#8EC247", + "source": "measured" }, { "mode": "CMYW", @@ -4914,11 +4914,11 @@ 20 ], "measured_lab": [ - 70.1, - -37.86, - 43.47 + 73.02, + -34.99, + 57.46 ], - "measured_rgb": "#7DBC58", + "measured_rgb": "#93C241", "source": "measured" }, { @@ -4944,12 +4944,12 @@ 55 ], "measured_lab": [ - 70.176, - -37.819, - 21.824 + 72.41, + -36.38, + 27.96 ], - "measured_rgb": "#6BBD82", - "source": "interpolated" + "measured_rgb": "#7BC27D", + "source": "measured" }, { "mode": "CMYW", @@ -4974,12 +4974,12 @@ 50 ], "measured_lab": [ - 69.947, - -38.248, - 24.663 + 71.93, + -38.31, + 37.24 ], - "measured_rgb": "#6CBC7D", - "source": "interpolated" + "measured_rgb": "#7DC16A", + "source": "measured" }, { "mode": "CMYW", @@ -5004,12 +5004,12 @@ 45 ], "measured_lab": [ - 69.443, - -39.038, - 29.554 + 72.08, + -39.6, + 45.03 ], - "measured_rgb": "#6DBB72", - "source": "interpolated" + "measured_rgb": "#7FC25A", + "source": "measured" }, { "mode": "CMYW", @@ -5034,12 +5034,12 @@ 40 ], "measured_lab": [ - 69.12, - -39.335, - 30.823 + 71.28, + -39.24, + 43.36 ], - "measured_rgb": "#6DBA6F", - "source": "interpolated" + "measured_rgb": "#7DC05C", + "source": "measured" }, { "mode": "CMYW", @@ -5064,12 +5064,12 @@ 35 ], "measured_lab": [ - 68.702, - -39.682, - 32.737 + 71.07, + -38.65, + 44.35 ], - "measured_rgb": "#6DB96A", - "source": "interpolated" + "measured_rgb": "#7EBF59", + "source": "measured" }, { "mode": "CMYW", @@ -5094,12 +5094,12 @@ 30 ], "measured_lab": [ - 68.57, - -40.245, - 36.555 + 71.18, + -38.31, + 50.43 ], - "measured_rgb": "#6EB962", - "source": "interpolated" + "measured_rgb": "#83BF4C", + "source": "measured" }, { "mode": "CMYW", @@ -5124,12 +5124,12 @@ 25 ], "measured_lab": [ - 68.759, - -40.381, - 40.397 + 70.55, + -38.48, + 48.2 ], - "measured_rgb": "#71B95B", - "source": "interpolated" + "measured_rgb": "#80BD50", + "source": "measured" }, { "mode": "CMYW", @@ -5154,12 +5154,12 @@ 20 ], "measured_lab": [ - 69.094, - -39.751, - 41.165 + 71.23, + -37.53, + 52.06 ], - "measured_rgb": "#74BA5B", - "source": "interpolated" + "measured_rgb": "#86BE49", + "source": "measured" }, { "mode": "CMYW", @@ -5184,11 +5184,11 @@ 50 ], "measured_lab": [ - 68.33, - -38.15, - 16.14 + 70.62, + -38.31, + 27.17 ], - "measured_rgb": "#5EB888", + "measured_rgb": "#70BE7A", "source": "measured" }, { @@ -5214,12 +5214,12 @@ 45 ], "measured_lab": [ - 68.196, - -38.822, - 20.824 + 70.27, + -40.7, + 36.95 ], - "measured_rgb": "#61B87F", - "source": "interpolated" + "measured_rgb": "#72BE66", + "source": "measured" }, { "mode": "CMYW", @@ -5243,194 +5243,194 @@ 30, 40 ], + "measured_lab": [ + 69.68, + -39.3, + 32.01 + ], + "measured_rgb": "#70BB6E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 69.43, + -40.46, + 39.0 + ], + "measured_rgb": "#72BB60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 68.97, + -40.7, + 38.6 + ], + "measured_rgb": "#70BA5F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 69.63, + -40.15, + 47.16 + ], + "measured_rgb": "#79BB4F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 69.41, + -39.15, + 50.18 + ], + "measured_rgb": "#7CBA48", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 68.36, + -39.74, + 23.12 + ], + "measured_rgb": "#62B87B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], "measured_lab": [ 68.0, - -39.06, - 23.72 + -41.34, + 28.71 ], - "measured_rgb": "#64B779", + "measured_rgb": "#62B870", "source": "measured" }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 35, - 35 - ], - "measured_lab": [ - 67.702, - -39.731, - 26.834 - ], - "measured_rgb": "#64B673", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 40, - 30 - ], - "measured_lab": [ - 67.31, - -39.95, - 28.01 - ], - "measured_rgb": "#64B570", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 45, - 25 - ], - "measured_lab": [ - 67.165, - -41.047, - 33.805 - ], - "measured_rgb": "#66B564", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 50, - 20 - ], - "measured_lab": [ - 66.94, - -42.1, - 38.9 - ], - "measured_rgb": "#67B559", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 20, - 45 - ], - "measured_lab": [ - 66.828, - -39.823, - 17.494 - ], - "measured_rgb": "#56B482", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Cyan", - "rgb": "#0086D6" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 25, - 40 - ], - "measured_lab": [ - 66.665, - -40.218, - 19.873 - ], - "measured_rgb": "#58B47D", - "source": "interpolated" - }, { "mode": "CMYW", "material": "PLA Basic", @@ -5454,12 +5454,12 @@ 35 ], "measured_lab": [ - 66.196, - -41.017, - 23.343 + 67.59, + -41.06, + 29.34 ], - "measured_rgb": "#58B375", - "source": "interpolated" + "measured_rgb": "#63B76E", + "source": "measured" }, { "mode": "CMYW", @@ -5484,12 +5484,12 @@ 30 ], "measured_lab": [ - 66.203, - -41.143, - 26.033 + 67.99, + -40.51, + 34.43 ], - "measured_rgb": "#5BB370", - "source": "interpolated" + "measured_rgb": "#6AB765", + "source": "measured" }, { "mode": "CMYW", @@ -5514,12 +5514,12 @@ 25 ], "measured_lab": [ - 66.233, - -41.326, - 29.073 + 68.47, + -41.01, + 39.93 ], - "measured_rgb": "#5EB36B", - "source": "interpolated" + "measured_rgb": "#6EB95B", + "source": "measured" }, { "mode": "CMYW", @@ -5544,12 +5544,12 @@ 20 ], "measured_lab": [ - 66.539, - -41.536, - 32.664 + 68.39, + -40.84, + 44.81 ], - "measured_rgb": "#62B465", - "source": "interpolated" + "measured_rgb": "#72B851", + "source": "measured" }, { "mode": "CMYW", @@ -5574,11 +5574,11 @@ 40 ], "measured_lab": [ - 65.49, - -41.1, - 16.47 + 68.26, + -42.84, + 32.38 ], - "measured_rgb": "#4CB180", + "measured_rgb": "#62B96A", "source": "measured" }, { @@ -5604,12 +5604,12 @@ 35 ], "measured_lab": [ - 65.258, - -41.626, - 19.645 + 67.53, + -43.05, + 33.52 ], - "measured_rgb": "#4FB17A", - "source": "interpolated" + "measured_rgb": "#61B765", + "source": "measured" }, { "mode": "CMYW", @@ -5634,11 +5634,11 @@ 30 ], "measured_lab": [ - 64.84, - -42.56, - 23.16 + 67.97, + -43.08, + 40.92 ], - "measured_rgb": "#4FB072", + "measured_rgb": "#68B858", "source": "measured" }, { @@ -5664,12 +5664,12 @@ 25 ], "measured_lab": [ - 64.848, - -42.5, - 25.195 + 67.12, + -42.36, + 35.29 ], - "measured_rgb": "#52B06E", - "source": "interpolated" + "measured_rgb": "#63B661", + "source": "measured" }, { "mode": "CMYW", @@ -5694,11 +5694,11 @@ 20 ], "measured_lab": [ - 64.66, - -43.0, - 29.24 + 67.2, + -42.55, + 42.58 ], - "measured_rgb": "#55AF66", + "measured_rgb": "#69B653", "source": "measured" }, { @@ -5724,12 +5724,12 @@ 35 ], "measured_lab": [ - 64.347, - -41.829, - 15.957 + 66.46, + -43.54, + 30.78 ], - "measured_rgb": "#45AE7E", - "source": "interpolated" + "measured_rgb": "#5AB468", + "source": "measured" }, { "mode": "CMYW", @@ -5754,12 +5754,12 @@ 30 ], "measured_lab": [ - 64.113, - -42.238, - 17.53 + 67.09, + -43.25, + 40.33 ], - "measured_rgb": "#46AE7B", - "source": "interpolated" + "measured_rgb": "#65B657", + "source": "measured" }, { "mode": "CMYW", @@ -5784,12 +5784,12 @@ 25 ], "measured_lab": [ - 63.979, - -42.717, - 20.384 + 66.18, + -43.88, + 36.56 ], - "measured_rgb": "#48AE75", - "source": "interpolated" + "measured_rgb": "#5EB35C", + "source": "measured" }, { "mode": "CMYW", @@ -5814,12 +5814,12 @@ 20 ], "measured_lab": [ - 64.149, - -42.788, - 22.598 + 66.19, + -43.23, + 41.16 ], - "measured_rgb": "#4CAE71", - "source": "interpolated" + "measured_rgb": "#63B353", + "source": "measured" }, { "mode": "CMYW", @@ -5844,11 +5844,11 @@ 30 ], "measured_lab": [ - 63.44, - -42.15, - 13.87 + 64.97, + -43.99, + 23.68 ], - "measured_rgb": "#3DAC80", + "measured_rgb": "#4BB172", "source": "measured" }, { @@ -5874,12 +5874,12 @@ 25 ], "measured_lab": [ - 63.204, - -42.248, - 14.638 + 65.56, + -44.13, + 36.03 ], - "measured_rgb": "#3DAC7E", - "source": "interpolated" + "measured_rgb": "#5BB25C", + "source": "measured" }, { "mode": "CMYW", @@ -5904,11 +5904,11 @@ 20 ], "measured_lab": [ - 62.68, - -43.14, - 16.62 + 64.72, + -44.5, + 33.55 ], - "measured_rgb": "#3CAA79", + "measured_rgb": "#55B05E", "source": "measured" }, { @@ -5934,12 +5934,12 @@ 25 ], "measured_lab": [ - 63.005, - -41.008, - 11.148 + 64.39, + -44.62, + 30.57 ], - "measured_rgb": "#3BAB83", - "source": "interpolated" + "measured_rgb": "#50AF63", + "source": "measured" }, { "mode": "CMYW", @@ -5964,12 +5964,12 @@ 20 ], "measured_lab": [ - 62.993, - -41.544, - 12.664 + 64.28, + -44.35, + 33.12 ], - "measured_rgb": "#3CAB81", - "source": "interpolated" + "measured_rgb": "#54AF5E", + "source": "measured" }, { "mode": "CMYW", @@ -5994,11 +5994,11 @@ 20 ], "measured_lab": [ - 62.36, - -39.43, - 6.74 + 63.99, + -44.5, + 31.36 ], - "measured_rgb": "#36A98A", + "measured_rgb": "#50AE61", "source": "measured" }, { @@ -6024,11 +6024,11 @@ 60 ], "measured_lab": [ - 70.73, - 16.88, - 29.74 + 69.61, + 16.79, + 31.35 ], - "measured_rgb": "#DBA178", + "measured_rgb": "#D99E72", "source": "measured" }, { @@ -6054,12 +6054,12 @@ 55 ], "measured_lab": [ - 69.381, - 17.858, - 34.612 + 68.82, + 19.72, + 31.9 ], - "measured_rgb": "#DB9C6B", - "source": "interpolated" + "measured_rgb": "#DB996F", + "source": "measured" }, { "mode": "CMYW", @@ -6084,11 +6084,11 @@ 50 ], "measured_lab": [ - 69.48, - 16.27, - 40.49 + 68.33, + 17.1, + 39.2 ], - "measured_rgb": "#DB9D60", + "measured_rgb": "#D89A60", "source": "measured" }, { @@ -6114,12 +6114,12 @@ 45 ], "measured_lab": [ - 68.666, - 17.873, - 45.073 + 68.46, + 16.11, + 42.41 ], - "measured_rgb": "#DC9A56", - "source": "interpolated" + "measured_rgb": "#D89B5A", + "source": "measured" }, { "mode": "CMYW", @@ -6144,11 +6144,11 @@ 40 ], "measured_lab": [ - 69.27, - 16.88, - 51.38 + 68.13, + 20.07, + 35.11 ], - "measured_rgb": "#DE9C4A", + "measured_rgb": "#DA9768", "source": "measured" }, { @@ -6174,12 +6174,12 @@ 35 ], "measured_lab": [ - 68.885, - 17.481, - 51.935 + 67.98, + 17.82, + 41.09 ], - "measured_rgb": "#DE9A48", - "source": "interpolated" + "measured_rgb": "#D9985C", + "source": "measured" }, { "mode": "CMYW", @@ -6204,11 +6204,11 @@ 30 ], "measured_lab": [ - 69.9, - 15.64, - 54.97 + 67.42, + 17.87, + 44.12 ], - "measured_rgb": "#DF9E44", + "measured_rgb": "#D89654", "source": "measured" }, { @@ -6234,12 +6234,12 @@ 25 ], "measured_lab": [ - 68.967, - 17.607, - 54.28 + 67.92, + 16.3, + 49.75 ], - "measured_rgb": "#DF9A43", - "source": "interpolated" + "measured_rgb": "#D9994A", + "source": "measured" }, { "mode": "CMYW", @@ -6264,11 +6264,11 @@ 20 ], "measured_lab": [ - 68.51, - 18.92, - 54.92 + 67.28, + 19.02, + 46.35 ], - "measured_rgb": "#DF9841", + "measured_rgb": "#DA9550", "source": "measured" }, { @@ -6294,12 +6294,12 @@ 55 ], "measured_lab": [ - 68.098, - 21.004, - 29.415 + 67.5, + 21.75, + 22.94 ], - "measured_rgb": "#DA9772", - "source": "interpolated" + "measured_rgb": "#D7957C", + "source": "measured" }, { "mode": "CMYW", @@ -6324,12 +6324,12 @@ 50 ], "measured_lab": [ - 67.933, - 20.422, - 33.605 + 67.1, + 22.38, + 25.65 ], - "measured_rgb": "#DA966A", - "source": "interpolated" + "measured_rgb": "#D79376", + "source": "measured" }, { "mode": "CMYW", @@ -6354,12 +6354,12 @@ 45 ], "measured_lab": [ - 68.071, - 19.105, - 40.002 + 66.59, + 19.58, + 37.69 ], - "measured_rgb": "#DA975E", - "source": "interpolated" + "measured_rgb": "#D6935F", + "source": "measured" }, { "mode": "CMYW", @@ -6384,12 +6384,12 @@ 40 ], "measured_lab": [ - 67.248, - 20.468, - 43.348 + 65.65, + 23.82, + 31.58 ], - "measured_rgb": "#DB9456", - "source": "interpolated" + "measured_rgb": "#D78E68", + "source": "measured" }, { "mode": "CMYW", @@ -6414,12 +6414,12 @@ 35 ], "measured_lab": [ - 66.701, - 21.497, - 46.382 + 65.85, + 22.45, + 35.34 ], - "measured_rgb": "#DC924E", - "source": "interpolated" + "measured_rgb": "#D78F62", + "source": "measured" }, { "mode": "CMYW", @@ -6444,12 +6444,12 @@ 30 ], "measured_lab": [ - 67.485, - 19.923, - 49.455 + 66.33, + 18.54, + 46.19 ], - "measured_rgb": "#DD954A", - "source": "interpolated" + "measured_rgb": "#D6934E", + "source": "measured" }, { "mode": "CMYW", @@ -6474,12 +6474,12 @@ 25 ], "measured_lab": [ - 68.282, - 18.367, - 52.279 + 66.54, + 17.79, + 48.57 ], - "measured_rgb": "#DD9846", - "source": "interpolated" + "measured_rgb": "#D69449", + "source": "measured" }, { "mode": "CMYW", @@ -6504,12 +6504,12 @@ 20 ], "measured_lab": [ - 68.339, - 18.505, - 52.939 + 66.05, + 22.67, + 41.69 ], - "measured_rgb": "#DE9845", - "source": "interpolated" + "measured_rgb": "#DA8F56", + "source": "measured" }, { "mode": "CMYW", @@ -6534,11 +6534,11 @@ 50 ], "measured_lab": [ - 65.63, - 25.71, - 24.9 + 65.98, + 25.27, + 25.85 ], - "measured_rgb": "#D88D74", + "measured_rgb": "#D98E73", "source": "measured" }, { @@ -6564,12 +6564,12 @@ 45 ], "measured_lab": [ - 65.949, - 23.927, - 32.271 + 64.68, + 23.73, + 29.99 ], - "measured_rgb": "#D98F68", - "source": "interpolated" + "measured_rgb": "#D48C69", + "source": "measured" }, { "mode": "CMYW", @@ -6594,11 +6594,11 @@ 40 ], "measured_lab": [ - 65.89, - 22.83, - 39.29 + 65.13, + 22.9, + 33.41 ], - "measured_rgb": "#D98F5A", + "measured_rgb": "#D58D63", "source": "measured" }, { @@ -6624,12 +6624,12 @@ 35 ], "measured_lab": [ - 65.294, - 24.002, - 41.295 + 65.09, + 25.37, + 33.28 ], - "measured_rgb": "#DA8D55", - "source": "interpolated" + "measured_rgb": "#D98B64", + "source": "measured" }, { "mode": "CMYW", @@ -6654,11 +6654,11 @@ 30 ], "measured_lab": [ - 64.35, - 25.89, - 42.23 + 63.98, + 23.77, + 34.82 ], - "measured_rgb": "#DA8951", + "measured_rgb": "#D48A5E", "source": "measured" }, { @@ -6684,12 +6684,12 @@ 25 ], "measured_lab": [ - 66.085, - 22.364, - 46.975 + 63.93, + 23.89, + 38.63 ], - "measured_rgb": "#DB904C", - "source": "interpolated" + "measured_rgb": "#D58957", + "source": "measured" }, { "mode": "CMYW", @@ -6714,11 +6714,11 @@ 20 ], "measured_lab": [ - 66.42, - 21.28, - 49.24 + 63.96, + 22.94, + 39.59 ], - "measured_rgb": "#DB9148", + "measured_rgb": "#D48A55", "source": "measured" }, { @@ -6744,12 +6744,12 @@ 45 ], "measured_lab": [ - 62.967, - 30.239, - 26.0 + 64.24, + 30.82, + 10.43 ], - "measured_rgb": "#D7826C", - "source": "interpolated" + "measured_rgb": "#D5868B", + "source": "measured" }, { "mode": "CMYW", @@ -6774,12 +6774,12 @@ 40 ], "measured_lab": [ - 63.57, - 28.218, - 30.77 + 63.91, + 27.46, + 22.59 ], - "measured_rgb": "#D78565", - "source": "interpolated" + "measured_rgb": "#D48774", + "source": "measured" }, { "mode": "CMYW", @@ -6804,12 +6804,12 @@ 35 ], "measured_lab": [ - 63.934, - 26.355, - 36.534 + 62.99, + 27.33, + 27.98 ], - "measured_rgb": "#D8875B", - "source": "interpolated" + "measured_rgb": "#D38568", + "source": "measured" }, { "mode": "CMYW", @@ -6834,12 +6834,12 @@ 30 ], "measured_lab": [ - 64.015, - 25.97, - 38.727 + 63.4, + 25.17, + 33.91 ], - "measured_rgb": "#D88857", - "source": "interpolated" + "measured_rgb": "#D4875E", + "source": "measured" }, { "mode": "CMYW", @@ -6864,12 +6864,12 @@ 25 ], "measured_lab": [ - 63.753, - 26.364, - 40.092 + 61.18, + 29.42, + 27.72 ], - "measured_rgb": "#D88754", - "source": "interpolated" + "measured_rgb": "#D17E64", + "source": "measured" }, { "mode": "CMYW", @@ -6894,12 +6894,12 @@ 20 ], "measured_lab": [ - 64.808, - 24.427, - 43.305 + 59.89, + 31.27, + 25.11 ], - "measured_rgb": "#D98B50", - "source": "interpolated" + "measured_rgb": "#CF7966", + "source": "measured" }, { "mode": "CMYW", @@ -6924,11 +6924,11 @@ 40 ], "measured_lab": [ - 59.7, - 36.79, - 22.33 + 59.5, + 33.72, + 17.49 ], - "measured_rgb": "#D5746B", + "measured_rgb": "#CF7772", "source": "measured" }, { @@ -6954,12 +6954,12 @@ 35 ], "measured_lab": [ - 61.215, - 32.341, - 28.641 + 59.49, + 33.87, + 17.34 ], - "measured_rgb": "#D67C63", - "source": "interpolated" + "measured_rgb": "#CF7773", + "source": "measured" }, { "mode": "CMYW", @@ -6984,11 +6984,11 @@ 30 ], "measured_lab": [ - 63.06, - 27.54, - 36.56 + 59.88, + 32.77, + 22.0 ], - "measured_rgb": "#D78459", + "measured_rgb": "#D0786B", "source": "measured" }, { @@ -7014,12 +7014,12 @@ 25 ], "measured_lab": [ - 62.983, - 27.508, - 36.188 + 58.58, + 33.89, + 21.05 ], - "measured_rgb": "#D68459", - "source": "interpolated" + "measured_rgb": "#CD746A", + "source": "measured" }, { "mode": "CMYW", @@ -7044,11 +7044,11 @@ 20 ], "measured_lab": [ - 62.76, - 27.62, - 36.83 + 59.5, + 31.18, + 26.51 ], - "measured_rgb": "#D68358", + "measured_rgb": "#CE7862", "source": "measured" }, { @@ -7074,12 +7074,12 @@ 35 ], "measured_lab": [ - 60.188, - 34.818, - 23.527 + 58.96, + 35.14, + 15.3 ], - "measured_rgb": "#D4776A", - "source": "interpolated" + "measured_rgb": "#CE7475", + "source": "measured" }, { "mode": "CMYW", @@ -7104,12 +7104,12 @@ 30 ], "measured_lab": [ - 60.885, - 32.692, - 27.032 + 58.15, + 35.31, + 18.11 ], - "measured_rgb": "#D57B65", - "source": "interpolated" + "measured_rgb": "#CD726E", + "source": "measured" }, { "mode": "CMYW", @@ -7134,12 +7134,12 @@ 25 ], "measured_lab": [ - 61.837, - 29.803, - 31.746 + 58.1, + 36.43, + 15.98 ], - "measured_rgb": "#D57F5F", - "source": "interpolated" + "measured_rgb": "#CE7172", + "source": "measured" }, { "mode": "CMYW", @@ -7164,12 +7164,12 @@ 20 ], "measured_lab": [ - 62.068, - 29.258, - 33.017 + 57.38, + 34.94, + 21.31 ], - "measured_rgb": "#D5805D", - "source": "interpolated" + "measured_rgb": "#CB7066", + "source": "measured" }, { "mode": "CMYW", @@ -7193,3672 +7193,3672 @@ 20, 30 ], + "measured_lab": [ + 57.08, + 37.78, + 13.15 + ], + "measured_rgb": "#CB6D74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 56.95, + 38.09, + 14.5 + ], + "measured_rgb": "#CC6D71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 57.03, + 37.09, + 17.89 + ], + "measured_rgb": "#CC6D6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 56.59, + 40.06, + 11.45 + ], + "measured_rgb": "#CC6A76", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 56.35, + 39.5, + 14.12 + ], + "measured_rgb": "#CC6A71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 56.79, + 44.06, + 0.12 + ], + "measured_rgb": "#CE688A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 60.61, + 34.14, + 50.34 + ], + "measured_rgb": "#DB7839", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 57.92, + 38.03, + 46.93 + ], + "measured_rgb": "#D86D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 54.45, + 45.01, + 43.39 + ], + "measured_rgb": "#D55D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 52.18, + 47.64, + 41.46 + ], + "measured_rgb": "#D15537", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.08, + 42.25, + 42.36 + ], + "measured_rgb": "#D05F3A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 51.19, + 47.93, + 39.54 + ], + "measured_rgb": "#CE5239", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 49.49, + 50.76, + 38.06 + ], + "measured_rgb": "#CC4A38", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 50.15, + 48.98, + 38.57 + ], + "measured_rgb": "#CC4E38", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 49.79, + 47.23, + 37.84 + ], + "measured_rgb": "#C94F39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 46.81, + 51.92, + 34.7 + ], + "measured_rgb": "#C54138", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 46.58, + 51.44, + 34.56 + ], + "measured_rgb": "#C34137", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 46.73, + 51.61, + 33.68 + ], + "measured_rgb": "#C44139", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 46.44, + 51.41, + 32.33 + ], + "measured_rgb": "#C3413B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 28.39, + 4.73, + -17.78 + ], + "measured_rgb": "#3A425E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 27.99, + 5.7, + -13.75 + ], + "measured_rgb": "#404057", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 28.01, + 5.88, + -12.79 + ], + "measured_rgb": "#414056", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 28.36, + 7.17, + -7.97 + ], + "measured_rgb": "#48404F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 28.46, + 8.34, + -6.09 + ], + "measured_rgb": "#4C3F4D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 29.18, + 7.99, + -6.06 + ], + "measured_rgb": "#4D414E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 29.47, + 9.68, + -3.27 + ], + "measured_rgb": "#52404B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 29.14, + 11.96, + -0.91 + ], + "measured_rgb": "#563E46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 29.79, + 10.84, + -2.53 + ], + "measured_rgb": "#55404A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 29.84, + 13.65, + 0.54 + ], + "measured_rgb": "#5B3F46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 30.46, + 16.09, + 2.93 + ], + "measured_rgb": "#613E44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 31.4, + 20.19, + 7.06 + ], + "measured_rgb": "#6B3D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 32.0, + 21.78, + 8.02 + ], + "measured_rgb": "#6E3D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 61.58, + 37.37, + 13.91 + ], + "measured_rgb": "#D8797E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 60.1, + 40.18, + 16.87 + ], + "measured_rgb": "#D97375", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 59.12, + 40.19, + 17.09 + ], + "measured_rgb": "#D67072", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 56.42, + 42.46, + 18.74 + ], + "measured_rgb": "#D26769", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.17, + 44.16, + 20.38 + ], + "measured_rgb": "#CE5F61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 53.53, + 44.14, + 20.39 + ], + "measured_rgb": "#CC5D5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 55.16, + 41.55, + 17.38 + ], + "measured_rgb": "#CC6468", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 52.54, + 43.49, + 19.07 + ], + "measured_rgb": "#C85B5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 52.43, + 43.2, + 19.01 + ], + "measured_rgb": "#C75B5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 49.22, + 47.26, + 24.6 + ], + "measured_rgb": "#C44E4E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 48.94, + 47.01, + 23.99 + ], + "measured_rgb": "#C34E4E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 48.65, + 46.66, + 23.5 + ], + "measured_rgb": "#C14D4F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 46.24, + 48.73, + 26.28 + ], + "measured_rgb": "#BD4444", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 35.03, + -18.52, + -8.74 + ], + "measured_rgb": "#185B60", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 34.35, + -18.03, + -10.06 + ], + "measured_rgb": "#145960", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 36.58, + -19.91, + -5.37 + ], + "measured_rgb": "#205F5E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 40.4, + -23.93, + 5.19 + ], + "measured_rgb": "#306956", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 40.47, + -25.72, + 6.68 + ], + "measured_rgb": "#2D6A54", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 42.28, + -26.69, + 10.44 + ], + "measured_rgb": "#346F52", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 42.84, + -27.22, + 11.67 + ], + "measured_rgb": "#357051", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 45.25, + -27.6, + 15.74 + ], + "measured_rgb": "#3F7750", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 48.65, + -28.39, + 22.55 + ], + "measured_rgb": "#4C7F4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 50.85, + -29.47, + 27.4 + ], + "measured_rgb": "#538549", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 54.25, + -29.09, + 32.92 + ], + "measured_rgb": "#608E46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 49.95, + -30.96, + 23.7 + ], + "measured_rgb": "#4A844D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 59.02, + -29.15, + 40.67 + ], + "measured_rgb": "#719A43", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 86.91, + -14.57, + 48.46 + ], + "measured_rgb": "#DDDF7B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 86.88, + -15.04, + 54.18 + ], + "measured_rgb": "#DFDF6F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 86.41, + -15.7, + 60.31 + ], + "measured_rgb": "#DFDE61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 86.06, + -14.91, + 59.87 + ], + "measured_rgb": "#DFDD61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 85.89, + -14.7, + 63.29 + ], + "measured_rgb": "#E0DC58", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 86.91, + -13.69, + 66.56 + ], + "measured_rgb": "#E6DE53", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 85.49, + -13.71, + 65.56 + ], + "measured_rgb": "#E1DA52", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 86.77, + -12.65, + 71.25 + ], + "measured_rgb": "#E9DD47", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 85.39, + -13.63, + 72.72 + ], + "measured_rgb": "#E3DA3F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 85.21, + -13.5, + 76.71 + ], + "measured_rgb": "#E4D931", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 86.67, + -11.8, + 76.43 + ], + "measured_rgb": "#EBDC37", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 85.49, + -11.97, + 77.96 + ], + "measured_rgb": "#E8D92E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 85.33, + -12.02, + 80.1 + ], + "measured_rgb": "#E8D925", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 59.26, + -2.93, + -35.61 + ], + "measured_rgb": "#5593CD", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 59.41, + -2.35, + -34.23 + ], + "measured_rgb": "#5B93CB", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 53.09, + -0.39, + -40.48 + ], + "measured_rgb": "#3E83C4", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 52.81, + 0.36, + -39.81 + ], + "measured_rgb": "#4281C2", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 48.06, + 2.47, + -43.46 + ], + "measured_rgb": "#2D75BB", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 46.13, + 3.72, + -44.68 + ], + "measured_rgb": "#2670B8", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 46.04, + 3.98, + -43.55 + ], + "measured_rgb": "#2D6FB6", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 44.42, + 5.04, + -44.41 + ], + "measured_rgb": "#286BB3", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 44.98, + 5.1, + -42.4 + ], + "measured_rgb": "#336CB1", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 42.2, + 6.27, + -44.38 + ], + "measured_rgb": "#2664AD", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 39.63, + 8.04, + -46.0 + ], + "measured_rgb": "#1C5EA9", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 38.59, + 8.36, + -46.18 + ], + "measured_rgb": "#175BA6", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 37.5, + 9.22, + -46.13 + ], + "measured_rgb": "#1858A3", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 32.23, + -3.53, + -0.01 + ], + "measured_rgb": "#464E4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 32.81, + -4.24, + 0.56 + ], + "measured_rgb": "#464F4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 34.15, + -4.49, + 3.87 + ], + "measured_rgb": "#4B524A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 37.35, + -6.99, + 11.16 + ], + "measured_rgb": "#545B46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 36.99, + -4.61, + 9.94 + ], + "measured_rgb": "#565947", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 37.25, + -1.03, + 10.47 + ], + "measured_rgb": "#5D5847", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 42.82, + -4.42, + 20.44 + ], + "measured_rgb": "#6A6643", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 44.71, + -2.87, + 24.85 + ], + "measured_rgb": "#736A40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 46.34, + -1.41, + 27.3 + ], + "measured_rgb": "#7B6D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 30.83, + -1.07, + -2.8 + ], + "measured_rgb": "#45494D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 32.93, + -0.38, + 3.48 + ], + "measured_rgb": "#4F4D48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 34.58, + -2.45, + 6.26 + ], + "measured_rgb": "#525247", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 36.48, + -2.69, + 10.74 + ], + "measured_rgb": "#585745", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 36.81, + 0.18, + 11.24 + ], + "measured_rgb": "#5E5645", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 40.56, + -1.97, + 18.21 + ], + "measured_rgb": "#676042", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 44.1, + -1.72, + 24.33 + ], + "measured_rgb": "#736840", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 45.28, + 1.15, + 26.65 + ], + "measured_rgb": "#7C693E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 31.65, + 0.49, + 0.75 + ], + "measured_rgb": "#4C4A49", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 34.96, + -1.74, + 8.49 + ], + "measured_rgb": "#555345", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 35.0, + -0.46, + 8.13 + ], + "measured_rgb": "#575245", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 35.49, + 2.18, + 9.86 + ], + "measured_rgb": "#5D5244", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 37.72, + 0.55, + 13.56 + ], + "measured_rgb": "#625843", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 43.18, + 0.87, + 23.69 + ], + "measured_rgb": "#75643F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 43.6, + 2.7, + 23.97 + ], + "measured_rgb": "#78643F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 33.04, + 2.13, + 3.9 + ], + "measured_rgb": "#544C48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 33.94, + 2.25, + 7.42 + ], + "measured_rgb": "#584E44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 33.08, + 7.54, + 8.06 + ], + "measured_rgb": "#5E4941", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 39.5, + -1.02, + 16.66 + ], + "measured_rgb": "#655D42", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 40.42, + 3.78, + 19.13 + ], + "measured_rgb": "#705C40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 42.47, + 7.35, + 22.43 + ], + "measured_rgb": "#7C5F40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 32.03, + 5.65, + 4.57 + ], + "measured_rgb": "#574844", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 32.62, + 7.5, + 6.94 + ], + "measured_rgb": "#5C4842", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 33.68, + 8.82, + 9.29 + ], + "measured_rgb": "#624A41", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 38.68, + 6.76, + 16.51 + ], + "measured_rgb": "#6F5641", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 38.56, + 10.09, + 17.18 + ], + "measured_rgb": "#73543F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 33.4, + 5.95, + 7.54 + ], + "measured_rgb": "#5C4B43", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 33.27, + 11.92, + 8.78 + ], + "measured_rgb": "#654741", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 39.06, + 7.04, + 17.32 + ], + "measured_rgb": "#705740", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 38.92, + 11.63, + 18.79 + ], + "measured_rgb": "#77543E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 32.56, + 13.56, + 9.0 + ], + "measured_rgb": "#66443F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 33.92, + 11.71, + 10.22 + ], + "measured_rgb": "#674940", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 35.68, + 13.35, + 13.16 + ], + "measured_rgb": "#6F4C40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 32.37, + 16.51, + 9.16 + ], + "measured_rgb": "#69423E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 38.21, + 13.95, + 17.68 + ], + "measured_rgb": "#78513E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 35.06, + 16.29, + 13.06 + ], + "measured_rgb": "#71483E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 60.59, + 34.71, + 26.62 + ], + "measured_rgb": "#D67865", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 59.65, + 36.54, + 32.53 + ], + "measured_rgb": "#D87458", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 60.45, + 34.97, + 33.46 + ], + "measured_rgb": "#D87759", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 60.48, + 35.03, + 35.45 + ], + "measured_rgb": "#D97755", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 60.74, + 35.59, + 37.63 + ], + "measured_rgb": "#DB7752", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 60.84, + 33.42, + 34.85 + ], + "measured_rgb": "#D87A57", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], "measured_lab": [ 59.98, - 34.97, - 21.22 + 34.25, + 42.34 ], - "measured_rgb": "#D3776D", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 25, - 25 - ], - "measured_lab": [ - 60.398, - 33.292, - 25.022 - ], - "measured_rgb": "#D37967", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 30, - 20 - ], - "measured_lab": [ - 60.8, - 31.47, - 28.02 - ], - "measured_rgb": "#D37C63", - "source": "measured" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 20, - 25 - ], - "measured_lab": [ - 59.099, - 36.827, - 20.252 - ], - "measured_rgb": "#D3736D", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 25, - 20 - ], - "measured_lab": [ - 59.751, - 34.909, - 23.141 - ], - "measured_rgb": "#D37669", - "source": "interpolated" - }, - { - "mode": "CMYW", - "material": "PLA Basic", - "components": [ - { - "key": "Magenta", - "rgb": "#EC008C" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 60, - 20, - 20 - ], - "measured_lab": [ - 57.81, - 39.76, - 17.5 - ], - "measured_rgb": "#D26D6E", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 62.49, - 27.88, - 52.76 - ], - "measured_rgb": "#D98237", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 60.433, - 30.793, - 50.433 - ], - "measured_rgb": "#D67A38", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 58.377, - 33.707, - 48.107 - ], - "measured_rgb": "#D47238", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 56.32, - 36.62, - 45.78 - ], - "measured_rgb": "#D16B38", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 54.263, - 39.533, - 43.453 - ], - "measured_rgb": "#CE6338", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 52.207, - 42.447, - 41.127 - ], - "measured_rgb": "#CB5A38", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 50.15, - 45.36, - 38.8 - ], - "measured_rgb": "#C85237", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 49.213, - 46.43, - 38.045 - ], - "measured_rgb": "#C64E37", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 48.277, - 47.5, - 37.29 - ], - "measured_rgb": "#C44B36", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 47.34, - 48.57, - 36.535 - ], - "measured_rgb": "#C34735", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 46.403, - 49.64, - 35.78 - ], - "measured_rgb": "#C14335", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 45.467, - 50.71, - 35.025 - ], - "measured_rgb": "#BF3F34", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 44.53, - 51.78, - 34.27 - ], - "measured_rgb": "#BD3B33", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 29.46, - 3.51, - -19.32 - ], - "measured_rgb": "#384563", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 29.34, - 4.79, - -16.368 - ], - "measured_rgb": "#3E445E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 29.22, - 6.07, - -13.417 - ], - "measured_rgb": "#44435A", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 29.1, - 7.35, - -10.465 - ], - "measured_rgb": "#484155", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 28.98, - 8.63, - -7.513 - ], - "measured_rgb": "#4D4050", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 28.86, - 9.91, - -4.562 - ], - "measured_rgb": "#503F4B", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 28.74, - 11.19, - -1.61 - ], - "measured_rgb": "#543E47", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 29.12, - 13.203, - 0.292 - ], - "measured_rgb": "#583D45", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 29.5, - 15.217, - 2.193 - ], - "measured_rgb": "#5D3D43", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 29.88, - 17.23, - 4.095 - ], - "measured_rgb": "#623C41", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 30.26, - 19.243, - 5.997 - ], - "measured_rgb": "#663B3F", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 30.64, - 21.257, - 7.898 - ], - "measured_rgb": "#6A3B3D", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 31.02, - 23.27, - 9.8 - ], - "measured_rgb": "#6E3A3B", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 60.85, - 40.5, - 16.46 - ], - "measured_rgb": "#DC7478", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 59.283, - 41.47, - 17.302 - ], - "measured_rgb": "#D96F72", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 57.717, - 42.44, - 18.143 - ], - "measured_rgb": "#D66A6D", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 56.15, - 43.41, - 18.985 - ], - "measured_rgb": "#D26568", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 54.583, - 44.38, - 19.827 - ], - "measured_rgb": "#CF6063", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 53.017, - 45.35, - 20.668 - ], - "measured_rgb": "#CC5B5E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 51.45, - 46.32, - 21.51 - ], - "measured_rgb": "#C95558", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 50.295, - 46.967, - 23.548 - ], - "measured_rgb": "#C75152", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 49.14, - 47.613, - 25.587 - ], - "measured_rgb": "#C54D4C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 47.985, - 48.26, - 27.625 - ], - "measured_rgb": "#C24946", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 46.83, - 48.907, - 29.663 - ], - "measured_rgb": "#C04540", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 45.675, - 49.553, - 31.702 - ], - "measured_rgb": "#BE413A", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 44.52, - 50.2, - 33.74 - ], - "measured_rgb": "#BB3D34", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 36.34, - -18.37, - -11.18 - ], - "measured_rgb": "#155E67", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 37.912, - -19.985, - -6.787 - ], - "measured_rgb": "#206264", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 39.483, - -21.6, - -2.393 - ], - "measured_rgb": "#286760", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 41.055, - -23.215, - 2.0 - ], - "measured_rgb": "#2F6B5D", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 42.627, - -24.83, - 6.393 - ], - "measured_rgb": "#356F59", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 44.198, - -26.445, - 10.787 - ], - "measured_rgb": "#3A7456", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 45.77, - -28.06, - 15.18 - ], - "measured_rgb": "#3F7852", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 48.035, - -28.535, - 19.455 - ], - "measured_rgb": "#477E50", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 50.3, - -29.01, - 23.73 - ], - "measured_rgb": "#50844E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 52.565, - -29.485, - 28.005 - ], - "measured_rgb": "#588A4C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 54.83, - -29.96, - 32.28 - ], - "measured_rgb": "#5F9049", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 57.095, - -30.435, - 36.555 - ], - "measured_rgb": "#679646", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 59.36, - -30.91, - 40.83 - ], - "measured_rgb": "#6E9C43", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 88.94, - -14.1, - 49.31 - ], - "measured_rgb": "#E5E57F", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 88.612, - -13.948, - 52.973 - ], - "measured_rgb": "#E6E477", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 88.283, - -13.797, - 56.637 - ], - "measured_rgb": "#E6E26E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 87.955, - -13.645, - 60.3 - ], - "measured_rgb": "#E7E165", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 87.627, - -13.493, - 63.963 - ], - "measured_rgb": "#E8E05C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 87.298, - -13.342, - 67.627 - ], - "measured_rgb": "#E8DF52", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 86.97, - -13.19, - 71.29 - ], - "measured_rgb": "#E9DE47", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 86.717, - -12.727, - 71.763 - ], - "measured_rgb": "#E9DD45", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 86.463, - -12.263, - 72.237 - ], - "measured_rgb": "#E9DC43", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 86.21, - -11.8, - 72.71 - ], - "measured_rgb": "#E9DB41", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 85.957, - -11.337, - 73.183 - ], - "measured_rgb": "#E9DA3F", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 85.703, - -10.873, - 73.657 - ], - "measured_rgb": "#E9D93D", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 85.45, - -10.41, - 74.13 - ], - "measured_rgb": "#EAD83B", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 80 - ], - "measured_lab": [ - 64.3, - -4.33, - -32.03 - ], - "measured_rgb": "#68A1D5", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 25, - 75 - ], - "measured_lab": [ - 61.86, - -3.247, - -33.64 - ], - "measured_rgb": "#619AD1", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 70 - ], - "measured_lab": [ - 59.42, - -2.163, - -35.25 - ], - "measured_rgb": "#5993CD", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 65 - ], - "measured_lab": [ - 56.98, - -1.08, - -36.86 - ], - "measured_rgb": "#528DC9", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 40, - 60 - ], - "measured_lab": [ - 54.54, - 0.003, - -38.47 - ], - "measured_rgb": "#4A86C5", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 45, - 55 - ], - "measured_lab": [ - 52.1, - 1.087, - -40.08 - ], - "measured_rgb": "#427FC1", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 50, - 50 - ], - "measured_lab": [ - 49.66, - 2.17, - -41.69 - ], - "measured_rgb": "#3979BD", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 55, - 45 - ], - "measured_lab": [ - 47.967, - 3.17, - -42.525 - ], - "measured_rgb": "#3474BA", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 60, - 40 - ], - "measured_lab": [ - 46.273, - 4.17, - -43.36 - ], - "measured_rgb": "#2F6FB6", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 65, - 35 - ], - "measured_lab": [ - 44.58, - 5.17, - -44.195 - ], - "measured_rgb": "#2A6BB3", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 70, - 30 - ], - "measured_lab": [ - 42.887, - 6.17, - -45.03 - ], - "measured_rgb": "#2566B0", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 75, - 25 - ], - "measured_lab": [ - 41.193, - 7.17, - -45.865 - ], - "measured_rgb": "#1F62AD", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 80, - 20 - ], - "measured_lab": [ - 39.5, - 8.17, - -46.7 - ], - "measured_rgb": "#175DAA", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 20, - 60 - ], - "measured_lab": [ - 31.91, - 0.15, - -2.61 - ], - "measured_rgb": "#494B4F", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 25, - 55 - ], - "measured_lab": [ - 33.44, - 0.855, - 2.003 - ], - "measured_rgb": "#514E4C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 30, - 50 - ], - "measured_lab": [ - 35.38, - -0.41, - 5.94 - ], - "measured_rgb": "#57534A", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 35, - 45 - ], - "measured_lab": [ - 35.64, - 1.093, - 7.858 - ], - "measured_rgb": "#5B5347", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 40, - 40 - ], - "measured_lab": [ - 36.29, - 0.41, - 9.25 - ], - "measured_rgb": "#5C5547", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 45, - 35 - ], - "measured_lab": [ - 37.468, - 2.187, - 12.217 - ], - "measured_rgb": "#635645", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 50, - 30 - ], - "measured_lab": [ - 38.64, - 1.69, - 14.18 - ], - "measured_rgb": "#665944", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 55, - 25 - ], - "measured_lab": [ - 40.133, - 3.466, - 17.156 - ], - "measured_rgb": "#6E5C43", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 20, - 60, - 20 - ], - "measured_lab": [ - 42.17, - 4.75, - 20.85 - ], - "measured_rgb": "#776041", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 20, - 55 - ], - "measured_lab": [ - 31.95, - 3.178, - 0.199 - ], - "measured_rgb": "#504A4B", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 25, - 50 - ], - "measured_lab": [ - 33.03, - 2.825, - 2.678 - ], - "measured_rgb": "#544C4A", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 30, - 45 - ], - "measured_lab": [ - 34.485, - 2.602, - 6.069 - ], - "measured_rgb": "#594F48", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 35, - 40 - ], - "measured_lab": [ - 35.25, - 3.28, - 8.383 - ], - "measured_rgb": "#5D5146", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 40, - 35 - ], - "measured_lab": [ - 36.043, - 3.661, - 10.318 - ], - "measured_rgb": "#615244", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 45, - 30 - ], - "measured_lab": [ - 37.473, - 4.46, - 13.22 - ], - "measured_rgb": "#675543", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 50, - 25 - ], - "measured_lab": [ - 39.044, - 4.952, - 16.087 - ], - "measured_rgb": "#6D5842", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 25, - 55, - 20 - ], - "measured_lab": [ - 39.826, - 4.901, - 17.277 - ], - "measured_rgb": "#6F5A42", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 20, - 50 - ], - "measured_lab": [ - 30.91, - 6.56, - 0.53 - ], - "measured_rgb": "#534548", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 25, - 45 - ], - "measured_lab": [ - 32.258, - 5.695, - 3.2 - ], - "measured_rgb": "#574947", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 30, - 40 - ], - "measured_lab": [ - 33.92, - 5.0, - 6.85 - ], - "measured_rgb": "#5C4D45", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 35, - 35 - ], - "measured_lab": [ - 34.678, - 6.296, - 8.956 - ], - "measured_rgb": "#614E44", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 40, - 30 - ], - "measured_lab": [ - 35.41, - 8.12, - 11.49 - ], - "measured_rgb": "#664E41", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 45, - 25 - ], - "measured_lab": [ - 37.46, - 7.938, - 14.712 - ], - "measured_rgb": "#6D5341", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 30, - 50, - 20 - ], - "measured_lab": [ - 39.55, - 7.62, - 17.96 - ], - "measured_rgb": "#735840", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 20, - 45 - ], - "measured_lab": [ - 31.482, - 8.343, - 3.323 - ], - "measured_rgb": "#594545", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 25, - 40 - ], - "measured_lab": [ - 32.495, - 8.078, - 5.33 - ], - "measured_rgb": "#5C4844", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 30, - 35 - ], - "measured_lab": [ - 33.939, - 8.505, - 8.552 - ], - "measured_rgb": "#624B43", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 35, - 30 - ], - "measured_lab": [ - 34.88, - 9.587, - 10.82 - ], - "measured_rgb": "#674C41", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 40, - 25 - ], - "measured_lab": [ - 35.457, - 10.859, - 12.473 - ], - "measured_rgb": "#6B4D40", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 35, - 45, - 20 - ], - "measured_lab": [ - 37.115, - 9.671, - 14.811 - ], - "measured_rgb": "#6E5140", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 20, - 40 - ], - "measured_lab": [ - 31.04, - 10.39, - 4.11 - ], - "measured_rgb": "#5B4343", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 25, - 35 - ], - "measured_lab": [ - 32.583, - 10.243, - 6.938 - ], - "measured_rgb": "#604742", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 30, - 30 - ], - "measured_lab": [ - 34.11, - 10.36, - 9.83 - ], - "measured_rgb": "#654A41", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 35, - 25 - ], - "measured_lab": [ - 35.023, - 11.606, - 11.92 - ], - "measured_rgb": "#6A4B40", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 40, - 40, - 20 - ], - "measured_lab": [ - 36.08, - 14.87, - 15.11 - ], - "measured_rgb": "#734B3D", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 20, - 35 - ], - "measured_lab": [ - 31.752, - 12.508, - 6.458 - ], - "measured_rgb": "#614341", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 25, - 30 - ], - "measured_lab": [ - 32.888, - 12.963, - 8.565 - ], - "measured_rgb": "#654640", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 30, - 25 - ], - "measured_lab": [ - 34.023, - 13.418, - 10.672 - ], - "measured_rgb": "#6A4840", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 45, - 35, - 20 - ], - "measured_lab": [ - 34.834, - 13.824, - 12.307 - ], - "measured_rgb": "#6D493F", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 50, - 20, - 30 - ], - "measured_lab": [ - 31.33, - 14.17, - 6.7 - ], - "measured_rgb": "#624140", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 50, - 25, - 25 - ], - "measured_lab": [ - 33.096, - 14.688, - 9.628 - ], - "measured_rgb": "#69453F", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 50, - 30, - 20 - ], - "measured_lab": [ - 35.07, - 16.93, - 13.62 - ], - "measured_rgb": "#72483E", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 55, - 20, - 25 - ], - "measured_lab": [ - 32.354, - 16.62, - 9.312 - ], - "measured_rgb": "#69423E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 55, - 25, - 20 - ], - "measured_lab": [ - 32.996, - 16.524, - 10.244 - ], - "measured_rgb": "#6B433E", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "Blue", - "rgb": "#0A2989" - } - ], - "ratios": [ - 60, - 20, - 20 - ], - "measured_lab": [ - 32.98, - 19.7, - 11.64 - ], - "measured_rgb": "#70413C", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 20, - 60 - ], - "measured_lab": [ - 62.23, - 35.29, - 25.26 - ], - "measured_rgb": "#DC7C6C", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 25, - 55 - ], - "measured_lab": [ - 61.534, - 35.748, - 26.888 - ], - "measured_rgb": "#DB7A67", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 30, - 50 - ], - "measured_lab": [ - 62.26, - 34.42, - 28.24 - ], - "measured_rgb": "#DB7D66", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 35, - 45 - ], - "measured_lab": [ - 61.158, - 35.561, - 30.821 - ], - "measured_rgb": "#DA795F", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 40, - 40 - ], - "measured_lab": [ - 61.75, - 34.7, - 32.79 - ], - "measured_rgb": "#DC7B5D", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 45, - 35 - ], - "measured_lab": [ - 60.502, - 35.282, - 34.869 - ], - "measured_rgb": "#D97756", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Yellow", - "rgb": "#F4EE2A" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 20, - 50, - 30 - ], - "measured_lab": [ - 60.7, - 34.27, - 37.47 - ], - "measured_rgb": "#D97852", + "measured_rgb": "#D87647", "source": "measured" }, { @@ -10884,12 +10884,12 @@ 25 ], "measured_lab": [ - 60.206, - 34.426, - 39.029 + 59.5, + 34.06, + 45.9 ], - "measured_rgb": "#D8774E", - "source": "interpolated" + "measured_rgb": "#D7753F", + "source": "measured" }, { "mode": "RYBW", @@ -10914,11 +10914,11 @@ 20 ], "measured_lab": [ - 60.1, - 33.81, - 42.27 + 60.73, + 32.52, + 47.98 ], - "measured_rgb": "#D87747", + "measured_rgb": "#D97A3E", "source": "measured" }, { @@ -10944,12 +10944,12 @@ 55 ], "measured_lab": [ - 60.271, - 37.504, - 25.308 + 57.33, + 40.74, + 32.02 ], - "measured_rgb": "#D97567", - "source": "interpolated" + "measured_rgb": "#D66A54", + "source": "measured" }, { "mode": "RYBW", @@ -10974,12 +10974,12 @@ 50 ], "measured_lab": [ - 60.112, - 37.532, - 27.162 + 57.86, + 38.74, + 32.01 ], - "measured_rgb": "#D97463", - "source": "interpolated" + "measured_rgb": "#D56D55", + "source": "measured" }, { "mode": "RYBW", @@ -11004,12 +11004,12 @@ 45 ], "measured_lab": [ - 59.954, - 37.561, - 29.018 + 59.26, + 36.69, + 29.23 ], - "measured_rgb": "#D97460", - "source": "interpolated" + "measured_rgb": "#D6735D", + "source": "measured" }, { "mode": "RYBW", @@ -11034,12 +11034,12 @@ 40 ], "measured_lab": [ - 59.465, - 37.562, - 31.432 + 57.69, + 38.61, + 37.53 ], - "measured_rgb": "#D8735A", - "source": "interpolated" + "measured_rgb": "#D66D4B", + "source": "measured" }, { "mode": "RYBW", @@ -11064,12 +11064,12 @@ 35 ], "measured_lab": [ - 59.055, - 37.325, - 33.396 + 58.84, + 37.53, + 35.54 ], - "measured_rgb": "#D77256", - "source": "interpolated" + "measured_rgb": "#D77151", + "source": "measured" }, { "mode": "RYBW", @@ -11094,12 +11094,12 @@ 30 ], "measured_lab": [ - 59.055, - 36.875, - 34.347 + 58.26, + 36.31, + 36.61 ], - "measured_rgb": "#D77254", - "source": "interpolated" + "measured_rgb": "#D4704E", + "source": "measured" }, { "mode": "RYBW", @@ -11124,12 +11124,12 @@ 25 ], "measured_lab": [ - 59.367, - 35.876, - 36.068 + 58.63, + 36.73, + 43.16 ], - "measured_rgb": "#D77451", - "source": "interpolated" + "measured_rgb": "#D77142", + "source": "measured" }, { "mode": "RYBW", @@ -11154,12 +11154,12 @@ 20 ], "measured_lab": [ - 59.55, - 35.359, - 37.618 + 58.5, + 37.1, + 43.64 ], - "measured_rgb": "#D7744F", - "source": "interpolated" + "measured_rgb": "#D87041", + "source": "measured" }, { "mode": "RYBW", @@ -11184,11 +11184,11 @@ 50 ], "measured_lab": [ - 58.47, - 39.69, - 23.5 + 56.37, + 41.44, + 30.12 ], - "measured_rgb": "#D66E66", + "measured_rgb": "#D46755", "source": "measured" }, { @@ -11214,12 +11214,12 @@ 45 ], "measured_lab": [ - 57.918, - 40.28, - 27.642 + 55.61, + 41.45, + 34.19 ], - "measured_rgb": "#D66C5D", - "source": "interpolated" + "measured_rgb": "#D2654C", + "source": "measured" }, { "mode": "RYBW", @@ -11244,11 +11244,11 @@ 40 ], "measured_lab": [ - 57.49, - 40.73, - 31.65 + 55.53, + 41.59, + 37.12 ], - "measured_rgb": "#D76A55", + "measured_rgb": "#D36447", "source": "measured" }, { @@ -11274,12 +11274,12 @@ 35 ], "measured_lab": [ - 56.315, - 41.212, - 32.738 + 56.91, + 39.02, + 32.21 ], - "measured_rgb": "#D46750", - "source": "interpolated" + "measured_rgb": "#D36A53", + "source": "measured" }, { "mode": "RYBW", @@ -11304,11 +11304,11 @@ 30 ], "measured_lab": [ - 56.36, - 40.4, - 33.05 + 57.16, + 38.42, + 33.48 ], - "measured_rgb": "#D36850", + "measured_rgb": "#D36C51", "source": "measured" }, { @@ -11334,12 +11334,12 @@ 25 ], "measured_lab": [ - 56.883, - 39.276, - 34.064 + 56.26, + 39.43, + 40.55 ], - "measured_rgb": "#D36A4F", - "source": "interpolated" + "measured_rgb": "#D36842", + "source": "measured" }, { "mode": "RYBW", @@ -11364,11 +11364,11 @@ 20 ], "measured_lab": [ - 57.41, - 38.13, - 34.08 + 56.61, + 38.72, + 43.85 ], - "measured_rgb": "#D46D50", + "measured_rgb": "#D4693C", "source": "measured" }, { @@ -11394,12 +11394,12 @@ 45 ], "measured_lab": [ - 55.689, - 42.794, - 28.026 + 54.0, + 44.02, + 32.55 ], - "measured_rgb": "#D36457", - "source": "interpolated" + "measured_rgb": "#D05E4B", + "source": "measured" }, { "mode": "RYBW", @@ -11424,12 +11424,12 @@ 40 ], "measured_lab": [ - 55.607, - 42.722, - 29.887 + 53.03, + 44.01, + 34.64 ], - "measured_rgb": "#D36454", - "source": "interpolated" + "measured_rgb": "#CE5B45", + "source": "measured" }, { "mode": "RYBW", @@ -11454,12 +11454,12 @@ 35 ], "measured_lab": [ - 55.526, - 42.651, - 31.749 + 53.36, + 44.2, + 37.81 ], - "measured_rgb": "#D36350", - "source": "interpolated" + "measured_rgb": "#D05C41", + "source": "measured" }, { "mode": "RYBW", @@ -11484,12 +11484,12 @@ 30 ], "measured_lab": [ - 55.095, - 42.505, - 33.515 + 55.03, + 40.44, + 32.66 ], - "measured_rgb": "#D2624C", - "source": "interpolated" + "measured_rgb": "#CF644D", + "source": "measured" }, { "mode": "RYBW", @@ -12024,11 +12024,11 @@ 60 ], "measured_lab": [ - 46.44, - 9.95, - -5.84 + 43.95, + 12.77, + -5.1 ], - "measured_rgb": "#7B6978", + "measured_rgb": "#796171", "source": "measured" }, { @@ -12054,12 +12054,12 @@ 55 ], "measured_lab": [ - 43.093, - 9.297, - -6.861 + 42.04, + 9.64, + -8.16 ], - "measured_rgb": "#706171", - "source": "interpolated" + "measured_rgb": "#6D5E71", + "source": "measured" }, { "mode": "RYBW", @@ -12084,11 +12084,11 @@ 50 ], "measured_lab": [ - 40.15, - 7.97, - -8.83 + 41.99, + 8.57, + -9.11 ], - "measured_rgb": "#655B6D", + "measured_rgb": "#6B5F72", "source": "measured" }, { @@ -12114,12 +12114,12 @@ 45 ], "measured_lab": [ - 39.33, - 7.278, - -9.51 + 37.82, + 7.98, + -12.1 ], - "measured_rgb": "#61596C", - "source": "interpolated" + "measured_rgb": "#5D566D", + "source": "measured" }, { "mode": "RYBW", @@ -12144,11 +12144,11 @@ 40 ], "measured_lab": [ - 39.65, - 5.81, - -11.22 + 37.04, + 10.17, + -6.18 ], - "measured_rgb": "#5E5B70", + "measured_rgb": "#635261", "source": "measured" }, { @@ -12174,12 +12174,12 @@ 35 ], "measured_lab": [ - 36.105, - 6.125, - -11.934 + 35.88, + 6.85, + -11.4 ], - "measured_rgb": "#555368", - "source": "interpolated" + "measured_rgb": "#575267", + "source": "measured" }, { "mode": "RYBW", @@ -12204,11 +12204,11 @@ 30 ], "measured_lab": [ - 33.79, - 5.58, - -14.05 + 34.82, + 8.38, + -8.59 ], - "measured_rgb": "#4D4E66", + "measured_rgb": "#594E60", "source": "measured" }, { @@ -12234,12 +12234,12 @@ 25 ], "measured_lab": [ - 32.584, - 5.717, - -14.126 + 33.2, + 9.06, + -8.11 ], - "measured_rgb": "#4A4B63", - "source": "interpolated" + "measured_rgb": "#574A5B", + "source": "measured" }, { "mode": "RYBW", @@ -12264,11 +12264,11 @@ 20 ], "measured_lab": [ - 30.83, - 5.37, - -15.57 + 32.07, + 8.43, + -9.28 ], - "measured_rgb": "#444761", + "measured_rgb": "#52485A", "source": "measured" }, { @@ -12294,12 +12294,12 @@ 55 ], "measured_lab": [ - 44.996, - 10.637, - -4.941 + 43.13, + 13.68, + -2.83 ], - "measured_rgb": "#796573", - "source": "interpolated" + "measured_rgb": "#7A5E6B", + "source": "measured" }, { "mode": "RYBW", @@ -12324,12 +12324,12 @@ 50 ], "measured_lab": [ - 42.687, - 9.97, - -5.912 + 40.14, + 12.29, + -4.61 ], - "measured_rgb": "#71606F", - "source": "interpolated" + "measured_rgb": "#6F5866", + "source": "measured" }, { "mode": "RYBW", @@ -12354,12 +12354,12 @@ 45 ], "measured_lab": [ - 39.862, - 8.788, - -7.563 + 37.63, + 10.55, + -7.27 ], - "measured_rgb": "#675A6A", - "source": "interpolated" + "measured_rgb": "#645364", + "source": "measured" }, { "mode": "RYBW", @@ -12384,12 +12384,12 @@ 40 ], "measured_lab": [ - 38.19, - 8.055, - -8.48 + 38.38, + 9.16, + -8.16 ], - "measured_rgb": "#615668", - "source": "interpolated" + "measured_rgb": "#635668", + "source": "measured" }, { "mode": "RYBW", @@ -12414,12 +12414,12 @@ 35 ], "measured_lab": [ - 37.5, - 7.445, - -9.22 + 35.95, + 10.58, + -5.28 ], - "measured_rgb": "#5E5567", - "source": "interpolated" + "measured_rgb": "#624F5D", + "source": "measured" }, { "mode": "RYBW", @@ -12444,12 +12444,12 @@ 30 ], "measured_lab": [ - 34.875, - 6.985, - -10.533 + 34.42, + 9.15, + -7.21 ], - "measured_rgb": "#554F63", - "source": "interpolated" + "measured_rgb": "#5A4C5C", + "source": "measured" }, { "mode": "RYBW", @@ -12474,12 +12474,12 @@ 25 ], "measured_lab": [ - 33.355, - 6.882, - -11.161 + 34.05, + 8.82, + -7.67 ], - "measured_rgb": "#514C60", - "source": "interpolated" + "measured_rgb": "#594C5C", + "source": "measured" }, { "mode": "RYBW", @@ -12504,12 +12504,12 @@ 20 ], "measured_lab": [ - 32.484, - 6.31, - -12.739 + 31.57, + 8.0, + -9.3 ], - "measured_rgb": "#4C4A60", - "source": "interpolated" + "measured_rgb": "#504759", + "source": "measured" }, { "mode": "RYBW", @@ -12534,283 +12534,283 @@ 50 ], "measured_lab": [ - 45.86, - 11.99, + 42.67, + 15.13, + -1.45 + ], + "measured_rgb": "#7C5C68", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 38.88, + 13.65, + -2.95 + ], + "measured_rgb": "#6F5461", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 36.38, + 12.23, + -5.02 + ], + "measured_rgb": "#664F5E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 36.56, + 10.6, + -6.32 + ], + "measured_rgb": "#635160", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 33.78, + 10.4, + -5.72 + ], + "measured_rgb": "#5C4A59", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 33.68, + 9.66, + -5.71 + ], + "measured_rgb": "#5B4A58", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 31.46, + 8.64, + -7.9 + ], + "measured_rgb": "#524656", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 37.64, + 16.45, + -0.45 + ], + "measured_rgb": "#724F5A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 35.97, + 14.23, + -2.54 + ], + "measured_rgb": "#694D59", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 33.12, + 14.21, -3.07 ], - "measured_rgb": "#7E6672", + "measured_rgb": "#624653", "source": "measured" }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 25, - 45 - ], - "measured_lab": [ - 42.708, - 11.107, - -4.343 - ], - "measured_rgb": "#745F6C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 30, - 40 - ], - "measured_lab": [ - 38.3, - 9.97, - -5.91 - ], - "measured_rgb": "#665564", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 35, - 35 - ], - "measured_lab": [ - 37.05, - 8.832, - -7.45 - ], - "measured_rgb": "#605363", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 40, - 30 - ], - "measured_lab": [ - 34.66, - 8.47, - -7.96 - ], - "measured_rgb": "#594D5E", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 45, - 25 - ], - "measured_lab": [ - 33.568, - 8.272, - -8.298 - ], - "measured_rgb": "#564B5C", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 30, - 50, - 20 - ], - "measured_lab": [ - 31.4, - 8.08, - -8.9 - ], - "measured_rgb": "#504658", - "source": "measured" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 20, - 45 - ], - "measured_lab": [ - 44.09, - 12.48, - -2.568 - ], - "measured_rgb": "#7B616D", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 25, - 40 - ], - "measured_lab": [ - 41.16, - 11.92, - -3.262 - ], - "measured_rgb": "#725B67", - "source": "interpolated" - }, - { - "mode": "RYBW", - "material": "PLA Basic", - "components": [ - { - "key": "Red", - "rgb": "#C12E1F" - }, - { - "key": "Blue", - "rgb": "#0A2989" - }, - { - "key": "White", - "rgb": "#FFFFFF" - } - ], - "ratios": [ - 35, - 30, - 35 - ], - "measured_lab": [ - 38.23, - 11.36, - -3.958 - ], - "measured_rgb": "#6A5461", - "source": "interpolated" - }, { "mode": "RYBW", "material": "PLA Basic", @@ -12834,12 +12834,12 @@ 30 ], "measured_lab": [ - 35.265, - 10.333, - -5.157 + 34.92, + 11.2, + -4.87 ], - "measured_rgb": "#604E5B", - "source": "interpolated" + "measured_rgb": "#614C5A", + "source": "measured" }, { "mode": "RYBW", @@ -12864,12 +12864,12 @@ 25 ], "measured_lab": [ - 33.946, - 9.492, - -6.243 + 32.77, + 11.87, + -3.19 ], - "measured_rgb": "#5B4B5A", - "source": "interpolated" + "measured_rgb": "#5D4752", + "source": "measured" }, { "mode": "RYBW", @@ -12894,12 +12894,12 @@ 20 ], "measured_lab": [ - 33.394, - 8.879, - -7.239 + 30.53, + 13.08, + -1.32 ], - "measured_rgb": "#584A5A", - "source": "interpolated" + "measured_rgb": "#5B414A", + "source": "measured" }, { "mode": "RYBW", @@ -12924,11 +12924,11 @@ 40 ], "measured_lab": [ - 45.25, - 13.53, - -1.37 + 39.18, + 16.47, + 0.67 ], - "measured_rgb": "#80636E", + "measured_rgb": "#76535C", "source": "measured" }, { @@ -12954,12 +12954,12 @@ 35 ], "measured_lab": [ - 40.543, - 13.197, - -1.734 + 33.52, + 16.44, + 0.11 ], - "measured_rgb": "#745863", - "source": "interpolated" + "measured_rgb": "#68454F", + "source": "measured" }, { "mode": "RYBW", @@ -12984,11 +12984,11 @@ 30 ], "measured_lab": [ - 35.23, - 12.19, - -2.7 + 33.8, + 14.01, + -1.98 ], - "measured_rgb": "#644C57", + "measured_rgb": "#644853", "source": "measured" }, { @@ -13014,12 +13014,12 @@ 25 ], "measured_lab": [ - 34.455, - 11.074, - -3.972 + 32.67, + 16.81, + 2.7 ], - "measured_rgb": "#604B58", - "source": "interpolated" + "measured_rgb": "#674349", + "source": "measured" }, { "mode": "RYBW", @@ -13044,11 +13044,11 @@ 20 ], "measured_lab": [ - 32.87, - 10.7, - -4.06 + 31.2, + 13.76, + -0.59 ], - "measured_rgb": "#5B4854", + "measured_rgb": "#5E424B", "source": "measured" }, { @@ -13074,12 +13074,12 @@ 35 ], "measured_lab": [ - 40.503, - 15.308, - 0.332 + 37.08, + 19.55, + 3.74 ], - "measured_rgb": "#78565F", - "source": "interpolated" + "measured_rgb": "#774B52", + "source": "measured" }, { "mode": "RYBW", @@ -13104,12 +13104,12 @@ 30 ], "measured_lab": [ - 37.998, - 14.625, - -0.243 + 32.33, + 16.27, + -0.27 ], - "measured_rgb": "#70515A", - "source": "interpolated" + "measured_rgb": "#64434D", + "source": "measured" }, { "mode": "RYBW", @@ -13134,12 +13134,12 @@ 25 ], "measured_lab": [ - 35.493, - 13.942, - -0.818 + 32.15, + 14.65, + -1.47 ], - "measured_rgb": "#694C55", - "source": "interpolated" + "measured_rgb": "#61434E", + "source": "measured" }, { "mode": "RYBW", @@ -13164,12 +13164,12 @@ 20 ], "measured_lab": [ - 34.5, - 12.689, - -2.045 + 31.51, + 13.89, + -1.21 ], - "measured_rgb": "#644A55", - "source": "interpolated" + "measured_rgb": "#5E424C", + "source": "measured" }, { "mode": "RYBW", @@ -13194,11 +13194,11 @@ 30 ], "measured_lab": [ - 38.26, - 17.77, - 2.61 + 36.64, + 20.21, + 4.79 ], - "measured_rgb": "#774F56", + "measured_rgb": "#774A4F", "source": "measured" }, { @@ -13224,12 +13224,12 @@ 25 ], "measured_lab": [ - 36.514, - 16.453, - 1.608 + 33.48, + 17.53, + 2.16 ], - "measured_rgb": "#704C54", - "source": "interpolated" + "measured_rgb": "#6A444C", + "source": "measured" }, { "mode": "RYBW", @@ -13254,11 +13254,11 @@ 20 ], "measured_lab": [ - 33.25, - 15.01, - 0.49 + 32.33, + 15.16, + 0.23 ], - "measured_rgb": "#65464E", + "measured_rgb": "#63434C", "source": "measured" }, { @@ -13284,12 +13284,12 @@ 25 ], "measured_lab": [ - 36.912, - 18.292, - 3.339 + 34.44, + 20.68, + 5.02 ], - "measured_rgb": "#754C52", - "source": "interpolated" + "measured_rgb": "#72444A", + "source": "measured" }, { "mode": "RYBW", @@ -13314,12 +13314,12 @@ 20 ], "measured_lab": [ - 36.228, - 17.339, - 2.496 + 31.82, + 18.04, + 1.96 ], - "measured_rgb": "#714B52", - "source": "interpolated" + "measured_rgb": "#674048", + "source": "measured" }, { "mode": "RYBW", @@ -13344,11 +13344,11 @@ 20 ], "measured_lab": [ - 35.37, - 20.0, - 5.16 + 33.8, + 20.81, + 5.55 ], - "measured_rgb": "#74474C", + "measured_rgb": "#714248", "source": "measured" }, { @@ -13374,11 +13374,11 @@ 60 ], "measured_lab": [ - 61.06, - -24.52, - 4.52 + 61.95, + -24.93, + 11.39 ], - "measured_rgb": "#629F8B", + "measured_rgb": "#6CA181", "source": "measured" }, { @@ -13404,12 +13404,12 @@ 55 ], "measured_lab": [ - 57.848, - -25.117, - 4.243 + 58.64, + -24.28, + 6.71 ], - "measured_rgb": "#589783", - "source": "interpolated" + "measured_rgb": "#609881", + "source": "measured" }, { "mode": "RYBW", @@ -13434,11 +13434,11 @@ 50 ], "measured_lab": [ - 54.77, - -24.86, - 1.44 + 53.18, + -26.38, + 5.24 ], - "measured_rgb": "#4D8F80", + "measured_rgb": "#4A8B75", "source": "measured" }, { @@ -13464,12 +13464,12 @@ 45 ], "measured_lab": [ - 52.842, - -24.709, - 1.617 + 48.85, + -26.53, + 1.34 ], - "measured_rgb": "#498A7B", - "source": "interpolated" + "measured_rgb": "#388071", + "source": "measured" }, { "mode": "RYBW", @@ -13494,11 +13494,11 @@ 40 ], "measured_lab": [ - 51.03, - -23.9, - 0.04 + 47.76, + -25.67, + 0.94 ], - "measured_rgb": "#448579", + "measured_rgb": "#377D6F", "source": "measured" }, { @@ -13524,12 +13524,12 @@ 35 ], "measured_lab": [ - 50.331, - -22.636, - -1.173 + 47.71, + -23.33, + -0.88 ], - "measured_rgb": "#448279", - "source": "interpolated" + "measured_rgb": "#3B7C72", + "source": "measured" }, { "mode": "RYBW", @@ -13554,11 +13554,11 @@ 30 ], "measured_lab": [ - 50.33, - -19.96, - -4.63 + 45.3, + -23.42, + -3.16 ], - "measured_rgb": "#46827F", + "measured_rgb": "#307670", "source": "measured" }, { @@ -13584,12 +13584,12 @@ 25 ], "measured_lab": [ - 47.473, - -21.21, - -4.144 + 42.32, + -22.68, + -5.37 ], - "measured_rgb": "#3C7B77", - "source": "interpolated" + "measured_rgb": "#256E6C", + "source": "measured" }, { "mode": "RYBW", @@ -13614,11 +13614,11 @@ 20 ], "measured_lab": [ - 44.4, - -21.12, - -5.79 + 40.66, + -22.75, + -6.54 ], - "measured_rgb": "#317372", + "measured_rgb": "#1C6A6A", "source": "measured" }, { @@ -13644,12 +13644,12 @@ 55 ], "measured_lab": [ - 59.761, - -26.071, - 8.356 + 58.19, + -28.22, + 13.86 ], - "measured_rgb": "#609C80", - "source": "interpolated" + "measured_rgb": "#5C9973", + "source": "measured" }, { "mode": "RYBW", @@ -13674,12 +13674,12 @@ 50 ], "measured_lab": [ - 57.712, - -25.973, - 6.767 + 57.54, + -26.75, + 11.39 ], - "measured_rgb": "#59977E", - "source": "interpolated" + "measured_rgb": "#5C9675", + "source": "measured" }, { "mode": "RYBW", @@ -13704,12 +13704,12 @@ 45 ], "measured_lab": [ - 54.838, - -25.724, - 4.421 + 54.04, + -25.84, + 7.96 ], - "measured_rgb": "#4F8F7B", - "source": "interpolated" + "measured_rgb": "#518D73", + "source": "measured" }, { "mode": "RYBW", @@ -13734,12 +13734,12 @@ 40 ], "measured_lab": [ - 52.725, - -25.367, - 3.373 + 49.15, + -27.67, + 5.27 ], - "measured_rgb": "#498A77", - "source": "interpolated" + "measured_rgb": "#3B816B", + "source": "measured" }, { "mode": "RYBW", @@ -13764,12 +13764,12 @@ 35 ], "measured_lab": [ - 51.078, - -24.654, - 2.192 + 50.78, + -24.99, + 5.57 ], - "measured_rgb": "#458575", - "source": "interpolated" + "measured_rgb": "#48846F", + "source": "measured" }, { "mode": "RYBW", @@ -13794,12 +13794,12 @@ 30 ], "measured_lab": [ - 49.633, - -24.048, - 1.07 + 46.19, + -27.43, + 6.2 ], - "measured_rgb": "#418173", - "source": "interpolated" + "measured_rgb": "#367962", + "source": "measured" }, { "mode": "RYBW", @@ -13824,12 +13824,12 @@ 25 ], "measured_lab": [ - 48.295, - -23.241, - -0.276 + 44.25, + -26.25, + 2.19 ], - "measured_rgb": "#3E7D72", - "source": "interpolated" + "measured_rgb": "#2D7464", + "source": "measured" }, { "mode": "RYBW", @@ -13854,12 +13854,12 @@ 20 ], "measured_lab": [ - 47.321, - -22.711, - -1.654 + 43.05, + -25.29, + -0.35 ], - "measured_rgb": "#3B7B72", - "source": "interpolated" + "measured_rgb": "#297166", + "source": "measured" }, { "mode": "RYBW", @@ -13914,12 +13914,12 @@ 45 ], "measured_lab": [ - 56.832, - -27.292, - 9.998 + 56.39, + -27.38, + 13.6 ], - "measured_rgb": "#579576", - "source": "interpolated" + "measured_rgb": "#5A936F", + "source": "measured" }, { "mode": "RYBW", @@ -13944,11 +13944,11 @@ 40 ], "measured_lab": [ - 54.51, - -26.79, - 7.33 + 55.04, + -26.57, + 11.27 ], - "measured_rgb": "#4F8F75", + "measured_rgb": "#56906F", "source": "measured" }, { @@ -13974,12 +13974,12 @@ 35 ], "measured_lab": [ - 52.699, - -26.542, - 6.207 + 50.22, + -27.61, + 7.99 ], - "measured_rgb": "#498A72", - "source": "interpolated" + "measured_rgb": "#428469", + "source": "measured" }, { "mode": "RYBW", @@ -14004,11 +14004,11 @@ 30 ], "measured_lab": [ - 50.59, - -25.92, - 4.68 + 48.43, + -28.67, + 10.95 ], - "measured_rgb": "#448470", + "measured_rgb": "#3F7F60", "source": "measured" }, { @@ -14034,12 +14034,12 @@ 25 ], "measured_lab": [ - 48.934, - -25.459, - 3.313 + 46.49, + -27.7, + 8.65 ], - "measured_rgb": "#3F806E", - "source": "interpolated" + "measured_rgb": "#397A5F", + "source": "measured" }, { "mode": "RYBW", @@ -14064,11 +14064,11 @@ 20 ], "measured_lab": [ - 46.58, - -26.41, - 4.19 + 45.03, + -26.36, + 4.91 ], - "measured_rgb": "#377A67", + "measured_rgb": "#347662", "source": "measured" }, { @@ -14094,12 +14094,12 @@ 45 ], "measured_lab": [ - 59.54, - -27.398, - 13.802 + 60.7, + -28.15, + 21.36 ], - "measured_rgb": "#629C76", - "source": "interpolated" + "measured_rgb": "#6A9F6C", + "source": "measured" }, { "mode": "RYBW", @@ -14124,12 +14124,12 @@ 40 ], "measured_lab": [ - 57.05, - -27.815, - 12.345 + 58.01, + -27.55, + 16.4 ], - "measured_rgb": "#599573", - "source": "interpolated" + "measured_rgb": "#60986E", + "source": "measured" }, { "mode": "RYBW", @@ -14154,12 +14154,12 @@ 35 ], "measured_lab": [ - 53.733, - -28.082, - 10.13 + 53.3, + -28.62, + 14.7 ], - "measured_rgb": "#4D8D6E", - "source": "interpolated" + "measured_rgb": "#508C65", + "source": "measured" }, { "mode": "RYBW", @@ -14184,12 +14184,12 @@ 30 ], "measured_lab": [ - 51.412, - -28.053, - 9.243 + 51.14, + -28.08, + 12.6 ], - "measured_rgb": "#46876A", - "source": "interpolated" + "measured_rgb": "#498663", + "source": "measured" }, { "mode": "RYBW", @@ -14214,12 +14214,12 @@ 25 ], "measured_lab": [ - 50.144, - -27.794, - 8.631 + 48.27, + -28.82, + 13.08 ], - "measured_rgb": "#438468", - "source": "interpolated" + "measured_rgb": "#407F5C", + "source": "measured" }, { "mode": "RYBW", @@ -14244,12 +14244,12 @@ 20 ], "measured_lab": [ - 48.907, - -27.218, - 6.971 + 44.97, + -27.91, + 6.3 ], - "measured_rgb": "#3F8068", - "source": "interpolated" + "measured_rgb": "#31765F", + "source": "measured" }, { "mode": "RYBW", @@ -14274,11 +14274,11 @@ 40 ], "measured_lab": [ - 61.06, - -26.66, - 15.28 + 59.26, + -29.58, + 23.49 ], - "measured_rgb": "#699F77", + "measured_rgb": "#659C64", "source": "measured" }, { @@ -14304,12 +14304,12 @@ 35 ], "measured_lab": [ - 56.013, - -28.529, - 13.977 + 55.03, + -29.9, + 20.19 ], - "measured_rgb": "#56936D", - "source": "interpolated" + "measured_rgb": "#569160", + "source": "measured" }, { "mode": "RYBW", @@ -14334,11 +14334,11 @@ 30 ], "measured_lab": [ - 52.12, - -30.09, - 12.99 + 52.18, + -28.85, + 16.83 ], - "measured_rgb": "#478965", + "measured_rgb": "#4F895F", "source": "measured" }, { @@ -14364,12 +14364,12 @@ 25 ], "measured_lab": [ - 50.654, - -29.184, - 11.401 + 51.03, + -30.5, + 17.18 ], - "measured_rgb": "#438564", - "source": "interpolated" + "measured_rgb": "#48865B", + "source": "measured" }, { "mode": "RYBW", @@ -14394,11 +14394,11 @@ 20 ], "measured_lab": [ - 48.43, - -29.41, - 11.97 + 50.23, + -27.11, + 13.19 ], - "measured_rgb": "#3E805E", + "measured_rgb": "#4A8360", "source": "measured" }, { @@ -14424,12 +14424,12 @@ 35 ], "measured_lab": [ - 59.968, - -26.563, - 15.444 + 59.6, + -28.48, + 25.96 ], - "measured_rgb": "#679D74", - "source": "interpolated" + "measured_rgb": "#6A9C61", + "source": "measured" }, { "mode": "RYBW", @@ -14454,12 +14454,12 @@ 30 ], "measured_lab": [ - 56.752, - -28.317, - 15.672 + 56.1, + -28.9, + 21.55 ], - "measured_rgb": "#5A956C", - "source": "interpolated" + "measured_rgb": "#5D9360", + "source": "measured" }, { "mode": "RYBW", @@ -14484,12 +14484,12 @@ 25 ], "measured_lab": [ - 53.538, - -30.073, - 15.901 + 52.36, + -29.7, + 18.82 ], - "measured_rgb": "#4E8D64", - "source": "interpolated" + "measured_rgb": "#4F8A5C", + "source": "measured" }, { "mode": "RYBW", @@ -14514,12 +14514,12 @@ 20 ], "measured_lab": [ - 50.997, - -30.209, - 14.208 + 50.32, + -30.46, + 18.03 ], - "measured_rgb": "#458660", - "source": "interpolated" + "measured_rgb": "#478558", + "source": "measured" }, { "mode": "RYBW", @@ -14544,11 +14544,11 @@ 30 ], "measured_lab": [ - 62.09, - -24.71, - 15.38 + 58.9, + -29.55, + 28.26 ], - "measured_rgb": "#70A17A", + "measured_rgb": "#689A5B", "source": "measured" }, { @@ -14574,12 +14574,12 @@ 25 ], "measured_lab": [ - 56.579, - -28.785, - 18.097 + 55.52, + -29.56, + 25.37 ], - "measured_rgb": "#5B9467", - "source": "interpolated" + "measured_rgb": "#5D9258", + "source": "measured" }, { "mode": "RYBW", @@ -14604,11 +14604,11 @@ 20 ], "measured_lab": [ - 51.74, - -31.81, - 19.04 + 51.61, + -30.92, + 22.57 ], - "measured_rgb": "#48895A", + "measured_rgb": "#4D8853", "source": "measured" }, { @@ -14634,12 +14634,12 @@ 25 ], "measured_lab": [ - 59.237, - -28.888, - 22.914 + 57.98, + -30.58, + 31.16 ], - "measured_rgb": "#669B65", - "source": "interpolated" + "measured_rgb": "#659853", + "source": "measured" }, { "mode": "RYBW", @@ -14664,12 +14664,12 @@ 20 ], "measured_lab": [ - 56.854, - -29.771, - 21.59 + 55.14, + -30.46, + 27.15 ], - "measured_rgb": "#5D9562", - "source": "interpolated" + "measured_rgb": "#5B9153", + "source": "measured" }, { "mode": "RYBW", @@ -14694,11 +14694,11 @@ 20 ], "measured_lab": [ - 57.68, - -32.73, - 32.07 + 57.58, + -31.03, + 33.45 ], - "measured_rgb": "#609850", + "measured_rgb": "#65974D", "source": "measured" } ] diff --git a/src/libslic3r/ColorDecomposeRecipe.cpp b/src/libslic3r/ColorDecomposeRecipe.cpp index 8e5de9c06f..f2ceb1860a 100644 --- a/src/libslic3r/ColorDecomposeRecipe.cpp +++ b/src/libslic3r/ColorDecomposeRecipe.cpp @@ -61,6 +61,42 @@ static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb) return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)}; } +static std::string lab_to_srgb_hex(const LabColor& lab) +{ + constexpr double Xn = 0.95047, Yn = 1.0, Zn = 1.08883; + + auto f_inv = [](double t) -> double { + constexpr double eps = 216.0 / 24389.0; + constexpr double kappa = 24389.0 / 27.0; + const double t3 = t * t * t; + return t3 > eps ? t3 : (t * 116.0 - 16.0) / kappa; + }; + + const double fy = (lab.l + 16.0) / 116.0; + const double fx = lab.a / 500.0 + fy; + const double fz = fy - lab.b / 200.0; + + const double X = Xn * f_inv(fx); + const double Y = Yn * f_inv(fy); + const double Z = Zn * f_inv(fz); + + double r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z; + double g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z; + double b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z; + + auto gamma = [](double c) -> double { + c = std::max(0.0, std::min(1.0, c)); + return c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; + }; + auto u8 = [&](double c) -> int { + return std::max(0, std::min(255, static_cast(std::lround(gamma(c) * 255.0)))); + }; + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", u8(r), u8(g), u8(b)); + return std::string(buf); +} + static double delta_e76(const LabColor& a, const LabColor& b) { return std::sqrt(std::pow(a.l - b.l, 2.0) + std::pow(a.a - b.a, 2.0) + std::pow(a.b - b.b, 2.0)); @@ -224,6 +260,29 @@ ColorDecomposeRecipeResult recommend_from_physical_filaments( if (preferred_material_type.empty() || material_matches(filament.type, preferred_material_type)) candidates.push_back(filament); } + + // Early exit: if a material-matched candidate has the exact target color, + // return it as 100%. Downstream rejects single-component results (no mixed + // slot created), which is correct -- the color already exists. + const std::string target_hex = color_decompose_rgb_to_hex(target); + for (const auto& cand : candidates) { + ColorDecomposeRgb cand_rgb; + if (!color_decompose_hex_to_rgb(cand.color_hex, cand_rgb)) + continue; + if (color_decompose_rgb_to_hex(cand_rgb) == target_hex) { + ColorDecomposeRecipeResult exact; + exact.valid = true; + exact.mode = ColorDecomposeRecipeMode::MaterialList; + exact.matched_color_hex = cand.color_hex; + ColorDecomposeRecipeComponent comp; + comp.color_hex = cand.color_hex; + comp.ratio = 100; + comp.filament_index = cand.filament_index; + exact.components.push_back(comp); + return exact; + } + } + if (candidates.size() < 2) candidates = physical_filaments; candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [](const auto& filament) { @@ -328,34 +387,144 @@ std::string lookup_measured_blend_color(const std::vector& componen return std::string(buf); }; - std::vector norm_hexes; - norm_hexes.reserve(component_hexes.size()); - for (const auto& h : component_hexes) { - std::string n = normalize_hex(h); - if (n.empty()) + // Stage 1: canonicalize input by sorting (hex, ratio) pairs so matching + // is independent of the caller's component order. + const size_t n = component_hexes.size(); + std::vector> in_pairs; + in_pairs.reserve(n); + for (size_t i = 0; i < n; ++i) { + std::string nh = normalize_hex(component_hexes[i]); + if (nh.empty()) return {}; - norm_hexes.push_back(std::move(n)); + in_pairs.emplace_back(std::move(nh), ratios[i]); } + std::sort(in_pairs.begin(), in_pairs.end()); + + std::vector in_hexes; + std::vector in_ratios; + in_hexes.reserve(n); + in_ratios.reserve(n); + for (const auto& p : in_pairs) { + in_hexes.push_back(p.first); + in_ratios.push_back(p.second); + } + + // Normalize ratios to sum=100 (callers may pass arbitrary weights, + // e.g. MixedFilamentDialog uses ratio*10000). + { + int sum = 0; + for (int r : in_ratios) sum += r; + if (sum > 0 && sum != 100) { + int new_sum = 0; + for (size_t i = 0; i < in_ratios.size(); ++i) { + in_ratios[i] = static_cast(std::lround( + static_cast(in_ratios[i]) * 100.0 / static_cast(sum))); + new_sum += in_ratios[i]; + } + if (new_sum != 100) { + auto it = std::max_element(in_ratios.begin(), in_ratios.end()); + *it += (100 - new_sum); + } + } + } + + // Fall back to polynomial model for ratios outside the measured range. + { + bool out_of_range = false; + if (n == 2) { + for (int r : in_ratios) + if (r < 20 || r > 80) { out_of_range = true; break; } + } else { + for (int r : in_ratios) + if (r < 20) { out_of_range = true; break; } + } + if (out_of_range) + return {}; + } + + // Stage 2: collect anchors with the same component hex set; try exact match. + struct Anchor { + std::vector ratios; + LabColor lab; + std::string hex; + }; + std::vector anchors; for (const StandardRecipeEntry& entry : standard_entries()) { if (entry.source != "measured" && entry.source != "interpolated") continue; - if (entry.component_hexes.size() != norm_hexes.size()) - continue; - if (entry.ratios != ratios) + if (entry.component_hexes.size() != n) continue; - bool match = true; - for (size_t i = 0; i < norm_hexes.size(); ++i) { - if (normalize_hex(entry.component_hexes[i]) != norm_hexes[i]) { - match = false; - break; - } - } - if (match) - return entry.measured_hex; + std::vector> e_pairs; + e_pairs.reserve(n); + for (size_t i = 0; i < n; ++i) + e_pairs.emplace_back(normalize_hex(entry.component_hexes[i]), entry.ratios[i]); + std::sort(e_pairs.begin(), e_pairs.end()); + + bool same_set = true; + for (size_t i = 0; i < n; ++i) + if (e_pairs[i].first != in_hexes[i]) { same_set = false; break; } + if (!same_set) + continue; + + Anchor a; + a.ratios.reserve(n); + for (const auto& p : e_pairs) a.ratios.push_back(p.second); + a.lab = entry.measured_lab; + a.hex = entry.measured_hex; + + if (a.ratios == in_ratios) + return a.hex; + + anchors.push_back(std::move(a)); } - return {}; + + if (anchors.size() < 2) + return {}; + + // Stage 3: interpolation in Lab space. + if (n == 2) { + // 1D linear interpolation along ratio[0]. + std::sort(anchors.begin(), anchors.end(), + [](const Anchor& a, const Anchor& b) { return a.ratios[0] < b.ratios[0]; }); + const double x = static_cast(in_ratios[0]); + size_t lo = 0; + while (lo + 2 < anchors.size() && static_cast(anchors[lo + 1].ratios[0]) <= x) + ++lo; + const Anchor& a0 = anchors[lo]; + const Anchor& a1 = anchors[lo + 1]; + const double span = static_cast(a1.ratios[0] - a0.ratios[0]); + const double t = span > 0.0 ? (x - static_cast(a0.ratios[0])) / span : 0.0; + return lab_to_srgb_hex({a0.lab.l + t * (a1.lab.l - a0.lab.l), + a0.lab.a + t * (a1.lab.a - a0.lab.a), + a0.lab.b + t * (a1.lab.b - a0.lab.b)}); + } + + // 3+ color: IDW (p=2) with 3 nearest anchors in the (ratio[0], ratio[1]) plane. + const double ra = static_cast(in_ratios[0]); + const double rb = static_cast(in_ratios[1]); + std::vector> dists; + dists.reserve(anchors.size()); + for (const Anchor& a : anchors) { + const double d = std::sqrt(std::pow(ra - static_cast(a.ratios[0]), 2.0) + + std::pow(rb - static_cast(a.ratios[1]), 2.0)); + if (d == 0.0) + return a.hex; + dists.emplace_back(d, &a); + } + const size_t k = std::min(static_cast(3), dists.size()); + std::partial_sort(dists.begin(), dists.begin() + k, dists.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + double num_l = 0.0, num_a = 0.0, num_b = 0.0, den = 0.0; + for (size_t j = 0; j < k; ++j) { + const double w = 1.0 / (dists[j].first * dists[j].first); + num_l += w * dists[j].second->lab.l; + num_a += w * dists[j].second->lab.a; + num_b += w * dists[j].second->lab.b; + den += w; + } + return lab_to_srgb_hex({num_l / den, num_a / den, num_b / den}); } } // namespace Slic3r diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp index 3877d71e10..3c2fbe4e45 100644 --- a/src/slic3r/GUI/ColorDecomposeDialog.cpp +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -652,7 +652,6 @@ void ColorDecomposeDialog::update_filament_limit_warning() m_limit_warning_panel->Hide(); Layout(); Fit(); - CenterOnParent(); } return; } @@ -678,12 +677,11 @@ void ColorDecomposeDialog::update_filament_limit_warning() m_limit_warning_text->Wrap(avail); Layout(); - // Only resize/recenter when the warning panel actually toggled from hidden - // to shown. While already visible, switching modes must not re-Fit/recenter - // the dialog, which would make it jump on every card switch. + // Only resize when the warning panel actually toggled from hidden to shown. + // While already visible, switching modes must not re-Fit the dialog, which + // would make it jump on every card switch. Fit keeps the user-moved position. if (!was_shown) { Fit(); - CenterOnParent(); } } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 2fc8ca482d..9a6b52016f 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -4544,7 +4544,13 @@ void Sidebar::collect_physical_filament_info(std::vector& color_str Preset* preset = nullptr; if (cfg_idx < preset_bundle.filament_presets.size()) preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]); - types.push_back(filament_type_for_color_decompose(preset)); + std::string ft; + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + if (ft.empty()) ft = "PLA"; + types.push_back(ft); } } @@ -4905,6 +4911,21 @@ void Sidebar::decompose_filament_color(int filament_idx) std::vector color_strs, names, types; std::vector physical_config_indices; collect_physical_filament_info(color_strs, names, types, &physical_config_indices); + + // Build decompose-specific types: ColorDecomposeDialog needs "PLA Basic" + // distinction (for CMYW/RYBW card visibility), while collect_physical_filament_info + // now returns coarse filament_type (e.g. "PLA" for all PLA variants). + std::vector decompose_types; + { + auto& pb = *wxGetApp().preset_bundle; + for (size_t i = 0; i < physical_config_indices.size(); ++i) { + const size_t ci = physical_config_indices[i]; + Preset* pr = (ci < pb.filament_presets.size()) + ? pb.filaments.find_preset(pb.filament_presets[ci]) : nullptr; + decompose_types.push_back(filament_type_for_color_decompose(pr)); + } + } + size_t source_physical_idx = size_t(-1); for (size_t i = 0; i < physical_config_indices.size(); ++i) { if (physical_config_indices[i] == static_cast(filament_idx)) { @@ -4915,7 +4936,7 @@ void Sidebar::decompose_filament_color(int filament_idx) ColorDecomposeDialog dlg(this, source_physical_idx == size_t(-1) ? -1 : static_cast(source_physical_idx), - target_color, color_strs, names, types, + target_color, color_strs, names, decompose_types, wxGetApp().preset_bundle->filament_presets.size(), static_cast(EnforcerBlockerType::ExtruderMax), physical_config_indices); @@ -4925,7 +4946,7 @@ void Sidebar::decompose_filament_color(int filament_idx) MixedFilamentResult mixed_result; std::vector missing_components; if (!prepare_decompose_mixed_result(dialog_result, static_cast(filament_idx), source_physical_idx, - color_strs, types, physical_config_indices, mixed_result, missing_components)) + color_strs, decompose_types, physical_config_indices, mixed_result, missing_components)) return; if (!confirm_create_decompose_missing_components(this, missing_components)) From b1e3cdc666b19948d64dc66e9ff9aa834a10ce9f Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 13:15:09 +0800 Subject: [PATCH 088/138] Port colored OBJ import pipeline from BambuStudio --- src/libslic3r/Format/objparser.cpp | 112 ++- src/libslic3r/Model.cpp | 68 +- src/libslic3r/TexturePainting.cpp | 63 ++ src/libslic3r/TexturePainting.hpp | 22 + .../TextureToColor/TextureToColor.cpp | 757 ++++++++++++------ .../TextureToColor/TextureToColor.hpp | 38 + src/slic3r/GUI/Plater.cpp | 3 + src/slic3r/GUI/TextureImportDialog.cpp | 119 ++- src/slic3r/GUI/TextureImportDialog.hpp | 8 + 9 files changed, 893 insertions(+), 297 deletions(-) diff --git a/src/libslic3r/Format/objparser.cpp b/src/libslic3r/Format/objparser.cpp index 6ee117adc9..886fa423bd 100644 --- a/src/libslic3r/Format/objparser.cpp +++ b/src/libslic3r/Format/objparser.cpp @@ -262,12 +262,9 @@ static bool obj_parseline(const char *line, ObjData &data) } face_index_count++; } - if (face_index_count == 3) {//tri - data.usemtls.back().face_end++; - } else if (face_index_count == 4) {//quad - data.usemtls.back().face_end++; - data.usemtls.back().face_end++; - } + if (face_index_count >= 3) { + data.usemtls.back().face_end += face_index_count - 2; + } } vertex.coordIdx = -1; vertex.normalIdx = -1; @@ -374,6 +371,107 @@ static bool obj_parseline(const char *line, ObjData &data) return true; } static std::string cur_mtl_name = ""; +static bool mtl_is_space(char c) +{ + return c == ' ' || c == '\t' || c == '\r'; +} + +static const char* mtl_skip_ws(const char *line) +{ + while (mtl_is_space(*line)) + ++line; + return line; +} + +static const char* mtl_skip_token(const char *line) +{ + while (*line != 0 && !mtl_is_space(*line)) + ++line; + return line; +} + +static bool mtl_token_equals(const char *begin, const char *end, const char *token) +{ + const size_t len = static_cast(end - begin); + return strlen(token) == len && strncmp(begin, token, len) == 0; +} + +static std::string mtl_trim_value(const char *line) +{ + const char *begin = mtl_skip_ws(line); + const char *end = begin + strlen(begin); + while (end > begin && mtl_is_space(*(end - 1))) + --end; + return std::string(begin, end); +} + +static bool mtl_skip_numeric_token(const char *&line) +{ + const char *begin = mtl_skip_ws(line); + if (*begin == 0) + return false; + char *endptr = 0; + strtod(begin, &endptr); + if (endptr == begin || (!mtl_is_space(*endptr) && *endptr != 0)) + return false; + line = mtl_skip_ws(endptr); + return true; +} + +static bool mtl_skip_required_tokens(const char *&line, int count) +{ + for (int i = 0; i < count; ++i) { + line = mtl_skip_ws(line); + if (*line == 0) + return false; + line = mtl_skip_token(line); + } + line = mtl_skip_ws(line); + return true; +} + +static std::string mtl_parse_texture_name(const char *line) +{ + const char *original = mtl_skip_ws(line); + const char *current = original; + + while (*current == '-') { + const char *option_begin = current; + const char *option_end = mtl_skip_token(current); + current = option_end; + + if (mtl_token_equals(option_begin, option_end, "-o") || + mtl_token_equals(option_begin, option_end, "-s") || + mtl_token_equals(option_begin, option_end, "-t")) { + int skipped = 0; + while (skipped < 3 && mtl_skip_numeric_token(current)) + ++skipped; + if (skipped == 0) + return mtl_trim_value(original); + continue; + } + + int option_args = -1; + if (mtl_token_equals(option_begin, option_end, "-mm")) + option_args = 2; + else if (mtl_token_equals(option_begin, option_end, "-bm") || + mtl_token_equals(option_begin, option_end, "-boost") || + mtl_token_equals(option_begin, option_end, "-texres") || + mtl_token_equals(option_begin, option_end, "-clamp") || + mtl_token_equals(option_begin, option_end, "-blendu") || + mtl_token_equals(option_begin, option_end, "-blendv") || + mtl_token_equals(option_begin, option_end, "-cc") || + mtl_token_equals(option_begin, option_end, "-imfchan") || + mtl_token_equals(option_begin, option_end, "-type")) + option_args = 1; + + if (option_args < 0 || !mtl_skip_required_tokens(current, option_args)) + return mtl_trim_value(original); + } + + return mtl_trim_value(current); +} + static bool mtl_parseline(const char *line, MtlData &data) { if (*line == 0) return true; @@ -401,7 +499,7 @@ static bool mtl_parseline(const char *line, MtlData &data) if (*(line++) != 'a' || *(line++) != 'p' || *(line++) != '_' || *(line++) != 'K' || *(line++) != 'd') return false; EATWS(); if (data.new_mtl_unmap.find(cur_mtl_name) != data.new_mtl_unmap.end()) { - data.new_mtl_unmap[cur_mtl_name]->map_Kd = line; + data.new_mtl_unmap[cur_mtl_name]->map_Kd = mtl_parse_texture_name(line); } break; } diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index c9322eff3c..81e5990d36 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -320,31 +320,55 @@ Model Model::read_from_file(const std::string& model.texture_mesh = tex_mesh; } } - else if (result){ - ObjDialogInOut in_out; - in_out.model = &model; - in_out.lost_material_name = obj_info.lost_material_name; + else if (result && !model.objects.empty() && !model.objects.back()->volumes.empty()) { + // Vertex-colour and MTL face-colour OBJs also go through the texture-to-color + // importer (as precomputed per-face colors) instead of the legacy flat + // per-face colour dialog, matching the uv_png branch above. + auto build_tex_mesh_geometry = [&]() { + auto tex_mesh = std::make_shared(); + const auto& its = model.objects.back()->volumes[0]->mesh().its; + tex_mesh->vertices.resize(its.vertices.size()); + for (size_t i = 0; i < its.vertices.size(); ++i) + tex_mesh->vertices[i] = {its.vertices[i].x(), its.vertices[i].y(), its.vertices[i].z()}; + tex_mesh->indices.resize(its.indices.size()); + for (size_t i = 0; i < its.indices.size(); ++i) + tex_mesh->indices[i] = {its.indices[i][0], its.indices[i][1], its.indices[i][2]}; + return tex_mesh; + }; if (obj_info.vertex_colors.size() > 0) { - if (objFn) { // 1.result is ok and pop up a dialog - in_out.input_colors = std::move(obj_info.vertex_colors); - in_out.is_single_color = false; - in_out.deal_vertex_color = true; - objFn(in_out); + auto tex_mesh = build_tex_mesh_geometry(); + const auto& its = model.objects.back()->volumes[0]->mesh().its; + tex_mesh->precomputed_face_colors.resize(its.indices.size()); + for (size_t i = 0; i < its.indices.size(); ++i) { + const auto& f = its.indices[i]; + auto avg = [&](int ch) -> std::size_t { + float v = (obj_info.vertex_colors[f[0]][ch] + + obj_info.vertex_colors[f[1]][ch] + + obj_info.vertex_colors[f[2]][ch]) / 3.0f * 255.0f; + return (std::size_t) std::clamp(v, 0.0f, 255.0f); + }; + tex_mesh->precomputed_face_colors[i] = {avg(0), avg(1), avg(2)}; } - } else if (obj_info.face_colors.size() > 0 && obj_info.has_uv_png == false) { // mtl file - if (objFn) { // 1.result is ok and pop up a dialog - in_out.input_colors = std::move(obj_info.face_colors); - in_out.is_single_color = obj_info.is_single_mtl; - in_out.deal_vertex_color = false; - objFn(in_out); + tex_mesh->precomputed_vertex_colors = obj_info.vertex_colors; + model.texture_mesh = tex_mesh; + } else if (obj_info.face_colors.size() > 0 && obj_info.has_uv_png == false) { + auto tex_mesh = build_tex_mesh_geometry(); + const size_t nf = tex_mesh->indices.size(); + tex_mesh->precomputed_face_colors.resize(nf); + for (size_t i = 0; i < nf; ++i) { + if (i < obj_info.face_colors.size()) { + const auto& c = obj_info.face_colors[i]; + tex_mesh->precomputed_face_colors[i] = { + (std::size_t) std::clamp(c[0] * 255.0f, 0.0f, 255.0f), + (std::size_t) std::clamp(c[1] * 255.0f, 0.0f, 255.0f), + (std::size_t) std::clamp(c[2] * 255.0f, 0.0f, 255.0f) + }; + } else { + tex_mesh->precomputed_face_colors[i] = {128, 128, 128}; + } } - } /*else if (obj_info.has_uv_png && obj_info.uvs.size() > 0) { - boost::filesystem::path full_path(input_file); - std::string obj_directory = full_path.parent_path().string(); - obj_info.obj_dircetory = obj_directory; - result = false; - message = _L("Importing obj with png function is developing."); - }*/ + model.texture_mesh = tex_mesh; + } } } else if (boost::algorithm::iends_with(input_file, ".glb") || diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp index 187f218863..9f83f282cd 100644 --- a/src/libslic3r/TexturePainting.cpp +++ b/src/libslic3r/TexturePainting.cpp @@ -353,6 +353,69 @@ bool texture_to_painting( return true; } +bool face_colors_to_painting( + const TexturedMesh& mesh, + PaintedMesh& painted, + const TexturePaintingSettings& settings, + PaintProgressCallback progress, + PaintCancelCallback cancel) +{ + if (mesh.vertices.empty() || mesh.indices.empty() || mesh.precomputed_face_colors.empty()) + return false; + + // Build tex2color::TriMesh from input geometry + tex2color::TriMesh input_mesh; + input_mesh.vertices.resize(mesh.vertices.size()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) + input_mesh.vertices[i] = Vec3f(mesh.vertices[i][0], mesh.vertices[i][1], mesh.vertices[i][2]); + input_mesh.indices.resize(mesh.indices.size()); + for (size_t i = 0; i < mesh.indices.size(); ++i) + input_mesh.indices[i] = Vec3i32(mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2]); + + // Forward settings to tex2color + tex2color::TextureToColorSettings algo_settings; + algo_settings.target_colors_num = settings.target_colors_num; + algo_settings.smooth_weight = settings.smooth_weight; + switch (settings.mesh_repair_decision) { + case TexturePaintingSettings::MeshRepairDecision::Ask: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; + break; + case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; + break; + case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: + default: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; + break; + } + algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; + algo_settings.mesh_repair_callback = settings.mesh_repair_callback; + + tex2color::AlgoProgressCallback algo_progress = nullptr; + if (progress) { + algo_progress = [&progress](tex2color::AlgoProgress p) { + progress(p.percent, p.message); + }; + } + tex2color::AlgoCancelCallback algo_cancel = nullptr; + if (cancel) { + algo_cancel = [&cancel]() -> bool { return cancel(); }; + } + + tex2color::TriMesh out_mesh; + std::vector> out_face_colors; + bool ok = tex2color::ClusterAndSmooth( + input_mesh, mesh.precomputed_face_colors, out_mesh, out_face_colors, + algo_settings, algo_progress, algo_cancel, + mesh.precomputed_vertex_colors); + + if (!ok) + return false; + + extract_painted_mesh(out_mesh, out_face_colors, painted); + return true; +} + double compute_delta_e( const std::array& rgb1, const std::array& rgba2) diff --git a/src/libslic3r/TexturePainting.hpp b/src/libslic3r/TexturePainting.hpp index ac98e968c7..fc4688620b 100644 --- a/src/libslic3r/TexturePainting.hpp +++ b/src/libslic3r/TexturePainting.hpp @@ -38,6 +38,18 @@ struct TexturedMesh { std::vector> uv_indices; // per-face UV indices into uv_coords bool has_face_uvs() const { return !uv_indices.empty() && !uv_coords.empty(); } + + // Pre-computed per-face colors (e.g. from OBJ vertex colors or MTL Kd). + // When non-empty, the pipeline skips texture decode/sample/oversample and + // consumes these instead of sampling a texture. + // Each entry is {R, G, B} in [0..255]. + std::vector> precomputed_face_colors; + + // Per-vertex colors from OBJ (RGBA, [0..1]), indexed by vertex index. + // On a low-poly mesh these are quantized into a small palette and the mesh is + // split along the resulting cluster boundaries, so color borders stay sharp + // instead of being averaged away into a single color per face. + std::vector> precomputed_vertex_colors; }; struct PaintedMesh { @@ -83,6 +95,16 @@ bool texture_to_painting( const TexturePaintingSettings& settings = {}, PaintProgressCallback progress = nullptr, PaintCancelCallback cancel = nullptr); +// Turn pre-computed per-face colors into a painted mesh, skipping texture decode +// and UV sampling. A low-poly mesh that also carries precomputed_vertex_colors is +// split along quantized color boundaries, which replaces its geometry. +bool face_colors_to_painting( + const TexturedMesh& mesh, + PaintedMesh& painted, + const TexturePaintingSettings& settings = {}, + PaintProgressCallback progress = nullptr, + PaintCancelCallback cancel = nullptr); + std::vector match_clusters_to_filaments( const std::vector>& cluster_colors, diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp index e3afc63cd9..bcdc985fc9 100644 --- a/src/libslic3r/TextureToColor/TextureToColor.cpp +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include "CgalUtils.hpp" @@ -379,6 +378,413 @@ static bool linear_subdivision(TriMesh& mesh, std::vector& uv_coord return true; } +using VertexColor = std::array; + +// Quantize continuous per-vertex colors into a small palette of cluster centers. +// The legacy OBJ vertex-color import consumed discrete filament ids, so split +// decisions could be made by comparing integers. Quantizing up front restores +// that property for the adaptive splitter below. +static bool quantize_vertex_colors( + const std::vector& vertex_colors, + const TextureToColorSettings& settings, + AlgoCancelCallback cancel_callback, + std::vector& out_centers, + std::vector& out_vertex_cluster_ids) +{ + out_centers.clear(); + out_vertex_cluster_ids.clear(); + if (vertex_colors.empty()) + return false; + + std::vector vertex_rgb(vertex_colors.size()); + for (std::size_t i = 0; i < vertex_colors.size(); ++i) { + for (int c = 0; c < 3; ++c) { + float v = std::clamp(vertex_colors[i][c] * 255.0f, 0.0f, 255.0f); + vertex_rgb[i][c] = static_cast(v); + } + } + + ClusterParameters para; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + if (settings.target_colors_num == 0) { + para.max_color_distance = settings.max_color_distance; + para.max_cluster_k = settings.max_cluster_k; + out_centers = cluster_adaptive(vertex_rgb, para); + } else { + para.cluster_k = settings.target_colors_num; + out_centers = cluster_k_means(vertex_rgb, para); + } + if (out_centers.empty()) { + BOOST_LOG_TRIVIAL(debug) << "quantize_vertex_colors: no cluster center generated."; + return false; + } + + out_vertex_cluster_ids.resize(vertex_rgb.size()); + for (std::size_t i = 0; i < vertex_rgb.size(); ++i) { + std::size_t nearest_id = 0; + if (!calc_nearest_color_id(out_centers, vertex_rgb[i], nearest_id)) + nearest_id = 0; + out_vertex_cluster_ids[i] = nearest_id; + } + BOOST_LOG_TRIVIAL(debug) << "quantize_vertex_colors: quantized " << vertex_rgb.size() + << " vertex colors into " << out_centers.size() << " clusters."; + return true; +} + +// Single-level adaptive subdivision driven by per-vertex cluster ids. +// +// Reproduces the split topology that the legacy OBJ vertex-color import encoded +// into mmu_segmentation_facets (TriangleSelector::perform_split cases 1/2/3), but +// materializes it as real geometry. An edge is split at its midpoint if and only +// if its two endpoints belong to different clusters. Because that predicate reads +// only the shared endpoints, adjacent faces always reach the same conclusion and +// no T-junctions can appear. +static bool adaptive_split_by_vertex_clusters( + TriMesh& mesh, + const std::vector& vertex_cluster_ids, + const std::vector& cluster_centers, + std::vector& out_face_colors) +{ + const TriVertices original_vertices = mesh.vertices; + const TriFaces original_faces = mesh.indices; + if (original_vertices.empty() || original_faces.empty() || cluster_centers.empty()) + return false; + if (vertex_cluster_ids.size() != original_vertices.size()) { + BOOST_LOG_TRIVIAL(warning) << "adaptive_split_by_vertex_clusters: cluster id count (" + << vertex_cluster_ids.size() << ") != vertex count (" + << original_vertices.size() << ")."; + return false; + } + if (original_vertices.size() >= (1ULL << 32)) [[unlikely]] { + BOOST_LOG_TRIVIAL(warning) << "adaptive_split_by_vertex_clusters: vertex_count=" + << original_vertices.size() << " exceeds 32-bit edge_key range."; + return false; + } + + TriVertices out_vertices = original_vertices; + TriFaces out_faces; + out_faces.reserve(original_faces.size() * 5); + out_face_colors.clear(); + out_face_colors.reserve(original_faces.size() * 5); + + auto edge_key = [](std::size_t a, std::size_t b) -> uint64_t { + return a < b ? ((static_cast(a) << 32) | b) + : ((static_cast(b) << 32) | a); + }; + std::unordered_map edge_to_mid; + edge_to_mid.reserve(original_faces.size() * 3 / 2); + + // Midpoints on shared edges must be deduplicated so that neighbouring faces + // reference the same vertex instead of coincident duplicates. + auto midpoint_of_edge = [&](std::size_t a, std::size_t b) -> std::size_t { + const uint64_t key = edge_key(a, b); + auto it = edge_to_mid.find(key); + if (it != edge_to_mid.end()) + return it->second; + const std::size_t idx = out_vertices.size(); + out_vertices.push_back((original_vertices[a] + original_vertices[b]) * 0.5f); + edge_to_mid.emplace(key, idx); + return idx; + }; + // Points strictly inside an original face are never shared, so they skip the map. + // The midpoint is computed before push_back so a reallocation cannot dangle it. + auto append_interior_midpoint = [&](std::size_t a, std::size_t b) -> std::size_t { + const TriVertex mid = (out_vertices[a] + out_vertices[b]) * 0.5f; + const std::size_t idx = out_vertices.size(); + out_vertices.push_back(mid); + return idx; + }; + auto emit = [&](std::size_t a, std::size_t b, std::size_t c, std::size_t cluster_id) { + out_faces.push_back(Vec3i32(static_cast(a), static_cast(b), static_cast(c))); + out_face_colors.push_back(cluster_centers[cluster_id]); + }; + + for (const auto& f : original_faces) { + const std::size_t v[3] = {static_cast(f[0]), static_cast(f[1]), static_cast(f[2])}; + const std::size_t c[3] = {vertex_cluster_ids[v[0]], vertex_cluster_ids[v[1]], vertex_cluster_ids[v[2]]}; + + // Case A: uniform cluster, keep the face untouched. + if (c[0] == c[1] && c[1] == c[2]) { + emit(v[0], v[1], v[2], c[0]); + continue; + } + + // Case B: two vertices share a cluster and the third is isolated. Split the + // two edges incident to the isolated vertex, which are exactly the + // cross-cluster ones; the opposite edge stays intact. + int iso = -1; + if (c[1] == c[2]) iso = 0; + else if (c[2] == c[0]) iso = 1; + else if (c[0] == c[1]) iso = 2; + if (iso >= 0) { + const int i = iso, j = (iso + 1) % 3, k = (iso + 2) % 3; + const std::size_t m_ij = midpoint_of_edge(v[i], v[j]); + const std::size_t m_ki = midpoint_of_edge(v[k], v[i]); + emit(v[i], m_ij, m_ki, c[i]); + emit(m_ij, v[j], m_ki, c[j]); + emit(v[j], v[k], m_ki, c[j]); + continue; + } + + // Case C: all three clusters differ. Split every edge, then cut the centre + // triangle once more. The centre is equidistant from all three clusters, so + // the legacy heuristic selects the cut by widest interior angle, which is + // the vertex opposite the longest edge. + const std::size_t m01 = midpoint_of_edge(v[0], v[1]); + const std::size_t m12 = midpoint_of_edge(v[1], v[2]); + const std::size_t m20 = midpoint_of_edge(v[2], v[0]); + emit(v[0], m01, m20, c[0]); + emit(m01, v[1], m12, c[1]); + emit(m12, v[2], m20, c[2]); + + const TriVertex& p0 = original_vertices[v[0]]; + const TriVertex& p1 = original_vertices[v[1]]; + const TriVertex& p2 = original_vertices[v[2]]; + const float sq_opposite_v0 = (p2 - p1).squaredNorm(); + const float sq_opposite_v1 = (p0 - p2).squaredNorm(); + const float sq_opposite_v2 = (p1 - p0).squaredNorm(); + int widest = 0; + float widest_len = sq_opposite_v0; + if (sq_opposite_v1 > widest_len) { widest = 1; widest_len = sq_opposite_v1; } + if (sq_opposite_v2 > widest_len) { widest = 2; } + + if (widest == 0) { + const std::size_t mc = append_interior_midpoint(m20, m01); + emit(m12, m20, mc, c[1]); + emit(mc, m01, m12, c[2]); + } else if (widest == 1) { + const std::size_t mc = append_interior_midpoint(m01, m12); + emit(m20, m01, mc, c[0]); + emit(mc, m12, m20, c[2]); + } else { + const std::size_t mc = append_interior_midpoint(m12, m20); + emit(m01, m12, mc, c[1]); + emit(mc, m20, m01, c[0]); + } + } + + BOOST_LOG_TRIVIAL(info) << "adaptive_split_by_vertex_clusters: faces " << original_faces.size() + << " -> " << out_faces.size() << ", vertices " << original_vertices.size() + << " -> " << out_vertices.size(); + mesh = TriMesh(out_faces, out_vertices); + return true; +} + +// Shared pipeline: mesh repair -> color clustering -> label assignment -> smoothing. +// Called by both TextureToColor (after UV sampling) and ClusterAndSmooth (after vertex-color oversample). +// progress_callback reports 0~100 within this function; the caller maps it to its own global range. +static bool repair_cluster_smooth( + TriMesh& mesh, + std::vector& face_colors, + std::vector& out_clustered_face_colors, + const TextureToColorSettings& settings, + AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback, + const char* log_prefix) +{ + auto report = [&](int pct, const char* msg) { + if (progress_callback) + progress_callback({pct, msg}); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << " cancelled"; + return true; + } + return false; + }; + + report(0, "Repairing mesh"); + if (cancelled()) return false; + + // Resample face colors onto a repaired mesh via centroid nearest-neighbor. + auto resample_face_colors = [&](TriMesh&& repaired_mesh) -> bool { + TriVertices old_vertices = std::move(mesh.vertices); + TriFaces old_indices = std::move(mesh.indices); + auto aabb_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); + mesh = std::move(repaired_mesh); + + if (is_closed(mesh)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": repaired mesh is closed."; + } else { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": repaired mesh is open."; + } + + std::vector new_face_colors(mesh.facets_count()); + tbb::parallel_for(tbb::blocked_range(0, mesh.facets_count()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + const auto& face = mesh.indices[fid]; + Vec3f center = (mesh.vertices[face[0]] + mesh.vertices[face[1]] + mesh.vertices[face[2]]) / 3.0f; + size_t hit_idx = 0; + Vec3f closest; + AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + old_vertices, old_indices, aabb_tree, center, hit_idx, closest); + new_face_colors[fid] = face_colors[hit_idx]; + } + }); + face_colors = std::move(new_face_colors); + return true; + }; + + auto repair_and_resample = [&]() -> bool { + std::shared_ptr repaired_mesh; + if (!RepairMesh(mesh, repaired_mesh)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": RepairMesh failed."; + return false; + } + if (cancelled()) return false; + return resample_face_colors(std::move(*repaired_mesh)); + }; + + { + TriangleMesh stats_mesh(static_cast(mesh)); + const auto& stats = stats_mesh.stats(); + // Orca's TriangleMeshStats defines manifold() as open_edges == 0 and does not track + // non-manifold edges/vertices separately, so BBS's "!manifold() || has_open_edges()" + // collapses to this single test and the extra counters drop out of the log. + if (!stats.manifold()) { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh has non-manifold geometry or open boundaries, open_edges=" + << stats.open_edges; + if (settings.mesh_repair_decision == MeshRepairDecision::Ask) { + if (settings.mesh_repair_decision_required) + *settings.mesh_repair_decision_required = true; + return false; + } + if (settings.mesh_repair_decision == MeshRepairDecision::RepairAndImport) { + indexed_triangle_set repaired_its; + std::string repair_error; + bool repaired = settings.mesh_repair_callback && settings.mesh_repair_callback( + static_cast(mesh), repaired_its, + [&](const char* message, unsigned /*percent*/) { + report(5, message ? message : "Repairing mesh"); + }, + [&]() { return cancelled(); }, &repair_error); + if (repaired) { + if (cancelled()) return false; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": Windows 3D mesh repair finished."; + if (!resample_face_colors(TriMesh(std::move(repaired_its)))) + return false; + } else { + BOOST_LOG_TRIVIAL(warning) << log_prefix << ": Windows 3D mesh repair failed: " << repair_error; + } + } else { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without Windows 3D repair."; + } + } + } + + if (!cgalutils::is_mesh_halfedge_compatible(mesh)) { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh not halfedge-compatible, attempting RepairMesh."; + if (!repair_and_resample()) + return false; + } + +#ifdef OUTPUT_TEST_RESULT + SaveToOFF(std::string(log_prefix) + "_1_repair.off", mesh, face_colors); +#endif + + report(20, "Color clustering"); + if (cancelled()) return false; + + // Clustering + std::vector cluster_centers; + out_clustered_face_colors = face_colors; + std::vector clustered_face_labels(face_colors.size()); + const bool adaptive_cluster = settings.target_colors_num == 0; + + if (adaptive_cluster) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": use cluster adaptive method."; + ClusterParameters para; + para.max_color_distance = settings.max_color_distance; + para.max_cluster_k = settings.max_cluster_k; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_adaptive(face_colors, para); + if (cancelled()) return false; + } else { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": use cluster k-means method."; + ClusterParameters para; + para.cluster_k = settings.target_colors_num; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_k_means(face_colors, para); + if (cancelled()) return false; + } + + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": k = " << cluster_centers.size() << "."; + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": no cluster center generated."; + return false; + } + + report(40, "Assigning cluster labels"); + if (cancelled()) return false; + + // Assign each face to nearest cluster center + { + std::atomic done{0}; + std::atomic cancel_requested{false}; + const size_t total = mesh.indices.size(); + const size_t interval = std::max(total / 20, 1); + tbb::parallel_for(tbb::blocked_range(0, total), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + if (cancel_requested.load(std::memory_order_relaxed)) return; + std::size_t nearest_id = 0; + calc_nearest_color_id(cluster_centers, face_colors[fid], nearest_id); + clustered_face_labels[fid] = nearest_id; + out_clustered_face_colors[fid] = cluster_centers[nearest_id]; + size_t cnt = done.fetch_add(1, std::memory_order_relaxed) + 1; + if (cnt % interval == 0) { + if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } + } + } + }); + if (cancel_requested.load() || cancelled()) return false; + } + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "cluster assignment")) + return false; + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "cluster assignment"); + } + +#ifdef OUTPUT_TEST_RESULT + { + std::vector tmp = out_clustered_face_colors; + for (std::size_t i = 0; i < tmp.size(); ++i) + tmp[i] = cluster_centers[clustered_face_labels[i]]; + SaveToOFF(std::string(log_prefix) + "_3_cluster.off", mesh, tmp); + } +#endif + + report(65, "Smoothing colors"); + if (cancelled()) return false; + + SmoothParameters smooth_parameters; + smooth_parameters.smooth_weight = settings.smooth_weight; + if (!smooth_region(mesh, clustered_face_labels, smooth_parameters)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": smooth region failed."; + return false; + } + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "color smoothing")) + return false; + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "color smoothing"); + } + + report(90, "Updating face colors"); + if (cancelled()) return false; + + for (std::size_t i = 0; i < out_clustered_face_colors.size(); ++i) + out_clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; + +#ifdef OUTPUT_TEST_RESULT + SaveToOFF(std::string(log_prefix) + "_4_smooth.off", mesh, out_clustered_face_colors); +#endif + + report(100, "Completed"); + return true; +} + bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& texture_mesh_uv_coords, const cv::Mat& texture, TriMesh& color_mesh, std::vector>& face_colors, const TextureToColorSettings& settings, AlgoProgressCallback progress_callback, AlgoCancelCallback cancel_callback) { @@ -525,259 +931,22 @@ bool TextureToColor(const TriMesh& texture_mesh, const std::vector clustered_face_colors; + if (!repair_cluster_smooth(color_mesh, face_colors, clustered_face_colors, + settings, rcs_progress, cancel_callback, "TextureToColor")) return false; - } - - // Sub-stage timing helper for the "Repairing mesh" outer lap. Logs each - // sub-phase under a [timing][Repairing mesh] prefix so that regressions in - // mesh inspection, RepairMesh, AABB resampling, etc. can be attributed - // to a specific sub-stage without changing the outer lap structure. - auto sub_lap = [&](const char* sub_name, Clock::time_point t0) { - double ms = std::chrono::duration(Clock::now() - t0).count(); - BOOST_LOG_TRIVIAL(debug) << "[timing][Repairing mesh] " << sub_name << ": " << ms << "ms"; - }; - - // Step 3: Repair mesh - // Many textured models have non-manifold, non-closed, or other issues that need to be fixed beforehand - auto resample_repaired_mesh = [&](TriMesh&& repaired_mesh) -> bool { - // AABBTreeIndirect references vertices/faces externally, so snapshot the - // pre-repair geometry by moving them out of color_mesh before it gets - // overwritten with the repaired mesh below. std::move on std::vector is - // O(1) (pointer adoption), no element copy. - const auto t_aabb = Clock::now(); - TriVertices old_vertices = std::move(color_mesh.vertices); - TriFaces old_indices = std::move(color_mesh.indices); - auto before_repair_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); - sub_lap("resample.aabb_build", t_aabb); - - color_mesh = std::move(repaired_mesh); - - const auto t_is_closed = Clock::now(); - if (is_closed(color_mesh)) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repaired mesh is closed."; - } else { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repaired mesh is open."; - } - sub_lap("resample.is_closed", t_is_closed); - - // New faces after repair inherit old face colors via centroid nearest-neighbor lookup. - // Since the mesh barely changes after repair, resampling via centroid nearest-neighbor is sufficient. - const auto t_resample = Clock::now(); - std::vector new_face_colors(color_mesh.facets_count()); - tbb::parallel_for(tbb::blocked_range(0, color_mesh.facets_count()), [&](const tbb::blocked_range& range) { - for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { - const auto& face = color_mesh.indices[fid]; - Vec3f center = (color_mesh.vertices[face[0]] + color_mesh.vertices[face[1]] + color_mesh.vertices[face[2]]) / 3.0f; - size_t hit_idx = 0; - Vec3f closest; - AABBTreeIndirect::squared_distance_to_indexed_triangle_set( - old_vertices, old_indices, before_repair_tree, center, hit_idx, closest); - new_face_colors[fid] = face_colors[hit_idx]; - } - }); - face_colors = std::move(new_face_colors); - sub_lap("resample.parallel_nearest", t_resample); - return true; - }; - - auto repair_and_resample_mesh = [&]() -> bool { - std::shared_ptr repaired_mesh; - const auto t_repair = Clock::now(); - bool success = RepairMesh(color_mesh, repaired_mesh); - sub_lap("RepairMesh", t_repair); - if (success == false) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repair mesh failed."; - return false; - } - if (cancelled()) return false; - return resample_repaired_mesh(std::move(*repaired_mesh)); - }; - - { - const auto t_stats = Clock::now(); - TriangleMesh stats_mesh(static_cast(color_mesh)); - const auto& stats = stats_mesh.stats(); - sub_lap("stats_check", t_stats); - // Orca's TriangleMeshStats defines manifold() as open_edges == 0 and does not track - // non-manifold edges/vertices separately, so BBS's "!manifold() || has_open_edges()" - // collapses to this single test and the extra counters drop out of the log. - if (!stats.manifold()) { - BOOST_LOG_TRIVIAL(info) << "TextureToColor: mesh has non-manifold geometry or open boundaries, open_edges=" - << stats.open_edges; - if (settings.mesh_repair_decision == MeshRepairDecision::Ask) { - if (settings.mesh_repair_decision_required) - *settings.mesh_repair_decision_required = true; - return false; - } - if (settings.mesh_repair_decision == MeshRepairDecision::RepairAndImport) { - indexed_triangle_set repaired_its; - std::string repair_error; - const auto t_win3d = Clock::now(); - bool repaired = settings.mesh_repair_callback && settings.mesh_repair_callback(static_cast(color_mesh), repaired_its, - [&](const char* message, unsigned percent) { - sub_report(static_cast(percent), 40, 60, message ? message : "Repairing mesh"); - }, - [&]() { return cancelled(); }, &repair_error); - sub_lap("windows_3d_repair", t_win3d); - if (repaired) { - if (cancelled()) return false; - BOOST_LOG_TRIVIAL(info) << "TextureToColor: Windows 3D mesh repair finished."; - if (!resample_repaired_mesh(TriMesh(std::move(repaired_its)))) - return false; - } else { - BOOST_LOG_TRIVIAL(warning) << "TextureToColor: Windows 3D mesh repair failed: " << repair_error; - } - } else { - BOOST_LOG_TRIVIAL(info) << "TextureToColor: importing mesh without Windows 3D repair."; - } - } - } - - const auto t_halfedge = Clock::now(); - const bool halfedge_ok = cgalutils::is_mesh_halfedge_compatible(color_mesh); - sub_lap("is_mesh_halfedge_compatible", t_halfedge); - if (!halfedge_ok && repair_and_resample_mesh() == false) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: repair and resample mesh failed."; - return false; - } - lap("Repairing mesh"); -#ifdef OUTPUT_TEST_RESULT - SaveToOFF("texture_to_color_1_repair.off", color_mesh, face_colors); -#endif - - report(65, "Color clustering"); - if (cancelled()) { - return false; - } - - // Step 5: Color clustering - std::vector cluster_centers; - std::vector clustered_face_colors = face_colors; - std::vector clustered_face_labels(face_colors.size()); - const bool adaptive_cluster = settings.target_colors_num == 0; - - // Compute cluster centers - if (adaptive_cluster) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: use cluster adaptive method."; - ClusterParameters para; - para.max_color_distance = settings.max_color_distance; - para.max_cluster_k = settings.max_cluster_k; - para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; - cluster_centers = cluster_adaptive(face_colors, para); - if (cancelled()) return false; - } else { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: use cluster k-means method."; - ClusterParameters para; - para.cluster_k = settings.target_colors_num; - para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; - cluster_centers = cluster_k_means(face_colors, para); - if (cancelled()) return false; - } - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: the k is " << cluster_centers.size() << "."; - if (cluster_centers.empty()) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: no cluster center generated."; - return false; - } - const std::set unique_cluster_centers(cluster_centers.begin(), cluster_centers.end()); - if (unique_cluster_centers.size() != cluster_centers.size()) { - BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cluster centers contain duplicated RGB values, unique exported colors may be fewer than centers."; - } - - report(70, "Assigning cluster labels"); - if (cancelled()) { - return false; - } - - // Assign each face's color to the nearest cluster center - constexpr bool use_simple_cluster = true; // Complex algorithm is still being optimized; use simple assignment for now - if (use_simple_cluster) { - std::atomic done_cluster{0}; - std::atomic cancel_requested{false}; - const size_t total_cluster = color_mesh.indices.size(); - const size_t cluster_interval = std::max(total_cluster / 20, 1); - tbb::parallel_for(tbb::blocked_range(0, total_cluster), [&](const tbb::blocked_range& range) { - for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { - if (cancel_requested.load(std::memory_order_relaxed)) return; - auto& face_color = face_colors[fid]; - auto nearest_color_id = std::numeric_limits::max(); - bool success = calc_nearest_color_id(cluster_centers, face_color, nearest_color_id); - if (success == false) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: calc nearest color id failed."; - continue; - } - clustered_face_labels[fid] = nearest_color_id; - clustered_face_colors[fid] = cluster_centers[nearest_color_id]; - size_t cnt = done_cluster.fetch_add(1, std::memory_order_relaxed) + 1; - if (cnt % cluster_interval == 0) { - if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } - sub_report(static_cast(cnt * 100 / total_cluster), 70, 85, "Assigning cluster labels"); - } - } - }); - if (cancel_requested.load() || cancelled()) return false; - } else { - bool success = mesh_cluster(color_mesh, cluster_centers, clustered_face_colors, clustered_face_labels); - if (success == false) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: mesh cluster failed."; - return false; - } - } - if (adaptive_cluster) { - if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "cluster assignment")) { - return false; - } - } else { - ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "cluster assignment"); - } - lap("Color clustering & labeling"); -#ifdef OUTPUT_TEST_RESULT - for (std::size_t i = 0; i < clustered_face_colors.size(); ++i) { - clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; - } - SaveToOFF("texture_to_color_3_cluster.off", color_mesh, clustered_face_colors); -#endif - - report(85, "Smoothing colors"); - if (cancelled()) { - return false; - } - - // Step 6: Post-process colors - SmoothParameters smooth_parameters; - smooth_parameters.smooth_weight = settings.smooth_weight; - if (!smooth_region(color_mesh, clustered_face_labels, smooth_parameters)) { - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: smooth region failed."; - return false; - } - BOOST_LOG_TRIVIAL(debug) << "TextureToColor: smooth region success."; - if (adaptive_cluster) { - if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "color smoothing")) { - return false; - } - } else { - ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "color smoothing"); - } - report(95, "Updating face colors"); - if (cancelled()) { - return false; - } - for (std::size_t i = 0; i < clustered_face_colors.size(); ++i) { - clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; - } - const std::set unique_exported_colors(clustered_face_colors.begin(), clustered_face_colors.end()); - if (unique_exported_colors.size() < cluster_centers.size()) { - BOOST_LOG_TRIVIAL(warning) << "TextureToColor: final exported unique colors (" << unique_exported_colors.size() - << ") are fewer than cluster centers (" << cluster_centers.size() - << "), likely due to duplicate centers or unsatisfied seed assignment."; - } -#ifdef OUTPUT_TEST_RESULT - SaveToOFF("texture_to_color_4_smooth.off", color_mesh, clustered_face_colors); -#endif face_colors = std::move(clustered_face_colors); - lap("Smoothing colors"); + lap("Repair + Clustering + Smoothing"); double total_ms = std::chrono::duration(Clock::now() - t_total_start).count(); BOOST_LOG_TRIVIAL(debug) << "[timing] TextureToColor total: " << total_ms << "ms" << " faces=" << color_mesh.facets_count(); @@ -785,5 +954,91 @@ bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& input_face_colors, + TriMesh& out_mesh, + std::vector>& out_face_colors, + const TextureToColorSettings& settings, + AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback, + const std::vector>& vertex_colors) +{ + auto report = [&](int pct, const char* msg) { + if (progress_callback) + progress_callback({pct, msg}); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " cancelled"; + return true; + } + return false; + }; + + out_mesh = mesh; + out_face_colors.clear(); + + if (mesh.indices.empty() || input_face_colors.empty()) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: empty mesh or face colors."; + return false; + } + if (input_face_colors.size() != mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "ClusterAndSmooth: face_colors size (" + << input_face_colors.size() << ") != indices size (" + << mesh.indices.size() << "), clamping."; + } + + report(0, "Initializing"); + if (cancelled()) return false; + + // Prepare face colors aligned to mesh size + std::vector face_colors(out_mesh.indices.size()); + for (size_t i = 0; i < out_mesh.indices.size(); ++i) { + if (i < input_face_colors.size()) + face_colors[i] = input_face_colors[i]; + else + face_colors[i] = {128, 128, 128}; + } + + // Low-poly vertex-color meshes take the legacy OBJ import route: quantize the + // vertex colors, then split only across cluster boundaries. Colors are exact + // cluster centers afterwards, so repair / re-clustering / smoothing are skipped + // to match the legacy behaviour, which never touched the mesh either. + // A vertex color count that disagrees with the mesh falls through to the generic + // pipeline below rather than failing the import outright. + if (!vertex_colors.empty() && + vertex_colors.size() == out_mesh.vertices.size() && + out_mesh.facets_count() < settings.oversampling_min_face_count) { + report(10, "Quantizing vertex colors"); + std::vector cluster_centers; + std::vector vertex_cluster_ids; + if (!quantize_vertex_colors(vertex_colors, settings, cancel_callback, cluster_centers, vertex_cluster_ids)) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: vertex color quantization failed."; + return false; + } + if (cancelled()) return false; + + report(50, "Splitting color boundaries"); + if (!adaptive_split_by_vertex_clusters(out_mesh, vertex_cluster_ids, cluster_centers, face_colors)) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: adaptive vertex-color split failed."; + return false; + } + if (cancelled()) return false; + + out_face_colors = std::move(face_colors); + report(100, "Completed"); + return true; + } + + std::vector clustered_face_colors; + if (!repair_cluster_smooth(out_mesh, face_colors, clustered_face_colors, + settings, progress_callback, cancel_callback, + "ClusterAndSmooth")) + return false; + + out_face_colors = std::move(clustered_face_colors); + return true; +} + } // namespace tex2color } // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TextureToColor.hpp b/src/libslic3r/TextureToColor/TextureToColor.hpp index f18cce5759..019a113fc4 100644 --- a/src/libslic3r/TextureToColor/TextureToColor.hpp +++ b/src/libslic3r/TextureToColor/TextureToColor.hpp @@ -61,5 +61,43 @@ bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& face_colors, const TextureToColorSettings& settings = TextureToColorSettings(), AlgoProgressCallback progress_callback = nullptr, AlgoCancelCallback cancel_callback = nullptr); +/** + * @brief Turn pre-computed per-face colors into a clustered color mesh (no texture/UV). + * + * Used for OBJ vertex colors and MTL face colors, which bypass texture sampling. + * Two routes are possible: + * - Low-poly meshes carrying per-vertex colors: the vertex colors are quantized + * into a small palette and the mesh is geometrically split along cluster + * boundaries, reproducing the split topology of the legacy OBJ vertex-color + * import. Output colors are then exact cluster centers, so mesh repair, + * re-clustering and smoothing are skipped. + * - Everything else: mesh repair, color clustering (K-Means or adaptive) and + * region smoothing, sharing the same pipeline as TextureToColor. + * + * @param[in] mesh Input triangle mesh + * @param[in] input_face_colors Pre-computed per-face RGB colors [0..255] + * @param[out] out_mesh Output mesh. Geometry is subdivided on the + * vertex-color route, and may still be replaced + * by mesh repair on the generic route. + * @param[out] out_face_colors Output per-face colors, one entry per out_mesh face + * @param[in] settings Algorithm parameters (target_colors_num, smooth_weight; + * oversampling_min_face_count doubles as the low-poly + * threshold for the vertex-color route) + * @param[in] progress_callback Progress callback + * @param[in] cancel_callback Cancel callback + * @param[in] vertex_colors Optional per-vertex RGBA [0..1]. Must match + * mesh.vertices in size to enable the vertex-color + * route; otherwise it is ignored. + * @return true on success, false on failure or cancellation + */ +bool ClusterAndSmooth(const TriMesh& mesh, + const std::vector>& input_face_colors, + TriMesh& out_mesh, + std::vector>& out_face_colors, + const TextureToColorSettings& settings = TextureToColorSettings(), + AlgoProgressCallback progress_callback = nullptr, + AlgoCancelCallback cancel_callback = nullptr, + const std::vector>& vertex_colors = {}); + } // namespace tex2color } // namespace Slic3r diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 9a6b52016f..29e6dab244 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -215,6 +215,9 @@ static bool has_importable_texture(const Slic3r::TexturedMesh& textured_mesh) if (textured_mesh.vertices.empty() || textured_mesh.indices.empty()) return false; + if (!textured_mesh.precomputed_face_colors.empty()) + return true; + return std::any_of(textured_mesh.textures.begin(), textured_mesh.textures.end(), [](const Slic3r::TextureImage& texture) { return !texture.data.empty(); }); } diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index c9e5fb2147..e2886687f3 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -491,7 +491,8 @@ public: std::function on_add_filament, std::function on_decompose_color, std::function can_add_filament, - std::function on_close) + std::function on_close, + std::vector display_numbers) : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) , m_entries(entries) , m_colors_rgba(colors_rgba) @@ -503,6 +504,7 @@ public: , m_on_decompose_color(std::move(on_decompose_color)) , m_can_add_filament(std::move(can_add_filament)) , m_on_close(std::move(on_close)) + , m_display_numbers(std::move(display_numbers)) { wxColour pop_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); SetBackgroundColour(pop_bg); @@ -550,9 +552,12 @@ public: } }; + // Section order matches compute_display_numbers() so the visible IDs + // ascend monotonically (ExistingPhysical -> NewPhysical -> ExistingMixed + // -> NewMixed) instead of jumping (e.g. 1,2 -> 7 -> 3,4,5,6 -> 8,9,10). add_section(_L("Project Physical Filaments"), TextureFilamentKind::ExistingPhysical); - add_section(_L("Project Mixed Filaments"), TextureFilamentKind::ExistingMixed); add_section(_L("New Physical Filaments"), TextureFilamentKind::NewPhysical); + add_section(_L("Project Mixed Filaments"), TextureFilamentKind::ExistingMixed); add_section(_L("New Mixed Filaments"), TextureFilamentKind::NewMixed); auto* decompose_label = new wxStaticText(this, wxID_ANY, _L("Decompose Color")); @@ -682,7 +687,7 @@ private: : wxColour(128, 128, 128); wxString name_str = (idx < m_names.size()) ? filament_name_to_wx_string(m_names[idx]) - : wxString::Format("Filament %d", (int)(idx + 1)); + : wxString::Format("Filament %d", display_number((int)idx)); row->SetToolTip(name_str); row->Bind(wxEVT_PAINT, [this, idx, sq, sq_r, sq_x, gap1, fil_clr, name_str, row_bg, hover_bg, name_fg](wxPaintEvent& e) { @@ -711,7 +716,7 @@ private: nf.SetPointSize(9); dc.SetFont(nf); dc.SetTextForeground(paint_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); - wxString ns = wxString::Format("%d", (int)(idx + 1)); + wxString ns = wxString::Format("%d", display_number((int)idx)); wxSize tsz = dc.GetTextExtent(ns); dc.DrawText(ns, sq_x + (sq - tsz.x) / 2, sq_y + (sq - tsz.y) / 2); } @@ -767,7 +772,7 @@ private: row->SetBackgroundColour(row_bg); row->SetBackgroundStyle(wxBG_STYLE_PAINT); row->SetCursor(wxCursor(wxCURSOR_HAND)); - row->SetToolTip(entry.name.empty() ? wxString::Format("Filament %d", idx + 1) : filament_name_to_wx_string(entry.name)); + row->SetToolTip(entry.name.empty() ? wxString::Format("Filament %d", display_number(idx)) : filament_name_to_wx_string(entry.name)); row->Bind(wxEVT_PAINT, [this, entry, idx, row_bg, hover_bg, name_fg, plus_fg](wxPaintEvent& e) { auto* p = static_cast(e.GetEventObject()); @@ -810,7 +815,7 @@ private: dc.DrawRoundedRectangle(x, y, sw, sw, sw_r); draw_filament_swatch_border(dc, comp_clr, x, y, sw, sw, sw_r); - wxString num = wxString::Format("%u", comp_id); + wxString num = wxString::Format("%d", display_number(comp_dialog_idx)); wxSize nsz = dc.GetTextExtent(num); dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); dc.DrawText(num, x + (sw - nsz.x) / 2, y + (sw - nsz.y) / 2); @@ -858,9 +863,19 @@ private: std::function m_on_decompose_color; std::function m_can_add_filament; std::function m_on_close; + // 1-based display number per dialog_index, mirroring the post-apply + // sidebar ordering (ExistingPhysical, NewPhysical, ExistingMixed, NewMixed). + std::vector m_display_numbers; int m_hover_idx = -1; bool m_closing_from_action = false; bool m_destroy_scheduled = false; + + // Returns the display number for a dialog_index, falling back to idx + 1 + // when no mapping is available (e.g. index out of range). + int display_number(int idx) const { + return (idx >= 0 && idx < (int)m_display_numbers.size() && m_display_numbers[idx] > 0) + ? m_display_numbers[idx] : idx + 1; + } }; // ============================================================ @@ -1735,8 +1750,11 @@ TextureImportDialog::TextureImportDialog( m_preview_canvas->set_mesh_data(m_textured_mesh.vertices, m_textured_mesh.indices); - // Prepare texture rendering data for the Original tab - if (!m_textured_mesh.textures.empty()) { + // Pre-computed face colors (OBJ vertex colors / MTL face colors): + // use them directly as the Original preview, skip texture decode. + if (!m_textured_mesh.precomputed_face_colors.empty()) { + m_preview_canvas->set_original_face_colors(m_textured_mesh.precomputed_face_colors); + } else if (!m_textured_mesh.textures.empty()) { std::vector> tex_pixels_rgb; std::vector tex_widths, tex_heights; tex_pixels_rgb.reserve(m_textured_mesh.textures.size()); @@ -2444,7 +2462,13 @@ void TextureImportDialog::start_computation(bool auto_color, bool initial) auto worker_settings = settings; bool mesh_repair_decision_required = false; worker_settings.mesh_repair_decision_required = &mesh_repair_decision_required; - bool ok = Slic3r::texture_to_painting(mesh_copy, result, worker_settings, progress_cb, cancel_cb); + bool ok; + if (!mesh_copy.precomputed_face_colors.empty()) { + ok = Slic3r::face_colors_to_painting( + mesh_copy, result, worker_settings, progress_cb, cancel_cb); + } else { + ok = Slic3r::texture_to_painting(mesh_copy, result, worker_settings, progress_cb, cancel_cb); + } if (m_cancel_flag.load()) { wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_ERROR)); @@ -3075,6 +3099,54 @@ void TextureImportDialog::compact_used_virtual_filaments() } } +std::vector TextureImportDialog::compute_display_numbers() const +{ + // Assigns each entry a 1-based display number in the order the sidebar will + // show after apply: ExistingPhysical, NewPhysical, ExistingMixed, NewMixed. + // This keeps the dialog's visible IDs in sync with the post-apply sidebar, + // instead of the raw dialog_index (which interleaves physicals and mixeds + // by processing order and causes e.g. CMYW to show 4,5,6,8 instead of 3,4,5,6). + // MUST mirror ordering in apply_textured_mesh_import_result (Plater.cpp:9896): + // - ExistingPhysical keeps its project_config_index + // - NewPhysical is inserted at existing_physical_count + new_order + // - ExistingMixed shifts to project_config_index + new_physical_count + // - NewMixed is appended after all existing mixeds + std::vector result(m_filament_entries.size(), 0); + int next = 1; + + auto assign_group = [&](TextureFilamentKind kind, bool by_project_config_index) { + if (by_project_config_index) { + std::vector group; + for (const auto& e : m_filament_entries) + if (e.kind == kind) + group.push_back(&e); + std::sort(group.begin(), group.end(), + [](const TextureFilamentEntry* a, const TextureFilamentEntry* b) { + return a->project_config_index < b->project_config_index; + }); + for (const auto* e : group) { + if (e->dialog_index >= 0 && e->dialog_index < (int)result.size()) + result[e->dialog_index] = next; + ++next; + } + } else { + for (const auto& e : m_filament_entries) { + if (e.kind != kind) + continue; + if (e.dialog_index >= 0 && e.dialog_index < (int)result.size()) + result[e.dialog_index] = next; + ++next; + } + } + }; + + assign_group(TextureFilamentKind::ExistingPhysical, true); + assign_group(TextureFilamentKind::NewPhysical, false); + assign_group(TextureFilamentKind::ExistingMixed, true); + assign_group(TextureFilamentKind::NewMixed, false); + return result; +} + void TextureImportDialog::dismiss_filament_popup() { if (!m_filament_popup) { @@ -3429,7 +3501,13 @@ void TextureImportDialog::show_filament_popup(size_t row_index) dismiss_filament_popup(); } - auto on_select = [this, row_index](int idx) { + const auto display_numbers = compute_display_numbers(); + auto display_number = [display_numbers](int idx) -> int { + return (idx >= 0 && idx < (int)display_numbers.size() && display_numbers[idx] > 0) + ? display_numbers[idx] : idx + 1; + }; + + auto on_select = [this, row_index, display_number](int idx) { if (row_index >= m_mapping_rows.size()) return; m_mapping_rows[row_index].target_filament_idx = idx; if (row_index < m_current_matches.size()) @@ -3437,7 +3515,7 @@ void TextureImportDialog::show_filament_popup(size_t row_index) if (m_mapping_rows[row_index].target_panel) { wxString label = (idx >= 0 && idx < (int)m_filament_names.size()) ? filament_name_to_wx_string(m_filament_names[idx]) - : wxString::Format("Filament %d", idx + 1); + : wxString::Format("Filament %d", display_number(idx)); m_mapping_rows[row_index].target_panel->SetToolTip(label); m_mapping_rows[row_index].target_panel->Refresh(); } @@ -3490,7 +3568,8 @@ void TextureImportDialog::show_filament_popup(size_t row_index) m_existing_filament_count, tp->GetSize().x, tp, on_select, on_add_filament, on_decompose_color, [this]() { return can_add_virtual_filament(); }, - on_close); + on_close, + display_numbers); wxPoint pos = tp->ClientToScreen(wxPoint(0, tp->GetSize().y)); wxRect display_rect; @@ -3673,10 +3752,16 @@ void TextureImportDialog::rebuild_mapping_rows() return wxColour(128, 128, 128); }; - auto get_filament_label = [this](int idx) -> wxString { + const auto display_numbers = compute_display_numbers(); + auto display_number = [display_numbers](int idx) -> int { + return (idx >= 0 && idx < (int)display_numbers.size() && display_numbers[idx] > 0) + ? display_numbers[idx] : idx + 1; + }; + + auto get_filament_label = [this, display_number](int idx) -> wxString { if (idx >= 0 && idx < (int)m_filament_names.size()) return filament_name_to_wx_string(m_filament_names[idx]); - return wxString::Format("Filament %d", idx + 1); + return wxString::Format("Filament %d", display_number(idx)); }; const wxColour dash_clr = dark_or(wxColour(179, 179, 179), wxColour(100, 100, 106)); @@ -3804,7 +3889,7 @@ void TextureImportDialog::rebuild_mapping_rows() row.target_panel->SetCursor(wxCursor(wxCURSOR_HAND)); row.target_panel->Bind(wxEVT_PAINT, [this, ci, get_target_wxcolor, get_filament_label, - card_bg, card_bd, name_fg, chev_clr](wxPaintEvent& e) { + display_number, card_bg, card_bd, name_fg, chev_clr](wxPaintEvent& e) { auto* p = static_cast(e.GetEventObject()); wxAutoBufferedPaintDC dc(p); wxSize sz = p->GetClientSize(); @@ -3856,7 +3941,7 @@ void TextureImportDialog::rebuild_mapping_rows() dc.DrawRoundedRectangle(x, sw_y, sw, sw, sw_r); draw_filament_swatch_border(dc, comp_clr, x, sw_y, sw, sw, sw_r); - wxString num_str = wxString::Format("%u", comp_id); + wxString num_str = wxString::Format("%d", display_number(comp_idx)); wxSize nsz = dc.GetTextExtent(num_str); dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); dc.DrawText(num_str, x + (sw - nsz.x) / 2, sw_y + (sw - nsz.y) / 2); @@ -3897,7 +3982,7 @@ void TextureImportDialog::rebuild_mapping_rows() num_font.SetPointSize(10); dc.SetFont(num_font); dc.SetTextForeground(fil_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); - wxString num_str = wxString::Format("%d", fil_idx + 1); + wxString num_str = wxString::Format("%d", display_number(fil_idx)); wxSize nsz = dc.GetTextExtent(num_str); dc.DrawText(num_str, sq_x + (sq - nsz.x) / 2, sq_y + (sq - nsz.y) / 2); } diff --git a/src/slic3r/GUI/TextureImportDialog.hpp b/src/slic3r/GUI/TextureImportDialog.hpp index 63e30dfc5b..3d7ba43c31 100644 --- a/src/slic3r/GUI/TextureImportDialog.hpp +++ b/src/slic3r/GUI/TextureImportDialog.hpp @@ -275,6 +275,14 @@ private: void update_drop_warning_visibility(); void compact_used_virtual_filaments(); int find_closest_filament_index(const std::array& color) const; + // Returns a vector indexed by dialog_index whose value is the 1-based + // display number that mirrors the final sidebar ordering produced by + // apply_textured_mesh_import_result (Plater.cpp): ExistingPhysical, + // NewPhysical, ExistingMixed, NewMixed. Used so the dialog shows the + // same IDs the sidebar will show after OK, instead of the raw + // dialog_index + 1 (which interleaves physicals and mixeds). + // MUST mirror ordering in apply_textured_mesh_import_result (Plater.cpp:9896). + std::vector compute_display_numbers() const; void on_color_preset_clicked(wxCommandEvent& evt); void on_color_slider_changed(wxCommandEvent& evt); From 1b5b8fce5420f1da3d9d057259cbff30bb950170 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 13:15:09 +0800 Subject: [PATCH 089/138] Port mixed filament dialog fixes from BambuStudio --- src/slic3r/GUI/ColorDecomposeDialog.cpp | 24 +++- src/slic3r/GUI/GradientCurveEditor.cpp | 40 ++++-- src/slic3r/GUI/GradientCurveEditor.hpp | 5 + src/slic3r/GUI/MixedFilamentDialog.cpp | 154 ++++++++++++------------ src/slic3r/GUI/MixedFilamentDialog.hpp | 6 + 5 files changed, 137 insertions(+), 92 deletions(-) diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp index 3c2fbe4e45..c52d4f4380 100644 --- a/src/slic3r/GUI/ColorDecomposeDialog.cpp +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -196,8 +196,10 @@ ColorDecomposeDialog::ColorDecomposeDialog(wxWindow* parent, build_ui(); wxGetApp().UpdateDlgDarkUI(this); // Restore target swatch after dark mode color remapping - if (m_target_swatch) + if (m_target_swatch) { m_target_swatch->SetBackgroundColour(m_target_color); + m_target_swatch->Refresh(); + } update_card_visibility(); Fit(); @@ -322,6 +324,26 @@ static wxPanel* create_color_swatch(wxWindow* parent, const wxColour& color, int auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(size, size)); panel->SetBackgroundColour(color); panel->SetMinSize(wxSize(size, size)); + panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + panel->Bind(wxEVT_PAINT, [panel](wxPaintEvent&) { + wxAutoBufferedPaintDC dc(panel); + wxSize sz = panel->GetClientSize(); + wxColour c = panel->GetBackgroundColour(); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(c)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + // Mirror sidebar (FilamentBitmapUtils::create_single_filament_bitmap): + // gray border for near-white in light mode so white swatches stay + // visible on a white background; light border for near-black in dark mode. + const bool light_mode = !wxGetApp().dark_mode(); + if ((light_mode && c.Red() > 224 && c.Green() > 224 && c.Blue() > 224) || + (!light_mode && c.Red() < 45 && c.Green() < 45 && c.Blue() < 45)) { + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(light_mode ? wxColour(130, 130, 128) : wxColour(207, 207, 207), + 1, wxPENSTYLE_SOLID)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + }); return panel; } diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index b3d3436465..782961aba4 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -183,13 +183,17 @@ wxRect GradientCurveEditor::plot_rect() const return wxRect(x, y, side, side); } -wxPoint GradientCurveEditor::data_to_px(double x, double y) const +wxPoint2DDouble GradientCurveEditor::data_to_px_f(double x, double y) const { const wxRect r = plot_rect(); - const int px = r.x + static_cast(std::lround(x * r.width)); // y axis is inverted: y=1 should sit at the top. - const int py = r.y + static_cast(std::lround((1.0 - y) * r.height)); - return wxPoint(px, py); + return wxPoint2DDouble(r.x + x * r.width, r.y + (1.0 - y) * r.height); +} + +wxPoint GradientCurveEditor::data_to_px(double x, double y) const +{ + const wxPoint2DDouble p = data_to_px_f(x, y); + return wxPoint(static_cast(std::lround(p.m_x)), static_cast(std::lround(p.m_y))); } void GradientCurveEditor::px_to_data(int px, int py, double& x, double& y) const @@ -321,6 +325,9 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) // Render through wxGCDC so curves, arrows and anchor circles get anti-aliased; the buffered // DC is the actual back buffer that gets blitted to the window. wxGCDC dc(raw_dc); + // The curve and its anchors are drawn straight on the graphics context so their + // coordinates stay sub-pixel accurate (see data_to_px_f). + wxGraphicsContext* gc = dc.GetGraphicsContext(); const wxRect rc = plot_rect(); if (rc.width <= 0 || rc.height <= 0) @@ -416,7 +423,7 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) dc.SetTextForeground(label_muted); dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); - if (m_points.size() < 2) + if (m_points.size() < 2 || !gc) return; auto color_for_curve = [&](int curve_idx) -> wxColour { @@ -428,22 +435,26 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) return c; }; - auto build_polyline = [&](int curve_idx) -> std::vector { + auto build_polyline = [&](int curve_idx) -> std::vector { const int samples = std::max(128, rc.width * 2); - std::vector poly; + std::vector poly; poly.reserve(samples + 1); for (int s = 0; s <= samples; ++s) { const double x = double(s) / samples; const double y0 = sample_curve_y(x); const double vy = to_visual_y(curve_idx, y0); - poly.push_back(data_to_px(x, vy)); + poly.push_back(data_to_px_f(x, vy)); } return poly; }; - auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { + // Only the geometry goes through the graphics context: dc.DrawLines() takes integer + // wxPoint and would quantize the curve back to whole pixels. The pen is still set on + // the dc, which forwards it to this same context while keeping the dc's own cached + // state in sync, so later dc drawing does not inherit the curve's pen. + auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { dc.SetPen(wxPen(col, FromDIP(stroke_dip))); - dc.DrawLines(static_cast(poly.size()), poly.data()); + gc->StrokeLines(poly.size(), poly.data()); }; // Outline only when the curve color is perceptually close to the background; otherwise @@ -472,13 +483,16 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) draw_one(m_selected_curve, kStrokeSelected); // Control points (selected curve only): hollow circle with axis-color border, theme-aware fill. - const int r = FromDIP(kPointRadius); + // Drawn on the graphics context with a sub-pixel center so the ring stays centered on the + // curve instead of drifting up to half a pixel off it; pen and brush go through the dc for + // the same reason as in draw_polyline above. + const double r = FromDIP(kPointRadius); dc.SetPen(wxPen(axis_color, 1)); dc.SetBrush(wxBrush(point_fill)); for (size_t i = 0; i < m_points.size(); ++i) { const double vy = to_visual_y(m_selected_curve, m_points[i].y); - const wxPoint p = data_to_px(m_points[i].x, vy); - dc.DrawCircle(p.x, p.y, r); + const wxPoint2DDouble p = data_to_px_f(m_points[i].x, vy); + gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); } } diff --git a/src/slic3r/GUI/GradientCurveEditor.hpp b/src/slic3r/GUI/GradientCurveEditor.hpp index 8412db3df2..f9858cab11 100644 --- a/src/slic3r/GUI/GradientCurveEditor.hpp +++ b/src/slic3r/GUI/GradientCurveEditor.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "libslic3r/FilamentMixer.hpp" @@ -74,6 +75,10 @@ private: // Coordinate mapping between data (x, y in [0,1]) and pixels in plot area. wxRect plot_rect() const; + // Sub-pixel accurate mapping, used for drawing: rounding the curve vertices to whole + // pixels leaves a staircase that anti-aliasing cannot smooth out, and the step is + // twice as coarse on 2x (Retina) displays. + wxPoint2DDouble data_to_px_f(double x, double y) const; wxPoint data_to_px(double x, double y) const; void px_to_data(int px, int py, double& x, double& y) const; // Anchor hit test for the currently-selected curve (uses translated visual y). diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 241f051905..84eb9ca0b9 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -258,6 +258,46 @@ wxBitmap MixedFilamentDialog::make_swatch_bitmap(size_t idx) }); } +void MixedFilamentDialog::apply_uniform_label_width(wxStaticText* lbl) +{ + // A material row places the combo right after the label, so the combo x follows the label + // width and the rows drift apart with fonts that render digits at different advances (which + // is what macOS does). Reserve the width of the widest row label on every row instead. + // The label itself is used as the measuring device on purpose: SetMinSize overrides the + // control's own best size rather than being merged with it, and on macOS the native cell is + // wider than the plain text extent, so a wxDC-measured width would clip the text. + const wxString text = lbl->GetLabel(); + int w = 0; + for (int i = 1; i <= MAX_COMPONENTS; ++i) { + lbl->SetLabel(wxString::Format(_L("Filament %d"), i)); + lbl->InvalidateBestSize(); + w = std::max(w, lbl->GetBestSize().x); + } + lbl->SetLabel(text); + lbl->InvalidateBestSize(); + lbl->SetMinSize(wxSize(w, -1)); +} + +void MixedFilamentDialog::append_material_row() +{ + auto* row = new wxBoxSizer(wxHORIZONTAL); + auto* lbl = new wxStaticText(this, wxID_ANY, + wxString::Format(_L("Filament %d"), (int)(m_combo_filaments.size() + 1))); + lbl->SetFont(::Label::Body_12); + apply_uniform_label_width(lbl); + row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); + combo->SetKeepDropArrow(true); + combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); + row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); + + m_combo_filaments.push_back(combo); + m_combo_to_physical.push_back({}); + m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); +} + void MixedFilamentDialog::reset_manual_ratio_state() { m_ratio_manual_order.clear(); @@ -444,13 +484,18 @@ void MixedFilamentDialog::start_ratio_editor(size_t idx, wxWindow* anchor, const m_ratio_editor->SetBackgroundColour(bg); m_ratio_editor->SetForegroundColour(fg); // Default wxTextCtrl best width (~140px) is too wide for the sizer to - // shrink, which would push the "%" suffix out of the panel. Cap the - // editor's min width to the digits only (ratios are always two digits). + // shrink, which would push the "%" suffix out of the panel. Size the + // editor for the *widest* three digits rather than the largest accepted + // value: SetMaxLength above lets anything up to "888" be typed, and the + // macOS system font renders digits at different advances, so "100" is + // narrower than what the user can actually enter. GetSizeFromTextSize() + // then adds the platform's own text field margins; on macOS those margins + // are what clipped the digits. { wxClientDC mdc(m_ratio_editor); mdc.SetFont(::Label::Body_10); - int digits_w = mdc.GetTextExtent(wxT("88")).GetWidth(); - m_ratio_editor->SetMinSize(wxSize(digits_w + FromDIP(2), -1)); + int digits_w = mdc.GetTextExtent(wxT("888")).GetWidth(); + m_ratio_editor->SetMinSize(m_ratio_editor->GetSizeFromTextSize(digits_w)); } auto* pct_label = new wxStaticText(m_ratio_editor_panel, wxID_ANY, wxT("%")); @@ -494,11 +539,21 @@ void MixedFilamentDialog::start_ratio_editor(size_t idx, wxWindow* anchor, const wxPoint pos = anchor->GetPosition() + anchor_rect.GetTopLeft(); // Match the editor to the label (hover box) size so the inline editor and - // the hover state look identical. A small floor keeps the "%" suffix from - // being squeezed out on very narrow labels. + // the hover state look identical, but never go below what the digits and + // the "%" suffix need: the sizer takes any missing width out of the + // stretchable editor, which would clip the value. + wxSize needed = m_ratio_editor_panel->ClientToWindowSize( + m_ratio_editor_panel->GetSizer()->CalcMin()); wxSize size = anchor->GetSize(); - size.SetWidth(std::max(size.GetWidth(), FromDIP(30))); - size.SetHeight(std::max(size.GetHeight(), FromDIP(18))); + size.SetWidth(std::max(size.GetWidth(), needed.GetWidth())); + size.SetHeight(std::max(size.GetHeight(), needed.GetHeight())); + // An editor wider than the label must still stay inside its parent, or the + // corner labels of the triangle picker would have it clipped at the edge. + if (wxWindow* editor_parent = m_ratio_editor_panel->GetParent()) { + wxSize avail = editor_parent->GetClientSize(); + pos.x = std::clamp(pos.x, 0, std::max(0, avail.GetWidth() - size.GetWidth())); + pos.y = std::clamp(pos.y, 0, std::max(0, avail.GetHeight() - size.GetHeight())); + } m_ratio_editor_panel->SetSize(wxRect(pos, size)); m_ratio_editor_panel->Layout(); m_ratio_editor->SetValue(wxString::Format(wxT("%d"), ratio(idx))); @@ -769,23 +824,8 @@ wxBoxSizer* MixedFilamentDialog::create_material_selection() m_combo_filaments.clear(); m_combo_to_physical.clear(); - for (size_t i = 0; i < m_result.components.size(); ++i) { - auto* row = new wxBoxSizer(wxHORIZONTAL); - wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(i + 1)); - auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); - lbl->SetFont(::Label::Body_12); - row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); - - auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, - wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); - combo->SetKeepDropArrow(true); - combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); - row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); - - m_combo_filaments.push_back(combo); - m_combo_to_physical.push_back({}); - m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); - } + for (size_t i = 0; i < m_result.components.size(); ++i) + append_material_row(); sizer->Add(m_material_rows_sizer, 0, wxEXPAND); @@ -1455,26 +1495,6 @@ void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) void MixedFilamentDialog::on_gradient_toggled() { - bool checked = m_chk_gradient->GetValue(); - - if (checked) { - auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; - if (!print_config.opt_bool("enable_mixed_color_sublayer")) { - wxMessageDialog dlg(this, - _L("Gradient effect requires 'Mixed color sublayer' to be enabled. Enable it now?"), - _L("Mixed Color Sublayer"), - wxYES_NO | wxICON_QUESTION); - if (dlg.ShowModal() == wxID_YES) { - DynamicPrintConfig new_conf; - new_conf.set_key_value("enable_mixed_color_sublayer", new ConfigOptionBool(true)); - wxGetApp().get_tab(Preset::TYPE_PRINT)->load_config(new_conf); - } else { - m_chk_gradient->SetValue(false); - return; - } - } - } - m_result.gradient_enabled = m_chk_gradient->GetValue(); if (m_ratio_sizer) @@ -1572,21 +1592,7 @@ void MixedFilamentDialog::on_add_material() } reset_manual_ratio_state(); - auto* row = new wxBoxSizer(wxHORIZONTAL); - wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(n + 1)); - auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); - lbl->SetFont(::Label::Body_12); - row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); - - auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, - wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); - combo->SetKeepDropArrow(true); - combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); - row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); - - m_combo_filaments.push_back(combo); - m_combo_to_physical.push_back({}); - m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); + append_material_row(); rebuild_all_combos(); refresh_curve_editor_colors(); @@ -1670,24 +1676,8 @@ void MixedFilamentDialog::on_recommendation_clicked_triple(unsigned int a, unsig // Ensure we have exactly 3 combo rows if (num_components() < 3) { // Need to add a 3rd combo row - while (m_combo_filaments.size() < 3) { - size_t idx = m_combo_filaments.size(); - auto* row = new wxBoxSizer(wxHORIZONTAL); - wxString lbl_text = wxString::Format(_L("Filament %d"), (int)(idx + 1)); - auto* lbl = new wxStaticText(this, wxID_ANY, lbl_text); - lbl->SetFont(::Label::Body_12); - row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); - - auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, - wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); - combo->SetKeepDropArrow(true); - combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); - row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); - - m_combo_filaments.push_back(combo); - m_combo_to_physical.push_back({}); - m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); - } + while (m_combo_filaments.size() < 3) + append_material_row(); } else if (num_components() > 3) { while (m_material_rows_sizer->GetItemCount() > 3) { auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); @@ -1813,7 +1803,11 @@ void MixedFilamentDialog::update_ok_button_state() parts += wxString::Format(_L("Slot %s (%s)"), slots, wxString::FromUTF8(it->first)); } m_type_mismatch_msg = parts + " " + _L("cannot be mixed. Please select the same filament type."); + } else { + m_type_mismatch_msg.clear(); } + } else { + m_type_mismatch_msg.clear(); } bool has_unselected = false; @@ -1839,6 +1833,10 @@ void MixedFilamentDialog::update_ok_button_state() if (m_warning_panel) { m_warning_panel->Show(has_type_mismatch); + // Force a repaint: when the panel is already visible and only the + // mismatch text changes (e.g. PETG -> ABS), Show()/Layout() do not + // generate a paint event, so paint_warning_panel keeps the stale text. + m_warning_panel->Refresh(); Layout(); } } diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp index a1deaa2022..a1af146897 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.hpp +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -100,6 +100,12 @@ private: wxBitmap make_swatch_bitmap(size_t idx); + // Reserves the same width on every material row label so the combo boxes line up. + static void apply_uniform_label_width(wxStaticText* lbl); + // Appends one "Filament N" label + combo row to m_material_rows_sizer. N follows the + // number of rows already there, so callers must not renumber anything themselves. + void append_material_row(); + // Helpers for component/ratio access size_t num_components() const { return m_result.components.size(); } unsigned int comp(size_t i) const { return (i < m_result.components.size()) ? m_result.components[i] : 1; } From 6e52c091f3d421361bf29b9910bab06e4a85f614 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 13:15:09 +0800 Subject: [PATCH 090/138] Initialize parse output in string_to_double_decimal_point --- src/libslic3r/LocalesUtils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/LocalesUtils.cpp b/src/libslic3r/LocalesUtils.cpp index d321072335..308752cc62 100644 --- a/src/libslic3r/LocalesUtils.cpp +++ b/src/libslic3r/LocalesUtils.cpp @@ -53,7 +53,7 @@ bool is_decimal_separator_point() double string_to_double_decimal_point(const std::string_view str, size_t* pos /* = nullptr*/) { - double out; + double out = 0.; size_t p = fast_float::from_chars(str.data(), str.data() + str.size(), out).ptr - str.data(); if (pos) *pos = p; From b2e1870a147e303ec16a43beba8c94597db37def Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 15:32:19 +0800 Subject: [PATCH 091/138] Restore sublayer prompt when enabling gradient mixing --- src/slic3r/GUI/MixedFilamentDialog.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 84eb9ca0b9..c81c90fe1c 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -1495,6 +1495,31 @@ void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) void MixedFilamentDialog::on_gradient_toggled() { + // Orca: the engine only produces a gradient when the print profile's + // "enable_mixed_color_sublayer" option is on (ToolOrdering::resolve_mixed_filaments + // falls back to whole-layer round-robin without it, and BBS leaves users to find the + // option themselves). Offer to switch it on so the gradient the user just enabled + // actually shows up in the sliced result. Keep this block on future BBS syncs. + bool checked = m_chk_gradient->GetValue(); + + if (checked) { + auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (!print_config.opt_bool("enable_mixed_color_sublayer")) { + wxMessageDialog dlg(this, + _L("Gradient effect requires 'Mixed color sublayer' to be enabled. Enable it now?"), + _L("Mixed Color Sublayer"), + wxYES_NO | wxICON_QUESTION); + if (dlg.ShowModal() == wxID_YES) { + DynamicPrintConfig new_conf; + new_conf.set_key_value("enable_mixed_color_sublayer", new ConfigOptionBool(true)); + wxGetApp().get_tab(Preset::TYPE_PRINT)->load_config(new_conf); + } else { + m_chk_gradient->SetValue(false); + return; + } + } + } + m_result.gradient_enabled = m_chk_gradient->GetValue(); if (m_ratio_sizer) From 6745a33d5393d8c5e5dab48eb064c2550a7d6305 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 15:32:19 +0800 Subject: [PATCH 092/138] Fix deleting mixed filaments from the sidebar --- src/libslic3r/Model.cpp | 15 ++++-- src/slic3r/GUI/Plater.cpp | 98 ++++++++++++++++++++++----------------- 2 files changed, 68 insertions(+), 45 deletions(-) diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 81e5990d36..71c042f4e0 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -2669,9 +2669,18 @@ void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_cou // Same stale-assignment cleanup as update_extruder_count, for the filament-delete path. // Ported from BambuStudio (STUDIO-15763). size_t eid = extruder_id(); - if (eid > extruder_count) { - // A mixed-color slot is virtual and legitimately sits past the physical filament count, - // so an assignment to one is not stale and must survive the delete. + // Judge out-of-range against the post-remap id, mirroring update_filament_values_for_items_when_delete_filament. + // Using the pre-remap eid would wrongly erase a high extruder that should remap (e.g. 5 -> 4 after + // deleting filament 1); update_filament_values_for_items_when_delete_filament would then skip it + // (!has("extruder")) and the volume would fall back to the object default color. + size_t remapped = eid; + if (eid == filament_id) + remapped = (replace_filament_id > 0) ? (size_t)replace_filament_id : 1; + else if (eid > filament_id) + remapped = eid - 1; + if (remapped > extruder_count) { + // filament_is_mixed is the pre-delete snapshot; index it with the ORIGINAL eid (1-based), + // not remapped, so we check whether this volume's current slot is a mixed slot. bool is_mixed = !filament_is_mixed.empty() && eid >= 1 && (eid - 1) < filament_is_mixed.size() && filament_is_mixed[eid - 1]; if (!is_mixed) this->config.erase("extruder"); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 29e6dab244..4676ff3c4b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -5294,43 +5294,44 @@ void Sidebar::on_filaments_delete(size_t filament_id) { auto &choices = combos_filament(); - if (filament_id >= choices.size()) - return; + // A mixed (virtual) slot has no combo of its own, so there is no combo UI to remove — + // but the shared refresh below must still run so the mixed filament panel drops its row. + if (filament_id < choices.size()) { + if (choices.size() == 1) + choices[0]->GetDropDown().Invalidate(); - if (choices.size() == 1) - choices[0]->GetDropDown().Invalidate(); + wxWindowUpdateLocker noUpdates_scrolled_panel(this); - wxWindowUpdateLocker noUpdates_scrolled_panel(this); + // delete UI item + { + const int last = p->combos_filament.size() - 1; + auto sizer_filaments = this->p->sizer_filaments->GetItem(last % 2)->GetSizer(); + sizer_filaments->Remove(last / 2); - // delete UI item - if (filament_id < p->combos_filament.size()) { - const int last = p->combos_filament.size() - 1; - auto sizer_filaments = this->p->sizer_filaments->GetItem(last % 2)->GetSizer(); - sizer_filaments->Remove(last / 2); + PlaterPresetComboBox* to_delete_combox = p->combos_filament[filament_id]; + (*p->combos_filament[last]).Destroy(); + p->combos_filament.pop_back(); - PlaterPresetComboBox* to_delete_combox = p->combos_filament[filament_id]; - (*p->combos_filament[last]).Destroy(); - p->combos_filament.pop_back(); - - // BBS: filament double columns - auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); - auto sizer_filaments1 = this->p->sizer_filaments->GetItem(1)->GetSizer(); - if (p->combos_filament.size() < 2) { - sizer_filaments1->Clear(); - } else { - size_t c0 = sizer_filaments0->GetChildren().GetCount(); - size_t c1 = sizer_filaments1->GetChildren().GetCount(); - if (c0 < c1) - sizer_filaments1->Remove(c1 - 1); - else if (c0 > c1) - sizer_filaments1->AddStretchSpacer(1); + // BBS: filament double columns + auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); + auto sizer_filaments1 = this->p->sizer_filaments->GetItem(1)->GetSizer(); + if (p->combos_filament.size() < 2) { + sizer_filaments1->Clear(); + } else { + size_t c0 = sizer_filaments0->GetChildren().GetCount(); + size_t c1 = sizer_filaments1->GetChildren().GetCount(); + if (c0 < c1) + sizer_filaments1->Remove(c1 - 1); + else if (c0 > c1) + sizer_filaments1->AddStretchSpacer(1); + } } - } - show_SEMM_buttons(); // ORCA + show_SEMM_buttons(); // ORCA - for (size_t idx = filament_id ; idx < p->combos_filament.size(); ++idx) { - p->combos_filament[idx]->update(); + for (size_t idx = filament_id ; idx < p->combos_filament.size(); ++idx) { + p->combos_filament[idx]->update(); + } } update_filaments_area_height(); // ORCA @@ -5368,15 +5369,22 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { filament_id = filament_count; } - if (filament_id > filament_count) + // Mixed (virtual) slots have no combo of their own, so their config index lies past + // filament_count; bound explicit ids by the total slot count instead. + size_t total_filaments = wxGetApp().preset_bundle->filament_presets.size(); + if (filament_id > filament_count && filament_id >= total_filaments) return; - if (wxGetApp().preset_bundle->is_the_only_edited_filament(filament_id) || (filament_id == 0)) { - wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", true); - } + bool is_mixed = (filament_id >= p->combos_filament.size()); - if (p->editing_filament == filament_id || p->editing_filament >= filament_count) { - p->editing_filament = -1; + if (!is_mixed) { + if (wxGetApp().preset_bundle->is_the_only_edited_filament(filament_id) || (filament_id == 0)) { + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", true); + } + + if (p->editing_filament == filament_id || p->editing_filament >= filament_count) { + p->editing_filament = -1; + } } // update_num_filaments() shrinks filament_is_mixed along with the other per-filament arrays, @@ -5387,8 +5395,12 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { is_mixed_snapshot = opt->values; wxGetApp().preset_bundle->update_num_filaments(filament_id); - wxGetApp().plater()->get_partplate_list().on_filament_deleted(filament_count, filament_id); - wxGetApp().plater()->on_filaments_delete(filament_count, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id, is_mixed_snapshot); + + // filament_count only counts physical combos, so with mixed slots present it is not the + // new number of slots; recompute from the shrunk preset list for the downstream updates. + size_t total_after_delete = wxGetApp().preset_bundle->filament_presets.size(); + wxGetApp().plater()->get_partplate_list().on_filament_deleted(total_after_delete, filament_id); + wxGetApp().plater()->on_filaments_delete(total_after_delete, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id, is_mixed_snapshot); wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); @@ -19495,8 +19507,10 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r } } - // update UI - sidebar().on_filaments_delete(filament_id); + // update object/volume/support(object and volume) filament id + // Must run before UI update which triggers update_mixed_filament_list() → + // update_objects_list_filament_column() that clips extruders above total count. + sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); // update global support filament static const char *keys[] = {"support_filament", "support_interface_filament"}; @@ -19510,8 +19524,8 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r } } - // update object/volume/support(object and volume) filament id - sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); + // update UI — runs after remap so update_mixed_filament_list() won't clip remapped extruder IDs + sidebar().on_filaments_delete(filament_id); // update customize gcode for (auto item = p->model.plates_custom_gcodes.begin(); item != p->model.plates_custom_gcodes.end(); ++item) { From 2131ef05605280c86cfb39d5508f7111a6422a47 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 17:10:47 +0800 Subject: [PATCH 093/138] Keep mixed filaments across app restarts --- src/libslic3r/PresetBundle.cpp | 111 ++++++++++-------- .../libslic3r/test_preset_bundle_loading.cpp | 29 ++--- 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 244a9e6607..74d48118e6 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -2716,28 +2716,53 @@ void PresetBundle::load_installed_sla_materials(AppConfig &config) } // 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) +// BambuStudio also snapshots it in the app config so the last session's mixes are back before any +// project is opened; there the filament list itself is a single global snapshot, so the mixed +// arrays live next to it in the global "presets" section. Orca's per-printer preset memory instead +// rebuilds the filament list from the selected printer's snapshot (filament_%02u/filament_colors) +// on startup AND on every printer selection — so the mixed arrays, whose component ids are 1-based +// indices into exactly that list, must live in the same per-printer snapshot or they end up +// describing a list they were never saved against (and previously got reset on every printer +// select, losing the mixes over a restart). +// Missing keys clear the arrays: a printer with no stored mixes must not inherit another's. +// fallback_to_global additionally reads the legacy shared "presets" keys (the old format) so a +// config saved by an earlier build still restores at startup; export_selections clears that +// section on the next save. +static void load_mixed_filament_settings(DynamicPrintConfig &project_config, AppConfig &config, + const std::string &printer_name, size_t n_filaments, + bool fallback_to_global) { + auto raw_value = [&](const char *key, bool &found) -> std::string { + if (config.has_printer_setting(printer_name, key)) { + found = true; + return config.get_printer_setting(printer_name, key); + } + if (fallback_to_global && config.has("presets", key)) { + found = true; + return config.get("presets", key); + } + found = false; + return std::string{}; + }; std::vector parts; 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(); + vals.clear(); + bool found = false; + const std::string s = raw_value(key, found); + if (found && !s.empty()) { + boost::algorithm::split(parts, s, boost::algorithm::is_any_of(",")); for (const auto &p : parts) vals.push_back(p == "1"); } vals.resize(n_filaments, false); }; 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.clear(); + bool found = false; + const std::string s = raw_value(key, found); + if (found && !s.empty()) { + boost::algorithm::split(parts, s, boost::algorithm::is_any_of("|")); vals = parts; } vals.resize(n_filaments, std::string{}); @@ -2754,9 +2779,12 @@ static void load_mixed_filament_settings(DynamicPrintConfig &project_config, con // 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")) { + vals.clear(); + bool found = false; + const std::string s = raw_value("filament_mixed_gradient_curve", found); + if (found && !s.empty()) { std::vector curves; - if (unescape_strings_cstyle(config.get("presets", "filament_mixed_gradient_curve"), curves)) + if (unescape_strings_cstyle(s, curves)) vals = std::move(curves); } vals.resize(n_filaments, std::string{}); @@ -2766,30 +2794,6 @@ static void load_mixed_filament_settings(DynamicPrintConfig &project_config, con } } -// 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) { std::string initial_printer_profile_name = printers.get_selected_preset_name(); @@ -2870,7 +2874,9 @@ 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()); } - reset_mixed_filament_settings(project_config, filament_presets.size()); + // No global fallback here: on a printer change the legacy shared keys describe another + // printer's filament list, so absent per-printer keys must clear the mixes, not revive them. + load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size(), false); // 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, @@ -3021,7 +3027,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, filament_presets.size()); + load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size(), true); // 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, @@ -3156,11 +3162,12 @@ void PresetBundle::export_selections(AppConfig &config) "|"); config.set_printer_setting(printer_name, "flush_multiplier", flush_multiplier_str); - // 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 '|'. + // Mixed-color filament metadata: stored in the per-printer snapshot next to the filament + // list it indexes (filament_%02u / filament_colors), so each printer's remembered config + // round-trips its own mixes and re-applying a snapshot never leaves the arrays describing a + // different list (see load_mixed_filament_settings). 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) { @@ -3170,19 +3177,19 @@ void PresetBundle::export_selections(AppConfig &config) return s; }; if (auto *opt = project_config.option("filament_is_mixed")) - config.set("presets", "filament_is_mixed", join_bools(opt->values)); + config.set_printer_setting(printer_name, "filament_is_mixed", join_bools(opt->values)); if (auto *opt = project_config.option("filament_mixed_components")) - config.set("presets", "filament_mixed_components", boost::algorithm::join(opt->values, "|")); + config.set_printer_setting(printer_name, "filament_mixed_components", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_sublayer_ratios")) - config.set("presets", "filament_mixed_sublayer_ratios", boost::algorithm::join(opt->values, "|")); + config.set_printer_setting(printer_name, "filament_mixed_sublayer_ratios", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_gradient")) - config.set("presets", "filament_mixed_gradient", join_bools(opt->values)); + config.set_printer_setting(printer_name, "filament_mixed_gradient", join_bools(opt->values)); if (auto *opt = project_config.option("filament_mixed_gradient_range")) - config.set("presets", "filament_mixed_gradient_range", boost::algorithm::join(opt->values, "|")); + config.set_printer_setting(printer_name, "filament_mixed_gradient_range", boost::algorithm::join(opt->values, "|")); if (auto *opt = project_config.option("filament_mixed_gradient_curve")) - config.set("presets", "filament_mixed_gradient_curve", escape_strings_cstyle(opt->values)); + config.set_printer_setting(printer_name, "filament_mixed_gradient_curve", escape_strings_cstyle(opt->values)); if (auto *opt = project_config.option("filament_mixed_gradient_per_part")) - config.set("presets", "filament_mixed_gradient_per_part", join_bools(opt->values)); + config.set_printer_setting(printer_name, "filament_mixed_gradient_per_part", join_bools(opt->values)); // BBS //config.set("presets", "sla_print", sla_prints.get_selected_preset_name()); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 47cf7d5c43..0fb6f3e2f8 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -614,13 +614,13 @@ TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament } } -// 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]") +// A mix is described by 1-based indices into the project's filament list. Orca's per-printer +// preset memory rebuilds that list from the selected printer's snapshot (filament_%02u / +// filament_colors) at startup and on every printer selection, so the mixed arrays must be stored +// in the SAME per-printer snapshot: kept globally (as BambuStudio does — its filament list is a +// single global snapshot too) they end up indexing a list they were never saved against, and used +// to be reset on every printer selection instead, losing the mixes over an app restart. +TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; // export_selections skips the built-in "Default Printer" placeholder entirely. @@ -636,16 +636,16 @@ TEST_CASE("Mixed-color filament metadata is snapshotted globally, never per prin 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)); + DYNAMIC_SECTION("per printer, not global: " << key) { + CHECK(app_config.has_printer_setting(printer_name, key)); + CHECK_FALSE(app_config.has("presets", 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"); + CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1"); + CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2"); + CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5"); } } @@ -668,7 +668,8 @@ TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Pre // 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)); + REQUIRE(unescape_strings_cstyle( + app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded)); CHECK(decoded == curves); } From 0c3d7c6ed1978c7f863c68b27b649b92ad9f23e6 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 21:14:08 +0800 Subject: [PATCH 094/138] Expand mixed slots in by-object filament bookkeeping --- src/libslic3r/Print.cpp | 29 ++++++++-- tests/fff_print/test_mixed_filament.cpp | 76 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index b50a6b9d43..3cd42eb96e 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -5,6 +5,7 @@ #include "Brim.hpp" #include "ClipperUtils.hpp" #include "Extruder.hpp" +#include "FilamentMixer.hpp" #include "Flow.hpp" #include "Geometry/ConvexHull.hpp" #include "I18N.hpp" @@ -2601,28 +2602,38 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (this->config().print_sequence == PrintSequence::ByObject) { // Order object instances for sequential print. print_object_instances_ordering = sort_object_instances_by_model_order(*this); + // A mixed slot is virtual; only its components reach a nozzle. These per-object orderings + // are unsorted (no resolve_mixed_filaments), so expand the slots here for the grouping, the + // unprintable sets and the slice-used lists. No-op without mixed filaments. + // Orca: the slice-used lists stay sourced from these expanded lists rather than from the + // sorted orderings (which may add the wipe-tower filament or seed dontcare layers + // differently), so prints without mixed filaments keep their used-filament set; the + // first-layer set therefore lists every component of a mixed slot, not just the one layer 0 + // resolves to. + const auto &is_mixed = m_config.filament_is_mixed.values; + const auto &comp_strs = m_config.filament_mixed_components.values; + const bool has_mixed = has_any_mixed_filament(is_mixed); std::vector first_layer_used_filaments; - std::vector used_mixed_filaments; std::vector> all_filaments; for (print_object_instance_sequential_active = print_object_instances_ordering.begin(); print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); for (size_t idx = 0; idx < tool_ordering.layer_tools().size(); ++idx) { - auto& layer_filament = tool_ordering.layer_tools()[idx].extruders; + auto layer_filament = tool_ordering.layer_tools()[idx].extruders; + if (has_mixed) + layer_filament = expand_mixed_filaments(layer_filament, is_mixed, comp_strs); all_filaments.emplace_back(layer_filament); if (idx == 0) first_layer_used_filaments.insert(first_layer_used_filaments.end(), layer_filament.begin(), layer_filament.end()); } - used_mixed_filaments.insert(used_mixed_filaments.end(), - tool_ordering.used_mixed_filaments().begin(), tool_ordering.used_mixed_filaments().end()); } sort_remove_duplicates(first_layer_used_filaments); - sort_remove_duplicates(used_mixed_filaments); auto used_filaments = collect_sorted_used_filaments(all_filaments); this->set_slice_used_filaments(first_layer_used_filaments,used_filaments); - this->set_slice_used_mixed_filaments(used_mixed_filaments); auto physical_unprintables = this->get_physical_unprintable_filaments(used_filaments); auto geometric_unprintables = this->get_geometric_unprintable_filaments(); + if (has_mixed) + expand_mixed_slots_in_unprintables(geometric_unprintables, is_mixed, comp_strs); auto filament_unprintable_volumes = this->get_filament_unprintable_flow(used_filaments); // Selector (per-layer regroup) prints skip the static grouping: their print-wide result // is stitched from the per-object plans after the ordering loop below. @@ -2674,6 +2685,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) std::vector> nozzle_map_per_layer; std::vector> stitched_layer_filaments; print_object_instance_sequential_active = print_object_instances_ordering.begin(); + std::vector used_mixed_filaments; for (; print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { const PrintObject *print_object = (*print_object_instance_sequential_active)->print_object; if (dynamic_reorder) { @@ -2705,10 +2717,15 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) if (!tool_ordering.layer_tools().empty()) seq_mixed_resolution[print_object->id()] = tool_ordering.layer_tools().front().mixed_filament_resolution; } + // Only sorted orderings have run resolve_mixed_filaments, so only they know which + // mixed slots actually print. + append(used_mixed_filaments, tool_ordering.used_mixed_filaments()); if ((initial_extruder_id = tool_ordering.first_extruder()) != static_cast(-1)) { append(printExtruders, tool_ordering.tools_for_layer(layers_to_print.front().first).extruders); } } + sort_remove_duplicates(used_mixed_filaments); + this->set_slice_used_mixed_filaments(used_mixed_filaments); if (dynamic_reorder && m_objects.size() > 1) { // Stitch the per-object plans into one print-wide selector result. A single-object // sequential print publishes (and writes back) from its own ordering instead: the diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp index 4f4d914cc2..84a3270605 100644 --- a/tests/fff_print/test_mixed_filament.cpp +++ b/tests/fff_print/test_mixed_filament.cpp @@ -1,6 +1,7 @@ #include #include "libslic3r/GCode/ToolOrdering.hpp" +#include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/Print.hpp" #include "test_helpers.hpp" @@ -135,3 +136,78 @@ TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilam CHECK(gc.find(";HEIGHT:0.12") == std::string::npos); CHECK(gc.find(";HEIGHT:0.08") == std::string::npos); } + +TEST_CASE("By-object prints without mixed filaments keep their used-filament set", "[MixedFilament]") +{ + // Regression guard for the mixed gate: with no mixed slot the by-object bookkeeping must + // be untouched by this change. Object 2 prints with filament 2, so both filaments are used + // and no mixed filament is reported. + DynamicPrintConfig config = multifilament_config(2, {{"print_sequence", "by object"}}); + const std::vector> overrides{ {}, { {"extruder", "2"} } }; + + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.objects().size() == 2); + print.process(); + + CHECK(print.get_slice_used_filaments(false) == std::vector{0, 1}); + CHECK(print.get_slice_used_filaments(true) == std::vector{0, 1}); + CHECK(print.get_slice_used_mixed_filaments().empty()); +} + +TEST_CASE("By-layer prints record a mixed slot's components and the slot itself", "[MixedFilament]") +{ + // Control for the by-object case below: the by-layer path publishes the physical + // components (0-based 0 and 1) as used filaments and the mixed slot (config index 2) as + // a used mixed filament. By-object prints must report exactly the same. + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(false)); + print.process(); + + CHECK(print.get_slice_used_filaments(false) == std::vector{0, 1}); + CHECK(print.get_slice_used_mixed_filaments() == std::vector{2}); +} + +TEST_CASE("By-object prints expand a mixed slot to its components in the slice bookkeeping", "[MixedFilament]") +{ + // Sequential prints build their filament lists from unsorted per-object orderings, which + // still carry the virtual slot (config index 2). The slice-used sets and the published + // grouping result must see the physical components 0 and 1 instead, and the slot itself + // must still be reported as a used mixed filament — exactly what the by-layer path yields. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"print_sequence", "by object"}}); + + Print print; + Model model; + init_print({cube(20), cube(20)}, print, model, config); + REQUIRE(print.objects().size() == 2); + print.process(); + + const std::vector components{0, 1}; + CHECK(print.get_slice_used_filaments(false) == components); + CHECK(print.get_slice_used_filaments(true) == components); + CHECK(print.get_slice_used_mixed_filaments() == std::vector{2}); + + auto group_result = print.get_layered_nozzle_group_result(); + REQUIRE(group_result != nullptr); + CHECK(group_result->get_used_filaments() == components); +} + +TEST_CASE("By-object G-code lists a mixed slot's components in the filament header", "[MixedFilament]") +{ + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"print_sequence", "by object"}}); + + Print print; + Model model; + init_print({cube(20), cube(20)}, print, model, config); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + // The header names the filaments that must be loaded (components 1 and 2, 1-based), + // never the virtual slot 3. + CHECK(gc.find("; filament: 1,2\n") != std::string::npos); + CHECK(gc.find("; filament: 3") == std::string::npos); +} From 8965b0be210bf4c92844fe99efdd36c1a15017fd Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 21:22:25 +0800 Subject: [PATCH 095/138] Skip mixed slots in flush volume auto-calculation Mixed-colour slots are virtual and never flushed. Guard auto_calc_flushing_volumes_internal against them as BambuStudio does, and make the flushing dialog's default matrix and the sidebar 'modified' comparison physical-only so the untouched mixed rows no longer count as a user edit and the Re-calculate result matches the physical-only table. --- src/slic3r/GUI/Plater.cpp | 6 +++ src/slic3r/GUI/WipeTowerDialog.cpp | 71 +++++++++++++++--------------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 4676ff3c4b..479b91e098 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -6477,6 +6477,10 @@ void Sidebar::auto_calc_flushing_volumes(const int filament_idx, const int extru void Sidebar::auto_calc_flushing_volumes_internal(const int modify_id, const int extruder_id) { auto& preset_bundle = wxGetApp().preset_bundle; + // A mixed-colour slot is virtual and is never flushed to or from: leave its row and column + // alone (the flushing dialog hides them and only compares physical slots). + if (modify_id >= 0 && preset_bundle->is_mixed_filament((size_t)modify_id)) + return; auto& project_config = preset_bundle->project_config; const auto& full_config = wxGetApp().preset_bundle->full_config(); auto& ams_multi_color_filament = preset_bundle->ams_multi_color_filment; @@ -6515,6 +6519,8 @@ void Sidebar::auto_calc_flushing_volumes_internal(const int modify_id, const int if (modify_id >= 0 && modify_id < multi_colours.size()) { for (int i = 0; i < multi_colours.size(); ++i) { + if (preset_bundle->is_mixed_filament((size_t)i)) + continue; // from to modify int from_idx = i; if (from_idx != modify_id) { diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index f1d0946bf5..70aba0404e 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -204,6 +204,10 @@ bool is_flush_config_modified() const auto &project_config = wxGetApp().preset_bundle->project_config; const std::vector &config_matrix = (project_config.option("flush_volumes_matrix"))->values; const std::vector &config_multiplier = (project_config.option("flush_multiplier"))->values; + // The config matrix is N x N per nozzle over every slot, while CalcFlushingVolumes is p x p + // over the physical slots (mixed slots never flush): map each default cell to its config index. + const auto physical_indices = wxGetApp().preset_bundle->physical_filament_config_indices(); + const size_t full_n = project_config.option("filament_colour")->values.size(); bool has_modify = false; for (int i = 0; i < config_multiplier.size(); i++) { @@ -212,11 +216,12 @@ bool is_flush_config_modified() break; } std::vector> default_matrix = WipingDialog::CalcFlushingVolumes(i); - int len = default_matrix.size(); - for (int m = 0; m < len; m++) { - for (int n = 0; n < len; n++) { - int idx = i * len * len + m * len + n; - if (config_matrix[idx] != default_matrix[m][n] * config_multiplier[i]) { + size_t p_len = default_matrix.size(); + size_t nozzle_offset = i * full_n * full_n; + for (size_t m = 0; m < p_len; m++) { + for (size_t n = 0; n < p_len; n++) { + size_t cfg_idx = nozzle_offset + physical_indices[m] * full_n + physical_indices[n]; + if (cfg_idx < config_matrix.size() && config_matrix[cfg_idx] != default_matrix[m][n] * config_multiplier[i]) { has_modify = true; break; } @@ -571,55 +576,51 @@ WipingDialog::VolumeMatrix WipingDialog::CalcFlushingVolumes(int extruder_id) auto& preset_bundle = wxGetApp().preset_bundle; auto full_config = preset_bundle->full_config(); auto& ams_multi_color_filament = preset_bundle->ams_multi_color_filment; + // Mixed-colour slots are virtual and never flushed: compute a p x p matrix over the physical + // slots only, laid out like the table; row/column k belongs to config slot physical_indices[k]. + auto physical_indices = preset_bundle->physical_filament_config_indices(); - std::vector filament_color_strs = full_config.option("filament_colour")->values; - std::vector> multi_colors; - std::vector filament_colors; - for (auto color_str : filament_color_strs) - filament_colors.emplace_back(color_str); - + std::vector all_color_strs = full_config.option("filament_colour")->values; int flush_dataset_value = full_config.option("nozzle_flush_dataset")->values[extruder_id]; + const std::vector min_flush_volumes = get_min_flush_volumes(full_config, extruder_id); + // Support for multi-color filament - for (int i = 0; i < filament_colors.size(); ++i) { + std::vector> multi_colors; + for (size_t cfg_idx : physical_indices) { std::vector single_filament; - if (i < ams_multi_color_filament.size()) { - if (!ams_multi_color_filament[i].empty()) { - std::vector colors = ams_multi_color_filament[i]; - for (int j = 0; j < colors.size(); ++j) { - single_filament.push_back(wxColour(colors[j])); - } - multi_colors.push_back(single_filament); - continue; - } + if (cfg_idx < ams_multi_color_filament.size() && !ams_multi_color_filament[cfg_idx].empty()) { + for (const auto& c : ams_multi_color_filament[cfg_idx]) + single_filament.push_back(wxColour(c)); + } else if (cfg_idx < all_color_strs.size()) { + single_filament.push_back(wxColour(all_color_strs[cfg_idx])); } - single_filament.push_back(wxColour(filament_colors[i])); multi_colors.push_back(single_filament); } VolumeMatrix matrix; - const std::vector min_flush_volumes = get_min_flush_volumes(full_config, extruder_id); - - for (int from_idx = 0; from_idx < multi_colors.size(); ++from_idx) { - bool is_from_support = is_support_filament(from_idx); + for (size_t pi = 0; pi < physical_indices.size(); ++pi) { + int from_cfg = (int)physical_indices[pi]; + bool is_from_support = is_support_filament(from_cfg); matrix.emplace_back(); - for (int to_idx = 0; to_idx < multi_colors.size(); ++to_idx) { - if (from_idx == to_idx) { + for (size_t pj = 0; pj < physical_indices.size(); ++pj) { + int to_cfg = (int)physical_indices[pj]; + if (from_cfg == to_cfg) { matrix.back().emplace_back(0); continue; } - bool is_to_support = is_support_filament(to_idx); - + bool is_to_support = is_support_filament(to_cfg); int flushing_volume = 0; if (is_to_support) { flushing_volume = Slic3r::g_flush_volume_to_support; } else { - for (int i = 0; i < multi_colors[from_idx].size(); ++i) { - const wxColour& from = multi_colors[from_idx][i]; - for (int j = 0; j < multi_colors[to_idx].size(); ++j) { - const wxColour& to = multi_colors[to_idx][j]; - int volume = CalcFlushingVolume(from, to, min_flush_volumes[from_idx], flush_dataset_value); + int min_flush_from = (from_cfg < (int)min_flush_volumes.size()) ? min_flush_volumes[from_cfg] : 0; + for (size_t i = 0; i < multi_colors[pi].size(); ++i) { + const wxColour& from = multi_colors[pi][i]; + for (size_t j = 0; j < multi_colors[pj].size(); ++j) { + const wxColour& to = multi_colors[pj][j]; + int volume = CalcFlushingVolume(from, to, min_flush_from, flush_dataset_value); flushing_volume = std::max(flushing_volume, volume); } } From 716bba53df8f64ae033d61437a68e8f0c46dde85 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 22 Aug 2026 23:52:11 +0800 Subject: [PATCH 096/138] Refuse to slice a broken mixed filament --- src/slic3r/GUI/Plater.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 479b91e098..3b91de019c 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -11575,9 +11575,11 @@ void Plater::priv::set_current_panel(wxPanel* panel, bool no_slice) if (current_plate->is_slice_result_valid() && this->model.objects.empty() && !current_has_print_instances) only_has_gcode_need_preview = true; - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": from set_current_panel, no_slice %1%, export_in_progress %2%, model_fits %3%, m_is_slicing %4%")%no_slice%export_in_progress%model_fits%m_is_slicing; + bool mixed_broken = sidebar->has_broken_mixed_filament(); - if (!no_slice && !this->model.objects.empty() && !export_in_progress && model_fits && current_has_print_instances) + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": from set_current_panel, no_slice %1%, export_in_progress %2%, model_fits %3%, m_is_slicing %4%, mixed_broken %5%")%no_slice%export_in_progress%model_fits%m_is_slicing%mixed_broken; + + if (!no_slice && !this->model.objects.empty() && !export_in_progress && model_fits && current_has_print_instances && !mixed_broken) { //if already running in background, not relice here //BBS: add more judge for slicing @@ -18792,6 +18794,15 @@ void Plater::reslice() return; } + // A mixed filament with deleted or type-mismatched components cannot be resolved at slicing + // time. MainFrame::get_enable_slice_status() already disables the Slice button for it, but the + // Preview-tab switch, auto-slice and queued slice events reach reslice() directly, so refuse + // here too instead of letting the engine slice the broken slot as a plain filament. + if (sidebar().has_broken_mixed_filament()) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": broken mixed filament detected, refuse to slice"; + return; + } + // In case SLA gizmo is in editing mode, refuse to continue // and notify user that he should leave it first. if (get_view3D_canvas3D()->get_gizmos_manager().is_in_editing_mode(true)) From cfeca9b9ff5402be6beec4efe99801e12b6a4a15 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 00:05:34 +0800 Subject: [PATCH 097/138] Hide mixed slots from the support and wipe tower filament dropdowns --- src/slic3r/GUI/ConfigManipulation.cpp | 15 +++++---- src/slic3r/GUI/Plater.cpp | 46 +++++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 1d44d1b356..55a41a5720 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -578,12 +578,13 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con // BBS // A filament override naming a slot that no longer exists is stale and falls back to the - // plater's value. Support is additionally restricted to physical filaments: the support paths - // (ToolOrdering::collect_extruders, Print::validate) consume support_filament directly, with - // no per-layer mixed resolution, so a virtual slot there would reach the G-code unresolved. - // The per-feature keys have no such restriction — LayerTools::extruder() and its siblings - // resolve a mixed slot to the physical filament chosen for each layer. - static const char* support_keys[] = { "support_filament", "support_interface_filament" }; + // plater's value. Support and the wipe tower are additionally restricted to physical filaments: + // the engine consumes those keys directly, with no per-layer mixed resolution, so a virtual + // slot there would reach the G-code unresolved. The per-feature keys have no such restriction — + // LayerTools::extruder() and its siblings resolve a mixed slot to the physical filament chosen + // for each layer. The sidebar dropdowns already hide mixed slots for the restricted keys + // (Plater.cpp DynamicFilamentList); this reset covers values loaded from projects. + static const char* physical_only_keys[] = { "support_filament", "support_interface_filament", "wipe_tower_filament" }; static const char* feature_keys[] = { "outer_wall_filament_id", "inner_wall_filament_id", "sparse_infill_filament_id", "internal_solid_filament_id", "top_surface_filament_id", "bottom_surface_filament_id" }; @@ -607,7 +608,7 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con new_conf.set_key_value(key, new ConfigOptionInt(new_value)); apply(config, &new_conf); }; - for (const char* key : support_keys) + for (const char* key : physical_only_keys) reset_invalid_filament(key, false); for (const char* key : feature_keys) reset_invalid_filament(key, true); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 3b91de019c..c3a163638b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -1096,23 +1096,38 @@ std::vector get_min_flush_volumes(const DynamicPrintConfig &full_config, si struct DynamicFilamentList : DynamicList { + // Orca: support and wipe-tower keys are consumed by the engine without per-layer mixed + // resolution (see ConfigManipulation::update_print_fff_config), so their dropdowns list + // physical slots only; the per-feature *_filament_id keys keep every slot. BBS uses one + // physical-only list for all of its keys. + explicit DynamicFilamentList(bool physical_only = false) : physical_only(physical_only) {} + bool physical_only; std::vector> items; + std::vector slot_map{0}; // combo index -> 1-based filament slot; slot_map[0] = 0 is "Default" void apply_on(Choice *c) override { + if (!c) + return; if (items.empty()) update(true); auto cb = dynamic_cast(c->window); + if (!cb) + return; wxString old_selection = cb->GetStringSelection(); int old_index = cb->GetSelection(); + // slot_map is already rebuilt here: restoring through it keeps the index of every slot + // still listed and sends a vanished slot to the fallback below. + int old_slot = old_index >= 0 && old_index < int(slot_map.size()) ? slot_map[old_index] : -1; cb->Clear(); cb->Append(_L("Default")); for (auto i : items) { cb->Append(i.first, i.second ? *i.second : wxNullBitmap); } - if (old_index >= 0 && (unsigned int) old_index < cb->GetCount()) { - cb->SetSelection(old_index); + int restored = index_of(wxString::Format("%d", old_slot)); + if (restored > 0 || old_slot == 0) { + cb->SetSelection(restored); return; } @@ -1128,27 +1143,36 @@ struct DynamicFilamentList : DynamicList wxString get_value(int index) override { wxString str; - str << index; + str << (index >= 0 && index < int(slot_map.size()) ? slot_map[index] : 0); return str; } int index_of(wxString value) override { long n = 0; - return (value.ToLong(&n) && n <= items.size()) ? int(n) : -1; + if (!value.ToLong(&n)) + return -1; + for (int i = 0; i < int(slot_map.size()); ++i) + if (slot_map[i] == int(n)) + return i; + return 0; } void update(bool force = false) { items.clear(); + slot_map.assign(1, 0); if (!force && m_choices.empty()) return; auto icons = get_extruder_color_icons(true); auto presets = wxGetApp().preset_bundle->filament_presets; for (int i = 0; i < presets.size(); ++i) { + if (physical_only && wxGetApp().preset_bundle->is_mixed_filament(i)) + continue; wxString str; std::string type; wxGetApp().preset_bundle->filaments.find_preset(presets[i])->get_filament_type(type); str << type; items.push_back({str, i < icons.size() ? icons[i] : nullptr}); + slot_map.push_back(i + 1); } DynamicList::update(); } @@ -1169,7 +1193,8 @@ static bool has_junction_deviation(const DynamicPrintConfig* printer_config) junction_dev->values.front() > 0.0; } -static DynamicFilamentList dynamic_filament_list; +static DynamicFilamentList dynamic_filament_list; // every slot, mixed included (per-feature *_filament_id keys) +static DynamicFilamentList dynamic_physical_filament_list(true); // physical slots only (support_*, wipe_tower_filament) class AMSCountPopupWindow : public PopupWindow { @@ -2391,15 +2416,15 @@ void Sidebar::update_sync_ams_btn_enable(wxUpdateUIEvent &e) Sidebar::Sidebar(Plater *parent) : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(39 * wxGetApp().em_unit(), -1)), p(new priv(parent)) { - Choice::register_dynamic_list("support_filament", &dynamic_filament_list); - Choice::register_dynamic_list("support_interface_filament", &dynamic_filament_list); + Choice::register_dynamic_list("support_filament", &dynamic_physical_filament_list); + Choice::register_dynamic_list("support_interface_filament", &dynamic_physical_filament_list); Choice::register_dynamic_list("outer_wall_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("inner_wall_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("sparse_infill_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("internal_solid_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("top_surface_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("bottom_surface_filament_id", &dynamic_filament_list); - Choice::register_dynamic_list("wipe_tower_filament", &dynamic_filament_list); + Choice::register_dynamic_list("wipe_tower_filament", &dynamic_physical_filament_list); p->scrolled = new wxPanel(this); // p->scrolled->SetScrollbars(0, 100, 1, 2); // ys_DELETE_after_testing. pixelsPerUnitY = 100 @@ -5341,7 +5366,7 @@ void Sidebar::on_filaments_delete(size_t filament_id) Layout(); p->m_panel_filament_title->Refresh(); update_ui_from_settings(); - dynamic_filament_list.update(); + update_dynamic_filament_list(); } void Sidebar::add_filament() { @@ -5842,7 +5867,7 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn) if (m_sync_dlg->is_dirty_filament()) { wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", false, true); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); - dynamic_filament_list.update(); + update_dynamic_filament_list(); } m_sync_dlg->set_check_dirty_fialment(false); dlg_res = m_sync_dlg->ShowModal(); @@ -6098,6 +6123,7 @@ void Sidebar::enable_nozzle_count_edit(bool enable) void Sidebar::update_dynamic_filament_list() { dynamic_filament_list.update(); + dynamic_physical_filament_list.update(); } PlaterPresetComboBox* Sidebar::printer_combox() From d27766aff0d2e41189fcaaa37abf31a7061ffc2a Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 00:28:49 +0800 Subject: [PATCH 098/138] Reject a mixed filament as the wipe tower filament --- src/libslic3r/Print.cpp | 13 +++++++- tests/fff_print/test_mixed_filament.cpp | 43 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 3cd42eb96e..e474818dfe 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -566,7 +566,7 @@ std::vector Print::extruders(bool conside_custom_gcode) const // If a wipe tower filament is explicitly set, ensure it participates in tool ordering. if (has_wipe_tower() && config().wipe_tower_filament != 0 && extruders.size() > 1) { - assert(config().wipe_tower_filament > 0 && config().wipe_tower_filament < int(config().nozzle_diameter.size())); + assert(config().wipe_tower_filament > 0 && config().wipe_tower_filament <= int(config().filament_diameter.size())); extruders.emplace_back(config().wipe_tower_filament - 1); // config value is 1-based } @@ -1472,6 +1472,17 @@ StringObjectException Print::validate(std::vector *warnin } if (this->has_wipe_tower() && ! m_objects.empty()) { + // Orca: wipe_tower_filament (issue #10971) is inserted into the tool order after + // resolve_mixed_filaments has expanded every mixed (virtual) slot, so a mixed slot here + // would reach the G-code as a tool change to a slot no nozzle carries. The GUI hides + // mixed slots from the option; this guards loaded projects and the CLI. + if (m_config.wipe_tower_filament > 0) { + const auto &is_mixed = m_config.filament_is_mixed.values; + const size_t wipe_idx = size_t(m_config.wipe_tower_filament - 1); + if (wipe_idx < is_mixed.size() && is_mixed[wipe_idx]) + return { L("The wipe tower filament cannot be a mixed filament."), nullptr, "wipe_tower_filament" }; + } + // Make sure all extruders use same diameter filament and have the same nozzle diameter // EPSILON comparison is used for nozzles and 10 % tolerance is used for filaments double first_nozzle_diam = m_config.nozzle_diameter.get_at(extruders.front()); diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp index 84a3270605..143ecdcb6e 100644 --- a/tests/fff_print/test_mixed_filament.cpp +++ b/tests/fff_print/test_mixed_filament.cpp @@ -211,3 +211,46 @@ TEST_CASE("By-object G-code lists a mixed slot's components in the filament head CHECK(gc.find("; filament: 1,2\n") != std::string::npos); CHECK(gc.find("; filament: 3") == std::string::npos); } + +TEST_CASE("Print::validate rejects a mixed filament as the wipe tower filament", "[MixedFilament]") +{ + // The validate backstop refuses a mixed (virtual) slot as the wipe tower filament; the GUI hides + // the slot from that option. Two cubes on physical filaments 1 and 2 make the tower real, and the + // region roles mixed_config() points at the slot are reset so only the tower uses it. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({ + {"enable_prime_tower", "1"}, + {"wipe_tower_x", "50"}, // inside the 200x200 test bed + {"wipe_tower_y", "50"}, // (the default y, 220, is not) + {"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset, as in test_print.cpp's build_cubes + {"outer_wall_filament_id", "0"}, + {"inner_wall_filament_id", "0"}, + {"sparse_infill_filament_id", "0"}, + {"internal_solid_filament_id", "0"}, + {"top_surface_filament_id", "0"}, + {"bottom_surface_filament_id", "0"}, + }); + const std::vector> overrides{ { {"extruder", "1"} }, { {"extruder", "2"} } }; + + SECTION("a physical wipe tower filament validates") { + config.set_deserialize_strict({{"wipe_tower_filament", "2"}}); + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.has_wipe_tower()); + const StringObjectException err = print.validate(); + INFO(err.string); + CHECK(err.string.empty()); + } + + SECTION("the mixed slot is refused") { + config.set_deserialize_strict({{"wipe_tower_filament", "3"}}); + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.has_wipe_tower()); + const StringObjectException err = print.validate(); + CHECK_FALSE(err.string.empty()); + CHECK(err.opt_key == "wipe_tower_filament"); + } +} From 9985688b5bbbe5fc04dcf009e17b8b9ac5514d25 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 13:37:14 +0800 Subject: [PATCH 099/138] Blend mixed slots in the machine-send and AMS-sync thumbnails --- src/slic3r/GUI/FilamentBitmapUtils.cpp | 64 +++++++++ src/slic3r/GUI/FilamentBitmapUtils.hpp | 10 ++ src/slic3r/GUI/SelectMachine.cpp | 28 +++- src/slic3r/GUI/SyncAmsInfoDialog.cpp | 19 +++ tests/slic3rutils/CMakeLists.txt | 1 + .../test_filament_bitmap_utils.cpp | 136 ++++++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 tests/slic3rutils/test_filament_bitmap_utils.cpp diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 9b43647ac0..b3f58fa4f1 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -4,7 +4,10 @@ #include #include "EncodedFilament.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" namespace Slic3r { namespace GUI { @@ -265,4 +268,65 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSiz } } +void recompute_mixed_slot_colors(std::vector& colors, + const Slic3r::DynamicPrintConfig& cfg) +{ + const auto* is_mixed_opt = cfg.option("filament_is_mixed"); + const auto* comp_opt = cfg.option("filament_mixed_components"); + const auto* ratio_opt = cfg.option("filament_mixed_sublayer_ratios"); + const auto* grad_opt = cfg.option("filament_mixed_gradient"); + if (!is_mixed_opt || !comp_opt) return; + + const size_t n = is_mixed_opt->values.size(); + if (colors.size() < n) colors.resize(n); + + const auto* colour_opt = cfg.option("filament_colour"); + const auto kFallback = wxColour(128, 128, 128, 255); + + for (size_t i = 0; i < n; ++i) { + if (!is_mixed_opt->values[i]) continue; + + if (i >= comp_opt->values.size()) { colors[i] = kFallback; continue; } + auto comp_ids = Slic3r::parse_mixed_components(comp_opt->values[i]); + if (comp_ids.empty()) { colors[i] = kFallback; continue; } + + bool is_gradient = grad_opt && i < grad_opt->values.size() && grad_opt->values[i]; + std::vector use_ids = comp_ids; + std::vector weights; + + if (is_gradient && comp_ids.size() >= 2) { + use_ids = { comp_ids.front(), comp_ids.back() }; + weights = { 5000, 5000 }; + } else { + auto ratios_d = Slic3r::parse_mixed_ratios( + (ratio_opt && i < ratio_opt->values.size()) ? ratio_opt->values[i] : std::string{}, + comp_ids.size()); + weights.reserve(comp_ids.size()); + for (double r : ratios_d) + weights.push_back(static_cast(std::lround(r * 10000.0))); + } + + std::vector hex_colors; + hex_colors.reserve(use_ids.size()); + bool any_invalid = false; + for (unsigned int id : use_ids) { + if (id == 0 || id > colors.size()) { any_invalid = true; break; } + wxColour c = colors[id - 1]; + if (c.IsOk() && (c.Red() > 0 || c.Green() > 0 || c.Blue() > 0)) { + hex_colors.push_back(wxString::Format("#%02X%02X%02X", c.Red(), c.Green(), c.Blue()).ToStdString()); + } else if (colour_opt && (id - 1) < colour_opt->values.size()) { + hex_colors.push_back(colour_opt->values[id - 1]); + } else { + any_invalid = true; break; + } + } + if (any_invalid) { colors[i] = kFallback; continue; } + + std::string hex = Slic3r::blend_color_multi(hex_colors, weights); + wxColour blended(hex); + if (!blended.IsOk()) blended = kFallback; + colors[i] = wxColour(blended.Red(), blended.Green(), blended.Blue(), 255); + } +} + }} // namespace Slic3r::GUI \ No newline at end of file diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 87d5b275cc..2e428e8d32 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -7,6 +7,10 @@ #include #include +// Orca: forward-declare so the header is self-contained outside libslic3r_gui's +// force-included pch (the GUI test suite includes it directly). +namespace Slic3r { class DynamicPrintConfig; } + namespace Slic3r { namespace GUI { // Fills a rect with a west->east linear gradient by drawing solid 1px columns. @@ -28,6 +32,12 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSize& size, bool force_gradient = false); +// Recompute blended representative colors for mixed (virtual) filament slots. +// Reads mixed-filament config keys from cfg and writes back into colors[i] +// for every slot where filament_is_mixed[i] is true. +void recompute_mixed_slot_colors(std::vector& colors, + const Slic3r::DynamicPrintConfig& cfg); + }} // namespace Slic3r::GUI #endif // slic3r_GUI_FilamentBitmapUtils_hpp_ \ No newline at end of file diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 7d724ae273..486be04243 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -4,6 +4,7 @@ #include "libslic3r/Utils.hpp" #include "libslic3r/Thread.hpp" #include "libslic3r/Color.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI.hpp" #include "GUI_App.hpp" #include "GUI_Preview.hpp" @@ -5693,10 +5694,15 @@ void SelectMachineDialog::clone_thumbnail_data() { m_preview_colors_in_thumbnail.resize(m_materialList.size()); } while (iter != m_materialList.end()) { - int id = iter->first; Material * item = iter->second; MaterialItem *m = item->item; - m_preview_colors_in_thumbnail[id] = m->m_material_coloul; + // Orca: key the preview colours by filament slot, as m_cur_colors_in_thumbnail and + // SyncAmsInfoDialog already do, so recompute_mixed_slot_colors() below can look a mixed + // slot's component colours up by id (BBS keys this array by list position). + if (item->id >= m_preview_colors_in_thumbnail.size()) { + m_preview_colors_in_thumbnail.resize(item->id + 1); + } + m_preview_colors_in_thumbnail[item->id] = m->m_material_coloul; if (item->id < m_cur_colors_in_thumbnail.size()) { m_cur_colors_in_thumbnail[item->id] = m->m_ams_coloul; } @@ -5706,6 +5712,20 @@ void SelectMachineDialog::clone_thumbnail_data() { } iter++; } + + // Expand color arrays to cover mixed (virtual) slots and compute their blended colors + const auto& cfg = wxGetApp().preset_bundle->project_config; + size_t total = 0; + if (auto* opt = cfg.option("filament_is_mixed")) + total = opt->values.size(); + size_t target = std::max(total, m_cur_colors_in_thumbnail.size()); + if (m_cur_colors_in_thumbnail.size() < target) + m_cur_colors_in_thumbnail.resize(target); + if (m_preview_colors_in_thumbnail.size() < target) + m_preview_colors_in_thumbnail.resize(target); + recompute_mixed_slot_colors(m_preview_colors_in_thumbnail, cfg); + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + //copy data auto &data = m_cur_input_thumbnail_data; m_preview_thumbnail_data.reset(); @@ -5880,6 +5900,10 @@ void SelectMachineDialog::change_default_normal(int old_filament_id, wxColour te return; } } + // Recompute mixed slot colors after physical slot color change + const auto& cfg = wxGetApp().preset_bundle->project_config; + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + ThumbnailData& data = m_cur_input_thumbnail_data; ThumbnailData& no_light_data = m_cur_no_light_thumbnail_data; if (data.width > 0 && data.height > 0 && data.width == no_light_data.width && data.height == no_light_data.height) { diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 3524d89c74..5005b49303 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -30,6 +30,7 @@ #include "DeviceCore/DevManager.h" #include "DeviceCore/DevMapping.h" #include "DeviceCore/DevStorage.h" +#include "FilamentBitmapUtils.hpp" using namespace Slic3r; using namespace Slic3r::GUI; @@ -2943,6 +2944,20 @@ void SyncAmsInfoDialog::clone_thumbnail_data() iter++; } } + + // Expand color arrays to cover mixed (virtual) slots and compute their blended colors + const auto& cfg = wxGetApp().preset_bundle->project_config; + size_t total = 0; + if (auto* opt = cfg.option("filament_is_mixed")) + total = opt->values.size(); + size_t target = std::max(total, m_cur_colors_in_thumbnail.size()); + if (m_cur_colors_in_thumbnail.size() < target) + m_cur_colors_in_thumbnail.resize(target); + if (m_preview_colors_in_thumbnail.size() < target) + m_preview_colors_in_thumbnail.resize(target); + recompute_mixed_slot_colors(m_preview_colors_in_thumbnail, cfg); + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + // copy data auto &data = m_cur_input_thumbnail_data; m_preview_thumbnail_data.reset(); @@ -3131,6 +3146,10 @@ void SyncAmsInfoDialog::change_default_normal(int old_filament_id, wxColour temp return; } } + // Recompute mixed slot colors after physical slot color change + const auto& cfg = wxGetApp().preset_bundle->project_config; + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + ThumbnailData &data = m_cur_input_thumbnail_data; ThumbnailData &no_light_data = m_cur_no_light_thumbnail_data; if (data.width > 0 && data.height > 0 && data.width == no_light_data.width && data.height == no_light_data.height) { diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index c1424064b2..ebbd62b820 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -2,6 +2,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests_main.cpp test_dev_mapping.cpp + test_filament_bitmap_utils.cpp test_network_versions.cpp test_action_source.cpp test_plugin_host_api.cpp diff --git a/tests/slic3rutils/test_filament_bitmap_utils.cpp b/tests/slic3rutils/test_filament_bitmap_utils.cpp new file mode 100644 index 0000000000..9054a82018 --- /dev/null +++ b/tests/slic3rutils/test_filament_bitmap_utils.cpp @@ -0,0 +1,136 @@ +// recompute_mixed_slot_colors lives in libslic3r_gui; this is the only suite that links it. +// Same Windows include prologue as test_dev_mapping.cpp (wx pulls in ; keep +// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers). +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include + +#include +#include + +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "slic3r/GUI/FilamentBitmapUtils.hpp" + +using namespace Slic3r; +using Slic3r::GUI::recompute_mixed_slot_colors; + +namespace { + +// Two physical slots (1 = red, 2 = blue) and mixed slot 3 built from them. +DynamicPrintConfig mixed_config(const std::string& components = "1,2", const std::string& ratios = "0.5,0.5") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components})); + cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", ratios})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"})); + return cfg; +} + +wxColour expected_blend(const std::vector& hex, const std::vector& weights) +{ + return wxColour(wxString(blend_color_multi(hex, weights))); +} + +// Compare channels one at a time so a failure names the channel. +void require_same_rgb(const wxColour& actual, const wxColour& expected) +{ + REQUIRE(int(actual.Red()) == int(expected.Red())); + REQUIRE(int(actual.Green()) == int(expected.Green())); + REQUIRE(int(actual.Blue()) == int(expected.Blue())); +} + +} // namespace + +TEST_CASE("recompute_mixed_slot_colors blends a mixed slot from its components' colours", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + recompute_mixed_slot_colors(colors, mixed_config()); + + REQUIRE(colors.size() == 3); + require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); + REQUIRE(int(colors[2].Alpha()) == 255); + // Physical slots are left alone. + require_same_rgb(colors[0], wxColour(255, 0, 0)); + require_same_rgb(colors[1], wxColour(0, 0, 255)); +} + +TEST_CASE("recompute_mixed_slot_colors leaves the colours alone without mixed slots", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + + SECTION("no mixed keys at all") { + recompute_mixed_slot_colors(colors, DynamicPrintConfig{}); + } + SECTION("mixed flags present but all false") { + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", ""})); + recompute_mixed_slot_colors(colors, cfg); + } + REQUIRE(colors.size() == 2); + require_same_rgb(colors[0], wxColour(255, 0, 0)); + require_same_rgb(colors[1], wxColour(0, 0, 255)); +} + +TEST_CASE("recompute_mixed_slot_colors falls back to grey for a broken component reference", "[FilamentBitmapUtils]") +{ + const wxColour grey(128, 128, 128, 255); + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + + SECTION("dangling component id") { + recompute_mixed_slot_colors(colors, mixed_config("1,9")); + } + SECTION("empty component list") { + recompute_mixed_slot_colors(colors, mixed_config("")); + } + REQUIRE(colors.size() == 3); + require_same_rgb(colors[2], grey); +} + +TEST_CASE("recompute_mixed_slot_colors uses the project colour when a slot colour is unset", "[FilamentBitmapUtils]") +{ + // Slot 2 carries no colour in the vector; filament_colour[1] = "#0000FF" is used instead. + std::vector colors{wxColour(255, 0, 0), wxColour()}; + recompute_mixed_slot_colors(colors, mixed_config()); + require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); +} + +TEST_CASE("recompute_mixed_slot_colors blends a gradient slot from its end points only", "[FilamentBitmapUtils]") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2,3"})); + cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", "", "0.2,0.3,0.5"})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false, true})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00", "#0000FF", "#000000"})); + + std::vector colors{wxColour(255, 0, 0), wxColour(0, 255, 0), wxColour(0, 0, 255)}; + recompute_mixed_slot_colors(colors, cfg); + + REQUIRE(colors.size() == 4); + require_same_rgb(colors[3], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); +} + +TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idempotent", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + const DynamicPrintConfig cfg = mixed_config("1,2", "0.7,0.3"); + recompute_mixed_slot_colors(colors, cfg); + const wxColour first = colors[2]; + // The configured 70/30 ratio must reach the blend (it is not the equal-share default). + require_same_rgb(first, expected_blend({"#FF0000", "#0000FF"}, {7000, 3000})); + REQUIRE(first != expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); + recompute_mixed_slot_colors(colors, cfg); + require_same_rgb(colors[2], first); +} From af5397d67862e2b3bdb30a06a98c6e6908831c97 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 13:53:48 +0800 Subject: [PATCH 100/138] Close the single-extruder mixed filament warning by type --- src/slic3r/GUI/GLCanvas3D.cpp | 8 ++++++++ src/slic3r/GUI/NotificationManager.hpp | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 3a919c800d..0aeb1b504e 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -10655,6 +10655,14 @@ void GLCanvas3D::_set_warning_notification(EWarning warning, bool state) notification_manager.close_slicing_customize_error_notification(NotificationType::BBLNozzleFilamentIncompatible, NotificationLevel::WarningNotificationLevel); } } + else if (warning == EWarning::SingleExtruderMixedFilament) { + // Close by type: check_single_extruder_mixed_filament_risk() clears the shared text + // buffer on every call, so a close-by-text would miss once the risk is gone. + if (state) + notification_manager.push_slicing_customize_error_notification(NotificationType::BBLSingleExtruderMixedFilamentRisk, NotificationLevel::WarningNotificationLevel, text); + else + notification_manager.close_slicing_customize_error_notification(NotificationType::BBLSingleExtruderMixedFilamentRisk, NotificationLevel::WarningNotificationLevel); + } else { if (state) notification_manager.push_plater_warning_notification(text); diff --git a/src/slic3r/GUI/NotificationManager.hpp b/src/slic3r/GUI/NotificationManager.hpp index 7a3e6b8bb5..bf0a5cfe1a 100644 --- a/src/slic3r/GUI/NotificationManager.hpp +++ b/src/slic3r/GUI/NotificationManager.hpp @@ -174,6 +174,8 @@ enum class NotificationType BBLBedFilamentIncompatible, BBLMixUsePLAAndPETG, BBLNozzleFilamentIncompatible, + // A mixed-color filament is printed on a single-nozzle printer (frequent changes and purging). + BBLSingleExtruderMixedFilamentRisk, OrcaSharedProfilesAvailable, OrcaCloudAPIError, OrcaSyncConflict, From 38d51783ae3845d93cfe665cd9906244b32cc8af Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 13:59:59 +0800 Subject: [PATCH 101/138] Refresh the mixed filament list when a component preset changes --- src/slic3r/GUI/Plater.cpp | 12 +++++++++++- tests/libslic3r/test_filament_mixer.cpp | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c3a163638b..40cffd2641 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -7318,7 +7318,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) "brim_width", "brim_object_gap", "brim_flow_ratio", "brim_use_efc_outline", "combine_brims", "brim_type", "nozzle_diameter", "single_extruder_multi_material", "preferred_orientation", "enable_prime_tower", "wipe_tower_x", "wipe_tower_y", "prime_tower_width", "prime_tower_brim_width", "prime_tower_skip_points", "prime_tower_enable_framework", "prime_tower_infill_gap", "prime_volume", - "extruder_colour", "filament_colour", "filament_type", "material_colour", "printable_height", "extruder_printable_height", "printer_model", "printer_technology", + "extruder_colour", "filament_colour", "filament_type", "filament_is_support", "material_colour", "printable_height", "extruder_printable_height", "printer_model", "printer_technology", // These values are necessary to construct SlicingParameters by the Canvas3D variable layer height editor. "layer_height", "initial_layer_print_height", "min_layer_height", "max_layer_height", "wall_loops", "outer_wall_filament_id", "inner_wall_filament_id", "sparse_infill_density", "sparse_infill_filament_id", "top_shell_layers", @@ -19661,6 +19661,7 @@ void Plater::on_config_change(const DynamicPrintConfig &config) update_scheduled = true; // update should be scheduled (for update 3DScene) #2738 if (update_filament_colors_in_full_config()) { + p->sidebar->update_mixed_filament_list(); p->sidebar->obj_list()->update_filament_colors(); p->sidebar->update_dynamic_filament_list(); continue; @@ -19668,6 +19669,15 @@ void Plater::on_config_change(const DynamicPrintConfig &config) } if (opt_key == "filament_type") { update_filament_colors_in_full_config(); + p->sidebar->update_mixed_filament_list(); + continue; + } + // The mixed-filament type check folds filament_is_support into the component type + // (DynamicPrintConfig::get_filament_type -> "PLA-S"), so a support-preset switch must + // refresh the list even though filament_type itself did not change. + if (opt_key == "filament_is_support") { + p->config->set_key_value(opt_key, config.option(opt_key)->clone()); + p->sidebar->update_mixed_filament_list(); continue; } if (opt_key == "material_colour") { diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp index 7b5842334f..fb11fa9481 100644 --- a/tests/libslic3r/test_filament_mixer.cpp +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -1,6 +1,7 @@ #include #include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" using namespace Slic3r; @@ -98,6 +99,29 @@ TEST_CASE("check_mixed_filament_type_consistency flags mismatched component type REQUIRE(bad == std::vector{2}); } +TEST_CASE("a support-flagged component reads as its own filament type for the consistency check", "[FilamentMixer]") +{ + // Sidebar::update_mixed_filament_list and Sidebar::has_broken_mixed_filament derive each + // component's type through DynamicPrintConfig::get_filament_type, which folds the + // filament_is_support flag into the type — so toggling that flag alone changes the verdict + // and Plater::on_config_change has to refresh the mixed list on filament_is_support too. + DynamicPrintConfig plain_pla; + plain_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); + plain_pla.set_key_value("filament_is_support", new ConfigOptionBools({false})); + std::string displayed; + REQUIRE(plain_pla.get_filament_type(displayed) == "PLA"); + + DynamicPrintConfig support_pla; + support_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); + support_pla.set_key_value("filament_is_support", new ConfigOptionBools({true})); + REQUIRE(support_pla.get_filament_type(displayed) == "PLA-S"); + REQUIRE(displayed == "Sup.PLA"); + + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA-S"}) == std::vector{2}); +} + TEST_CASE("gradient curves round-trip and sample monotonically", "[FilamentMixer]") { SECTION("Empty input yields an empty curve") { From f02423074c2f6ac0f0ed0cce7f188e4fb242abe7 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 14:13:33 +0800 Subject: [PATCH 102/138] Warn about mixed color sublayer when adding height ranges --- src/slic3r/GUI/GUI_ObjectList.cpp | 18 ++++++++++++++++++ src/slic3r/GUI/Plater.cpp | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index dc89f5f9bb..48182da77b 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -3233,6 +3233,24 @@ void ObjectList::merge(bool to_multipart_object) void ObjectList::layers_editing() { + // Height ranges give each range its own layer height, varying the mixed sub-layer heights just + // like an adaptive profile; sibling of the on_action_layersediting/ConfigManipulation warnings, + // sharing the same do-not-show-again flag. + const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (print_config.opt_bool("enable_mixed_color_sublayer")) { + if (wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + // Orca: parent to the plater like the sibling site in Plater::priv::on_action_layersediting + // (BBS passes nullptr, which MsgDialog remaps to the main frame). + MessageDialog dlg(wxGetApp().plater(), + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + _L("Warning"), wxICON_WARNING | wxOK); + dlg.show_dsa_button(); + dlg.ShowModal(); + if (dlg.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + } + } + const Selection& selection = scene_selection(); const int obj_idx = selection.get_object_idx(); wxDataViewItem item = obj_idx >= 0 && GetSelectedItemsCount() > 1 && selection.is_single_full_object() ? diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 40cffd2641..7ff7b193df 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -14077,8 +14077,8 @@ void Plater::priv::on_action_layersediting(SimpleEvent&) // Sub-layer splitting divides each layer by the mix ratio, so an adaptive layer profile makes // those sub-layer heights vary and degrades the blend. ConfigManipulation warns when the // option is switched on with a variable profile already present; this is the other direction, - // warning when variable layer editing is switched on while the option is active. Both honour - // the same do-not-show-again flag. + // warning when variable layer editing is switched on while the option is active. All three + // sites (with ObjectList::layers_editing for height ranges) honour the same do-not-show-again flag. if (!view3D->is_layers_editing_enabled()) { const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; if (print_config.opt_bool("enable_mixed_color_sublayer")) { From 6f32d59997aa52a94cfe84fa7c1aeb842aa4b125 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 17:51:00 +0800 Subject: [PATCH 103/138] Fixed an issue that gradient color button in color painting gizmo don't have number --- .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 43 ++++++++++++------- .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 7 ++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 77b58d3bb5..45839b66ea 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -313,15 +313,39 @@ void GLGizmoMmuSegmentation::render_tooltip_button(float x, float y) } // ORCA -bool GLGizmoMmuSegmentation::draw_color_button(int idx, std::string id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale) +bool GLGizmoMmuSegmentation::draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale) { + // Inset of the frame stroked below, which is what trims the swatch down to its visible shape. + const float frame_inset = 1.5f; + ImDrawList* draw_list = ImGui::GetWindowDrawList(); std::string label_id = std::to_string(idx) + id_str + std::to_string(idx); ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 size = ImVec2(27.f * scale, 27.f * scale); ImVec4 color_vec = ImGuiWrapper::to_ImVec4(color); ImU32 br_color = ImGui::ColorConvertFloat4ToU32(active ? ImGuiWrapper::COL_ORCA : m_is_dark_mode ? ImVec4(.35f, .35f, .35f, 1) : ImVec4(.85f, .85f, .85f, 1)); - bool dark_tone = (0.299f * color.r() + 0.587f * color.g() + 0.114f * color.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 + // Every caller labels the button with the 1 based slot number, so idx - 1 picks out the slot's fade. + const GradientInfo* gradient = gradient_of(idx - 1); + // ImGui interpolates the fade linearly, so the centered slot number lands on the midpoint of the two + // endpoints - take its contrast from there, not from the slot's blended color. + ColorRGBA tone = gradient ? ColorRGBA(0.5f * (gradient->color_from[0] + gradient->color_to[0]), + 0.5f * (gradient->color_from[1] + gradient->color_to[1]), + 0.5f * (gradient->color_from[2] + gradient->color_to[2]), 1.f) + : color; + bool dark_tone = (0.299f * tone.r() + 0.587f * tone.g() + 0.114f * tone.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 + + // Paint a gradient mixed filament's fade before the button and keep the button transparent, so the + // slot number and the frame below stay on top of it. AddRectFilledMultiColor cannot round its + // corners, so the fade is drawn at the frame's inset and the frame masks it into the same shape a + // plain color slot gets. + if (gradient) { + auto to_imu32 = [](const std::array& c) { return ImGui::ColorConvertFloat4ToU32({c[0], c[1], c[2], c[3]}); }; + draw_list->AddRectFilledMultiColor({pos.x + frame_inset * scale, pos.y + frame_inset * scale}, + {pos.x + size.x - frame_inset * scale, pos.y + size.y - frame_inset * scale}, + to_imu32(gradient->color_from), to_imu32(gradient->color_to), + to_imu32(gradient->color_to), to_imu32(gradient->color_from)); + color_vec.w = 0.f; // let the fade show through + } ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding , 7.f * scale); @@ -337,7 +361,7 @@ bool GLGizmoMmuSegmentation::draw_color_button(int idx, std::string id_str, cons auto drawBorder = [&](float d, float r, float t, ImU32 col) { draw_list->AddRect({pos.x + d * scale, pos.y + d * scale}, {pos.x + size.x - d * scale , pos.y + size.y - d * scale}, col, r * scale, 0, t * scale); }; - drawBorder(1.5f, 3.f, 4.f, ImGui::ColorConvertFloat4ToU32(ImGui::GetStyleColorVec4(ImGuiCol_WindowBg))); + drawBorder(frame_inset, 3.f, 4.f, ImGui::ColorConvertFloat4ToU32(ImGui::GetStyleColorVec4(ImGuiCol_WindowBg))); if(active) drawBorder(.5f, 4.f , 2.f, br_color); else @@ -441,19 +465,6 @@ void GLGizmoMmuSegmentation::on_render_input_window(float x, float y, float bott m_selected_extruder_idx = extruder_idx; } - // Overlay a two-tone fade for gradient mixed filaments; a single flat colour would - // misrepresent a slot that fades between two filaments over Z. - if (extruder_idx < (int) m_gradient_info.size() && m_gradient_info[extruder_idx].is_gradient) { - auto to_imu32 = [](const std::array &c) -> ImU32 { - return IM_COL32(uint8_t(c[0]*255.f), uint8_t(c[1]*255.f), uint8_t(c[2]*255.f), uint8_t(c[3]*255.f)); - }; - ImVec2 r_min = ImGui::GetItemRectMin(); - ImVec2 r_max = ImGui::GetItemRectMax(); - ImU32 col_from = to_imu32(m_gradient_info[extruder_idx].color_from); - ImU32 col_to = to_imu32(m_gradient_info[extruder_idx].color_to); - ImGui::GetWindowDrawList()->AddRectFilledMultiColor(r_min, r_max, col_from, col_to, col_to, col_from); - } - if (extruder_idx < int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT) && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); } // ORCA: Remap filaments section (Border only, Title in border). diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index b244a68860..9f080fb300 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -145,7 +145,12 @@ private: void init_model_triangle_selectors(); // ORCA - bool draw_color_button(int idx, std::string id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); + bool draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); + // Gradient endpoints of a filament slot, or nullptr when the slot is a plain single color filament. + const GradientInfo* gradient_of(int idx) const + { + return idx >= 0 && idx < (int) m_gradient_info.size() && m_gradient_info[idx].is_gradient ? &m_gradient_info[idx] : nullptr; + } // BBS void update_triangle_selectors_colors(); From ff35dacf4c14e3158b3201d316c0d9c27a3f4ab8 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 18:43:41 +0800 Subject: [PATCH 104/138] Fix UI hanging on Mac --- src/slic3r/GUI/GradientCurveEditor.cpp | 8 ++++ src/slic3r/GUI/GradientCurveEditor.hpp | 2 + src/slic3r/GUI/MixedFilamentDialog.cpp | 54 ++++++++++++++++++-------- src/slic3r/GUI/MixedFilamentDialog.hpp | 9 ++++- src/slic3r/GUI/TextureImportDialog.cpp | 21 +++++++++- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index 782961aba4..d34e71a132 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -85,6 +85,14 @@ GradientCurveEditor::GradientCurveEditor(wxWindow* parent, }); } +GradientCurveEditor::~GradientCurveEditor() +{ + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); +} + void GradientCurveEditor::set_points(const PointList& pts) { m_points = pts; diff --git a/src/slic3r/GUI/GradientCurveEditor.hpp b/src/slic3r/GUI/GradientCurveEditor.hpp index f9858cab11..f2e082aff5 100644 --- a/src/slic3r/GUI/GradientCurveEditor.hpp +++ b/src/slic3r/GUI/GradientCurveEditor.hpp @@ -36,6 +36,8 @@ public: const wxColour& color_low = wxColour(217, 217, 217), const wxColour& color_high = wxColour(217, 217, 217)); + ~GradientCurveEditor() override; + // Replace the entire point list. The widget enforces x in [0,1], y in [0,1], // sorts by x, and clamps the first / last x to 0 / 1. Tangent overrides are // preserved as-is (NaN entries continue to use PCHIP defaults). diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index c81c90fe1c..b72846452f 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -154,6 +154,18 @@ MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, m_preview_bmp_three = wxBitmap(img); } +MixedFilamentDialog::~MixedFilamentDialog() +{ + // Backstop: a child must never be destroyed while it still holds the mouse + // capture. wxWidgets only asserts about this (compiled out in release), and + // the macOS port never unwinds its capture stack, so the stale entry would + // make wxNSWindow::sendEvent swallow every mouse event in the application. + if (m_ratio_bar && m_ratio_bar->HasCapture()) + m_ratio_bar->ReleaseMouse(); + if (m_triangle_panel && m_triangle_panel->HasCapture()) + m_triangle_panel->ReleaseMouse(); +} + MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, const MixedFilamentResult& existing, const std::vector& physical_colors, @@ -892,24 +904,30 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() m_ratio_bar->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) commit_ratio_editor(true); - m_dragging = true; - m_ratio_bar->CaptureMouse(); + m_ratio_dragging = true; + if (!m_ratio_bar->HasCapture()) + m_ratio_bar->CaptureMouse(); int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); }); m_ratio_bar->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { - if (!m_dragging) return; + if (!m_ratio_dragging) return; int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); }); + // Release whenever the capture is held, not only when the drag flag is set: + // the flag can be cleared behind our back, and a capture that outlives the + // widget wedges mouse input for the whole application. m_ratio_bar->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { - if (m_dragging) { - m_dragging = false; - if (m_ratio_bar->HasCapture()) - m_ratio_bar->ReleaseMouse(); - } + m_ratio_dragging = false; + if (m_ratio_bar->HasCapture()) + m_ratio_bar->ReleaseMouse(); + }); + + m_ratio_bar->Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_ratio_dragging = false; }); sizer->Add(m_ratio_bar, 0, wxEXPAND); @@ -1122,11 +1140,12 @@ wxBoxSizer* MixedFilamentDialog::create_triangle_picker() // clicks outside the triangle must not change the mix ratio. if (!tri_contains(p, v0, v1, v2)) return; - m_dragging = true; - m_triangle_panel->CaptureMouse(); + m_tri_dragging = true; + if (!m_triangle_panel->HasCapture()) + m_triangle_panel->CaptureMouse(); } - if (!m_dragging) return; + if (!m_tri_dragging) return; TriPoint clamped = tri_clamp(p, v0, v1, v2); tri_barycentric(clamped, v0, v1, v2, m_tri_wx, m_tri_wy, m_tri_wz); @@ -1161,15 +1180,16 @@ wxBoxSizer* MixedFilamentDialog::create_triangle_picker() handle_mouse(e, true); }); m_triangle_panel->Bind(wxEVT_MOTION, [this, handle_mouse](wxMouseEvent& e) { - if (m_dragging) + if (m_tri_dragging) handle_mouse(e, false); }); m_triangle_panel->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { - if (m_dragging) { - m_dragging = false; - if (m_triangle_panel->HasCapture()) - m_triangle_panel->ReleaseMouse(); - } + m_tri_dragging = false; + if (m_triangle_panel->HasCapture()) + m_triangle_panel->ReleaseMouse(); + }); + m_triangle_panel->Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_tri_dragging = false; }); sizer->Add(m_triangle_panel, 0); diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp index a1af146897..6085f77873 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.hpp +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -53,6 +53,8 @@ public: const std::vector& physical_names, const std::vector& physical_types = {}); + ~MixedFilamentDialog(); + MixedFilamentResult get_result() const { return m_result; } protected: @@ -160,8 +162,11 @@ private: wxBitmap m_preview_bmp_two; wxBitmap m_preview_bmp_three; - // Drag state - bool m_dragging{false}; + // Drag state. The ratio bar and the triangle picker capture the mouse + // independently, so they must not share a flag: a mouse-up on one would + // otherwise clear the other's flag and skip its ReleaseMouse(). + bool m_ratio_dragging{false}; + bool m_tri_dragging{false}; std::vector m_ratio_manual_order; size_t m_ratio_editor_idx{0}; bool m_ratio_editor_committing{false}; diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index e2886687f3..688898ed0a 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -184,6 +184,7 @@ public: GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize); + ~GreenSlider() override; int GetValue() const; void SetValue(int val); bool Enable(bool enable = true) override; @@ -213,6 +214,15 @@ GreenSlider::GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, Bind(wxEVT_LEFT_DOWN, &GreenSlider::OnMouse, this); Bind(wxEVT_LEFT_UP, &GreenSlider::OnMouse, this); Bind(wxEVT_MOTION, &GreenSlider::OnMouse, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { m_dragging = false; }); +} + +GreenSlider::~GreenSlider() +{ + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); } int GreenSlider::GetValue() const { return m_value; } @@ -302,7 +312,7 @@ void GreenSlider::OnMouse(wxMouseEvent& evt) if (evt.LeftDown()) { m_dragging = true; - CaptureMouse(); + if (!HasCapture()) CaptureMouse(); update(evt.GetX()); } else if (evt.LeftUp()) { m_dragging = false; @@ -1019,11 +1029,20 @@ TexturePreviewCanvas::TexturePreviewCanvas(wxWindow* parent, const wxGLAttribute Bind(wxEVT_MIDDLE_DOWN, &TexturePreviewCanvas::on_mouse, this); Bind(wxEVT_MIDDLE_UP, &TexturePreviewCanvas::on_mouse, this); Bind(wxEVT_MOTION, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_drag_mode = DragMode::None; + m_reset_overlay_pressed = false; + }); Bind(wxEVT_LEAVE_WINDOW, &TexturePreviewCanvas::on_mouse, this); } TexturePreviewCanvas::~TexturePreviewCanvas() { + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); + if (m_context) { SetCurrent(*m_context); if (m_tex_id) From 4a32a9e0664b4d2a9fac452018ed51ceef96ac67 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 21:55:02 +0800 Subject: [PATCH 105/138] Match mixed filament swatches to the editor's gradient preview The sidebar Mixed Filament list, the extruder icons, the color painting gizmo and the canvas filament bar now show the same bottom-to-top fade the Edit Mixed Filament preview shows, custom gradient curves included, instead of a horizontal fade between the two component colours. Ordinary and vendor multi-colour filaments are drawn exactly as before. --- src/slic3r/GUI/FilamentBitmapUtils.cpp | 123 +++++++++++++++++- src/slic3r/GUI/FilamentBitmapUtils.hpp | 29 ++++- src/slic3r/GUI/GLCanvas3D.cpp | 18 +-- .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 35 ++--- .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 20 ++- src/slic3r/GUI/ImGuiWrapper.cpp | 19 +++ src/slic3r/GUI/ImGuiWrapper.hpp | 16 +++ src/slic3r/GUI/MixedFilamentDialog.cpp | 33 +---- src/slic3r/GUI/Plater.cpp | 74 ++++++----- src/slic3r/GUI/Plater.hpp | 14 +- src/slic3r/GUI/wxExtensions.cpp | 56 +++++--- src/slic3r/GUI/wxExtensions.hpp | 5 +- .../test_filament_bitmap_utils.cpp | 120 +++++++++++++++++ 13 files changed, 429 insertions(+), 133 deletions(-) diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index b3f58fa4f1..45368e0537 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -31,6 +31,114 @@ void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, } } +static std::string to_hex(const wxColour& c) +{ + return wxString::Format("#%02X%02X%02X", c.Red(), c.Green(), c.Blue()).ToStdString(); +} + +wxColour blend_n_colors(const std::vector& cols, const std::vector& weights) +{ + const size_t n = std::min(cols.size(), weights.size()); + std::vector hex_colors; + std::vector int_weights; + hex_colors.reserve(n); + int_weights.reserve(n); + for (size_t i = 0; i < n; ++i) { + hex_colors.push_back(to_hex(cols[i])); + // Scale double weights (e.g. 0.5) to int (5000) for blend_color_multi; + // only relative magnitude matters. + int_weights.push_back(static_cast(std::lround(weights[i] * 10000.0))); + } + wxColour blended(Slic3r::blend_color_multi(hex_colors, int_weights)); + return blended.IsOk() ? blended : wxColour(128, 128, 128); +} + +std::vector sample_gradient_ramp(const wxColour& first, + const wxColour& second, + const Slic3r::GradientCurve& curve, + int steps) +{ + std::vector ramp; + if (steps <= 0 || curve.points.size() < 2) return ramp; + + ramp.reserve(steps); + for (int i = 0; i < steps; ++i) { + const double t = (steps > 1) ? (i + 0.5) / steps : 0.5; + const double r1 = Slic3r::sample_gradient_curve(curve, t); + ramp.push_back(blend_n_colors({first, second}, {r1, 1.0 - r1})); + } + return ramp; +} + +// Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in +// ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's +// endpoints, otherwise the 0.10 -> 0.90 default. +static Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) +{ + const auto* curve_opt = cfg.option("filament_mixed_gradient_curve"); + if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { + Slic3r::GradientCurve custom = Slic3r::parse_gradient_curve(curve_opt->values[slot]); + if (custom.points.size() >= 2) return custom; + } + + double start = kGradientMinRatio, end = kGradientMaxRatio; + const auto* range_opt = cfg.option("filament_mixed_gradient_range"); + if (range_opt && slot < range_opt->values.size() && !range_opt->values[slot].empty()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(range_opt->values[slot].c_str(), "%f,%f", &v0, &v1) == 2 && + v0 > 0 && v0 < 1.0 && v1 > 0 && v1 < 1.0) { + start = v0; + end = v1; + } + } + + Slic3r::GradientCurve curve; + curve.points = {{0.0, start, NAN, NAN}, {1.0, end, NAN, NAN}}; + return curve; +} + +std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps) +{ + const auto* is_mixed_opt = cfg.option("filament_is_mixed"); + const auto* grad_opt = cfg.option("filament_mixed_gradient"); + const auto* comp_opt = cfg.option("filament_mixed_components"); + const auto* colour_opt = cfg.option("filament_colour"); + if (!is_mixed_opt || !grad_opt || !comp_opt || !colour_opt) return {}; + if (slot >= is_mixed_opt->values.size() || !is_mixed_opt->values[slot]) return {}; + if (slot >= grad_opt->values.size() || !grad_opt->values[slot]) return {}; + if (slot >= comp_opt->values.size()) return {}; + + // Only two-component slots fade; anything else stays on the plain blended swatch. + const auto comp_ids = Slic3r::parse_mixed_components(comp_opt->values[slot]); + if (comp_ids.size() != 2) return {}; + + auto component_colour = [&](unsigned int id) { + wxColour c = (id >= 1 && id <= colour_opt->values.size()) ? wxColour(colour_opt->values[id - 1]) : wxColour(); + return c.IsOk() ? c : wxColour("#D9D9D9"); + }; + + // Both gradient_range and the curve express the *first* component's ratio over Z, so + // the components stay in config order and the curve alone decides which end is which. + return sample_gradient_ramp(component_colour(comp_ids[0]), component_colour(comp_ids[1]), + mixed_gradient_curve(cfg, slot), steps); +} + +void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp) +{ + if (rect.width <= 0 || rect.height <= 0 || ramp.empty()) return; + + dc.SetPen(*wxTRANSPARENT_PEN); + for (int y = 0; y < rect.height; ++y) { + // Row 0 is the top of the rect and so takes the ramp's last entry, the model's top. + // Mapping over height - 1 keeps both ends of the ramp on screen; a swatch is often + // shorter than the ramp is long, so truncating either end would be visible. + const double t = (rect.height > 1) ? (double) (rect.height - 1 - y) / (rect.height - 1) : 0.5; + dc.SetBrush(wxBrush(ramp[static_cast(t * (ramp.size() - 1) + 0.5)])); + dc.DrawRectangle(rect.x, rect.y + y, rect.width, 1); + } +} + // Helper struct to hold bitmap and DC struct BitmapDC { wxBitmap bitmap; @@ -50,6 +158,19 @@ static BitmapDC init_bitmap_dc(const wxSize& size) { return BitmapDC(size); } +wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wxSize& size) +{ + if (ramp.empty()) return wxNullBitmap; + + BitmapDC bdc = init_bitmap_dc(size); + if (!bdc.dc.IsOk()) return wxNullBitmap; + + fill_gradient_ramp_rect(bdc.dc, wxRect(0, 0, size.GetWidth(), size.GetHeight()), ramp); + + bdc.dc.SelectObject(wxNullBitmap); + return bdc.bitmap; +} + // Check if a color is transparent (alpha == 0) static bool is_transparent_color(const wxColour& color) { return color.Alpha() == 0; @@ -313,7 +434,7 @@ void recompute_mixed_slot_colors(std::vector& colors, if (id == 0 || id > colors.size()) { any_invalid = true; break; } wxColour c = colors[id - 1]; if (c.IsOk() && (c.Red() > 0 || c.Green() > 0 || c.Blue() > 0)) { - hex_colors.push_back(wxString::Format("#%02X%02X%02X", c.Red(), c.Green(), c.Blue()).ToStdString()); + hex_colors.push_back(to_hex(c)); } else if (colour_opt && (id - 1) < colour_opt->values.size()) { hex_colors.push_back(colour_opt->values[id - 1]); } else { diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 2e428e8d32..9cb8d64249 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -9,7 +9,7 @@ // Orca: forward-declare so the header is self-contained outside libslic3r_gui's // force-included pch (the GUI test suite includes it directly). -namespace Slic3r { class DynamicPrintConfig; } +namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; } namespace Slic3r { namespace GUI { @@ -32,6 +32,33 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSize& size, bool force_gradient = false); +// Blend colours at the given relative weights through blend_color_multi, so a measured +// real-world mix is used where one exists instead of a plain channel lerp. +wxColour blend_n_colors(const std::vector& cols, const std::vector& weights); + +// Sample a gradient mixed filament the way the slicer builds it: t runs 0..1 over the +// model's height, the curve gives the first component's ratio at t, and the two +// components are blended at that ratio. Entry 0 is the bottom of the model, the last +// entry its top. Blending goes through blend_n_colors, so measured mixes and the +// reserved [kGradientMinRatio, kGradientMaxRatio] band are both respected — a plain +// two-endpoint fade is neither. +std::vector sample_gradient_ramp(const wxColour& first, + const wxColour& second, + const Slic3r::GradientCurve& curve, + int steps); + +// Same ramp for a project config slot, resolving components, colours and curve (or the +// linear gradient_range fallback) from cfg. Empty unless the slot is a two-component +// gradient mixed filament, which is what gates every caller to mixed slots only. +// steps is the ramp's resolution; pass the destination's height in pixels. +std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); + +// Fill rect with a ramp, ramp.front() along the bottom edge. +void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp); + +// Swatch bitmap for a gradient mixed filament, drawn bottom to top from the ramp. +wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wxSize& size); + // Recompute blended representative colors for mixed (virtual) filament slots. // Reads mixed-filament config keys from cfg and writes back into colors[i] // for every slot where filament_is_mixed[i] is true. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 0aeb1b504e..94b0923885 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -9718,9 +9718,9 @@ void GLCanvas3D::_render_paint_toolbar() const bool disabled = !wxGetApp().plater()->can_fillcolor(); ColorRGBA rgba; - // Gradient mixed filaments fade between two colours over Z, so their swatch is drawn as a - // two-tone fade rather than the single blended colour in `colors`. - auto gradient_info = wxGetApp().plater()->get_filament_gradient_info(); + // Gradient mixed filaments fade over Z, so their swatch is drawn as that fade rather than + // the single blended colour in `colors`. Every other slot's ramp is empty. + const auto& gradient_ramps = wxGetApp().plater()->get_filament_gradient_ramps(); for (int i = 0; i < extruder_num; i++) { if (i > 0) @@ -9735,16 +9735,8 @@ void GLCanvas3D::_render_paint_toolbar() const if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) wxPostEvent(m_canvas, IntEvent(EVT_GLTOOLBAR_FILLCOLOR, i + 1)); } - if (i < (int) gradient_info.size() && gradient_info[i].is_gradient) { - auto to_imu32 = [](const std::array &c) -> ImU32 { - return IM_COL32(uint8_t(c[0]*255.f), uint8_t(c[1]*255.f), uint8_t(c[2]*255.f), uint8_t(c[3]*255.f)); - }; - ImVec2 r_min = ImGui::GetItemRectMin(); - ImVec2 r_max = ImGui::GetItemRectMax(); - ImU32 col_from = to_imu32(gradient_info[i].color_from); - ImU32 col_to = to_imu32(gradient_info[i].color_to); - ImGui::GetWindowDrawList()->AddRectFilledMultiColor(r_min, r_max, col_from, col_to, col_to, col_from); - } + if (i < (int) gradient_ramps.size() && !gradient_ramps[i].empty()) + ImGuiWrapper::draw_gradient_ramp(draw_list, ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), gradient_ramps[i]); if (ImGui::IsItemHovered() && i < 9) { if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 20.0f * f_scale, 10.0f * f_scale }); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 45839b66ea..3f05f9c22c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -78,13 +78,8 @@ void GLGizmoMmuSegmentation::init_extruders_data() m_extruders_colors = wxGetApp().plater()->get_extruders_colors(); m_selected_extruder_idx = 0; - auto plater_grad = wxGetApp().plater()->get_filament_gradient_info(); - m_gradient_info.resize(m_extruders_colors.size()); - for (size_t i = 0; i < m_gradient_info.size() && i < plater_grad.size(); ++i) { - m_gradient_info[i].is_gradient = plater_grad[i].is_gradient; - m_gradient_info[i].color_from = plater_grad[i].color_from; - m_gradient_info[i].color_to = plater_grad[i].color_to; - } + m_gradient_ramps = wxGetApp().plater()->get_filament_gradient_ramps(); + m_gradient_ramps.resize(m_extruders_colors.size()); // keep remap table consistent with current extruder count m_extruder_remap.resize(m_extruders_colors.size()); @@ -325,25 +320,19 @@ bool GLGizmoMmuSegmentation::draw_color_button(int idx, const char* id_str, cons ImVec4 color_vec = ImGuiWrapper::to_ImVec4(color); ImU32 br_color = ImGui::ColorConvertFloat4ToU32(active ? ImGuiWrapper::COL_ORCA : m_is_dark_mode ? ImVec4(.35f, .35f, .35f, 1) : ImVec4(.85f, .85f, .85f, 1)); // Every caller labels the button with the 1 based slot number, so idx - 1 picks out the slot's fade. - const GradientInfo* gradient = gradient_of(idx - 1); - // ImGui interpolates the fade linearly, so the centered slot number lands on the midpoint of the two - // endpoints - take its contrast from there, not from the slot's blended color. - ColorRGBA tone = gradient ? ColorRGBA(0.5f * (gradient->color_from[0] + gradient->color_to[0]), - 0.5f * (gradient->color_from[1] + gradient->color_to[1]), - 0.5f * (gradient->color_from[2] + gradient->color_to[2]), 1.f) - : color; - bool dark_tone = (0.299f * tone.r() + 0.587f * tone.g() + 0.114f * tone.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 + const std::vector* gradient = gradient_of(idx - 1); + // The centered slot number sits at the swatch's mid height, so take its contrast from the colour + // printed there rather than from the slot's blended color. + bool dark_tone = gradient ? (*gradient)[gradient->size() / 2].GetLuminance() < 0.51 : + (0.299f * color.r() + 0.587f * color.g() + 0.114f * color.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 // Paint a gradient mixed filament's fade before the button and keep the button transparent, so the - // slot number and the frame below stay on top of it. AddRectFilledMultiColor cannot round its - // corners, so the fade is drawn at the frame's inset and the frame masks it into the same shape a - // plain color slot gets. + // slot number and the frame below stay on top of it. The bands cannot round their corners, so the + // fade is drawn at the frame's inset and the frame masks it into the same shape a plain color slot + // gets. if (gradient) { - auto to_imu32 = [](const std::array& c) { return ImGui::ColorConvertFloat4ToU32({c[0], c[1], c[2], c[3]}); }; - draw_list->AddRectFilledMultiColor({pos.x + frame_inset * scale, pos.y + frame_inset * scale}, - {pos.x + size.x - frame_inset * scale, pos.y + size.y - frame_inset * scale}, - to_imu32(gradient->color_from), to_imu32(gradient->color_to), - to_imu32(gradient->color_to), to_imu32(gradient->color_from)); + ImGuiWrapper::draw_gradient_ramp(draw_list, {pos.x + frame_inset * scale, pos.y + frame_inset * scale}, + {pos.x + size.x - frame_inset * scale, pos.y + size.y - frame_inset * scale}, *gradient); color_vec.w = 0.f; // let the fade show through } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index 9f080fb300..7d83468ea5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -78,14 +78,6 @@ public: // filaments occupy ordinary slots, so they draw from the same budget as physical ones. static const constexpr size_t EXTRUDERS_LIMIT = static_cast(EnforcerBlockerType::ExtruderMax); - // Endpoint colours for gradient mixed filaments, mirrored from Plater so the extruder - // swatches below can be drawn as a two-tone fade instead of a single blended colour. - struct GradientInfo { - bool is_gradient = false; - std::array color_from = {0.5f, 0.5f, 0.5f, 1.0f}; - std::array color_to = {0.5f, 0.5f, 0.5f, 1.0f}; - }; - const float get_cursor_radius_min() const override { return CursorRadiusMin; } // BBS @@ -123,7 +115,10 @@ protected: // Filament remap feature std::vector m_extruder_remap; // index → target extruder index - std::vector m_gradient_info; // per-slot gradient endpoints, empty entries for plain filaments + // Colours each gradient mixed filament actually prints, bottom of the model first, mirrored + // from Plater so the extruder swatches draw the same fade the editor previews. Plain + // filament slots keep an empty ramp. + std::vector> m_gradient_ramps; // ORCA: Cache used filaments to filter UI std::set m_used_filaments; // Set of used filament indices (cached) @@ -146,10 +141,11 @@ private: // ORCA bool draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); - // Gradient endpoints of a filament slot, or nullptr when the slot is a plain single color filament. - const GradientInfo* gradient_of(int idx) const + // Gradient ramp of a filament slot, or nullptr when the slot is a plain single color + // filament, so callers can index into what they get back freely. + const std::vector* gradient_of(int idx) const { - return idx >= 0 && idx < (int) m_gradient_info.size() && m_gradient_info[idx].is_gradient ? &m_gradient_info[idx] : nullptr; + return idx >= 0 && idx < (int) m_gradient_ramps.size() && !m_gradient_ramps[idx].empty() ? &m_gradient_ramps[idx] : nullptr; } // BBS diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index d46e8ed31b..0c237a36f2 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -2404,6 +2404,25 @@ void ImGuiWrapper::draw( } } +void ImGuiWrapper::draw_gradient_ramp(ImDrawList *draw_list, const ImVec2 &top_left, const ImVec2 &bottom_right, const std::vector &ramp) +{ + if (draw_list == nullptr || ramp.empty() || bottom_right.x <= top_left.x || bottom_right.y <= top_left.y) + return; + + const int rows = std::max(1, (int) std::lround(bottom_right.y - top_left.y)); + const float row_h = (bottom_right.y - top_left.y) / rows; + const size_t last = ramp.size() - 1; + for (int r = 0; r < rows; ++r) { + // Row 0 is the top of the rect and so takes the ramp's last entry, the model's top. + const double t = (rows > 1) ? (double) (rows - 1 - r) / (rows - 1) : 0.5; + const wxColour &c = ramp[(size_t) (t * last + 0.5)]; + // The bottom row snaps to the rect's edge so rounding never leaves a sliver uncovered. + const float y0 = top_left.y + r * row_h; + const float y1 = (r + 1 == rows) ? bottom_right.y : top_left.y + (r + 1) * row_h; + draw_list->AddRectFilled({top_left.x, y0}, {bottom_right.x, y1}, IM_COL32(c.Red(), c.Green(), c.Blue(), c.Alpha())); + } +} + void ImGuiWrapper::draw_cross_hair(const ImVec2 &position, float radius, ImU32 color, int num_segments, float thickness) { auto draw_list = ImGui::GetOverlayDrawList(); draw_list->AddCircle(position, radius, color, num_segments, thickness); diff --git a/src/slic3r/GUI/ImGuiWrapper.hpp b/src/slic3r/GUI/ImGuiWrapper.hpp index b586094ab3..db94b3edcb 100644 --- a/src/slic3r/GUI/ImGuiWrapper.hpp +++ b/src/slic3r/GUI/ImGuiWrapper.hpp @@ -3,10 +3,12 @@ #include #include +#include #include #include +#include #include #include "libslic3r/Point.hpp" @@ -299,6 +301,20 @@ public: int num_segments = 0, float thickness = 4.f); + /// + /// Fill a rect with a filament gradient ramp, one band per pixel row, ramp.front() along + /// the bottom edge. Bands rather than one interpolated rect, because the ramp follows the + /// slot's gradient curve and ImGui's corner interpolation could only draw a straight fade. + /// + /// Define where to draw it + /// Upper left corner of the rect + /// Lower right corner of the rect + /// Colours printed, bottom of the model first + static void draw_gradient_ramp(ImDrawList * draw_list, + const ImVec2 & top_left, + const ImVec2 & bottom_right, + const std::vector &ramp); + /// /// Check that font ranges contain all chars in string /// (rendered Unicodes are stored in GlyphRanges) diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index b72846452f..42ed49bb70 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -21,6 +21,7 @@ #include "GUI.hpp" #include "GUI_App.hpp" #include "GradientCurveEditor.hpp" +#include "FilamentBitmapUtils.hpp" #include "wxExtensions.hpp" #include "Tab.hpp" #include "libslic3r/Preset.hpp" @@ -115,19 +116,6 @@ static wxColour blend_colors(const wxColour& a, const wxColour& b, double ratio_ return wxColour(r, g, bl); } -static wxColour blend_n_colors(const std::vector& cols, const std::vector& weights) -{ - std::vector hex_colors; - std::vector int_weights; - for (size_t i = 0; i < cols.size() && i < weights.size(); ++i) { - hex_colors.push_back(cols[i].GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); - // Scale double weights (e.g. 0.5) to int (5000) for blend_color_multi; - // only relative magnitude matters. - int_weights.push_back(static_cast(std::lround(weights[i] * 10000))); - } - std::string hex = Slic3r::blend_color_multi(hex_colors, int_weights); - return wxColour(hex); -} // ---- Constructors ---- @@ -709,21 +697,10 @@ wxBoxSizer* MixedFilamentDialog::create_preview_panel() curve.points = {{0.0, yStart, NAN, NAN}, {1.0, yEnd, NAN, NAN}}; } - wxColour colA = comp_colour(0); - wxColour colB = comp_colour(1); - const int bands = std::max(80, swatch_sz); - double band_h = static_cast(swatch_sz) / bands; - dc.SetPen(*wxTRANSPARENT_PEN); - for (int b = 0; b < bands; ++b) { - double t = 1.0 - (b + 0.5) / bands; - double r1 = Slic3r::sample_gradient_curve(curve, t); - double r2 = 1.0 - r1; - wxColour band_col = blend_n_colors({colA, colB}, {r1, r2}); - dc.SetBrush(wxBrush(band_col)); - int by = y0 + static_cast(b * band_h); - int bh = static_cast((b + 1) * band_h) - static_cast(b * band_h) + 1; - dc.DrawRectangle(x0, by, swatch_sz, bh); - } + // Same sampler the sidebar, extruder icons and paint gizmo swatches use, so this + // preview and every swatch drawn for the filament agree on what it looks like. + auto ramp = sample_gradient_ramp(comp_colour(0), comp_colour(1), curve, std::max(80, swatch_sz)); + fill_gradient_ramp_rect(dc, wxRect(x0, y0, swatch_sz, swatch_sz), ramp); // Mask corners: overdraw a thick background-colored rounded rect frame // so the inner edge forms the desired rounded corners. diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 7ff7b193df..c730dd705e 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -4101,30 +4101,27 @@ void Sidebar::update_mixed_filament_list() wxColour mix_col(mix_color_str); unsigned int mix_num = (unsigned int)(cfg_idx + 1); - if (is_gradient && comp_ids.size() == 2) { - unsigned int from_id = (gradient_direction == 0) ? comp_ids[0] : comp_ids[1]; - unsigned int to_id = (gradient_direction == 0) ? comp_ids[1] : comp_ids[0]; - wxColour col_from = (from_id >= 1 && from_id <= physical_colors.size()) - ? wxColour(physical_colors[from_id - 1]) : wxColour("#D9D9D9"); - wxColour col_to = (to_id >= 1 && to_id <= physical_colors.size()) - ? wxColour(physical_colors[to_id - 1]) : wxColour("#D9D9D9"); - int swatch_sz = FromDIP(20); + // The swatch fades bottom to top over the model's height, sampled the same way + // the slicer builds the sublayers, so it matches the editor's Effect Preview. It + // comes back empty for every slot that is not a two component gradient mix. + const int swatch_sz = FromDIP(20); + const std::vector gradient_ramp = mixed_gradient_ramp(project_config, cfg_idx, swatch_sz); + + if (!gradient_ramp.empty()) { auto* grad_panel = new wxPanel(p->m_panel_mixed_content, wxID_ANY, wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); grad_panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); grad_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); - grad_panel->Bind(wxEVT_PAINT, [grad_panel, col_from, col_to, mix_num, mc_text](wxPaintEvent&) { + grad_panel->Bind(wxEVT_PAINT, [grad_panel, gradient_ramp, mix_num, mc_text](wxPaintEvent&) { wxBufferedPaintDC dc(grad_panel); wxSize sz = grad_panel->GetClientSize(); - fill_gradient_rect_east(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), col_from, col_to); + fill_gradient_ramp_rect(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), gradient_ramp); wxString txt = wxString::Format("%u", mix_num); dc.SetFont(::Label::Body_14); wxSize txt_sz = dc.GetTextExtent(txt); - wxColour mid( - (col_from.Red() + col_to.Red()) / 2, - (col_from.Green() + col_to.Green()) / 2, - (col_from.Blue() + col_to.Blue()) / 2); - dc.SetTextForeground(mid.GetLuminance() > 0.5 ? mc_text : *wxWHITE); + // The number sits at the swatch's middle, so take its contrast from the + // colour printed at mid height rather than from either endpoint. + dc.SetTextForeground(gradient_ramp[gradient_ramp.size() / 2].GetLuminance() > 0.5 ? mc_text : *wxWHITE); dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, (sz.GetHeight() - txt_sz.GetHeight()) / 2); }); @@ -19989,24 +19986,41 @@ std::vector Plater::get_filament_color_render_type() const return ctype; } -std::vector Plater::get_filament_gradient_info() const +const std::vector>& Plater::get_filament_gradient_ramps() const { - const Slic3r::DynamicPrintConfig* config = &wxGetApp().preset_bundle->project_config; - size_t n = get_extruder_colors_from_plater_config().size(); - std::vector info(n); + // Sampling a ramp walks the measured-blend recipe table once per step, and the paint toolbar + // asks for the ramps on every rendered frame, so they are cached against the config values + // they are built from and resampled only when one of those actually changes. + // + // The cache cannot live on the Plater: the extruder icons ask for the ramps from inside + // MenuFactory::init(), which runs while this Plater is still being constructed, so `this` is + // not usable yet. Everything the ramps are built from is global anyway, and there is one + // Plater per process, which is the same reasoning behind the icons' own static BitmapCache. + static std::string s_ramps_key; + static std::vector> s_ramps; - auto slots = parse_mixed_gradient_slots(*config, n); - unsigned char rgba[4] = {}; - for (size_t i = 0; i < n; ++i) { - if (!slots[i].is_gradient) continue; - info[i].is_gradient = true; - Slic3r::GUI::BitmapCache::parse_color4(slots[i].color_from, rgba); - info[i].color_from = {rgba[0] / 255.f, rgba[1] / 255.f, rgba[2] / 255.f, rgba[3] / 255.f}; - Slic3r::GUI::BitmapCache::parse_color4(slots[i].color_to, rgba); - info[i].color_to = {rgba[0] / 255.f, rgba[1] / 255.f, rgba[2] / 255.f, rgba[3] / 255.f}; - } + static const char* ramp_keys[] = {"filament_is_mixed", "filament_mixed_gradient", + "filament_mixed_components", "filament_colour", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve"}; - return info; + const Slic3r::DynamicPrintConfig& config = wxGetApp().preset_bundle->project_config; + std::string key; + for (const char* opt_key : ramp_keys) + if (const ConfigOption* opt = config.option(opt_key)) + key += opt->serialize() + '\n'; + if (key == s_ramps_key) + return s_ramps; + + // 64 bands outresolve every swatch drawn from this, all of which resample it down to their + // own height, so one cached resolution serves the icons and both ImGui filament bars. + const auto* colour_opt = config.option("filament_colour"); + const size_t n = colour_opt ? colour_opt->values.size() : 0; + s_ramps.assign(n, {}); + for (size_t i = 0; i < n; ++i) + s_ramps[i] = mixed_gradient_ramp(config, i, 64); + s_ramps_key = std::move(key); + + return s_ramps; } /* Get vector of colors used for rendering of a Preview scene in "Color print" mode diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 147f61bed6..5308deec61 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -5,6 +5,7 @@ #include #include +#include #include // BBS #include @@ -607,14 +608,11 @@ public: std::vector get_filament_colors_render_info() const; std::vector get_filament_color_render_type() const; - // Endpoint colours for gradient mixed filaments, so the 3D scene and the paint gizmo can - // draw a two-tone swatch. is_gradient is false for every ordinary filament slot. - struct FilamentGradientInfo { - bool is_gradient = false; - std::array color_from = {0.5f, 0.5f, 0.5f, 1.0f}; - std::array color_to = {0.5f, 0.5f, 0.5f, 1.0f}; - }; - std::vector get_filament_gradient_info() const; + // Per slot, the colours a gradient mixed filament actually prints, sampled bottom (index 0) + // to top, so the sidebar, the paint gizmo and the extruder icons draw the same fade the + // editor previews rather than a straight blend of two endpoints. A slot that is not a + // gradient mixed filament gets an empty ramp. Cached; recomputed when the config changes. + const std::vector>& get_filament_gradient_ramps() const; std::vector get_colors_for_color_print(const GCodeProcessorResult* const result = nullptr) const; void set_global_filament_map_mode(FilamentMapMode mode); diff --git a/src/slic3r/GUI/wxExtensions.cpp b/src/slic3r/GUI/wxExtensions.cpp index 2ca8f3cfbd..88e2df31c0 100644 --- a/src/slic3r/GUI/wxExtensions.cpp +++ b/src/slic3r/GUI/wxExtensions.cpp @@ -555,14 +555,20 @@ std::vector get_extruder_color_icons(bool thin_icon/* = false*/) const int icon_width = lround((thin_icon ? 2 : 4.4) * em); const int icon_height = lround(2 * em); + // A gradient mixed filament fades over the model's height, so it gets the same + // curve-sampled ramp the editor previews instead of a fade between two endpoints. + const auto& gradient_ramps = Slic3r::GUI::wxGetApp().plater()->get_filament_gradient_ramps(); + int index = 0; for (const auto &colors : readable_color_info) { auto label = std::to_string(++index); - bool is_gradient = ctype[index-1] == "0"; - if (colors.size() == 1) { + const size_t slot = index - 1; + bool is_gradient = ctype[slot] == "0"; + const std::vector* ramp = (slot < gradient_ramps.size() && !gradient_ramps[slot].empty()) ? &gradient_ramps[slot] : nullptr; + if (ramp == nullptr && colors.size() == 1) { bmps.push_back(get_extruder_color_icon(colors[0], label, icon_width, icon_height)); } else { - bmps.push_back(get_extruder_color_icon(colors, is_gradient, label, icon_width, icon_height)); + bmps.push_back(get_extruder_color_icon(colors, is_gradient, label, icon_width, icon_height, ramp)); } } } else { @@ -630,14 +636,27 @@ wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_da return data; } -wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height){ +wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, + const std::vector *ramp){ static Slic3r::GUI::BitmapCache bmp_cache; - // build cache key, include all color info + // build cache key, include all color info. A ramp already encodes its slot's components, + // colours and curve, so keying on it rebuilds the icon whenever any of them change. std::string bitmap_key = ""; - for (const auto& color : colors) { - bitmap_key += color + "_"; + if (ramp != nullptr) { + static const char hex_digits[] = "0123456789ABCDEF"; + bitmap_key = "grad_"; + for (const wxColour &c : *ramp) + for (unsigned char v : {c.Red(), c.Green(), c.Blue()}) { + bitmap_key += hex_digits[v >> 4]; + bitmap_key += hex_digits[v & 0x0F]; + } + bitmap_key += "_"; + } else { + for (const auto& color : colors) { + bitmap_key += color + "_"; + } } bitmap_key += "h" + std::to_string(icon_height) + "-w" + std::to_string(icon_width) + "-i" + label; @@ -647,16 +666,21 @@ wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradi #endif if (bitmap == nullptr) { - std::vector wx_colors; - for (const auto& color_str : colors) { - wx_colors.push_back(wxColour(color_str)); - } - if (wx_colors.empty()) { - wx_colors.push_back(wxColour("#636363")); // default color if no colors provided - } + wxBitmap base_bitmap; + if (ramp != nullptr) { + base_bitmap = Slic3r::GUI::create_gradient_ramp_bitmap(*ramp, wxSize(icon_width, icon_height)); + } else { + std::vector wx_colors; + for (const auto& color_str : colors) { + wx_colors.push_back(wxColour(color_str)); + } + if (wx_colors.empty()) { + wx_colors.push_back(wxColour("#636363")); // default color if no colors provided + } - // create filament bitmap in multi color - wxBitmap base_bitmap = Slic3r::GUI::create_filament_bitmap(wx_colors, wxSize(icon_width, icon_height), is_gradient); + // create filament bitmap in multi color + base_bitmap = Slic3r::GUI::create_filament_bitmap(wx_colors, wxSize(icon_width, icon_height), is_gradient); + } if (!base_bitmap.IsOk()) { // if create failed, return nullptr diff --git a/src/slic3r/GUI/wxExtensions.hpp b/src/slic3r/GUI/wxExtensions.hpp index 502614eb92..2754b3e5ba 100644 --- a/src/slic3r/GUI/wxExtensions.hpp +++ b/src/slic3r/GUI/wxExtensions.hpp @@ -75,7 +75,10 @@ wxBitmap create_scaled_bitmap(const std::string& bmp_name, wxWindow *win = nullp wxBitmap* get_default_extruder_color_icon(bool thin_icon = false); std::vector get_extruder_color_icons(bool thin_icon = false); wxBitmap * get_extruder_color_icon(std::string color, std::string label, int icon_width, int icon_height); -wxBitmap * get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height); +// A non-null ramp draws the slot as a gradient mixed filament instead: it holds the colours the +// slot actually prints, bottom entry first, and is drawn bottom to top rather than from colors. +wxBitmap * get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, + const std::vector *ramp = nullptr); std::vector> read_color_pack(std::vector color_pack); wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_data); diff --git a/tests/slic3rutils/test_filament_bitmap_utils.cpp b/tests/slic3rutils/test_filament_bitmap_utils.cpp index 9054a82018..35c32570f4 100644 --- a/tests/slic3rutils/test_filament_bitmap_utils.cpp +++ b/tests/slic3rutils/test_filament_bitmap_utils.cpp @@ -13,6 +13,8 @@ #include +#include + #include #include @@ -134,3 +136,121 @@ TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idem recompute_mixed_slot_colors(colors, cfg); require_same_rgb(colors[2], first); } + +// --- mixed_gradient_ramp / sample_gradient_ramp ----------------------------------------- +// +// The ramp is what every mixed filament swatch is drawn from, so these pin the three things +// a plain two-endpoint fade got wrong: the reserved ratio band, the component order, and the +// custom curve. + +namespace { + +// Slot 3 (index 2) is a gradient mix of physical slots 1 (red) and 2 (blue). +DynamicPrintConfig gradient_config(const std::string& components = "1,2", + const std::string& range = "0.9,0.1", + const std::string& curve = "") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({"", "", range})); + cfg.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({"", "", curve})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"})); + return cfg; +} + +} // namespace + +TEST_CASE("mixed_gradient_ramp runs bottom to top and never reaches a pure component", "[FilamentBitmapUtils]") +{ + // range "0.9,0.1": component 1 (red) is the majority at the bottom and the minority at the top. + const auto ramp = Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 2, 16); + REQUIRE(ramp.size() == 16); + + // Neither end is the pure component colour - the slicer clamps the blend to + // [kGradientMinRatio, kGradientMaxRatio], which is exactly what a two-endpoint fade missed. + REQUIRE(ramp.front() != wxColour(255, 0, 0)); + REQUIRE(ramp.back() != wxColour(0, 0, 255)); + + // Red falls and blue rises monotonically from bottom to top. + for (size_t i = 1; i < ramp.size(); ++i) { + REQUIRE(int(ramp[i].Red()) <= int(ramp[i - 1].Red())); + REQUIRE(int(ramp[i].Blue()) >= int(ramp[i - 1].Blue())); + } +} + +TEST_CASE("mixed_gradient_ramp follows the range's direction rather than the component order", "[FilamentBitmapUtils]") +{ + const auto rising = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.1,0.9"), 2, 16); + const auto falling = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16); + REQUIRE(rising.size() == 16); + REQUIRE(falling.size() == 16); + + // "0.1,0.9" starts blue-heavy at the bottom; "0.9,0.1" starts red-heavy. Reversing the + // range must reverse the ramp, which HSV-sorted endpoint colours could not express. + REQUIRE(int(rising.front().Blue()) > int(rising.front().Red())); + REQUIRE(int(falling.front().Red()) > int(falling.front().Blue())); + require_same_rgb(rising.front(), falling.back()); +} + +TEST_CASE("mixed_gradient_ramp bends with a custom curve", "[FilamentBitmapUtils]") +{ + // Component 1 holds near its maximum for the first half, then drops - a shape a straight + // fade between two endpoints cannot draw. + const auto curved = Slic3r::GUI::mixed_gradient_ramp( + gradient_config("1,2", "0.9,0.1", "0,0.9|0.5,0.85|1,0.1"), 2, 16); + const auto linear = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16); + REQUIRE(curved.size() == 16); + + // The curve holds component 1 high through the lower half, so every band up to mid height + // is at least as red as the straight fade and mid height is strictly redder. + for (size_t i = 0; i <= curved.size() / 2; ++i) + REQUIRE(int(curved[i].Red()) >= int(linear[i].Red())); + REQUIRE(int(curved[curved.size() / 2].Red()) > int(linear[linear.size() / 2].Red())); + // It still ends blue-dominant, like the straight fade. + REQUIRE(int(curved.back().Blue()) > int(curved.back().Red())); +} + +TEST_CASE("mixed_gradient_ramp is empty for anything but a two-component gradient slot", "[FilamentBitmapUtils]") +{ + SECTION("slot is not mixed") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 0, 16).empty()); + } + SECTION("gradient is off") { + DynamicPrintConfig cfg = gradient_config(); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false})); + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(cfg, 2, 16).empty()); + } + SECTION("three components") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2,3"), 2, 16).empty()); + } + SECTION("slot out of range") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 9, 16).empty()); + } + SECTION("no mixed keys at all") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(DynamicPrintConfig{}, 0, 16).empty()); + } +} + +TEST_CASE("sample_gradient_ramp blends each step through the shared blender", "[FilamentBitmapUtils]") +{ + // A flat curve makes every step the same 30/70 mix, which must come out as the blend the + // dialog's own swatches are drawn with - not a channel lerp between the two components. + GradientCurve curve; + curve.points = {{0.0, 0.3, NAN, NAN}, {1.0, 0.3, NAN, NAN}}; + const auto ramp = Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 4); + REQUIRE(ramp.size() == 4); + + const wxColour expected = Slic3r::GUI::blend_n_colors({wxColour(255, 0, 0), wxColour(0, 0, 255)}, {0.3, 0.7}); + for (const wxColour& c : ramp) + require_same_rgb(c, expected); +} + +TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step count", "[FilamentBitmapUtils]") +{ + GradientCurve curve; + REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 8).empty()); + curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}}; + REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty()); +} From 7934814077a6d71965be6fd8d7abcec4ab68fb95 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 22:10:05 +0800 Subject: [PATCH 106/138] fix wrong size of the last swatch of each row in Mixing Recommendations --- src/slic3r/GUI/MixedFilamentDialog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 42ed49bb70..91453a3113 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -1258,7 +1258,7 @@ wxBoxSizer* MixedFilamentDialog::create_recommendation_grid() m_recommendation_scroll->SetScrollRate(0, 5); m_recommendation_scroll->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); - m_recommendation_grid = new wxWrapSizer(wxHORIZONTAL, wxWRAPSIZER_DEFAULT_FLAGS); + m_recommendation_grid = new wxWrapSizer(wxHORIZONTAL, wxREMOVE_LEADING_SPACES); auto* scroll_inner_sizer = new wxBoxSizer(wxVERTICAL); scroll_inner_sizer->Add(m_recommendation_grid, 1, wxEXPAND | wxLEFT | wxTOP, FromDIP(8)); m_recommendation_scroll->SetSizer(scroll_inner_sizer); From 2b1499a0878b5ea8f93f058366c95f05c17d3673 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 23 Aug 2026 22:43:41 +0800 Subject: [PATCH 107/138] clean up comments --- src/libslic3r/Format/OBJ.hpp | 4 +-- src/libslic3r/GCode.cpp | 7 ++-- src/libslic3r/GCode/ToolOrdering.cpp | 11 +++--- src/libslic3r/Model.cpp | 9 +++-- src/libslic3r/PresetBundle.cpp | 35 +++++++----------- src/libslic3r/Print.cpp | 9 ++--- src/libslic3r/PrintApply.cpp | 4 +-- .../TextureToColor/TextureToColor.cpp | 5 ++- src/libslic3r/libslic3r.h | 7 ++-- src/slic3r/GUI/ConfigManipulation.cpp | 10 ++---- src/slic3r/GUI/FilamentBitmapUtils.cpp | 3 +- src/slic3r/GUI/FilamentBitmapUtils.hpp | 12 +++---- src/slic3r/GUI/GLCanvas3D.cpp | 7 ++-- src/slic3r/GUI/GUI_App.cpp | 6 ++-- src/slic3r/GUI/GUI_ObjectList.cpp | 4 +-- .../GUI/Gizmos/GLGizmoMmuSegmentation.cpp | 9 +++-- .../GUI/Gizmos/GLGizmoMmuSegmentation.hpp | 2 +- src/slic3r/GUI/Gizmos/GLGizmosManager.cpp | 4 +-- src/slic3r/GUI/GradientCurveEditor.cpp | 30 ++++++---------- src/slic3r/GUI/MixedFilamentDialog.cpp | 14 ++++---- src/slic3r/GUI/PartPlate.cpp | 9 ++--- src/slic3r/GUI/PlateSettingsDialog.cpp | 3 +- src/slic3r/GUI/Plater.cpp | 36 ++++++++----------- src/slic3r/GUI/SyncAmsInfoDialog.cpp | 4 +-- src/slic3r/GUI/TextureImportDialog.cpp | 15 ++++---- src/slic3r/GUI/Widgets/DropDown.cpp | 3 +- src/slic3r/GUI/WipeTowerDialog.cpp | 7 ++-- tests/fff_print/test_mixed_filament.cpp | 7 ++-- tests/libslic3r/test_3mf.cpp | 6 ++-- tests/libslic3r/test_filament_mixer.cpp | 11 +++--- .../libslic3r/test_preset_bundle_loading.cpp | 17 ++++----- tests/libslic3r/test_triangle_selector.cpp | 2 +- .../test_filament_bitmap_utils.cpp | 8 ++--- 33 files changed, 127 insertions(+), 193 deletions(-) diff --git a/src/libslic3r/Format/OBJ.hpp b/src/libslic3r/Format/OBJ.hpp index 7338fe0813..c103326af6 100644 --- a/src/libslic3r/Format/OBJ.hpp +++ b/src/libslic3r/Format/OBJ.hpp @@ -38,8 +38,8 @@ extern bool load_obj(const char *path, TriangleMesh *mesh, ObjInfo &vertex_color extern bool load_obj(const char *path, Model *model, ObjInfo &vertex_colors, std::string &message, const char *object_name = nullptr, ObjParser::MtlData *out_mtl = nullptr); struct TexturedMesh; -// Build a TexturedMesh (vertices + per-face UVs + decoded texture images) from a parsed OBJ -// plus its material table, so the texture-to-color importer can sample face colours. +// Build a TexturedMesh (vertices + per-face UVs + the texture files named by map_Kd) from a +// parsed OBJ plus its material table, so the texture-to-color importer can sample face colours. extern bool obj_to_textured_mesh( const ObjInfo& obj_info, const indexed_triangle_set& its, diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index a98a991f03..f28918f05d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6106,8 +6106,7 @@ LayerResult GCode::process_layer( // A mixed-color slot is absent from layer_tools.extruders by design: resolve_mixed_filaments() // replaced it with its physical components. Its geometry is still keyed under the slot in // by_extruder though, and the sublayer emitter looks the plan up by slot id, so append the - // slots here. Appended (not merged) so the existing order is untouched, and empty for every - // configuration without sublayer splitting. + // slots here. Appending rather than merging leaves the flush-optimized order untouched. std::vector plan_filaments = layer_tools.extruders; for (const auto &grp : layer_tools.mixed_sub_layer_groups) if (std::find(plan_filaments.begin(), plan_filaments.end(), grp.mixed_slot_0based) == plan_filaments.end()) @@ -6593,8 +6592,8 @@ LayerResult GCode::process_layer( // Mixed-color sublayer extrusion: if this extruder is a component of a mixed sublayer // group, extrude the mixed slot's geometry at the appropriate sub-Z with scaled flow. - // Ported from BambuStudio's 混色耗材 feature; adapted to Orca's InstanceVisit-based - // instance loop and its finer-grained per-role region filament options. + // Ported from BambuStudio and adapted to Orca's instance loop and its finer-grained + // per-role region filament options. for (const auto &grp : layer_tools.mixed_sub_layer_groups) { int sub_idx = -1; for (size_t k = 0; k < grp.components_0based.size(); ++k) { diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index e59a607d7d..0a97e7ac41 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -91,11 +91,9 @@ bool check_filament_printable_after_group(const std::vector &used_ } // Return a zero based extruder from the region, or extruder_override if overriden. -// The region accessors below resolve mixed-color slots to the physical filament chosen for -// this layer. Without sub-layer splitting a mixed slot is realized by alternating whole layers -// (deficit round-robin, see resolve_mixed_filaments), so a region asking "which filament?" must -// get the resolved physical one, not the virtual slot id. resolve_mixed() is identity when the -// slot is not mixed, so this is a no-op for every non-mixed setup. +// The region accessors below resolve mixed-color slots to the physical filament chosen for this +// layer by resolve_mixed_filaments(), because a virtual slot id is never a real tool. resolve_mixed() +// returns its argument unchanged for every filament that is not a mixed slot. unsigned int LayerTools::wall_extruder_id(const PrintRegion ®ion) const { assert(region.config().outer_wall_filament_id.value > 0); @@ -2522,8 +2520,7 @@ void ToolOrdering::resolve_mixed_filaments(const PrintConfig &config) // - untagged region (modifier / painted / etc.) -> per_object_gradient[obj] // Populating both keeps the per-object run state correct even when per-volume // takes over for the same (slot, obj), and lets untagged geometry (which is - // explicitly NOT split per-volume in v1 per the design doc) keep its legacy - // per-object gradient ratios. + // never split per-volume) keep its per-object gradient ratios. if (grp.is_gradient) { auto vol_runs_slot_it = per_vol_runs.find(ext); if (vol_runs_slot_it != per_vol_runs.end()) { diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 71c042f4e0..600c46e7f5 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -309,9 +309,8 @@ Model Model::read_from_file(const std::string& ObjParser::MtlData mtl_data; result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data); if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) { - // Textured OBJ: hand the mesh + materials to the texture-to-color importer instead - // of the flat per-face colour dialog. Replaces Orca's previous "not implemented" - // placeholder for this branch. + // Textured OBJ: hand the mesh + materials to the texture-to-color importer + // instead of the flat per-face colour dialog. auto tex_mesh = std::make_shared(); std::string obj_dir = boost::filesystem::path(input_file).parent_path().string(); if (obj_to_textured_mesh(obj_info, @@ -322,7 +321,7 @@ Model Model::read_from_file(const std::string& } else if (result && !model.objects.empty() && !model.objects.back()->volumes.empty()) { // Vertex-colour and MTL face-colour OBJs also go through the texture-to-color - // importer (as precomputed per-face colors) instead of the legacy flat + // importer (as precomputed per-face colors) instead of the flat // per-face colour dialog, matching the uv_png branch above. auto build_tex_mesh_geometry = [&]() { auto tex_mesh = std::make_shared(); @@ -374,7 +373,7 @@ Model Model::read_from_file(const std::string& else if (boost::algorithm::iends_with(input_file, ".glb") || boost::algorithm::iends_with(input_file, ".gltf") || boost::algorithm::iends_with(input_file, ".fbx")) { - // These formats always carry material/texture data, so they go through the textured + // These formats can carry material/texture data, so they go through the textured // import path: the geometry becomes a normal object and the texture is handed to the // texture-to-color dialog via Model::texture_mesh. auto tex_mesh = std::make_shared(); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 74d48118e6..f92bb354ee 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -2715,19 +2715,13 @@ void PresetBundle::load_installed_sla_materials(AppConfig &config) preset.set_visible_from_appconfig(config); } -// Mixed-color filament metadata is project state, carried in the 3mf's project_settings.config. -// BambuStudio also snapshots it in the app config so the last session's mixes are back before any -// project is opened; there the filament list itself is a single global snapshot, so the mixed -// arrays live next to it in the global "presets" section. Orca's per-printer preset memory instead -// rebuilds the filament list from the selected printer's snapshot (filament_%02u/filament_colors) -// on startup AND on every printer selection — so the mixed arrays, whose component ids are 1-based -// indices into exactly that list, must live in the same per-printer snapshot or they end up -// describing a list they were never saved against (and previously got reset on every printer -// select, losing the mixes over a restart). -// Missing keys clear the arrays: a printer with no stored mixes must not inherit another's. -// fallback_to_global additionally reads the legacy shared "presets" keys (the old format) so a -// config saved by an earlier build still restores at startup; export_selections clears that -// section on the next save. +// Mixed-color filament metadata is project state saved in the 3mf, also mirrored into the app +// config so the last session's mixes are back before any project is opened. It is kept in the +// per-printer snapshot next to the filament list it indexes (filament_%02u/filament_colors), +// because that list is rebuilt on every printer selection and the component ids are 1-based +// indices into exactly that list. Missing keys clear the arrays, so one printer never inherits +// another's mixes; fallback_to_global also reads the shared "presets" keys an older config +// layout used, which export_selections drops on the next save. static void load_mixed_filament_settings(DynamicPrintConfig &project_config, AppConfig &config, const std::string &printer_name, size_t n_filaments, bool fallback_to_global) @@ -3162,12 +3156,9 @@ void PresetBundle::export_selections(AppConfig &config) "|"); config.set_printer_setting(printer_name, "flush_multiplier", flush_multiplier_str); - // Mixed-color filament metadata: stored in the per-printer snapshot next to the filament - // list it indexes (filament_%02u / filament_colors), so each printer's remembered config - // round-trips its own mixes and re-applying a snapshot never leaves the arrays describing a - // different list (see load_mixed_filament_settings). Bools are ','-joined; the - // component/ratio/range strings are '|'-joined; the gradient curve is escaped instead, - // because its values contain '|'. + // Mixed-color filament metadata goes into the per-printer snapshot next to the filament list + // it indexes (see load_mixed_filament_settings). Bools are ','-joined and the component, ratio + // and range strings '|'-joined; the gradient curve is escaped instead, as it contains '|'. auto join_bools = [](const std::vector &vals) { std::string s; for (size_t i = 0; i < vals.size(); ++i) { @@ -3227,8 +3218,7 @@ void PresetBundle::set_num_filaments(unsigned int n, std::vector ne 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. Missing this leaves the - // arrays short and every lookup of a newly created slot reads past the end. + // with the filament count exactly like filament_colour above. if (auto* opt = project_config.option("filament_is_mixed")) opt->values.resize(n, false); if (auto* opt = project_config.option("filament_mixed_components")) @@ -3285,8 +3275,7 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) 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. Missing this leaves the - // arrays short and every lookup of a newly created slot reads past the end. + // with the filament count exactly like filament_colour above. if (auto* opt = project_config.option("filament_is_mixed")) opt->values.resize(n, false); if (auto* opt = project_config.option("filament_mixed_components")) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index e474818dfe..8be66da2a4 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2615,12 +2615,9 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) print_object_instances_ordering = sort_object_instances_by_model_order(*this); // A mixed slot is virtual; only its components reach a nozzle. These per-object orderings // are unsorted (no resolve_mixed_filaments), so expand the slots here for the grouping, the - // unprintable sets and the slice-used lists. No-op without mixed filaments. - // Orca: the slice-used lists stay sourced from these expanded lists rather than from the - // sorted orderings (which may add the wipe-tower filament or seed dontcare layers - // differently), so prints without mixed filaments keep their used-filament set; the - // first-layer set therefore lists every component of a mixed slot, not just the one layer 0 - // resolves to. + // unprintable sets and the slice-used lists. Because the expansion happens here rather than + // on the sorted orderings, the first-layer used set lists every component of a mixed slot, + // not just the one layer 0 resolves to. No-op without mixed filaments. const auto &is_mixed = m_config.filament_is_mixed.values; const auto &comp_strs = m_config.filament_mixed_components.values; const bool has_mixed = has_any_mixed_filament(is_mixed); diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index 7eb40946a4..f03271bf73 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1931,8 +1931,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ for (const ModelVolume *volume : volumes) { const std::vector &volume_used_facet_states = volume->mmu_segmentation_facets.get_data().used_states; - // Sizes may legitimately differ: paint data stored before the state range was - // extended carries a shorter used_states vector. Merge over the common prefix. + // Paint data saved before the painted state range was extended deserializes a + // shorter used_states vector, so merge over the common prefix. for (size_t state_idx = 0; state_idx < std::min(volume_used_facet_states.size(), used_facet_states.size()); ++state_idx) used_facet_states[state_idx] |= volume_used_facet_states[state_idx]; } diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp index bcdc985fc9..e5dde36714 100644 --- a/src/libslic3r/TextureToColor/TextureToColor.cpp +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -639,9 +639,8 @@ static bool repair_cluster_smooth( { TriangleMesh stats_mesh(static_cast(mesh)); const auto& stats = stats_mesh.stats(); - // Orca's TriangleMeshStats defines manifold() as open_edges == 0 and does not track - // non-manifold edges/vertices separately, so BBS's "!manifold() || has_open_edges()" - // collapses to this single test and the extra counters drop out of the log. + // Orca's TriangleMeshStats only counts open edges: manifold() is open_edges == 0, and + // there are no separate non-manifold edge/vertex counters to test or log here. if (!stats.manifold()) { BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh has non-manifold geometry or open boundaries, open_edges=" << stats.open_edges; diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index dee0a93087..6584566f40 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -64,10 +64,9 @@ static constexpr double LARGE_BED_THRESHOLD = 2147; // Orca: maximum number of extruders is 64. For SEMM printers, it defines maximum filament number. static constexpr size_t MAXIMUM_EXTRUDER_NUMBER = 64; -// Orca: how many filament slots syncing an AMS setup may create. This used to follow -// EnforcerBlockerType::ExtruderMax, which capped it at the number of paintable filaments; that -// limit has since been raised to 32, so the value is pinned here to keep AMS sync behaving as -// before for projects that use no mixed-colour filaments. +// Orca: how many filament slots syncing an AMS setup may create. This was derived from +// EnforcerBlockerType::ExtruderMax, but that cap now covers 32 paintable filaments, so the AMS +// limit is pinned here to keep sync behaving as it does for projects without mixed-color filaments. static constexpr size_t MAXIMUM_AMS_SYNC_FILAMENT_NUMBER = 16; // Orca: maximum line width is 5 times the nozzle diameter diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 55a41a5720..d15164ef63 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -577,13 +577,9 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con } // BBS - // A filament override naming a slot that no longer exists is stale and falls back to the - // plater's value. Support and the wipe tower are additionally restricted to physical filaments: - // the engine consumes those keys directly, with no per-layer mixed resolution, so a virtual - // slot there would reach the G-code unresolved. The per-feature keys have no such restriction — - // LayerTools::extruder() and its siblings resolve a mixed slot to the physical filament chosen - // for each layer. The sidebar dropdowns already hide mixed slots for the restricted keys - // (Plater.cpp DynamicFilamentList); this reset covers values loaded from projects. + // Reset filament overrides pointing at a slot that no longer exists. Support and the wipe + // tower additionally reject mixed slots: the engine consumes those keys directly, so a virtual + // slot would reach the G-code unresolved, while the per-feature keys are resolved per layer. static const char* physical_only_keys[] = { "support_filament", "support_interface_filament", "wipe_tower_filament" }; static const char* feature_keys[] = { "outer_wall_filament_id", "inner_wall_filament_id", "sparse_infill_filament_id", "internal_solid_filament_id", diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 45368e0537..1f51fc79b3 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -131,8 +131,7 @@ void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector 1) ? (double) (rect.height - 1 - y) / (rect.height - 1) : 0.5; dc.SetBrush(wxBrush(ramp[static_cast(t * (ramp.size() - 1) + 0.5)])); dc.DrawRectangle(rect.x, rect.y + y, rect.width, 1); diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 9cb8d64249..11696f3401 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -38,19 +38,17 @@ wxColour blend_n_colors(const std::vector& cols, const std::vector sample_gradient_ramp(const wxColour& first, const wxColour& second, const Slic3r::GradientCurve& curve, int steps); // Same ramp for a project config slot, resolving components, colours and curve (or the -// linear gradient_range fallback) from cfg. Empty unless the slot is a two-component -// gradient mixed filament, which is what gates every caller to mixed slots only. -// steps is the ramp's resolution; pass the destination's height in pixels. +// linear gradient_range fallback) from cfg. Returns empty for any slot that is not a +// two-component gradient mixed filament. steps is the ramp's resolution; pass the +// destination's height in pixels. std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); // Fill rect with a ramp, ramp.front() along the bottom edge. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 94b0923885..6d03f992cd 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -9681,10 +9681,9 @@ void GLCanvas3D::_render_paint_toolbar() const } } } - // ORCA: the loop above only produces a label for a slot whose preset is found in the preset - // collection, while the render loop below iterates extruder_num (= colour count). Pad the - // label arrays so a slot without a matching preset cannot index past them — reading a garbage - // std::string here crashes in ImGui::CalcTextSize (strlen). + // ORCA: the loop above only labels a slot whose preset was found in the preset collection, + // while the render loop below iterates extruder_num. Pad the label arrays so a slot without a + // matching preset cannot index past them; a garbage std::string crashes ImGui::CalcTextSize. while (int(filament_text_first_line.size()) < extruder_num) { filament_text_first_line.emplace_back(); filament_text_second_line.emplace_back(); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index d14943c94a..738af5e24c 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -8906,9 +8906,9 @@ void GUI_App::load_current_presets(bool active_preset_combox/*= false*/, bool ch auto* nozzle_diameter = edited_printer_preset.config.option("nozzle_diameter"); if (nozzle_diameter) { // 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. + // nozzle of their own. Sizing to the nozzle count alone would silently drop the mixes of + // a just-loaded project, and update_extruder_count() would then strip the facets painted + // with them. preset_bundle->set_num_filaments(nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments()); } } diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 48182da77b..b33cc82abc 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -3234,8 +3234,8 @@ void ObjectList::merge(bool to_multipart_object) void ObjectList::layers_editing() { // Height ranges give each range its own layer height, varying the mixed sub-layer heights just - // like an adaptive profile; sibling of the on_action_layersediting/ConfigManipulation warnings, - // sharing the same do-not-show-again flag. + // like an adaptive profile, so this raises the same warning as variable layer height and shares + // its do-not-show-again flag. const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; if (print_config.opt_bool("enable_mixed_color_sublayer")) { if (wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 3f05f9c22c..3d4af75cde 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -326,10 +326,9 @@ bool GLGizmoMmuSegmentation::draw_color_button(int idx, const char* id_str, cons bool dark_tone = gradient ? (*gradient)[gradient->size() / 2].GetLuminance() < 0.51 : (0.299f * color.r() + 0.587f * color.g() + 0.114f * color.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 - // Paint a gradient mixed filament's fade before the button and keep the button transparent, so the - // slot number and the frame below stay on top of it. The bands cannot round their corners, so the - // fade is drawn at the frame's inset and the frame masks it into the same shape a plain color slot - // gets. + // Paint a gradient mixed filament's fade before the button and keep the button transparent, so + // the slot number and the frame below stay on top of it. The bands cannot round their corners, + // so the fade is inset to the frame, which masks it into the shape a plain color slot gets. if (gradient) { ImGuiWrapper::draw_gradient_ramp(draw_list, {pos.x + frame_inset * scale, pos.y + frame_inset * scale}, {pos.x + size.x - frame_inset * scale, pos.y + size.y - frame_inset * scale}, *gradient); @@ -778,7 +777,7 @@ void GLGizmoMmuSegmentation::update_triangle_selectors_colors() TriangleSelectorPatch* selector = dynamic_cast(m_triangle_selectors[i].get()); int extruder_idx = m_volumes_extruder_idxs[i]; int extruder_color_idx = std::max(0, extruder_idx - 1); - // As above: a mixed-color slot can index past the physical colour list. + // A mixed-color slot can index past the physical colour list; fall back to the first colour. if (extruder_color_idx >= (int)m_extruders_colors.size()) extruder_color_idx = 0; std::vector ebt_colors; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index 7d83468ea5..70cfde5aed 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -142,7 +142,7 @@ private: // ORCA bool draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); // Gradient ramp of a filament slot, or nullptr when the slot is a plain single color - // filament, so callers can index into what they get back freely. + // filament. A non-null result is never empty. const std::vector* gradient_of(int idx) const { return idx >= 0 && idx < (int) m_gradient_ramps.size() && !m_gradient_ramps[idx].empty() ? &m_gradient_ramps[idx] : nullptr; diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 92c691f696..7882cf2269 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -998,8 +998,8 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) keyCode = keyCode- WXK_NUMPAD0+'0'; } if (keyCode >= '0' && keyCode <= '9') { - // The paint palette now reaches EXTRUDERS_LIMIT (mixed-color filaments share - // the same slots), so any leading digit that can start a valid two-digit + // The paint palette reaches EXTRUDERS_LIMIT slots (mixed-color filaments take + // ordinary slots too), so any leading digit that can start a valid two-digit // number waits briefly for a second one. const int digit = keyCode - '0'; const int shortcut_max = int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT); diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index d34e71a132..678fee0efc 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -19,7 +19,7 @@ namespace GUI { wxDEFINE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); namespace { -// Layout (Figma "Property 1=Default", 214.06 x 179.63 px reference). +// Layout ratios of the plot rect within the widget, taken from a 214 x 180 px reference drawing. // Plot rect occupies the upper-left region; right + bottom margins host axis arrows / labels. constexpr double kPlotLeftRatio = 0.0316; constexpr double kPlotRightRatio = 0.6766; @@ -37,7 +37,7 @@ constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling - constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) -// Light-mode design tokens from Figma. Resolved through StateColor::darkModeColorFor() +// Light-mode design tokens. Resolved through StateColor::darkModeColorFor() // at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> // #818183, #262E30 -> #EFEFF0, *wxWHITE -> #2D2D31). Don't read these directly in paint; // always go through the resolved locals declared at the top of on_paint(). @@ -46,11 +46,9 @@ const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 -// LAB (DeltaE76) threshold for "curve color is too close to the background". Below this -// we paint a subtle axis-color outline so the curve doesn't visually vanish; above this -// we draw the curve plain. ~15 is "perceptible but still close", looser than the strict -// 5.0 used by FlushPredict::is_similar_color but loose enough that a pastel pink on white -// or a charcoal on #2B2B2B still triggers an outline. +// LAB (DeltaE76) threshold for "curve color is too close to the background": below it the curve +// gets a subtle outline so it does not visually vanish, otherwise it is drawn plain. Looser than +// the 5.0 of FlushPredict::is_similar_color, so a pastel pink on white still gets an outline. constexpr float kBgSimilarThreshold = 15.0f; constexpr int kOutlineExtraDip = 2; } // namespace @@ -65,8 +63,6 @@ GradientCurveEditor::GradientCurveEditor(wxWindow* parent, SetBackgroundStyle(wxBG_STYLE_PAINT); SetBackgroundColour(wxGetApp().get_window_default_clr()); // Wide enough so the X-axis "Material Ratio" label fits past the arrow tip without overlap. - // 260 (was 240): adds room for the "Material Ratio" label that gets shifted right by the - // longer axis arrow; the hosting MixedFilamentDialog grows to 470 DIP to accommodate. SetMinSize(FromDIP(wxSize(260, 200))); reset_to_linear(0.10, 0.90); @@ -456,10 +452,9 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) return poly; }; - // Only the geometry goes through the graphics context: dc.DrawLines() takes integer - // wxPoint and would quantize the curve back to whole pixels. The pen is still set on - // the dc, which forwards it to this same context while keeping the dc's own cached - // state in sync, so later dc drawing does not inherit the curve's pen. + // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint + // and would quantize the curve back to whole pixels. The pen is still set on the dc, which + // forwards it here while keeping its own cached state in sync for later dc drawing. auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { dc.SetPen(wxPen(col, FromDIP(stroke_dip))); gc->StrokeLines(poly.size(), poly.data()); @@ -552,12 +547,9 @@ void GradientCurveEditor::on_left_down(wxMouseEvent& evt) // 4) Selected curve line body hit -> insert a new anchor at cursor x (snapped // to the current smooth curve so the initial click is visually invisible) - // and immediately enter Anchor drag mode. PS Curves style: the drag-bend - // interaction has no separate "bend without anchor" mode; pressing and - // dragging on the line is equivalent to clicking to add then dragging the - // fresh anchor. Trades the previous (failed) "no anchor on drag" promise - // for genuine cursor tracking, since a single cubic between two existing - // anchors mathematically cannot put its peak under an off-center cursor. + // and immediately enter Anchor drag mode. Bending the segment without + // inserting an anchor is not an option: a single cubic between two existing + // anchors cannot put its peak under an off-center cursor. double nx = 0, dummy = 0; px_to_data(pos.x, pos.y, nx, dummy); if (nx <= 0.0 || nx >= 1.0 || seg < 0) { diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 91453a3113..46563664cc 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -894,9 +894,9 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); }); - // Release whenever the capture is held, not only when the drag flag is set: - // the flag can be cleared behind our back, and a capture that outlives the - // widget wedges mouse input for the whole application. + // Key the release off the capture itself, not off the drag flag: the two can fall out of + // sync (a lost capture clears the flag on its own), and a capture that outlives the widget + // wedges mouse input for the whole application. m_ratio_bar->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { m_ratio_dragging = false; if (m_ratio_bar->HasCapture()) @@ -1492,11 +1492,9 @@ void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) void MixedFilamentDialog::on_gradient_toggled() { - // Orca: the engine only produces a gradient when the print profile's - // "enable_mixed_color_sublayer" option is on (ToolOrdering::resolve_mixed_filaments - // falls back to whole-layer round-robin without it, and BBS leaves users to find the - // option themselves). Offer to switch it on so the gradient the user just enabled - // actually shows up in the sliced result. Keep this block on future BBS syncs. + // Orca: a gradient is only sliced when the print profile's "enable_mixed_color_sublayer" + // option is on; without it ToolOrdering picks a single component per whole layer. Offer to + // turn the option on instead of silently ignoring the gradient the user just enabled. bool checked = m_chk_gradient->GetValue(); if (checked) { diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 74f13d94df..73c8073e4f 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -2048,13 +2048,8 @@ bool PartPlate::check_tpu_printable_status(const DynamicPrintConfig & config, co } // A mixed-color filament alternates between its components constantly. On a single-nozzle -// printer every one of those switches is a full filament change plus a purge, so warn the -// user before they commit to it. Printers with more than one nozzle can keep the components -// loaded simultaneously and are not affected. -// -// BBS additionally excludes its H2C/H2D/X2D models by name; those are multi-nozzle machines -// already ruled out by the nozzle_diameter test above, so the name check is dropped here -// rather than carried over as a Bambu-specific special case. +// printer every one of those switches is a full filament change plus a purge, so warn before +// slicing. Multi-nozzle printers keep the components loaded at once and are not affected. bool PartPlate::check_single_extruder_mixed_filament_risk(const DynamicPrintConfig &config, std::string &warning_text) const { warning_text.clear(); diff --git a/src/slic3r/GUI/PlateSettingsDialog.cpp b/src/slic3r/GUI/PlateSettingsDialog.cpp index e7f1d926d9..bc81335d61 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.cpp +++ b/src/slic3r/GUI/PlateSettingsDialog.cpp @@ -473,8 +473,7 @@ PlateSettingsDialog::PlateSettingsDialog(wxWindow* parent, const wxString& title m_sizer_main->Add(m_other_layers_seq_panel, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(30)); // A mixed-color slot resolves to a different physical filament per layer, so a user-defined - // filament order cannot be honoured. Disable the choice and say why. BBS puts this warning - // inside its button sizer; Orca builds the buttons with DialogButtons, so it gets its own row. + // filament order cannot be honoured; grey out the choice and explain that in the dialog. { auto &proj_cfg = wxGetApp().preset_bundle->project_config; auto *is_mixed_opt = proj_cfg.option("filament_is_mixed"); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c730dd705e..8b0fcc3902 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3876,10 +3876,8 @@ void Sidebar::change_top_border_for_mode_sizer(bool increase_border) // ---- Mixed-color filament sidebar support ---- -// Ported from BambuStudio's 混色耗材 feature. BBS hosts these widgets in an -// m_filament_area_wrapper that Orca's sidebar has no counterpart for, so the mixed -// section is parented to p->scrolled and sized with Orca's own row-height preference -// (filaments_area_preferred_count) rather than BBS's fixed 3-row / 12-filament cap. +// The mixed rows get their own scroll area, capped by Orca's filaments_area_preferred_count +// row budget rather than BBS's fixed 3-row / 12-filament limit. void Sidebar::recalc_filament_scroll_sizes() { if (!p->m_mixed_scroll_area || !p->m_mixed_scroll_area->GetSizer()) @@ -4102,8 +4100,8 @@ void Sidebar::update_mixed_filament_list() unsigned int mix_num = (unsigned int)(cfg_idx + 1); // The swatch fades bottom to top over the model's height, sampled the same way - // the slicer builds the sublayers, so it matches the editor's Effect Preview. It - // comes back empty for every slot that is not a two component gradient mix. + // the slicer builds the sublayers, so it matches the editor's Effect Preview. The + // ramp comes back empty for every slot that is not a two component gradient mix. const int swatch_sz = FromDIP(20); const std::vector gradient_ramp = mixed_gradient_ramp(project_config, cfg_idx, swatch_sz); @@ -4642,9 +4640,8 @@ static bool create_mixed_filament_from_result( multi_colour_opt->values[new_idx] = mixed_color; } - // set_num_filaments() above is what grows these parallel arrays. Guard the writes anyway, - // matching the gradient writes below, so a sizing bug degrades into a no-op rather than a - // heap overwrite. + // set_num_filaments() above already grows these parallel arrays; the writes are still + // size-guarded so a sizing bug degrades into a no-op rather than a heap overwrite. { auto* is_mixed_opt = project_config.option("filament_is_mixed"); while (is_mixed_opt->values.size() <= new_idx) is_mixed_opt->values.push_back(false); @@ -14071,11 +14068,9 @@ bool Plater::priv::can_layers_editing() const void Plater::priv::on_action_layersediting(SimpleEvent&) { - // Sub-layer splitting divides each layer by the mix ratio, so an adaptive layer profile makes - // those sub-layer heights vary and degrades the blend. ConfigManipulation warns when the - // option is switched on with a variable profile already present; this is the other direction, - // warning when variable layer editing is switched on while the option is active. All three - // sites (with ObjectList::layers_editing for height ranges) honour the same do-not-show-again flag. + // Sub-layer splitting divides each layer by the mix ratio, so a variable layer height profile + // makes those sub-layer heights uneven and degrades the blend. ConfigManipulation warns for the + // opposite order, when the option is switched on while a variable profile already exists. if (!view3D->is_layers_editing_enabled()) { const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; if (print_config.opt_bool("enable_mixed_color_sublayer")) { @@ -19988,14 +19983,11 @@ std::vector Plater::get_filament_color_render_type() const const std::vector>& Plater::get_filament_gradient_ramps() const { - // Sampling a ramp walks the measured-blend recipe table once per step, and the paint toolbar - // asks for the ramps on every rendered frame, so they are cached against the config values - // they are built from and resampled only when one of those actually changes. - // - // The cache cannot live on the Plater: the extruder icons ask for the ramps from inside - // MenuFactory::init(), which runs while this Plater is still being constructed, so `this` is - // not usable yet. Everything the ramps are built from is global anyway, and there is one - // Plater per process, which is the same reasoning behind the icons' own static BitmapCache. + // Sampling a ramp walks the measured-blend recipe table once per step and the paint toolbar + // asks for the ramps every rendered frame, so they are cached against the config values they + // are built from. The cache is static rather than a Plater member because the extruder icons + // ask for the ramps from MenuFactory::init(), which runs while this Plater is still inside its + // own constructor, so wxGetApp().plater_ is not assigned yet. static std::string s_ramps_key; static std::vector> s_ramps; diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 5005b49303..9515ecc116 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -2577,7 +2577,7 @@ void SyncAmsInfoDialog::reset_and_sync_ams_list() m_filaments.clear(); // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as - // AMS sync targets. Look the flags up once and skip those slots in the loop below. + // AMS sync targets. auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); bool use_double_extruder = get_is_double_extruder(); @@ -2801,7 +2801,7 @@ void SyncAmsInfoDialog::generate_override_fix_ams_list() m_fix_filaments.clear(); // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as - // AMS sync targets. Look the flags up once and skip those slots in the loop below. + // AMS sync targets. auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); bool use_double_extruder = get_is_double_extruder(); diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index 688898ed0a..ef9beda4d0 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -1368,11 +1368,9 @@ void TexturePreviewCanvas::ensure_gl_ready() { if (m_gl_initialized) return; - // BBS loads GL entry points here with GLEW. Orca uses glad and centralises loading in - // OpenGLManager, which has already run by the time any canvas is realized, so just - // verify the loader is up and drain any stale error state. - // glad leaves unresolved entry points as null pointers, so this is a cheap guard against - // painting before OpenGLManager::init_gl() has run. + // BBS loads the GL entry points here with GLEW; Orca loads them centrally in + // OpenGLManager, so only check that this has already happened (glad leaves unresolved + // entry points null) and drain any stale error state. if (glGetString == nullptr) { BOOST_LOG_TRIVIAL(error) << "TexturePreviewCanvas: OpenGL functions are not loaded yet"; return; @@ -2436,10 +2434,9 @@ void TextureImportDialog::start_computation(bool auto_color, bool initial) settings.target_colors_num = auto_color ? 0 : (size_t)m_param_color_count; settings.smooth_weight = m_param_smooth / 10.0; settings.mesh_repair_decision = m_mesh_repair_decision; - // BBS repairs the mesh through the Windows 3D SDK, which only exists on Windows and only - // when the SDK is present at build time. Orca already ships a CGAL-based repair - // (MeshBoolean::cgal::repair) that works on all three platforms, so use that instead — - // this makes the repair path available on Linux and macOS too. + // BBS repairs the mesh through the Windows 3D SDK, which is only available on Windows + // builds that ship the SDK. Orca's CGAL-based repair (MeshBoolean::cgal::repair) works + // on all three platforms, so use that instead. settings.mesh_repair_callback = [](const indexed_triangle_set& mesh, indexed_triangle_set& repaired_mesh, std::function progress_callback, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index a44303169a..aae8bccf9e 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -360,8 +360,7 @@ void DropDown::render(wxDC &dc) for (int i = 0; i < items.size(); ++i) { auto &item = items[i]; int states2 = states; - // Dimmed items stay selectable but render greyed out (used by the mixed-filament - // dialog to show components that are already consumed by another mix). + // Dimmed items render greyed out but stay selectable, so they cannot reuse the disabled state. bool is_dimmed = (item.style & DD_ITEM_STYLE_DIMMED) != 0; if ((item.style & DD_ITEM_STYLE_DISABLED) != 0) states2 &= ~StateColor::Enabled; diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index 70aba0404e..d4fbcc6fe3 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -261,10 +261,9 @@ static std::vector MatrixFlatten(const WipingDialog::VolumeMatrix& matrix return vec; } -// Mixed-color slots are virtual: they are never loaded into a tray and so have no flushing -// volumes of their own. The dialog therefore shows only the physical filaments, which means -// converting between the full config matrix (indexed by config slot) and a dense physical -// sub-matrix (indexed by row/column in the table). +// Mixed-color slots are virtual and have no flushing volumes, so the dialog shows only the +// physical filaments. That means converting between the full config matrix (indexed by config +// slot) and a dense physical sub-matrix (indexed by row/column in the table). static std::vector extract_physical_sub_matrix( const std::vector& full_matrix, size_t full_n, const std::vector& indices) diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp index 143ecdcb6e..a8f1e2e84c 100644 --- a/tests/fff_print/test_mixed_filament.cpp +++ b/tests/fff_print/test_mixed_filament.cpp @@ -37,7 +37,7 @@ DynamicPrintConfig mixed_config(bool sublayer_on, const char *ratios = "0.6,0.4" return config; } -// Total sub-layer groups and per-layer DRR resolutions across the whole tool ordering. +// Total sub-layer groups and per-layer mixed-filament resolutions across the whole tool ordering. void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions) { groups = resolutions = 0; @@ -139,9 +139,8 @@ TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilam TEST_CASE("By-object prints without mixed filaments keep their used-filament set", "[MixedFilament]") { - // Regression guard for the mixed gate: with no mixed slot the by-object bookkeeping must - // be untouched by this change. Object 2 prints with filament 2, so both filaments are used - // and no mixed filament is reported. + // With no mixed slot the by-object bookkeeping stays plain: object 2 prints with filament 2, + // so both filaments are used and no mixed filament is reported. DynamicPrintConfig config = multifilament_config(2, {{"print_sequence", "by object"}}); const std::vector> overrides{ {}, { {"extruder", "2"} } }; diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index e09f664b7e..4c0a09cf3f 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -501,10 +501,8 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { // 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. +// 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. 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; diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp index fb11fa9481..ade0c910dc 100644 --- a/tests/libslic3r/test_filament_mixer.cpp +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -101,10 +101,9 @@ TEST_CASE("check_mixed_filament_type_consistency flags mismatched component type TEST_CASE("a support-flagged component reads as its own filament type for the consistency check", "[FilamentMixer]") { - // Sidebar::update_mixed_filament_list and Sidebar::has_broken_mixed_filament derive each - // component's type through DynamicPrintConfig::get_filament_type, which folds the - // filament_is_support flag into the type — so toggling that flag alone changes the verdict - // and Plater::on_config_change has to refresh the mixed list on filament_is_support too. + // The sidebar derives each component's type through DynamicPrintConfig::get_filament_type, + // which folds filament_is_support into the type, so toggling that flag alone flips the + // verdict and the mixed filament list has to be refreshed on filament_is_support too. DynamicPrintConfig plain_pla; plain_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); plain_pla.set_key_value("filament_is_support", new ConfigOptionBools({false})); @@ -193,8 +192,8 @@ TEST_CASE("blend_color_multi weights components", "[FilamentMixer]") } SECTION("Mixing a color with itself stays close to that color") { - // The mixer is a degree-4 polynomial fit of pigment behaviour, so a round trip through - // it is near-identity rather than exact (the model documents a mean Delta-E around 2). + // The mixer is a degree-4 polynomial fit of pigment behaviour, so mixing a color with + // itself lands near it rather than exactly on it; allow a small per-channel drift. std::string mixed = blend_color_multi({"#123456", "#123456"}, {1, 1}); REQUIRE(mixed.size() == 7); auto comp = [](const std::string &hex, int i) { diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 0fb6f3e2f8..ea05ec0cf5 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -614,12 +614,10 @@ TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament } } -// A mix is described by 1-based indices into the project's filament list. Orca's per-printer -// preset memory rebuilds that list from the selected printer's snapshot (filament_%02u / -// filament_colors) at startup and on every printer selection, so the mixed arrays must be stored -// in the SAME per-printer snapshot: kept globally (as BambuStudio does — its filament list is a -// single global snapshot too) they end up indexing a list they were never saved against, and used -// to be reset on every printer selection instead, losing the mixes over an app restart. +// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds +// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every +// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up +// indexing a filament list they were never saved against. TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; @@ -674,10 +672,9 @@ TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Pre } // 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. +// virtual filaments at the tail of that list with no nozzle of their own, so the count has to +// allow for them: sizing to the nozzle count alone drops the project's mixes and strips 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. diff --git a/tests/libslic3r/test_triangle_selector.cpp b/tests/libslic3r/test_triangle_selector.cpp index dfeae477b9..0bdc639626 100644 --- a/tests/libslic3r/test_triangle_selector.cpp +++ b/tests/libslic3r/test_triangle_selector.cpp @@ -99,7 +99,7 @@ TEST_CASE("Extruder states above 17 are encoded in a second nibble", "[TriangleS } // Model.cpp writes these hex strings into the 3MF for colored mesh imports; the selector must -// decode exactly the states that table assigns to them. +// decode exactly the states CONST_FILAMENTS assigns to them. TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSelector]") { struct Case { const char *hex; int state; }; diff --git a/tests/slic3rutils/test_filament_bitmap_utils.cpp b/tests/slic3rutils/test_filament_bitmap_utils.cpp index 35c32570f4..997a119521 100644 --- a/tests/slic3rutils/test_filament_bitmap_utils.cpp +++ b/tests/slic3rutils/test_filament_bitmap_utils.cpp @@ -140,8 +140,8 @@ TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idem // --- mixed_gradient_ramp / sample_gradient_ramp ----------------------------------------- // // The ramp is what every mixed filament swatch is drawn from, so these pin the three things -// a plain two-endpoint fade got wrong: the reserved ratio band, the component order, and the -// custom curve. +// a plain fade between two endpoint colours cannot express: the reserved ratio band, the +// component order, and the custom curve. namespace { @@ -169,7 +169,7 @@ TEST_CASE("mixed_gradient_ramp runs bottom to top and never reaches a pure compo REQUIRE(ramp.size() == 16); // Neither end is the pure component colour - the slicer clamps the blend to - // [kGradientMinRatio, kGradientMaxRatio], which is exactly what a two-endpoint fade missed. + // [kGradientMinRatio, kGradientMaxRatio], which a fade between the pure colours would ignore. REQUIRE(ramp.front() != wxColour(255, 0, 0)); REQUIRE(ramp.back() != wxColour(0, 0, 255)); @@ -188,7 +188,7 @@ TEST_CASE("mixed_gradient_ramp follows the range's direction rather than the com REQUIRE(falling.size() == 16); // "0.1,0.9" starts blue-heavy at the bottom; "0.9,0.1" starts red-heavy. Reversing the - // range must reverse the ramp, which HSV-sorted endpoint colours could not express. + // range must reverse the ramp, which endpoint colours ordered by HSV cannot express. REQUIRE(int(rising.front().Blue()) > int(rising.front().Red())); REQUIRE(int(falling.front().Red()) > int(falling.front().Blue())); require_same_rgb(rising.front(), falling.back()); From 877180829c540729f17a804c1df9d3d446dd6184 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Sun, 23 Aug 2026 12:18:06 -0300 Subject: [PATCH 108/138] Unify colinear simplify tolerance in Arachne (#15314) Introduced a shared `colinear_vertex_tolerance()` helper in `ExtrusionLine.hpp` and updated both simplify paths (`ExtrusionLine.cpp` and `WallToolPaths.cpp`) to use it instead of duplicated hardcoded `0.005` scaled thresholds. This keeps the near-colinear early-out tied to `SCALED_EPSILON` (rounding-noise scale) and avoids unintended curve decimation from larger tolerances, while documenting the geometric impact in code. --- src/libslic3r/Arachne/WallToolPaths.cpp | 4 ++-- src/libslic3r/Arachne/utils/ExtrusionLine.cpp | 4 ++-- src/libslic3r/Arachne/utils/ExtrusionLine.hpp | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/libslic3r/Arachne/WallToolPaths.cpp b/src/libslic3r/Arachne/WallToolPaths.cpp index 0a59619560..724016bcb1 100644 --- a/src/libslic3r/Arachne/WallToolPaths.cpp +++ b/src/libslic3r/Arachne/WallToolPaths.cpp @@ -154,8 +154,8 @@ void simplify(Polygon &thiss, const int64_t smallest_line_segment_squared, const //h^2 = L^2 / b^2 [factor the divisor] const int64_t height_2 = double(area_removed_so_far) * double(area_removed_so_far) / double(base_length_2); // Orca: The value of `height_2` is squared, so we need to compare it with the squared value - if ((height_2 <= Slic3r::sqr(scaled(0.005)) //Almost exactly colinear (barring rounding errors). - && Line::distance_to_infinite(current, previous, next) <= scaled(0.005))) // make sure that height_2 is not small because of cancellation of positive and negative areas + if ((height_2 <= Slic3r::sqr(colinear_vertex_tolerance()) //Almost exactly colinear (barring rounding errors). + && Line::distance_to_infinite(current, previous, next) <= double(colinear_vertex_tolerance()))) // make sure that height_2 is not small because of cancellation of positive and negative areas continue; if (length2 < smallest_line_segment_squared diff --git a/src/libslic3r/Arachne/utils/ExtrusionLine.cpp b/src/libslic3r/Arachne/utils/ExtrusionLine.cpp index eebd5d5d1c..66bb707ebe 100644 --- a/src/libslic3r/Arachne/utils/ExtrusionLine.cpp +++ b/src/libslic3r/Arachne/utils/ExtrusionLine.cpp @@ -133,8 +133,8 @@ void ExtrusionLine::simplify(const int64_t smallest_line_segment_squared, const const auto height_2 = int64_t(double(area_removed_so_far) * double(area_removed_so_far) / double(base_length_2)); const int64_t extrusion_area_error = calculateExtrusionAreaDeviationError(previous, current, next); // Orca: The value of `height_2` is squared, so we need to compare it with the squared value - if ((height_2 <= Slic3r::sqr(scaled(0.005)) // Almost exactly colinear (barring rounding errors). - && Line::distance_to_infinite(current.p, previous.p, next.p) <= scaled(0.005)) // Make sure that height_2 is not small because of cancellation of positive and negative areas + if ((height_2 <= Slic3r::sqr(colinear_vertex_tolerance()) // Almost exactly colinear (barring rounding errors). + && Line::distance_to_infinite(current.p, previous.p, next.p) <= double(colinear_vertex_tolerance())) // Make sure that height_2 is not small because of cancellation of positive and negative areas // We shouldn't remove middle junctions of colinear segments if the area changed for the C-P segment is exceeding the maximum allowed && extrusion_area_error <= maximum_extrusion_area_deviation) { diff --git a/src/libslic3r/Arachne/utils/ExtrusionLine.hpp b/src/libslic3r/Arachne/utils/ExtrusionLine.hpp index 21791000f0..72e008cef1 100644 --- a/src/libslic3r/Arachne/utils/ExtrusionLine.hpp +++ b/src/libslic3r/Arachne/utils/ExtrusionLine.hpp @@ -32,6 +32,14 @@ class Flow; namespace Slic3r::Arachne { +// ORCA: Tolerance of the "almost exactly colinear" early-out shared by the two simplify() passes +// (this file and WallToolPaths.cpp). That test drops a vertex regardless of the user's Maximum wall +// resolution/deviation, so it has to stay at the scale of coordinate rounding noise. A larger value +// silently decimates finely tessellated curves: on a circle, one vertex may be removed whenever the +// sagitta of the resulting chord falls below the tolerance, which halves the point count and turns +// smooth arcs into corners the firmware has to decelerate through. +inline coord_t colinear_vertex_tolerance() { return coord_t(SCALED_EPSILON); } + /*! * Represents a polyline (not just a line) that is to be extruded with variable * line width. From ea376e858c809a745eec4d64e93276d295814e69 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Sun, 23 Aug 2026 12:18:22 -0300 Subject: [PATCH 109/138] Show move length in Previewr panel (#15326) Adds a new "Length" row to the sequential marker position popup in GCodeViewer and updates row capacity accordingly. For arc commands (G2/G3) that are split into multiple vertices, it now sums segment distances across vertices with the same gcode_id so the displayed value reflects the full move length instead of a single chord. --- src/slic3r/GUI/GCodeViewer.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index b882117aae..15085c3cc4 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -420,7 +420,7 @@ void GCodeViewer::SequentialView::Marker::render_position_window(const libvgcode if (properties_shown) { float label_w = 0.0f; float value_w = 0.0f; - properties_rows.reserve(13); + properties_rows.reserve(14); auto add_row = [&properties_rows, &label_w, &value_w](std::string label, std::string value) { label_w = std::max(label_w, ImGui::CalcTextSize(label.c_str()).x); value_w = std::max(value_w, ImGui::CalcTextSize(value.c_str()).x); @@ -433,6 +433,27 @@ void GCodeViewer::SequentialView::Marker::render_position_window(const libvgcode add_row(_u8L("Width"), buff); if (is_extrusion) sprintf(buff, ("%.3f " + _u8L("mm")).c_str(), vertex.height); else strcpy(buff, NA_CSTR); add_row(_u8L("Height"), buff); + // ORCA: Length of the move ending at the current vertex. Arc moves (G2/G3) are discretized + // into several vertices sharing the same gcode line id, so accumulate the whole run to report + // the arc length instead of the length of a single chord. + if (vertex_id > 0 && (is_extrusion || vertex.is_travel() || vertex.is_wipe())) { + const size_t vertices_count = viewer->get_vertices_count(); + size_t first_id = vertex_id; + while (first_id > 0 && viewer->get_vertex_at(first_id - 1).gcode_id == vertex.gcode_id) + --first_id; + size_t last_id = vertex_id; + while (last_id + 1 < vertices_count && viewer->get_vertex_at(last_id + 1).gcode_id == vertex.gcode_id) + ++last_id; + float length = 0.0f; + for (size_t i = std::max(first_id, 1); i <= last_id; ++i) { + length += (libvgcode::convert(viewer->get_vertex_at(i).position) - + libvgcode::convert(viewer->get_vertex_at(i - 1).position)).norm(); + } + sprintf(buff, ("%.3f " + _u8L("mm")).c_str(), length); + } + else + strcpy(buff, NA_CSTR); + add_row(_u8L("Length"), buff); sprintf(buff, "%d", vertex.layer_id + 1); add_row(_u8L("Layer"), buff); sprintf(buff, ("%.1f " + _u8L("mm/s")).c_str(), vertex.feedrate); From c72908e377c834a7dd35824729c81c8f07b5964f Mon Sep 17 00:00:00 2001 From: GlauTech <33813227+GlauTechCo@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:34:51 +0300 Subject: [PATCH 110/138] Update OrcaSlicer_tr.po (#15309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update OrcaSlicer_tr.po * Update OrcaSlicer_tr.po Fixed inaccurate AI-generated text and updated missing translations. * REmoive # AI Translated * Update OrcaSlicer_tr.po The following changes were made in this version: - The term "Instance" was changed to "Eş kopya". - The term "Jerk" was changed to "sarsıntı". - Semantic discrepancies regarding certain words were corrected. * Update OrcaSlicer_tr.po The necessary arrangements have been made. * Update OrcaSlicer_tr.po * Update OrcaSlicer_tr.po The necessary updates have been made. * G-kodu to G-code --------- Co-authored-by: Ian Bassi --- localization/i18n/tr/OrcaSlicer_tr.po | 406 ++++++++++++-------------- 1 file changed, 189 insertions(+), 217 deletions(-) diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index a31d2216f4..c3deff8bd8 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -4,7 +4,7 @@ msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-08-19 14:07-0300\n" -"PO-Revision-Date: 2026-08-04 19:36+0300\n" +"PO-Revision-Date: 2026-08-21 23:18+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" "Language: tr\n" @@ -14,27 +14,21 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n==1) ? 0 : 1;\n" "X-Generator: Poedit 3.9\n" -# AI Translated msgid "Main Extruder" msgstr "Ana Ekstruder" -# AI Translated msgid "Main extruder" msgstr "Ana ekstruder" -# AI Translated msgid "main extruder" msgstr "ana ekstruder" -# AI Translated msgid "Auxiliary Extruder" msgstr "Yardımcı Ekstruder" -# AI Translated msgid "Auxiliary extruder" msgstr "Yardımcı ekstruder" -# AI Translated msgid "auxiliary extruder" msgstr "yardımcı ekstruder" @@ -56,27 +50,21 @@ msgstr "Sağ ekstruder" msgid "right extruder" msgstr "sağ ekstruder" -# AI Translated msgid "Main Nozzle" msgstr "Ana Nozul" -# AI Translated msgid "Main nozzle" msgstr "Ana nozul" -# AI Translated msgid "main nozzle" msgstr "ana nozul" -# AI Translated msgid "Auxiliary Nozzle" msgstr "Yardımcı Nozul" -# AI Translated msgid "Auxiliary nozzle" msgstr "Yardımcı nozul" -# AI Translated msgid "auxiliary nozzle" msgstr "yardımcı nozul" @@ -106,59 +94,45 @@ msgstr "Ana Hotend" msgid "Main hotend" msgstr "Ana hotend" -# AI Translated msgid "main hotend" msgstr "ana hotend" -# AI Translated msgid "Auxiliary Hotend" msgstr "Yardımcı Hotend" -# AI Translated msgid "Auxiliary hotend" msgstr "Yardımcı hotend" -# AI Translated msgid "auxiliary hotend" msgstr "yardımcı hotend" -# AI Translated msgid "Left Hotend" msgstr "Sol Hotend" -# AI Translated msgid "Left hotend" msgstr "Sol hotend" -# AI Translated msgid "left hotend" msgstr "sol hotend" -# AI Translated msgid "Right Hotend" msgstr "Sağ Hotend" -# AI Translated msgid "Right hotend" msgstr "Sağ hotend" -# AI Translated msgid "right hotend" msgstr "sağ hotend" -# AI Translated msgid "main" msgstr "ana" -# AI Translated msgid "auxiliary" msgstr "yardımcı" -# AI Translated msgid "Main" msgstr "Ana" -# AI Translated msgid "Auxiliary" msgstr "Yardımcı" @@ -1237,7 +1211,7 @@ msgid "Text move" msgstr "Metin taşıma" msgid "Set Mirror" -msgstr "Aynayı Ayarla" +msgstr "Aynalamayı ayarla" msgid "Embossed text" msgstr "Kabartmalı metin" @@ -1784,10 +1758,10 @@ msgid "Lock/unlock rotation angle when dragging above the surface." msgstr "Yüzeyin üzerinde sürüklerken dönüş açısını kilitleyin/kilidini açın." msgid "Mirror vertically" -msgstr "Dikey olarak yansıt" +msgstr "Dikey aynala" msgid "Mirror horizontally" -msgstr "Yatay olarak yansıt" +msgstr "Yatay aynala" #. TRN: This is the name of the action that shows in undo/redo stack (changing part type from SVG to something else). msgid "Change SVG Type" @@ -1795,7 +1769,7 @@ msgstr "SVG Türünü Değiştir" #. TRN - Input label. Be short as possible msgid "Mirror" -msgstr "Ayna" +msgstr "Aynala" msgid "Choose SVG file for emboss:" msgstr "Kabartma için SVG dosyasını seçin:" @@ -2072,10 +2046,10 @@ msgid "3MF files" msgstr "3MF dosyaları" msgid "G-code 3MF files" -msgstr "Gcode 3MF dosyaları" +msgstr "G-code 3MF dosyaları" msgid "G-code files" -msgstr "G kodu dosyaları" +msgstr "G-code dosyaları" msgid "Supported files" msgstr "Desteklenen dosyalar" @@ -2569,7 +2543,7 @@ msgid "Ongoing uploads" msgstr "Devam eden yüklemeler" msgid "Select a G-code file:" -msgstr "G kodu dosyası seçin:" +msgstr "G-code dosyası seçin:" msgid "Could not start URL download. Destination folder is not set. Please choose destination folder in Configuration Wizard." msgstr "URL indirme işlemi başlatılamadı. Hedef klasör ayarlanmamış. Lütfen Yapılandırma Sihirbazı’nda hedef klasörü seçin." @@ -2663,7 +2637,7 @@ msgid "Add Negative Part" msgstr "Negatif parça ekle" msgid "Add Modifier" -msgstr "Değiştirici Ekle" +msgstr "Değiştirici ekle" msgid "Add Support Blocker" msgstr "Destek engelleyici ekle" @@ -2804,10 +2778,10 @@ msgid "Set as Individual Objects" msgstr "Bireysel nesneler olarak ayarla" msgid "Fill bed with copies" -msgstr "Tablayı kopyalarla doldur" +msgstr "Yatağı kopyalarla doldur" msgid "Fill the remaining area of bed with copies of the selected object" -msgstr "Yatağın kalan alanını seçilen nesnenin kopyalarıyla doldurun" +msgstr "Yatağın kalan alanını seçili nesnenin kopyalarıyla doldur" msgid "Printable" msgstr "Yazdırılabilir" @@ -2921,19 +2895,19 @@ msgid "Along X Axis" msgstr "X ekseni boyunca" msgid "Mirror along the X Axis" -msgstr "X ekseni boyunca aynalama" +msgstr "X ekseni boyunca aynala" msgid "Along Y Axis" msgstr "Y ekseni boyunca" msgid "Mirror along the Y Axis" -msgstr "Y ekseni boyunca aynalama" +msgstr "Y ekseni boyunca aynala" msgid "Along Z Axis" msgstr "Z ekseni boyunca" msgid "Mirror along the Z Axis" -msgstr "Z ekseni boyunca aynalama" +msgstr "Z ekseni boyunca aynala" msgid "Mirror object" msgstr "Nesneyi aynala" @@ -3041,28 +3015,28 @@ msgid "Remove the selected plate" msgstr "Seçilen plakayı kaldır" msgid "Add instance" -msgstr "Kopya ekle" +msgstr "Eş kopya ekle" msgid "Add one more instance of the selected object" -msgstr "Seçilen nesnenin bir örneğini daha ekle" +msgstr "Seçili nesneye bir eş kopya ekle" msgid "Remove instance" -msgstr "Kopyayı kaldır" +msgstr "Eş kopyayı kaldır" msgid "Remove one instance of the selected object" -msgstr "Seçilen nesnenin bir örneğini kaldır" +msgstr "Seçili nesnenin bir eş kopyasını kaldır" msgid "Set number of instances" -msgstr "Örnek sayısını ayarlayın" +msgstr "Eş kopya sayısını ayarla" msgid "Change the number of instances of the selected object" -msgstr "Seçilen nesnenin kopya sayısını değiştirme" +msgstr "Seçili nesnenin eş kopya sayısını değiştir" msgid "Fill bed with instances" -msgstr "Tablayı kopyalarla doldur" +msgstr "Yatağı eş kopyalarla doldur" msgid "Fill the remaining area of bed with instances of the selected object" -msgstr "Yatağın kalan alanını seçilen nesnenin örnekleriyle doldurun" +msgstr "Yatağın kalan alanını seçili nesnenin eş kopyalarıyla doldur" msgid "Clone" msgstr "Klon oluştur" @@ -3321,7 +3295,7 @@ msgid "Part manipulation" msgstr "Parça manipülasyonu" msgid "Instance manipulation" -msgstr "Örnek manipülasyonu" +msgstr "Eş kopya manipülasyonu" msgid "Height ranges" msgstr "Yükseklik aralıkları" @@ -3357,7 +3331,7 @@ msgstr "Parça tipini seçin" # AI Translated msgid "Instances to Separated Objects" -msgstr "Örnekleri Ayrı Nesnelere Dönüştür" +msgstr "Eş Kopyaları Ayrı Nesnelere Dönüştür" msgid "Enter new name" msgstr "Yeni adı girin" @@ -3501,13 +3475,13 @@ msgid "Custom Template:" msgstr "Özel Şablon:" msgid "Custom G-code:" -msgstr "Özel G kodu:" +msgstr "Özel G-code:" msgid "Custom G-code" -msgstr "Özel G kodu" +msgstr "Özel G-code" msgid "Enter Custom G-code used on current layer:" -msgstr "Geçerli katmanda kullanılan Özel G kodunu girin:" +msgstr "Geçerli katmanda kullanılan Özel G-code'u girin:" msgid "Jump to layer" msgstr "Katmana Atla" @@ -3522,16 +3496,16 @@ msgid "Insert a pause command at the beginning of this layer." msgstr "Bu katmanın başına bir duraklatma komutu ekleyin." msgid "Add Custom G-code" -msgstr "Özel G Kodu Ekle" +msgstr "Özel G-code Ekle" msgid "Insert custom G-code at the beginning of this layer." -msgstr "Bu katmanın başına özel G kodunu ekleyin." +msgstr "Bu katmanın başına özel G-code'u ekleyin." msgid "Add Custom Template" msgstr "Özel Şablon Ekle" msgid "Insert template custom G-code at the beginning of this layer." -msgstr "Bu katmanın başlangıcına şablon özel G kodunu ekleyin." +msgstr "Bu katmanın başlangıcına şablon özel G-code'u ekleyin." # AI Translated msgid "Filament " @@ -3547,10 +3521,10 @@ msgid "Delete Custom Template" msgstr "Özel Şablonu Sil" msgid "Edit Custom G-code" -msgstr "Özel G Kodunu Düzenle" +msgstr "Özel G-code'u Düzenle" msgid "Delete Custom G-code" -msgstr "Özel G Kodunu Sil" +msgstr "Özel G-code'u Sil" msgid "Delete Filament Change" msgstr "Filament Değişikliğini Sil" @@ -4065,10 +4039,10 @@ msgid "Encountered an unknown error with the Storage status. Please try again." msgstr "Depolama durumuyla ilgili bilinmeyen bir hatayla karşılaşıldı. Lütfen tekrar deneyin." msgid "Sending G-code file over LAN" -msgstr "LAN üzerinden gcode dosyası gönderiliyor" +msgstr "LAN üzerinden G-code dosyası gönderiliyor" msgid "Sending G-code file to SD card" -msgstr "Gcode dosyası sdcard'a gönderiliyor" +msgstr "G-code dosyası sdcard'a gönderiliyor" #, c-format, boost-format msgid "Successfully sent. Close current page in %s s" @@ -4078,7 +4052,7 @@ msgid "Storage needs to be inserted before sending to printer." msgstr "Yazıcıya göndermeden önce depolama biriminin eklenmesi gerekir." msgid "Sending G-code file over LAN, but the Storage in the printer is abnormal and print-issues may be caused by this." -msgstr "G kodu dosyası LAN üzerinden gönderiliyor ancak yazıcıdaki Depolama anormal ve yazdırma sorunları bundan kaynaklanabilir." +msgstr "G-code dosyası LAN üzerinden gönderiliyor ancak yazıcıdaki Depolama anormal ve yazdırma sorunları bundan kaynaklanabilir." msgid "The Storage in the printer is abnormal. Please replace it with a normal Storage before sending to printer." msgstr "Yazıcıdaki Depolama anormal. Lütfen yazıcıya göndermeden önce normal bir Depolama ile değiştirin." @@ -4618,7 +4592,7 @@ msgid "Please save your project and restart the application." msgstr "Lütfen projeyi kaydedin ve programı yeniden başlatın." msgid "Processing G-Code from previous file…" -msgstr "Önceki dosyadan G-Kodu işleniyor…" +msgstr "Önceki dosyadan G-code işleniyor…" msgid "Slicing complete" msgstr "Dilimleme tamamlandı" @@ -4651,35 +4625,35 @@ msgid "Successfully executed post-processing script" msgstr "İşlem sonrası komut dosyası başarıyla çalıştırıldı" msgid "Unknown error occurred during exporting G-code." -msgstr "G kodu dışa aktarılırken bilinmeyen bir hata oluştu." +msgstr "G-code dışa aktarılırken bilinmeyen bir hata oluştu." #, boost-format msgid "" "Copying of the temporary G-code to the output G-code failed. Maybe the SD card is write locked?\n" "Error message: %1%" msgstr "" -"Geçici G kodunun çıkış G koduna kopyalanması başarısız oldu. Belki SD kart yazma kilitlidir.\n" +"Geçici G-code'un çıkış G-code'a kopyalanması başarısız oldu. Belki SD kart yazma kilitlidir.\n" "Hata mesajı: %1%" #, boost-format msgid "Copying of the temporary G-code to the output G-code failed. There might be problem with target device, please try exporting again or using different device. The corrupted output G-code is at %1%.tmp." -msgstr "Geçici G kodunun çıkış G koduna kopyalanması başarısız oldu. Hedef cihazda sorun olabilir, lütfen tekrar dışa aktarmayı veya farklı bir cihaz kullanmayı deneyin. Bozuk çıktı G kodu %1%.tmp konumunda." +msgstr "Geçici G-code'un çıkış G-code'a kopyalanması başarısız oldu. Hedef cihazda sorun olabilir, lütfen tekrar dışa aktarmayı veya farklı bir cihaz kullanmayı deneyin. Bozuk çıktı G-code %1%.tmp konumunda." #, boost-format msgid "Renaming of the G-code after copying to the selected destination folder has failed. Current path is %1%.tmp. Please try exporting again." -msgstr "Seçilen hedef klasöre kopyalandıktan sonra G kodunun yeniden adlandırılması başarısız oldu. Geçerli yol: %1%.tmp. Lütfen dışa aktarmayı tekrar deneyin." +msgstr "Seçilen hedef klasöre kopyalandıktan sonra G-code'un yeniden adlandırılması başarısız oldu. Geçerli yol: %1%.tmp. Lütfen dışa aktarmayı tekrar deneyin." #, boost-format msgid "Copying of the temporary G-code has finished but the original code at %1% couldn't be opened during copy check. The output G-code is at %2%.tmp." -msgstr "Geçici G kodunun kopyalanması tamamlandı ancak %1% konumundaki orijinal kod kopyalama kontrolü sırasında açılamadı. Çıkış G kodu %2%.tmp konumundadır." +msgstr "Geçici G-code'un kopyalanması tamamlandı ancak %1% konumundaki orijinal kod kopyalama kontrolü sırasında açılamadı. Çıkış G-code %2%.tmp konumundadır." #, boost-format msgid "Copying of the temporary G-code has finished but the exported code couldn't be opened during copy check. The output G-code is at %1%.tmp." -msgstr "Geçici G kodunun kopyalanması tamamlandı ancak kopya kontrolü sırasında dışa aktarılan kod açılamadı. Çıkış G kodu %1%.tmp konumundadır." +msgstr "Geçici G-code'un kopyalanması tamamlandı ancak kopya kontrolü sırasında dışa aktarılan kod açılamadı. Çıkış G-code %1%.tmp konumundadır." #, boost-format msgid "G-code file exported to %1%" -msgstr "G kodu dosyası %1%’e aktarıldı" +msgstr "G-code dosyası %1%’e aktarıldı" msgid "Unknown error with G-code export" msgstr "G-code dışa aktarımında bilinmeyen hata" @@ -4690,12 +4664,12 @@ msgid "" "Error message: %1%.\n" "Source file %2%." msgstr "" -"Gcode dosyası kaydedilemedi.\n" +"G-code dosyası kaydedilemedi.\n" "Hata mesajı: %1%.\n" "Kaynak dosya %2%." msgid "Copying of the temporary G-code to the output G-code failed." -msgstr "Geçici G-kodu dosyasının çıktı G-kodu dosyasına kopyalanması başarısız oldu." +msgstr "Geçici G-code dosyasının çıktı G-code dosyasına kopyalanması başarısız oldu." #, boost-format msgid "Scheduling upload to `%1%`. See Window -> Print Host Upload Queue" @@ -4708,7 +4682,7 @@ msgid "Size in X and Y of the rectangular plate." msgstr "Dikdörtgen plakanın X ve Y boyutları." msgid "Distance of the 0,0 G-code coordinate from the front left corner of the rectangle." -msgstr "0,0 G kodu koordinatının dikdörtgenin sol ön köşesinden uzaklığı." +msgstr "0,0 G-code koordinatının dikdörtgenin sol ön köşesinden uzaklığı." msgid "Diameter of the print bed. It is assumed that origin (0,0) is located in the center." msgstr "Baskı yatağının çapı. Orjinin (0,0) merkezde olduğu varsayılmaktadır." @@ -5058,7 +5032,7 @@ msgid "Cooling chamber" msgstr "Soğutma haznesi" msgid "Pause (G-code inserted by user)" -msgstr "Duraklat (Kullanıcı tarafından eklenen G kodu)" +msgstr "Duraklat (Kullanıcı tarafından eklenen G-code)" msgid "Motor noise showoff" msgstr "Motor gürültü gösterimi" @@ -5306,16 +5280,16 @@ msgstr "varsayılan" #, boost-format msgid "Edit Custom G-code (%1%)" -msgstr "Özel G Kodunu Düzenle (%1%)" +msgstr "Özel G-code'u Düzenle (%1%)" msgid "Built-in placeholders (Double click item to add to G-code)" -msgstr "Yerleşik yer tutucular (G koduna eklemek için öğeye çift tıklayın)" +msgstr "Yerleşik yer tutucular (G-code'a eklemek için öğeye çift tıklayın)" msgid "Search G-code placeholders" -msgstr "Gcode yer tutucularını arayın" +msgstr "G-code yer tutucularını arayın" msgid "Add selected placeholder to G-code" -msgstr "Seçili yer tutucuyu G koduna ekle" +msgstr "Seçili yer tutucuyu G-code'a ekle" msgid "Select placeholder" msgstr "Yer tutucuyu seçin" @@ -6079,16 +6053,16 @@ msgstr "Boyut:" #, boost-format msgid "Conflicts of G-code paths have been found at layer %d, Z = %.2lfmm. Please separate the conflicted objects farther (%s <-> %s)." -msgstr "%d katmanında gcode yollarında çakışmalar bulundu, Z = %.2lfmm. Lütfen çakışan nesneleri daha uzağa ayırın (%s <-> %s)." +msgstr "%d katmanında G-code yollarında çakışmalar bulundu, Z = %.2lfmm. Lütfen çakışan nesneleri daha uzağa ayırın (%s <-> %s)." msgid "An object is laid over the plate boundaries." msgstr "Plakanın sınırına bir nesne serilir." msgid "A G-code path goes beyond the max print height." -msgstr "Bir G kodu yolu maksimum baskı yüksekliğinin ötesine geçer." +msgstr "Bir G-code yolu maksimum baskı yüksekliğinin ötesine geçer." msgid "A G-code path goes beyond plate boundaries." -msgstr "Bir G kodu yolu plakanın sınırlarının ötesine geçer." +msgstr "Bir G-code yolu plakanın sınırlarının ötesine geçer." msgid "Not support printing 2 or more TPU filaments." msgstr "2 veya daha fazla TPU filamentinin yazdırılmasını desteklemez." @@ -6099,19 +6073,19 @@ msgstr "Araç %d" #, c-format, boost-format msgid "Filament %s is placed in the %s, but the generated G-code path exceeds the printable range of the %s." -msgstr "%s filamenti %s içine yerleştirildi, ancak oluşturulan G kodu yolu %s'nin yazdırılabilir aralığını aşıyor." +msgstr "%s filamenti %s içine yerleştirildi, ancak oluşturulan G-code yolu %s'nin yazdırılabilir aralığını aşıyor." #, c-format, boost-format msgid "Filaments %s are placed in the %s, but the generated G-code path exceeds the printable range of the %s." -msgstr "%s filamentleri %s içine yerleştirildi, ancak oluşturulan G kodu yolu %s'nin yazdırılabilir aralığını aşıyor." +msgstr "%s filamentleri %s içine yerleştirildi, ancak oluşturulan G-code yolu %s'nin yazdırılabilir aralığını aşıyor." #, c-format, boost-format msgid "Filament %s is placed in the %s, but the generated G-code path exceeds the printable height of the %s." -msgstr "%s filamenti %s'e yerleştirildi, ancak oluşturulan G kodu yolu %s'nin yazdırılabilir yüksekliğini aşıyor." +msgstr "%s filamenti %s'e yerleştirildi, ancak oluşturulan G-code yolu %s'nin yazdırılabilir yüksekliğini aşıyor." #, c-format, boost-format msgid "Filaments %s are placed in the %s, but the generated G-code path exceeds the printable height of the %s." -msgstr "%s filamentleri %s'e yerleştirildi, ancak oluşturulan G kodu yolu %s'nin yazdırılabilir yüksekliğini aşıyor." +msgstr "%s filamentleri %s'e yerleştirildi, ancak oluşturulan G-code yolu %s'nin yazdırılabilir yüksekliğini aşıyor." msgid "Open wiki for more information." msgstr "Daha fazla bilgi için wiki'yi açın." @@ -6279,7 +6253,7 @@ msgid "Print plate" msgstr "Plakayı Yazdır" msgid "Export G-code file" -msgstr "G-kod dosyasını dışa aktar" +msgstr "G-code dosyasını dışa aktar" msgctxt "Verb" msgid "Print" @@ -6436,10 +6410,10 @@ msgid "Export all plate sliced file" msgstr "Dilimlenmiş tüm plaka dosyalarını dışa aktar" msgid "Export G-code" -msgstr "G-kodunu dışa aktar" +msgstr "G-code'u dışa aktar" msgid "Export current plate as G-code" -msgstr "Geçerli plakayı G kodu olarak dışa aktar" +msgstr "Geçerli plakayı G-code olarak dışa aktar" msgid "Export toolpaths as OBJ" msgstr "Takımyollarını OBJ olarak dışa aktar" @@ -6523,7 +6497,7 @@ msgid "Show &G-code Window" msgstr "&G-code Penceresini Göster" msgid "Show G-code window in Preview scene." -msgstr "Previce sahnesinde G-kodu penceresini göster." +msgstr "Previce sahnesinde G-code penceresini göster." msgid "Show 3D Navigator" msgstr "3D gezgini göster" @@ -6633,10 +6607,10 @@ msgid "Calibration Guide" msgstr "Kalibrasyon kılavuzu" msgid "&Open G-code" -msgstr "&G kodunu aç" +msgstr "&G-code'u aç" msgid "Open a G-code file" -msgstr "G kodu dosyası aç" +msgstr "G-code dosyası aç" msgid "Re&load from Disk" msgstr "Diskten yeniden yükle" @@ -6927,7 +6901,7 @@ msgid "Failed to parse model information." msgstr "Model bilgileri ayrıştırılamadı." msgid "The .gcode.3mf file contains no G-code data. Please slice it with Orca Slicer and export a new .gcode.3mf file." -msgstr ".gcode.3mf dosyası hiçbir G kodu verisi içermiyor. Lütfen dosyayı Bambu Studio ile dilimleyin ve yeni bir .gcode.3mf dosyasını dışa aktarın." +msgstr ".gcode.3mf dosyası hiçbir G-code verisi içermiyor. Lütfen dosyayı Bambu Studio ile dilimleyin ve yeni bir .gcode.3mf dosyasını dışa aktarın." #, c-format, boost-format msgid "File '%s' was lost! Please download it again." @@ -7629,7 +7603,7 @@ msgid "Your model needs support! Please enable support material." msgstr "Modelinizin desteğe ihtiyacı var! Lütfen destek materyalini etkinleştirin." msgid "G-code path overlap" -msgstr "Gcode yolu çakışması" +msgstr "G-code yolu çakışması" msgid "Cut connectors" msgstr "Konektörleri kes" @@ -8180,19 +8154,19 @@ msgid "Please correct them in the Param tabs" msgstr "Lütfen bunları parametre sekmelerinde düzeltin" msgid "The 3MF has the following modified G-code in filament or printer presets:" -msgstr "3mf dosyasında filament veya yazıcı ön ayarlarında şu değiştirilmiş G-kodları bulunmaktadır:" +msgstr "3mf dosyasında filament veya yazıcı ön ayarlarında şu değiştirilmiş G-code'ları bulunmaktadır:" msgid "Please confirm that all modified G-code is safe to prevent any damage to the machine!" -msgstr "Lütfen bu değiştirilmiş G-kodlarının makineye herhangi bir zarar vermemesi için güvenli olduğunu onaylayın!" +msgstr "Lütfen bu değiştirilmiş G-code'larının makineye herhangi bir zarar vermemesi için güvenli olduğunu onaylayın!" msgid "Modified G-code" -msgstr "G-kodları Değişti" +msgstr "G-code'ları Değişti" msgid "The 3MF has the following customized filament or printer presets:" msgstr "3mf dosyasında şu özel filament veya yazıcı ayarları bulunmaktadır:" msgid "Please confirm that the G-code within these presets is safe to prevent any damage to the machine!" -msgstr "Lütfen bu ön ayarlar içindeki G-kodlarının makineye herhangi bir zararı önlemek için güvenli olduğunu onaylayın!" +msgstr "Lütfen bu ön ayarlar içindeki G-code'larının makineye herhangi bir zararı önlemek için güvenli olduğunu onaylayın!" msgid "Customized Preset" msgstr "Özel Ayar" @@ -8437,7 +8411,7 @@ msgid "" "The loaded file contains G-code only, cannot enter the Prepare page." msgstr "" "Yalnızca önizleme modu:\n" -"Yüklenen dosya yalnızca Gcode içeriyor, hazırlama sayfasına girilemiyor." +"Yüklenen dosya yalnızca G-code içeriyor, hazırlama sayfasına girilemiyor." msgid "" "The nozzle type and AMS quantity information has not been synced from the connected printer.\n" @@ -8508,7 +8482,7 @@ msgid "The selected file" msgstr "Seçili dosya" msgid "Does not contain valid G-code." -msgstr "Geçerli bir G-kodu içermiyor." +msgstr "Geçerli bir G-code içermiyor." msgid "An Error has occurred while loading the G-code file." msgstr "G-code dosyası yüklenirken bir hata oluştu." @@ -8540,13 +8514,13 @@ msgid "Import geometry only" msgstr "Yalnızca geometriyi içe aktar" msgid "Only one G-code file can be opened at a time." -msgstr "Aynı anda yalnızca bir G kodu dosyası açılabilir." +msgstr "Aynı anda yalnızca bir G-code dosyası açılabilir." msgid "G-code loading" -msgstr "G-kod yükleniyor" +msgstr "G-code yükleniyor" msgid "G-code files and models cannot be loaded together!" -msgstr "G kodu dosyaları modellerle birlikte yüklenemez!" +msgstr "G-code dosyaları modellerle birlikte yüklenemez!" msgid "Unable to add models in preview mode" msgstr "Önizleme modundayken model ekleyemezsiniz" @@ -8564,7 +8538,7 @@ msgid "Copies of the selected object" msgstr "Seçilen nesnenin kopyaları" msgid "Save G-code file as:" -msgstr "G-kod dosyasını şu şekilde kaydedin:" +msgstr "G-code dosyasını şu şekilde kaydedin:" msgid "Save SLA file as:" msgstr "SLA dosyasını farklı bir isimle kaydet:" @@ -8635,7 +8609,7 @@ msgstr "" "Yazdırma sırasında çarpışmaları önlemek için otomatik düzenlemeyi kullanmanızı önerin." msgid "Send G-code" -msgstr "G-kodu gönder" +msgstr "G-code gönder" msgid "Send to printer" msgstr "Yazıcıya gönder" @@ -8894,10 +8868,10 @@ msgid "Current Association: " msgstr "Mevcut Bağlantı: " msgid "Current Instance" -msgstr "Mevcut Kopya" +msgstr "Mevcut Örnek" msgid "Current Instance Path: " -msgstr "Mevcut Kopya Yolu: " +msgstr "Mevcut Örnek Yolu: " msgid "General" msgstr "Genel" @@ -8924,13 +8898,13 @@ msgid "Enable dark Mode" msgstr "Karanlık modu etkinleştir" msgid "Allow only one OrcaSlicer instance" -msgstr "Yalnızca bir orca slicer örneğine izin ver" +msgstr "Yalnızca tek bir OrcaSlicer örneğine izin ver" msgid "On OSX there is always only one instance of app running by default. However it is allowed to run multiple instances of same app from the command line. In such case this settings will allow only one instance." -msgstr "OSX’te her zaman varsayılan olarak çalışan tek bir uygulama örneği vardır. Ancak aynı uygulamanın birden fazla örneğinin komut satırından çalıştırılmasına izin verilir. Böyle bir durumda bu ayarlar yalnızca bir örneğe izin verecektir." +msgstr "macOS'ta varsayılan olarak her zaman uygulamanın yalnızca tek bir örneği çalışır. Ancak komut satırından aynı uygulamanın birden fazla örneğinin çalıştırılmasına izin verilir. Böyle bir durumda bu ayar, yalnızca tek bir örneğe izin verecektir." msgid "If this is enabled, when starting OrcaSlicer and another instance of the same OrcaSlicer is already running, that instance will be reactivated instead." -msgstr "Bu etkinleştirilirse, OrcaSlicer başlatıldığında ve aynı OrcaSlicer’ın başka bir örneği zaten çalışıyorken, bunun yerine bu örnek yeniden etkinleştirilecektir." +msgstr "Bu seçenek etkinleştirildiğinde; OrcaSlicer başlatılırken aynı OrcaSlicer'ın başka bir örneği zaten çalışıyorsa, yeni bir pencere yerine o örnek yeniden etkinleştirilir." msgid "Show splash screen" msgstr "Açılış ekranını göster" @@ -8985,7 +8959,7 @@ msgid "Add STL/STEP files to recent files list" msgstr "STL/STEP dosyalarını son dosyalar listesine ekle" msgid "Don't warn when loading 3MF with modified G-code" -msgstr "Değiştirilmiş G-kodları içeren 3MF dosyalarını yüklerken uyarma" +msgstr "Değiştirilmiş G-code'ları içeren 3MF dosyalarını yüklerken uyarma" msgid "Show options when importing STEP file" msgstr "STEP dosyasını içe aktarırken seçenekleri göster" @@ -10033,7 +10007,7 @@ msgid "The filament type setting of external spool is different from the filamen msgstr "Harici makaranın filament türü ayarı, dilimleme dosyasındaki filamentden farklıdır." msgid "The printer type selected when generating G-code is not consistent with the currently selected printer. It is recommended that you use the same printer type for slicing." -msgstr "G Kodu oluşturulurken seçilen yazıcı türü mevcut seçili yazıcıyla tutarlı değil. Dilimleme için aynı yazıcı tipini kullanmanız tavsiye edilir." +msgstr "G-code oluşturulurken seçilen yazıcı türü mevcut seçili yazıcıyla tutarlı değil. Dilimleme için aynı yazıcı tipini kullanmanız tavsiye edilir." msgid "There are some unknown filaments in the AMS mappings. Please check whether they are the required filaments. If they are okay, click \"Confirm\" to start printing." msgstr "AMS eşlemelerinde bazı bilinmeyen filamentler var. Lütfen bunların gerekli filamentler olup olmadığını kontrol edin. Sorun yoksa, yazdırmayı başlatmak için \"Onayla\"ya basın." @@ -10717,10 +10691,10 @@ msgid "Special mode" msgstr "Özel Mod" msgid "G-code output" -msgstr "G Kodu Çıktısı" +msgstr "G-code Çıktısı" msgid "Change extrusion role G-code" -msgstr "Ekstrüzyon Rolü G-kodu Değiştirme" +msgstr "Ekstrüzyon Rolü G-code Değiştirme" msgid "Post-processing Scripts" msgstr "İşlem Sonrası Komut Dosyaları" @@ -10748,10 +10722,10 @@ msgid_plural "" "Please remove them, or G-code visualization and print time estimation will be broken." msgstr[0] "" "Aşağıdaki %s satırı ayrılmış anahtar kelimeler içeriyor.\n" -"Lütfen onu kaldırın, aksi takdirde G kodu görselleştirmesini ve yazdırma süresi tahminini geçeceksiniz." +"Lütfen onu kaldırın, aksi takdirde G-code görselleştirmesini ve yazdırma süresi tahminini geçeceksiniz." msgstr[1] "" "Aşağıdaki satırlar %s ayrılmış anahtar sözcükler içeriyor.\n" -"Lütfen bunları kaldırın, aksi takdirde G kodu görselleştirmesini ve yazdırma süresi tahminini geçeceksiniz." +"Lütfen bunları kaldırın, aksi takdirde G-code görselleştirmesini ve yazdırma süresi tahminini geçeceksiniz." msgid "Reserved keywords found" msgstr "Ayrılmış anahtar kelimeler bulundu" @@ -10863,10 +10837,10 @@ msgid "Complete print" msgstr "Baskı tamamlanınca" msgid "Filament start G-code" -msgstr "Filament Başlangıç G Kodu" +msgstr "Filament Başlangıç G-code" msgid "Filament end G-code" -msgstr "Filament Bitiş G Kodu" +msgstr "Filament Bitiş G-code" msgid "Wipe tower parameters" msgstr "Silme Kulesi Parametreleri" @@ -10907,7 +10881,7 @@ msgid "Invalid value provided for parameter %1%: %2%" msgstr "%1% parametresi için geçersiz değer sağlandı: %2%" msgid "G-code flavor is switched" -msgstr "G-kod çeşidi değiştirildi" +msgstr "G-code çeşidi değiştirildi" msgid "Cooling Fan" msgstr "Soğutucu Fan" @@ -10925,40 +10899,40 @@ msgid "Accessory" msgstr "Aksesuar" msgid "Machine G-code" -msgstr "Yazıcı G-kod" +msgstr "Yazıcı G-code" msgid "File header G-code" -msgstr "Dosya başlığı G kodu" +msgstr "Dosya başlığı G-code" msgid "Machine start G-code" -msgstr "Yazıcı Başlangıç G-kod" +msgstr "Yazıcı Başlangıç G-code" msgid "Machine end G-code" -msgstr "Yazıcı Bitiş G-kod" +msgstr "Yazıcı Bitiş G-code" msgid "Printing by object G-code" -msgstr "Nesneye Göre Yazdırma G-kod" +msgstr "Nesneye Göre Yazdırma G-code" msgid "Before layer change G-code" -msgstr "Katman Değişimi Öncesi G-kod" +msgstr "Katman Değişimi Öncesi G-code" msgid "Layer change G-code" -msgstr "Katman Değişimi G-kod" +msgstr "Katman Değişimi G-code" msgid "Timelapse G-code" -msgstr "Timelapse G-kod" +msgstr "Timelapse G-code" msgid "Clumping Detection G-code" -msgstr "Topaklanma Tespiti G Kodu" +msgstr "Topaklanma Tespiti G-code" msgid "Change filament G-code" -msgstr "Filament Değişimi G-kod" +msgstr "Filament Değişimi G-code" msgid "Pause G-code" -msgstr "Duraklatma G-Kod" +msgstr "Duraklatma G-code" msgid "Template Custom G-code" -msgstr "Şablon Özel G-kod" +msgstr "Şablon Özel G-code" msgid "Motion ability" msgstr "Hareket" @@ -11974,7 +11948,7 @@ msgid "On/Off one layer mode of the vertical slider" msgstr "Dikey kaydırıcının tek katman modunu açma/kapama" msgid "On/Off G-code window" -msgstr "G-kodu penceresini aç/kapat" +msgstr "G-code penceresini aç/kapat" msgid "Move slider 5x faster" msgstr "Kaydırıcıyı 5 kat daha hızlı hareket ettirin" @@ -12208,7 +12182,7 @@ msgid " updated to " msgstr " güncellendi " msgid "Open G-code file:" -msgstr "G kodu dosyasını açın:" +msgstr "G-code dosyasını açın:" msgid "One object has an empty first layer and can't be printed. Please Cut the bottom or enable supports." msgstr "Bir nesnenin ilk katmanı boş ve yazdırılamıyor. Lütfen alt kısmı kesin veya destekleri etkinleştirin." @@ -12242,15 +12216,15 @@ msgid "" "Failed to generate G-code for invalid custom G-code.\n" "\n" msgstr "" -"Geçersiz özel G kodu için gcode oluşturulamadı.\n" +"Geçersiz özel G-code için G-code oluşturulamadı.\n" "\n" msgid "Please check the custom G-code or use the default custom G-code." -msgstr "Lütfen özel G kodunu kontrol edin veya varsayılan özel G kodunu kullanın." +msgstr "Lütfen özel G-code'u kontrol edin veya varsayılan özel G-code'u kullanın." #, boost-format msgid "Generating G-code: layer %1%" -msgstr "G kodu oluşturuluyor: katman %1%" +msgstr "G-code oluşturuluyor: katman %1%" msgid "Flush volumes matrix do not match to the correct size!" msgstr "Yıkama hacimleri matrisi doğru boyutla eşleşmiyor!" @@ -12473,7 +12447,7 @@ msgid "Ooze prevention is only supported with the wipe tower when 'single_extrud msgstr "Sızıntı önleme yalnızca ‘tek ekstruder çoklu malzeme’ kapalıyken silme kulesiyle desteklenir." msgid "The prime tower is currently only supported for the Marlin, RepRap/Sprinter, RepRapFirmware and Repetier G-code flavors." -msgstr "Prime tower şu anda yalnızca Marlin, RepRap/Sprinter, RepRapFirmware ve Repetier G kodu türleri için desteklenmektedir." +msgstr "Prime tower şu anda yalnızca Marlin, RepRap/Sprinter, RepRapFirmware ve Repetier G-code türleri için desteklenmektedir." msgid "A prime tower is not supported in “By object” print." msgstr "Prime tower, \"Nesneye göre\" yazdırmada desteklenmez." @@ -12628,10 +12602,10 @@ msgstr "" "Nesneleri birbirinden uzaklaştırın, kenar/etek boyutunu küçültün, Etek tipini Birleşik olarak değiştirin veya Yazdırma sırasını Katmana göre olarak değiştirin." msgid "Exporting G-code" -msgstr "G kodu dışa aktarılıyor" +msgstr "G-code dışa aktarılıyor" msgid "Generating G-code" -msgstr "G kodu oluşturuluyor" +msgstr "G-code oluşturuluyor" # AI Translated msgid "Processing of the filename_format template failed." @@ -12754,7 +12728,7 @@ msgid "Hostname, IP or URL" msgstr "Ana bilgisayar adı, IP veya URL" msgid "Orca Slicer can upload G-code files to a printer host. This field should contain the hostname, IP address or URL of the printer host instance. Print host behind HAProxy with basic auth enabled can be accessed by putting the user name and password into the URL in the following format: https://username:password@your-octopi-address/" -msgstr "Orca Slicer, G kodu dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan, yazıcı ana bilgisayar örneğinin ana bilgisayar adını, IP adresini veya URL'sini içermelidir. Temel kimlik doğrulamanın etkin olduğu HAProxy'nin arkasındaki yazdırma ana bilgisayarına, kullanıcı adı ve parolanın aşağıdaki biçimdeki URL'ye girilmesiyle erişilebilir: https://username:password@your-octopi-address/" +msgstr "OrcaSlicer, G-code dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan; yazıcı ana bilgisayarı örneğinin ana bilgisayar adını, IP adresini veya URL'sini içermelidir. Temel kimlik doğrulaması etkin ve HAProxy arkasında çalışan yazıcı ana bilgisayarlarına URL içine kullanıcı adı ve parola şu biçimde eklenerek erişilebilir: https://kullaniciadi:parola@octopi-adresiniz/" msgid "Device UI" msgstr "Cihaz kullanıcı arayüzü" @@ -12766,7 +12740,7 @@ msgid "API Key / Password" msgstr "API Anahtarı / Şifre" msgid "Orca Slicer can upload G-code files to a printer host. This field should contain the API Key or the password required for authentication." -msgstr "Orca Slicer, G kodu dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan, kimlik doğrulama için gereken API Anahtarını veya şifreyi içermelidir." +msgstr "Orca Slicer, G-code dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan, kimlik doğrulama için gereken API Anahtarını veya şifreyi içermelidir." # AI Translated msgid "Serial Number" @@ -12895,7 +12869,7 @@ msgid "Other layers filament sequence" msgstr "Diğer katmanlar filament dizisi" msgid "This G-code is inserted at every layer change before the Z lift." -msgstr "Bu G kodu, z'yi kaldırmadan önce her katman değişikliğinde eklenir." +msgstr "Bu G-code, z'yi kaldırmadan önce her katman değişikliğinde eklenir." msgid "Bottom shell layers" msgstr "Alt katmanlar" @@ -13545,7 +13519,6 @@ msgstr "Nesneye göre" msgid "Intra-layer order" msgstr "Katman içi sıra" -# AI Translated msgid "" "Order in which object instances are visited within a single layer, which controls how much travel is spent moving between them.\n" "\n" @@ -13556,14 +13529,17 @@ msgid "" "\n" "With multiple filaments or tools in the same layer, minimizing tool changes takes priority: objects are grouped by filament first and this setting only orders the instances within each filament group, so the overall sequence may not look like the shortest path across the plate." msgstr "" -"Tek bir katman içinde nesne örneklerinin hangi sırayla ziyaret edileceği; bu da aralarında ne kadar seyahat harcanacağını belirler.\n" +"Tek bir katman içinde nesne eş kopyalarının (instances) basılma sırasıdır; bunlar arasındaki seyahat mesafesini ve süresini kontrol eder.\n" "\n" -"Varsayılan: en yakın komşu zincirlemesi, 2-opt ve kesişim giderme ile iyileştirilir. İyi bir genel seçim.\n" -"Nesne listesi olarak: örnekler, herhangi bir yol optimizasyonu olmadan nesne listesindeki sırayla yazdırılır. Öngörülebilir, elle denetlenen bir sıraya ihtiyacınız olduğunda kullanın.\n" -"Hepsinin en iyisi (en kısa yol): her strateji değerlendirilir ve en kısa olanı kullanılır. Nesne örneklerinin sırası tüm baskı için bir kez belirlenir, tek tek adaların sırası ise her katman için ayrı belirlenir; bu nedenle farklı katmanlar farklı stratejiler kullanabilir. Dilimleme biraz daha yavaştır.\n" -"Yılankavi: satır satır ilerleyen yılankavi geçiş, 2-opt ile iyileştirilir. Çok sayıda küçük parçadan oluşan düzenli ızgaralar için çok uygundur.\n" +"Varsayılan (Default): 2-opt algoritması ve hat kesişimi giderme ile iyileştirilmiş en yakın komşu zincirleme yöntemi. Genel kullanım için dengeli ve ideal bir tercihtir.\n" "\n" -"Aynı katmanda birden fazla filament veya araç varsa, araç değişimlerini en aza indirmek önceliklidir: nesneler önce filamente göre gruplanır ve bu ayar yalnızca her filament grubu içindeki örnekleri sıralar; bu nedenle genel sıra, tabla genelindeki en kısa yol gibi görünmeyebilir." +"Nesne listesi olarak (As object list): Eş kopyalar (instances), herhangi bir rota optimizasyonu yapılmadan doğrudan nesne listesindeki sıralamayla basılır. Manuel ve öngörülebilir bir sıra istendiğinde kullanılır.\n" +"\n" +"Hepsinin en iyisi (en kısa yol): Mevcut tüm stratejiler hesaplanır ve en kısa mesafe sunan rota seçilir. Nesne eş kopyalarının sırası tüm baskı için tek seferde kararlaştırılırken, bağımsız adacıkların sıralaması katman bazında hesaplanır (farklı katmanlarda farklı stratejiler devreye girebilir). Dilimleme süresini biraz uzatabilir.\n" +"\n" +"Yılankavi (Snake): 2-opt ile optimize edilmiş satır satır kıvrımlı (serpantin) tarama rotası. Yatağa ızgara şeklinde dizilmiş çok sayıda küçük parçalı baskılar için son derece uygundur.\n" +"\n" +"Aynı katmanda birden fazla filament veya nozül/takım kullanıldığında, takım değişimlerini en aza indirmek önceliklidir: Nesneler önce filamente göre gruplanır; bu ayar ise sadece ilgili filament grubu içindeki eş kopyaları (instances) sıralar. Bu nedenle genel hareket sırası plakanın tamamına bakıldığında her zaman en kısa rota gibi görünmeyebilir." msgid "As object list" msgstr "Nesne listesi olarak" @@ -13626,7 +13602,7 @@ msgid "Activate air filtration" msgstr "Hava filtrelemesini etkinleştirin" msgid "Activate for better air filtration. G-code command: M106 P3 S(0-255)" -msgstr "Daha iyi hava filtrasyonu için etkinleştirin. G-kodu komutu: M106 P3 S(0-255)" +msgstr "Daha iyi hava filtrasyonu için etkinleştirin. G-code komutu: M106 P3 S(0-255)" # AI Translated msgid "Enable this to override the fan speed set in custom G-code during print." @@ -13641,7 +13617,7 @@ msgid "Enable this to override the fan speed set in custom G-code after print co msgstr "Baskı tamamlandıktan sonra özel G-code'da ayarlanan fan hızını geçersiz kılmak için bunu etkinleştirin." msgid "Speed of exhaust fan during printing. This speed will override the speed in filament custom G-code." -msgstr "Baskı sırasında egzoz fanının hızı. Bu hız, filament özel gcode'undaki hızın üzerine yazılacaktır." +msgstr "Baskı sırasında egzoz fanının hızı. Bu hız, filament özel G-code'undaki hızın üzerine yazılacaktır." msgid "Speed of exhaust fan after printing completes." msgstr "Baskı tamamlandıktan sonra egzoz fanının hızı." @@ -13755,19 +13731,19 @@ msgid "This is the maximum length of bridges that don't need support. Set it to msgstr "Desteğe ihtiyaç duymayan maksimum köprü uzunluğu. Tüm köprülerin desteklenmesini istiyorsanız bunu 0'a, hiçbir köprünün desteklenmesini istemiyorsanız çok büyük bir değere ayarlayın." msgid "End G-code" -msgstr "Bitiş G kodu" +msgstr "Bitiş G-code" msgid "Add end G-Code when finishing the entire print." -msgstr "Tüm yazdırmayı tamamladığında çalışacak olan G Kodu." +msgstr "Tüm yazdırmayı tamamladığında çalışacak olan G-code." msgid "Between Object G-code" -msgstr "Nesne Arası Gcode" +msgstr "Nesne Arası G-code" msgid "Insert G-code between objects. This parameter will only come into effect when you print your models object by object." -msgstr "Nesnelerin arasına Gcode ekleyin. Bu parametre yalnızca modellerinizi nesne nesne yazdırdığınızda etkili olacaktır." +msgstr "Nesnelerin arasına G-code ekleyin. Bu parametre yalnızca modellerinizi nesne nesne yazdırdığınızda etkili olacaktır." msgid "Add end G-code when finishing the printing of this filament." -msgstr "Bu filament ile baskı bittiğinde çalışacak G kod." +msgstr "Bu filament ile baskı bittiğinde çalışacak G-code." msgid "Ensure vertical shell thickness" msgstr "Dikey kabuk kalınlığını koru" @@ -14089,14 +14065,14 @@ msgid "Extruder offset" msgstr "Ekstruder konumu" msgid "The material may have volumetric change after switching between molten and crystalline states. This setting changes all extrusion flow of this filament in G-code proportionally. The recommended value range is between 0.95 and 1.05. You may be able to tune this value to get a nice flat surface if there is slight overflow or underflow." -msgstr "Malzeme, erimiş hal ile kristal hal arasında geçiş yaptıktan sonra hacimsel değişime sahip olabilir. Bu ayar, bu filamentin gcode'daki tüm ekstrüzyon akışını orantılı olarak değiştirir. Önerilen değer aralığı 0,95 ile 1,05 arasındadır. Belki hafif taşma veya taşma olduğunda güzel düz bir yüzey elde etmek için bu değeri ayarlayabilirsiniz." +msgstr "Malzeme, erimiş hal ile kristal hal arasında geçiş yaptıktan sonra hacimsel değişime sahip olabilir. Bu ayar, bu filamentin G-code'daki tüm ekstrüzyon akışını orantılı olarak değiştirir. Önerilen değer aralığı 0,95 ile 1,05 arasındadır. Belki hafif taşma veya taşma olduğunda güzel düz bir yüzey elde etmek için bu değeri ayarlayabilirsiniz." msgid "" "The material may have volumetric change after switching between molten and crystalline states. This setting changes all extrusion flow of this filament in G-code proportionally. The recommended value range is between 0.95 and 1.05. You may be able to tune this value to get a nice flat surface if there is slight overflow or underflow.\n" "\n" "The final object flow ratio is this value multiplied by the filament flow ratio." msgstr "" -"Malzeme, erimiş hal ile kristal hal arasında geçiş yaptıktan sonra hacimsel değişime sahip olabilir. Bu ayar, bu filamentin gcode’daki tüm ekstrüzyon akışını orantılı olarak değiştirir. Önerilen değer aralığı 0,95 ile 1,05 arasındadır. Belki hafif taşma veya taşma olduğunda güzel düz bir yüzey elde etmek için bu değeri ayarlayabilirsiniz.\n" +"Malzeme, erimiş hal ile kristal hal arasında geçiş yaptıktan sonra hacimsel değişime sahip olabilir. Bu ayar, bu filamentin G-code’daki tüm ekstrüzyon akışını orantılı olarak değiştirir. Önerilen değer aralığı 0,95 ile 1,05 arasındadır. Belki hafif taşma veya taşma olduğunda güzel düz bir yüzey elde etmek için bu değeri ayarlayabilirsiniz.\n" "\n" "Nihai nesne akış oranı, bu değerin filament akış oranıyla çarpılmasıyla elde edilir." @@ -14308,7 +14284,7 @@ msgid "By Highest Temp" msgstr "En yüksek sıcaklığa göre" msgid "Filament diameter is used to calculate extrusion variables in G-code, so it is important that this is accurate and precise." -msgstr "Filament çapı, gcode'da ekstrüzyonu hesaplamak için kullanılır; bu nedenle önemlidir ve doğru olmalıdır." +msgstr "Filament çapı, G-code'da ekstrüzyonu hesaplamak için kullanılır; bu nedenle önemlidir ve doğru olmalıdır." msgid "Pellet flow coefficient" msgstr "Pelet akış katsayısı" @@ -14757,24 +14733,23 @@ msgid "Jerk of inner walls." msgstr "İç duvarlar sarsıntı değeri." msgid "Jerk for top surface." -msgstr "Üst yüzey için JERK değeri." +msgstr "Üst yüzey için Sarsıntı değeri." msgid "Jerk for infill." -msgstr "Dolgu için JERK değeri." +msgstr "Dolgu için Sarsıntı değeri." msgid "Jerk for the first layer." -msgstr "İlk katman için JERK değeri." +msgstr "İlk katman için Sarsıntı değeri." msgid "Jerk for travel." -msgstr "Seyahat için JERK değeri." +msgstr "Seyahat için Sarsıntı değeri." -# AI Translated msgid "" "Travel jerk of first layer.\n" "The percentage value is relative to Travel Jerk." msgstr "" -"İlk katmanın seyahat jerk'i.\n" -"Yüzde değeri Seyahat Jerk'ine göredir." +"İlk katmanın seyahat sarsıntısı (travel jerk).\n" +"Yüzde değeri, Seyahat Sarsıntısı (Travel Jerk) değerine bağlıdır." msgid "Line width of the first layer. If expressed as a %, it will be computed over the nozzle diameter." msgstr "İlk katmanın çizgi genişliği. % olarak ifade edilirse Nozul çapı üzerinden hesaplanacaktır." @@ -15093,7 +15068,7 @@ msgid "" "\n" "Note: For Klipper machines, this option is recommended to be disabled. Klipper does not benefit from arc commands as these are split again into line segments by the firmware. This results in a reduction in surface quality as line segments are converted to arcs by the slicer and then back to line segments by the firmware." msgstr "" -"G2 ve G3 hareketlerine sahip bir G kodu dosyası elde etmek için bunu etkinleştirin. Montaj toleransı çözünürlükle aynıdır.\n" +"G2 ve G3 hareketlerine sahip bir G-code dosyası elde etmek için bunu etkinleştirin. Montaj toleransı çözünürlükle aynıdır.\n" "\n" "Not: Klipper makineler için bu seçeneğin devre dışı bırakılması önerilir. Klipper, yazılım tarafından tekrar çizgi bölümlerine bölündüğü için yay komutlarından faydalanmaz. Bu, çizgi bölümlerinin dilimleyici tarafından yaylara dönüştürülmesi ve ardından donanım yazılımı tarafından tekrar çizgi bölümlerine dönüştürülmesi nedeniyle yüzey kalitesinde bir azalmaya neden olur." @@ -15101,7 +15076,7 @@ msgid "Add line number" msgstr "Satır numarası ekle" msgid "Enable this to add line number(Nx) at the beginning of each G-code line." -msgstr "Her G Kodu satırının başına satır numarası (Nx) eklemek için bunu etkinleştirin." +msgstr "Her G-code satırının başına satır numarası (Nx) eklemek için bunu etkinleştirin." msgid "Scan first layer" msgstr "İlk katmanı tara" @@ -15113,7 +15088,7 @@ msgid "Power Loss Recovery" msgstr "Güç Kaybının Geri Kazanımı" msgid "Choose how to control power loss recovery. When set to Printer configuration, the slicer will not emit power loss recovery G-code and will leave the printer's configuration unchanged. Applicable to Bambu Lab or Marlin 2 firmware based printers." -msgstr "Güç kaybı kurtarmanın nasıl kontrol edileceğini seçin. Yazıcı yapılandırması olarak ayarlandığında, dilimleyici güç kaybı kurtarma G kodunu yayınlamayacak ve yazıcının yapılandırmasını değiştirmeden bırakacaktır. Bambu Lab veya Marlin 2 ürün yazılımı tabanlı yazıcılar için geçerlidir." +msgstr "Güç kaybı kurtarmanın nasıl kontrol edileceğini seçin. Yazıcı yapılandırması olarak ayarlandığında, dilimleyici güç kaybı kurtarma G-code'u yayınlamayacak ve yazıcının yapılandırmasını değiştirmeden bırakacaktır. Bambu Lab veya Marlin 2 ürün yazılımı tabanlı yazıcılar için geçerlidir." msgid "Printer configuration" msgstr "Yazıcı yapılandırması" @@ -15189,7 +15164,7 @@ msgid "" msgstr "" "Fanı hedef başlangıç zamanından bu kadar saniye önce başlatın (kesirli saniyeleri kullanabilirsiniz). Bu süre tahmini için sonsuz ivme varsayar ve yalnızca G1 ve G0 hareketlerini hesaba katar (yay uydurma desteklenmez).\n" "Fan komutlarını özel kodlardan taşımaz (bir çeşit 'bariyer' görevi görürler).\n" -"'Yalnızca özel başlangıç gcode'u etkinleştirilmişse, fan komutları başlangıç gcode'una taşınmayacaktır.\n" +"'Yalnızca özel başlangıç G-code'u etkinleştirilmişse, fan komutları başlangıç G-code'una taşınmayacaktır.\n" "Devre dışı bırakmak için 0'ı kullanın." msgid "Only overhangs" @@ -15266,7 +15241,7 @@ msgid "G-code flavor" msgstr "G-code türü" msgid "What kind of G-code the printer is compatible with." -msgstr "Yazıcının ne tür bir gcode ile uyumlu olduğu." +msgstr "Yazıcının ne tür bir G-code ile uyumlu olduğu." msgid "Klipper" msgstr "Klipper" @@ -15301,13 +15276,13 @@ msgid "Exclude objects" msgstr "Nesneleri hariç tut" msgid "Enable this option to add EXCLUDE OBJECT command in G-code." -msgstr "G koduna EXCLUDE OBJECT komutunu eklemek için bu seçeneği etkinleştirin." +msgstr "G-code'a EXCLUDE OBJECT komutunu eklemek için bu seçeneği etkinleştirin." msgid "Verbose G-code" msgstr "Ayrıntılı G-code" msgid "Enable this to get a commented G-code file, with each line explained by a descriptive text. If you print from SD card, the additional weight of the file could make your firmware slow down." -msgstr "Her satırın açıklayıcı bir metinle açıklandığı, yorumlu bir G kodu dosyası almak için bunu etkinleştirin. SD karttan yazdırırsanız dosyanın ilave ağırlığı ürün yazılımınızın yavaşlamasına neden olabilir." +msgstr "Her satırın açıklayıcı bir metinle açıklandığı, yorumlu bir G-code dosyası almak için bunu etkinleştirin. SD karttan yazdırırsanız dosyanın ilave ağırlığı ürün yazılımınızın yavaşlamasına neden olabilir." msgid "Infill combination" msgstr "Dolgu kombinasyonu" @@ -15672,10 +15647,10 @@ msgstr "" "Ayrıca dilimleme düzlemini de denetler." msgid "This G-code is inserted at every layer change after the Z lift." -msgstr "Bu gcode kısmı, z kaldırma işleminden sonra her katman değişikliğinde eklenir." +msgstr "Bu G-code kısmı, z kaldırma işleminden sonra her katman değişikliğinde eklenir." msgid "Clumping detection G-code" -msgstr "Topaklanma tespiti G kodu" +msgstr "Topaklanma tespiti G-code" # AI Translated msgid "Silent Mode" @@ -15685,7 +15660,7 @@ msgid "Whether the machine supports silent mode in which machine uses lower acce msgstr "Daha sessiz baskı için ivmelenmeyi düşüren sessiz mod desteği" msgid "Emit limits to G-code" -msgstr "G-kod sınırları" +msgstr "G-code sınırları" msgid "Machine limits" msgstr "Yazıcı sınırları" @@ -15694,14 +15669,14 @@ msgid "" "If enabled, the machine limits will be emitted to G-code file.\n" "This option will be ignored if the G-code flavor is set to Klipper." msgstr "" -"Etkinleştirilirse, makine sınırları G kodu dosyasına aktarılacaktır.\n" -"G kodu tadı Klipper olarak ayarlandığında bu seçenek göz ardı edilecektir." +"Etkinleştirilirse, makine sınırları G-code dosyasına aktarılacaktır.\n" +"G-code tadı Klipper olarak ayarlandığında bu seçenek göz ardı edilecektir." msgid "This G-code will be used as a code for the pause print. Users can insert pause G-code in the G-code viewer." -msgstr "Bu G kodu duraklatma yazdırması için bir kod olarak kullanılacaktır. Kullanıcı gcode görüntüleyiciye duraklatma G kodunu ekleyebilir." +msgstr "Bu G-code duraklatma yazdırması için bir kod olarak kullanılacaktır. Kullanıcı G-code görüntüleyiciye duraklatma G-code'u ekleyebilir." msgid "This G-code will be used as a custom code." -msgstr "Bu G kodu özel kod olarak kullanılacak." +msgstr "Bu G-code özel kod olarak kullanılacak." msgid "Small area flow compensation (beta)" msgstr "Küçük alan akış telafisi (beta)" @@ -16043,7 +16018,7 @@ msgid "" "\n" "Allowed values: 0.5-5" msgstr "" -"Daha düşük bir değer, daha düzgün ekstrüzyon hızı geçişleriyle sonuçlanır. Ancak bu, önemli ölçüde daha büyük bir gcode dosyasına ve yazıcının işlemesi için daha fazla talimata neden olur.\n" +"Daha düşük bir değer, daha düzgün ekstrüzyon hızı geçişleriyle sonuçlanır. Ancak bu, önemli ölçüde daha büyük bir G-code dosyasına ve yazıcının işlemesi için daha fazla talimata neden olur.\n" "\n" "Varsayılan 3 değeri çoğu durumda işe yarar. Yazıcınız tutukluk yapıyorsa, yapılan ayarlama sayısını azaltmak için bu değeri artırın\n" "\n" @@ -16100,13 +16075,13 @@ msgid "Configuration notes" msgstr "Yapılandırma notları" msgid "You can put here your personal notes. This text will be added to the G-code header comments." -msgstr "Buraya kişisel notlarınızı yazabilirsiniz. Bu not G-kodu başlık yorumlarına eklenecektir." +msgstr "Buraya kişisel notlarınızı yazabilirsiniz. Bu not G-code başlık yorumlarına eklenecektir." msgid "Host Type" msgstr "Bağlantı Türü" msgid "Orca Slicer can upload G-code files to a printer host. This field must contain the kind of the host." -msgstr "Orca Slicer, G kodu dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan ana bilgisayarın türünü içermelidir." +msgstr "Orca Slicer, G-code dosyalarını bir yazıcı ana bilgisayarına yükleyebilir. Bu alan ana bilgisayarın türünü içermelidir." msgid "Nozzle volume" msgstr "Nozul hacmi" @@ -16155,7 +16130,7 @@ msgstr "Dolguda geri çekmeyi azalt" # AI Translated msgid "Don't retract when the travel is entirely within an infill area. That means the oozing can't been seen. This can reduce times of retraction for complex model and save printing time, but make slicing and G-code generating slower. Note that z-hop is also not performed in areas where retraction is skipped." -msgstr "Hareket tamamen dolgu alanı içindeyken geri çekme yapılmaz. Bu, sızıntının görülemeyeceği anlamına gelir. Bu, karmaşık modellerde geri çekme sayısını azaltabilir ve yazdırma süresinden tasarruf sağlayabilir, ancak dilimlemeyi ve G kodu oluşturmayı yavaşlatır. Geri çekmenin atlandığı alanlarda z-hop'un da uygulanmadığını unutmayın." +msgstr "Hareket tamamen dolgu alanı içindeyken geri çekme yapılmaz. Bu, sızıntının görülemeyeceği anlamına gelir. Bu, karmaşık modellerde geri çekme sayısını azaltabilir ve yazdırma süresinden tasarruf sağlayabilir, ancak dilimlemeyi ve G-code oluşturmayı yavaşlatır. Geri çekmenin atlandığı alanlarda z-hop'un da uygulanmadığını unutmayın." msgid "This option will drop the temperature of the inactive extruders to prevent oozing." msgstr "Bu seçenek sızıntıyı önlemek için aktif olmayan ekstrüderlerin sıcaklığını düşürecektir." @@ -16241,7 +16216,7 @@ msgstr "" "İlave çevrelerin sabitleneceği dolgu sınırlı olduğundan, bu seçenekle birlikte yıldırım dolgusunun kullanılması önerilmez." msgid "If you want to process the output G-code through custom scripts, just list their absolute paths here. Separate multiple scripts with a semicolon. Scripts will be passed the absolute path to the G-code file as the first argument, and they can access the Orca Slicer config settings by reading environment variables." -msgstr "Çıktı G-kodunu özel komut dosyaları aracılığıyla işlemek istiyorsanız, mutlak yollarını burada listeleyin. Birden fazla betiği noktalı virgülle ayırın. Betiklere ilk argüman olarak G-code dosyasının mutlak yolu aktarılır ve ortam değişkenlerini okuyarak Orca Slicer yapılandırma ayarlarına erişebilirler." +msgstr "Çıktı G-code'u özel komut dosyaları aracılığıyla işlemek istiyorsanız, mutlak yollarını burada listeleyin. Birden fazla betiği noktalı virgülle ayırın. Betiklere ilk argüman olarak G-code dosyasının mutlak yolu aktarılır ve ortam değişkenlerini okuyarak Orca Slicer yapılandırma ayarlarına erişebilirler." # AI Translated msgid "Change extrusion role G-code (process)" @@ -16309,7 +16284,7 @@ msgid "Object will be raised by this number of support layers. Use this function msgstr "Nesne bu sayıdaki destek katmanı tarafından yükseltilecektir. ABS yazdırırken sarmayı önlemek için bu işlevi kullanın." msgid "The G-code path is generated after simplifying the contour of models to avoid too many points and G-code lines. Smaller values mean higher resolution and more time required to slice." -msgstr "Gcode dosyasında çok fazla nokta ve gcode çizgisinin olmaması için modelin konturu basitleştirildikten sonra G-code yolu oluşturulur. Daha küçük değer, daha yüksek çözünürlük ve dilimleme için daha fazla zaman anlamına gelir." +msgstr "G-code dosyasında çok fazla nokta ve G-code çizgisinin olmaması için modelin konturu basitleştirildikten sonra G-code yolu oluşturulur. Daha küçük değer, daha yüksek çözünürlük ve dilimleme için daha fazla zaman anlamına gelir." msgid "Travel distance threshold" msgstr "Seyahat mesafesi" @@ -16513,7 +16488,7 @@ msgid "Disable set remaining print time" msgstr "Kalan yazdırma süresini ayarlamayı devre dışı bırak" msgid "Disable generating of the M73: Set remaining print time in the final G-code." -msgstr "M73'ün oluşturulmasını devre dışı bırakın: Son gcode'da kalan yazdırma süresini ayarlayın." +msgstr "M73'ün oluşturulmasını devre dışı bırakın: Son G-code'da kalan yazdırma süresini ayarlayın." msgid "Seam position" msgstr "Dikiş konumu" @@ -16734,7 +16709,7 @@ msgstr "" "Nihai döngü sayısı, nesnelerin mesafesini düzenlerken veya doğrularken dikkate alınmaz. Böyle bir durumda döngü sayısını artırın." msgid "The printing speed in exported G-code will be slowed down when the estimated layer time is shorter than this value in order to get better cooling for these layers." -msgstr "Tahmini katman süresi bu değerden kısa olduğunda, bu katmanlar için daha iyi soğutma sağlamak amacıyla, dışa aktarılan gcode'daki yazdırma hızı yavaşlatılacaktır." +msgstr "Tahmini katman süresi bu değerden kısa olduğunda, bu katmanlar için daha iyi soğutma sağlamak amacıyla, dışa aktarılan G-code'daki yazdırma hızı yavaşlatılacaktır." msgid "Minimum sparse infill threshold" msgstr "Minimum seyrek dolgu" @@ -16842,16 +16817,16 @@ msgid "Insert multiple preheat commands (e.g. M104.1). Only useful for Prusa XL. msgstr "Birden fazla ön ısıtma komutu ekleyin (örn. M104.1). Yalnızca Prusa XL için kullanışlıdır. Diğer yazıcılar için lütfen 1’e ayarlayın." msgid "G-code written at the very top of the output file, before any other content. Useful for adding metadata that printer firmware reads from the first lines of the file (e.g. estimated print time, filament usage). Supports placeholders like {print_time_sec} and {used_filament_length}." -msgstr "G kodu, çıktı dosyasının en üstünde, diğer içeriklerden önce yazılır. Yazıcı ürün yazılımının dosyanın ilk satırlarından okuduğu meta verileri (ör. tahmini yazdırma süresi, filament kullanımı) eklemek için kullanışlıdır. {print_time_sec} ve {used_filament_length} gibi yer tutucuları destekler." +msgstr "G-code, çıktı dosyasının en üstünde, diğer içeriklerden önce yazılır. Yazıcı ürün yazılımının dosyanın ilk satırlarından okuduğu meta verileri (ör. tahmini yazdırma süresi, filament kullanımı) eklemek için kullanışlıdır. {print_time_sec} ve {used_filament_length} gibi yer tutucuları destekler." msgid "Start G-code" -msgstr "Başlangıç G Kodu" +msgstr "Başlangıç G-code" msgid "G-code added when starting a print." -msgstr "Baskı başladığında çalışacak G Kodu." +msgstr "Baskı başladığında çalışacak G-code." msgid "G-code added when the printer starts using this filament" -msgstr "Bu filament kullanılırken yazıcı başladığında eklenen G-kodu" +msgstr "Bu filament kullanılırken yazıcı başladığında eklenen G-code" msgid "Single Extruder Multi Material" msgstr "Tek ekstruder çoklu malzeme" @@ -16863,7 +16838,7 @@ msgid "Manual Filament Change" msgstr "Manuel filament değişimi" msgid "Enable this option to omit the custom Change filament G-code only at the beginning of the print. The tool change command (e.g., T0) will be skipped throughout the entire print. This is useful for manual multi-material printing, where we use M600/PAUSE to trigger the manual filament change action." -msgstr "Sadece baskının başında özel Filament Değiştirme G-kodu'nu atlamak için bu seçeneği etkinleştirin. Aracı değiştirme komutu (örneğin, T0), baskının tamamı boyunca atlanacaktır. Bu, manuel çoklu malzeme baskısı için kullanışlıdır, burada manuel filament değişim eylemini tetiklemek için M600/PAUSE kullanırız." +msgstr "Sadece baskının başında özel Filament Değiştirme G-code'u atlamak için bu seçeneği etkinleştirin. Aracı değiştirme komutu (örneğin, T0), baskının tamamı boyunca atlanacaktır. Bu, manuel çoklu malzeme baskısı için kullanışlıdır, burada manuel filament değişim eylemini tetiklemek için M600/PAUSE kullanırız." msgid "Wipe tower type" msgstr "Temizleme kulesi tipi" @@ -16960,7 +16935,7 @@ msgid "Z offset" msgstr "Z ofseti" msgid "This value will be added (or subtracted) from all the Z coordinates in the output G-code. It is used to compensate for bad Z endstop position: for example, if your endstop zero actually leaves the nozzle 0.3mm far from the print bed, set this to -0.3 (or fix your endstop)." -msgstr "Bu değer, çıkış G-kodu içindeki tüm Z koordinatlarına eklenir (veya çıkarılır).Bu, kötü Z endstop konumunu telafi etmek için kullanılır: örneğin, endstop sıfır noktanız aslında nozulu baskı plakasından 0.3mm uzakta bırakıyorsa, bu değeri -0.3 olarak ayarlayın (veya endstop'unuzu düzeltin)." +msgstr "Bu değer, çıkış G-code içindeki tüm Z koordinatlarına eklenir (veya çıkarılır).Bu, kötü Z endstop konumunu telafi etmek için kullanılır: örneğin, endstop sıfır noktanız aslında nozulu baskı plakasından 0.3mm uzakta bırakıyorsa, bu değeri -0.3 olarak ayarlayın (veya endstop'unuzu düzeltin)." msgid "Enable support" msgstr "Desteği etkinleştir" @@ -17311,7 +17286,7 @@ msgstr "" "\n" "PLA, PETG, TPU, PVA ve diğer düşük sıcaklıktaki malzemeler için, ısı kırılmasında malzemenin yumuşamasından kaynaklanan ekstrüderin tıkanmasını önlemek için oda sıcaklığının düşük olması gerektiğinden bu seçenek devre dışı bırakılmalıdır (0’a ayarlanmalıdır).\n" "\n" -"Etkinleştirilirse, bu parametre aynı zamanda istenen oda sıcaklığını yazdırma başlatma makronuza veya şuna benzer bir ısı emme makrosuna iletmek için kullanılabilecek Chamber_temperature adlı bir gcode değişkenini de ayarlar: PRINT_START (diğer değişkenler) CHAMBER_TEMP=[chamber_temperature]. Yazıcınız M141/M191 komutlarını desteklemiyorsa veya aktif oda ısıtıcısı takılı değilse yazdırma başlatma makrosunda ısı bekletme işlemini gerçekleştirmek istiyorsanız bu yararlı olabilir." +"Etkinleştirilirse, bu parametre aynı zamanda istenen oda sıcaklığını yazdırma başlatma makronuza veya şuna benzer bir ısı emme makrosuna iletmek için kullanılabilecek Chamber_temperature adlı bir G-code değişkenini de ayarlar: PRINT_START (diğer değişkenler) CHAMBER_TEMP=[chamber_temperature]. Yazıcınız M141/M191 komutlarını desteklemiyorsa veya aktif oda ısıtıcısı takılı değilse yazdırma başlatma makrosunda ısı bekletme işlemini gerçekleştirmek istiyorsanız bu yararlı olabilir." # AI Translated msgid "" @@ -17341,10 +17316,10 @@ msgid "This detects thin walls which can’t contain two lines and uses a single msgstr "İki çizgi genişliğini içeremeyen ince duvarı tespit edin. Ve yazdırmak için tek satır kullanın. Kapalı döngü olmadığından pek iyi basılmamış olabilir." msgid "This G-code is inserted when filament is changed, including T commands to trigger tool change." -msgstr "Bu gcode, takım değişimini tetiklemek için T komutu da dahil olmak üzere filament değiştirildiğinde eklenir." +msgstr "Bu G-code, takım değişimini tetiklemek için T komutu da dahil olmak üzere filament değiştirildiğinde eklenir." msgid "This G-code is inserted when the extrusion role is changed." -msgstr "Bu gcode, ekstrüzyon rolü değiştirildiğinde eklenir." +msgstr "Bu G-code, ekstrüzyon rolü değiştirildiğinde eklenir." # AI Translated msgid "Change extrusion role G-code (filament)" @@ -17696,10 +17671,10 @@ msgid "Picture sizes to be stored into a .gcode and .sl1 / .sl1s files, in the f msgstr "Resim boyutları aşağıdaki formatta bir .gcode ve .sl1 / .sl1s dosyalarında saklanacaktır: \"XxY, XxY, ...\"" msgid "Format of G-code thumbnails" -msgstr "G kodu küçük resimlerinin formatı" +msgstr "G-code küçük resimlerinin formatı" msgid "Format of G-code thumbnails: PNG for best quality, JPG for smallest size, QOI for low memory firmware." -msgstr "G kodu küçük resimlerinin formatı: En iyi kalite için PNG, en küçük boyut için JPG, düşük bellekli donanım yazılımı için QOI." +msgstr "G-code küçük resimlerinin formatı: En iyi kalite için PNG, en küçük boyut için JPG, düşük bellekli donanım yazılımı için QOI." msgid "Use relative E distances" msgstr "Göreceli (relative) E mesafelerini kullan" @@ -17949,7 +17924,7 @@ msgid "No check" msgstr "Kontrol yok" msgid "Do not run any validity checks, such as G-code path conflicts check." -msgstr "Gcode yol çakışmaları kontrolü gibi herhangi bir geçerlilik kontrolü çalıştırmayın." +msgstr "G-code yol çakışmaları kontrolü gibi herhangi bir geçerlilik kontrolü çalıştırmayın." msgid "Normative check" msgstr "Normatif kontrol" @@ -18113,10 +18088,10 @@ msgid "If enabled, this slicing will be considered using timelapse." msgstr "Etkinleştirilirse, bu dilimleme hızlandırılmış çekim kullanılarak değerlendirilecektir." msgid "Load custom G-code" -msgstr "Özel gcode yükle" +msgstr "Özel G-code yükle" msgid "Load custom G-code from json." -msgstr "Json'dan özel gcode yükleyin." +msgstr "Json'dan özel G-code yükleyin." msgid "Load filament IDs" msgstr "Filament kimliklerini yükle" @@ -18143,10 +18118,10 @@ msgid "If enabled, Arrange will avoid extrusion calibrate region when placing ob msgstr "Etkinleştirilirse, nesne yerleştirildiğinde düzenleme ekstrüzyon kalibrasyon bölgesini önleyecektir." msgid "Skip modified G-code in 3MF" -msgstr "3mf’de değiştirilmiş gcode’ları atla" +msgstr "3mf’de değiştirilmiş G-code’ları atla" msgid "Skip the modified G-code in 3MF from printer or filament presets." -msgstr "Yazıcı veya filament Ön Ayarlarından 3mf’deki değiştirilmiş gcode’ları atlayın." +msgstr "Yazıcı veya filament Ön Ayarlarından 3mf’deki değiştirilmiş G-code’ları atlayın." msgid "MakerLab name" msgstr "MakerLab adı" @@ -18183,13 +18158,13 @@ msgid "Current Z-hop" msgstr "Mevcut z-hop" msgid "Contains Z-hop present at the beginning of the custom G-code block." -msgstr "Özel G kodu bloğunun başında bulunan z-hop'u içerir." +msgstr "Özel G-code bloğunun başında bulunan z-hop'u içerir." msgid "Position of the extruder at the beginning of the custom G-code block. If the custom G-code travels somewhere else, it should write to this variable so OrcaSlicer knows where it travels from when it gets control back." -msgstr "Ekstruderin özel G kodu bloğunun başlangıcındaki konumu. Özel G kodu başka bir yere seyahat ederse, Slicer'ın kontrolü geri aldığında nereden seyahat ettiğini bilmesi için bu değişkene yazması gerekir." +msgstr "Ekstruderin özel G-code bloğunun başlangıcındaki konumu. Özel G-code başka bir yere seyahat ederse, Slicer'ın kontrolü geri aldığında nereden seyahat ettiğini bilmesi için bu değişkene yazması gerekir." msgid "Retraction state at the beginning of the custom G-code block. If the custom G-code moves the extruder axis, it should write to this variable so OrcaSlicer de-retracts correctly when it gets control back." -msgstr "Özel G kodu bloğunun başlangıcındaki geri çekilme durumu. Özel G kodu ekstruder eksenini hareket ettirirse, Slicer'ın kontrolü geri aldığında doğru şekilde geri çekme yapması için bu değişkene yazması gerekir." +msgstr "Özel G-code bloğunun başlangıcındaki geri çekilme durumu. Özel G-code ekstruder eksenini hareket ettirirse, Slicer'ın kontrolü geri aldığında doğru şekilde geri çekme yapması için bu değişkene yazması gerekir." msgid "Extra de-retraction" msgstr "Ekstra deretraksiyon" @@ -18342,10 +18317,10 @@ msgid "Total number of objects in the print." msgstr "Baskıdaki toplam nesne sayısı." msgid "Number of instances" -msgstr "Örnek sayısı" +msgstr "Eş kopya sayısı" msgid "Total number of object instances in the print, summed over all objects." -msgstr "Tüm nesneler üzerinden toplanan, yazdırmadaki nesne örneklerinin toplam sayısı." +msgstr "Tüm nesneler genelinde toplanmış, baskıdaki toplam nesne eş kopyası (instance) sayısı." msgid "Scale per object" msgstr "Nesne başına ölçeklendirme" @@ -19450,7 +19425,7 @@ msgid "Only materials of the same type can be selected." msgstr "Yalnızca aynı tipteki malzemeler seçilebilir." msgid "Send G-code to printer host" -msgstr "G Kodunu yazıcı ana bilgisayarına gönder" +msgstr "G-code'u yazıcı ana bilgisayarına gönder" msgid "Upload to Printer Host with the following filename:" msgstr "Yazıcıya aşağıdaki dosya adıyla yükleyin:" @@ -20356,9 +20331,8 @@ msgstr "İletişim kutusunu kapatıp projeyi incelemek için HAYIR'ı seçin." msgid "No project file on current session. Only logs will be included to package" msgstr "Geçerli oturumda proje dosyası yok. Pakete yalnızca günlükler eklenecek" -# AI Translated msgid "Please make sure any instances of OrcaSlicer are not running" -msgstr "Lütfen çalışan bir OrcaSlicer örneği olmadığından emin olun" +msgstr "Lütfen hiçbir OrcaSlicer örneğinin çalışmadığından emin olun" # AI Translated msgid "System folder cannot be deleted because some files are in use by another application. Please close any applications using these files and try again." @@ -20373,7 +20347,7 @@ msgid "Failed to determine executable path." msgstr "Yürütülebilir dosya yolu belirlenemedi." msgid "Failed to launch a new instance." -msgstr "Yeni bir kopya başlatılamadı." +msgstr "Yeni bir örnek başlatılamadı." # AI Translated msgid "log(s)" @@ -21697,8 +21671,8 @@ msgid "" "G-code window\n" "You can turn on/off the G-code window by pressing the C key." msgstr "" -"G-kodu penceresi\n" -"C tuşuna basarak G*kodu penceresini açabilir/kapatabilirsiniz." +"G-code penceresi\n" +"C tuşuna basarak G-code penceresini açabilir/kapatabilirsiniz." #: resources/data/hints.ini: [hint:Switch workspaces] msgid "" @@ -21855,8 +21829,7 @@ msgstr "" "Baskılarınızı plakalara ayırın\n" "Çok sayıda parçası olan bir modeli baskıya hazır ayrı kalıplara bölebileceğinizi biliyor muydunuz? Bu, tüm parçaları takip etme sürecini basitleştirecektir." -#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer -#: Height] +#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer Height] msgid "" "Speed up your print with Adaptive Layer Height\n" "Did you know that you can print a model even faster by using the Adaptive Layer Height option? Check it out!" @@ -21929,8 +21902,7 @@ msgstr "" "Gücü artırın\n" "Modelin gücünü artırmak için daha fazla duvar halkası ve daha yüksek seyrek dolgu yoğunluğu kullanabileceğinizi biliyor muydunuz?" -#: resources/data/hints.ini: [hint:When do you need to print with the printer -#: door opened] +#: resources/data/hints.ini: [hint:When do you need to print with the printer door opened] msgid "" "When do you need to print with the printer door opened?\n" "Did you know that opening the printer door can reduce the probability of extruder/hotend clogging when printing lower temperature filament with a higher enclosure temperature? There is more info about this in the Wiki." From d7fed95390ea4bc848034cf8a507d2ef8e5e613c Mon Sep 17 00:00:00 2001 From: Valentin Date: Sun, 23 Aug 2026 18:42:30 +0300 Subject: [PATCH 111/138] Fix Ukrainian translation typo (#15333) --- localization/i18n/uk/OrcaSlicer_uk.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 4c3cc1d56c..9f57b6b71a 100644 --- a/localization/i18n/uk/OrcaSlicer_uk.po +++ b/localization/i18n/uk/OrcaSlicer_uk.po @@ -4142,10 +4142,10 @@ msgid "PA Profile" msgstr "Профіль PA" msgid "Factor K" -msgstr "Коэф. K" +msgstr "Коеф. K" msgid "Factor N" -msgstr "Коэф. N" +msgstr "Коеф. N" msgid "Setting AMS slot information while printing is not supported" msgstr "Зміна інформації про слоти AMS під час друку не підтримується" From 07b81cfdc9e3933ee7cd8539d32bcf937f9d99b4 Mon Sep 17 00:00:00 2001 From: TheLegendTubaGuy <95944177+thelegendtubaguy@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:10:41 -0500 Subject: [PATCH 112/138] Fix Qidi X-Max 4 chamber heating profiles (#15244) --- resources/profiles/Qidi.json | 2 +- resources/profiles/Qidi/filament/X4/QIDI PA12-CF @X-Max 4.json | 3 +++ resources/profiles/Qidi/filament/X4/QIDI PAHT-CF @X-Max 4.json | 3 +++ resources/profiles/Qidi/filament/X4/QIDI PAHT-GF @X-Max 4.json | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json index 08a2d6a230..ecceff5a8a 100644 --- a/resources/profiles/Qidi.json +++ b/resources/profiles/Qidi.json @@ -1,6 +1,6 @@ { "name": "Qidi", - "version": "02.04.00.10", + "version": "02.04.00.11", "force_update": "0", "description": "Qidi configurations", "machine_model_list": [ diff --git a/resources/profiles/Qidi/filament/X4/QIDI PA12-CF @X-Max 4.json b/resources/profiles/Qidi/filament/X4/QIDI PA12-CF @X-Max 4.json index e217915579..e268f6081c 100644 --- a/resources/profiles/Qidi/filament/X4/QIDI PA12-CF @X-Max 4.json +++ b/resources/profiles/Qidi/filament/X4/QIDI PA12-CF @X-Max 4.json @@ -20,6 +20,9 @@ "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "fan_cooling_layer_time": [ "10" ], diff --git a/resources/profiles/Qidi/filament/X4/QIDI PAHT-CF @X-Max 4.json b/resources/profiles/Qidi/filament/X4/QIDI PAHT-CF @X-Max 4.json index 83cd3e27cb..2bf1ca53fc 100644 --- a/resources/profiles/Qidi/filament/X4/QIDI PAHT-CF @X-Max 4.json +++ b/resources/profiles/Qidi/filament/X4/QIDI PAHT-CF @X-Max 4.json @@ -20,6 +20,9 @@ "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "fan_cooling_layer_time": [ "10" ], diff --git a/resources/profiles/Qidi/filament/X4/QIDI PAHT-GF @X-Max 4.json b/resources/profiles/Qidi/filament/X4/QIDI PAHT-GF @X-Max 4.json index 8a35c7330b..37afc97c29 100644 --- a/resources/profiles/Qidi/filament/X4/QIDI PAHT-GF @X-Max 4.json +++ b/resources/profiles/Qidi/filament/X4/QIDI PAHT-GF @X-Max 4.json @@ -20,6 +20,9 @@ "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "fan_cooling_layer_time": [ "10" ], From d61e0cb7bf8a95dde4fcbe4656c8a2a35907837c Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Sun, 23 Aug 2026 17:55:48 -0500 Subject: [PATCH 113/138] build: unify the warning policy across compilers (clang-cl: 124k warnings -> 2.7k, 21% faster) (#15328) --- CMakeLists.txt | 81 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fe3ae1d26d..9f0db669b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,6 +59,13 @@ if (APPLE) message(STATUS "CMAKE_OSX_DEPLOYMENT_TARGET: ${CMAKE_OSX_DEPLOYMENT_TARGET}") endif () +# Keep MSVC's default /W3 out of CMAKE__FLAGS so it can be applied to our own +# targets only. Silencing a bundled target would otherwise override a warning level, +# which cl reports as D9025 for every file it compiles. +if (POLICY CMP0092) + cmake_policy(SET CMP0092 NEW) +endif () + project(OrcaSlicer) # Backward compatibility for old CMake versions @@ -126,6 +133,8 @@ option(SLIC3R_GUI "Compile OrcaSlicer with GUI components (OpenGL, option(SLIC3R_FHS "Assume OrcaSlicer is to be installed in a FHS directory structure" 0) option(SLIC3R_PROFILE "Compile OrcaSlicer with an invasive Shiny profiler" 0) option(SLIC3R_PCH "Use precompiled headers" 1) +option(SLIC3R_WARNINGS "Emit compiler warnings for OrcaSlicer sources" 1) +option(SLIC3R_BUNDLED_WARNINGS "Emit compiler warnings for bundled third-party sources" 0) option(SLIC3R_MSVC_COMPILE_PARALLEL "Compile on Visual Studio in parallel" 1) option(SLIC3R_MSVC_PDB "Generate PDB files on MSVC in Release mode" 1) option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0) @@ -335,14 +344,16 @@ if (MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL Clang) # clang-cl can interpret SYSTEM header paths if -imsvc is used set(CMAKE_INCLUDE_SYSTEM_FLAG_CXX "-imsvc") - - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall \ - -Wno-old-style-cast -Wno-reserved-id-macro -Wno-c++98-compat-pedantic") else () set(IS_CLANG_CL FALSE) endif () if (MSVC) + # CMP0092 only applies when the cache is created; an existing tree keeps its /W3, + # which a silenced bundled target would then override (D9025, once per file). + string(REGEX REPLACE "/W[0-4]" "" CMAKE_C_FLAGS "${CMAKE_C_FLAGS}") + string(REGEX REPLACE "/W[0-4]" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + # /MP only matters for the VS generators, where CMake turns it into the # MultiProcessorCompilation property. Ninja parallelises on its own, and # clang-cl warns "argument unused" if the flag reaches it. @@ -526,8 +537,15 @@ if (CMAKE_COMPILER_IS_GNUCC OR CMAKE_COMPILER_IS_GNUXX) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fext-numeric-literals" ) endif() -if (NOT MSVC AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR "${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang")) - if (NOT MINGW) +if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR "${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang")) + if (IS_CLANG_CL) + # clang-cl reads -Wall as MSVC /Wall, which clang maps to -Weverything. /W4 is + # its -Wall -Wextra and, unlike /clang:-Wall, is ordered with the -Wno-* below + # instead of after them. The -Wextra-only warnings are dropped again so the set + # matches what -Wall gives the GNU/Clang builds. + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4" ) + add_compile_options(-Wno-unused-parameter -Wno-ignored-qualifiers -Wno-missing-field-initializers) + elseif (NOT MINGW) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall" ) endif () set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-reorder" ) @@ -1089,8 +1107,57 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) endfunction() +# Bundled sources set their own warning flags, and a plain -Wall there means /Wall +# (= -Weverything) under clang-cl. Target options are applied after the ones a target +# set on itself, so these win. Targets are discovered rather than listed so a newly +# bundled library needs no maintenance here. +function(orcaslicer_silence_third_party_warnings _dir) + get_property(_subdirs DIRECTORY "${_dir}" PROPERTY SUBDIRECTORIES) + foreach (_subdir IN LISTS _subdirs) + orcaslicer_silence_third_party_warnings("${_subdir}") + endforeach () + get_property(_targets DIRECTORY "${_dir}" PROPERTY BUILDSYSTEM_TARGETS) + foreach (_target IN LISTS _targets) + get_target_property(_type ${_target} TYPE) + if (NOT _type STREQUAL "INTERFACE_LIBRARY" AND NOT _type STREQUAL "UTILITY") + if (MSVC AND NOT IS_CLANG_CL) + # Drop any level the target set for itself, or -w overrides it and cl + # reports D9025 once per file. + get_target_property(_opts ${_target} COMPILE_OPTIONS) + if (_opts) + string(REGEX REPLACE "/W[0-4]|/Wall" "" _opts "${_opts}") + string(REGEX REPLACE ";;+" ";" _opts "${_opts}") + set_target_properties(${_target} PROPERTIES COMPILE_OPTIONS "${_opts}") + endif () + # CMake maps a level into the VS generator's WarningLevel element, while a + # bare -w stays on the command line and trips D9025 there, once per file. + target_compile_options(${_target} PRIVATE /W0) + else () + target_compile_options(${_target} PRIVATE -w) + endif () + endif () + endforeach () +endfunction() + + # libslic3r, OrcaSlicer GUI and the OrcaSlicer executable. add_subdirectory(deps_src) + +if (NOT SLIC3R_BUNDLED_WARNINGS) + orcaslicer_silence_third_party_warnings("${CMAKE_CURRENT_SOURCE_DIR}/deps_src") +endif () + +# Warning level for the targets added below: our sources, plus glad and libvgcode, +# which are vendored but live under src/. The deps_src libraries were configured just +# above. CMP0092 left MSVC without a default level, so it is set here. +if (NOT SLIC3R_WARNINGS) + add_compile_options(-w) +elseif (MSVC AND NOT IS_CLANG_CL) + # /we4715 is C4715, no return from a non-void function, matching the + # -Werror=return-type the GNU/Clang builds apply. + add_compile_options(/W3 /we4715) +endif () + add_subdirectory(src) set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT OrcaSlicer_app_gui) @@ -1102,6 +1169,10 @@ endif() if(BUILD_TESTS) add_subdirectory(tests) + if (NOT SLIC3R_BUNDLED_WARNINGS) + # Catch2 is vendored under tests/ and sets its own warning flags too. + orcaslicer_silence_third_party_warnings("${CMAKE_CURRENT_SOURCE_DIR}/tests/catch2") + endif () endif() if (NOT WIN32 AND NOT APPLE) From e342698d8ef37ccf0ce1fb095192a81cd5413c53 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 24 Aug 2026 15:30:02 +0800 Subject: [PATCH 114/138] Update sublayer option check. Add validation warning for gradient mixed filament without sublayer mixing --- src/libslic3r/Print.cpp | 13 +++++ src/slic3r/GUI/MixedFilamentDialog.cpp | 24 --------- tests/fff_print/test_mixed_filament.cpp | 69 +++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 8be66da2a4..1bc1015477 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1328,6 +1328,19 @@ StringObjectException Print::validate(std::vector *warnin if (extruders.empty()) return { L("No extrusions under current settings.") }; + // Orca: a gradient mixed filament only renders its gradient with "Mixed color sublayer" on; + // without it ToolOrdering::resolve_mixed_filaments prints one whole component per layer and + // the gradient is dropped silently. extruders() already covers painting, height ranges, + // per-feature filament ids and supports, and still lists mixed slots under their own id here. + if (!m_config.enable_mixed_color_sublayer.value) { + const auto &is_mixed = m_config.filament_is_mixed.values; + const auto &gradient = m_config.filament_mixed_gradient.values; + if (std::any_of(extruders.begin(), extruders.end(), [&](unsigned int e) { + return e < is_mixed.size() && is_mixed[e] && e < gradient.size() && gradient[e]; })) + warn(L("A gradient mixed filament is used, but 'Mixed color sublayer' is disabled. The gradient will not be printed."), + "enable_mixed_color_sublayer"); + } + if (nozzles < 2 && extruders.size() > 1) { auto ret = check_multi_filament_valid(*this); if (!ret.string.empty()) diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 46563664cc..a947f0ed6c 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -23,8 +23,6 @@ #include "GradientCurveEditor.hpp" #include "FilamentBitmapUtils.hpp" #include "wxExtensions.hpp" -#include "Tab.hpp" -#include "libslic3r/Preset.hpp" #include "Widgets/Button.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/ComboBox.hpp" @@ -1492,28 +1490,6 @@ void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) void MixedFilamentDialog::on_gradient_toggled() { - // Orca: a gradient is only sliced when the print profile's "enable_mixed_color_sublayer" - // option is on; without it ToolOrdering picks a single component per whole layer. Offer to - // turn the option on instead of silently ignoring the gradient the user just enabled. - bool checked = m_chk_gradient->GetValue(); - - if (checked) { - auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; - if (!print_config.opt_bool("enable_mixed_color_sublayer")) { - wxMessageDialog dlg(this, - _L("Gradient effect requires 'Mixed color sublayer' to be enabled. Enable it now?"), - _L("Mixed Color Sublayer"), - wxYES_NO | wxICON_QUESTION); - if (dlg.ShowModal() == wxID_YES) { - DynamicPrintConfig new_conf; - new_conf.set_key_value("enable_mixed_color_sublayer", new ConfigOptionBool(true)); - wxGetApp().get_tab(Preset::TYPE_PRINT)->load_config(new_conf); - } else { - m_chk_gradient->SetValue(false); - return; - } - } - } m_result.gradient_enabled = m_chk_gradient->GetValue(); diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp index a8f1e2e84c..25b426c210 100644 --- a/tests/fff_print/test_mixed_filament.cpp +++ b/tests/fff_print/test_mixed_filament.cpp @@ -253,3 +253,72 @@ TEST_CASE("Print::validate rejects a mixed filament as the wipe tower filament", CHECK(err.opt_key == "wipe_tower_filament"); } } + +TEST_CASE("Print::validate warns when a gradient mixed filament is used without sublayer mixing", "[MixedFilament]") +{ + // A gradient mixed filament only renders its gradient with the process option enabled; without + // it ToolOrdering prints one whole component per layer and the gradient is dropped silently, + // so validate() warns whenever the slot actually takes part in the print. The layer-change + // reset avoids an unrelated relative-extrusion warning, as in the wipe tower test above. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({ + {"filament_mixed_gradient", "0,0,1"}, + {"layer_change_gcode", "G92 E0\n"}, + }); + + auto count_opt = [](Print &print, const char *opt_key) { + std::vector warnings; + print.validate(&warnings); + return std::count_if(warnings.begin(), warnings.end(), + [&](const StringObjectException &w) { return w.opt_key == opt_key; }); + }; + + SECTION("gradient slot used, sublayer mixing off") { + Print print; + Model model; + init_print({cube(20)}, print, model, config); + std::vector warnings; + const StringObjectException err = print.validate(&warnings); + CHECK(err.string.empty()); + const auto it = std::find_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) { + return w.opt_key == "enable_mixed_color_sublayer"; + }); + REQUIRE(it != warnings.end()); + CHECK(it->is_warning); + CHECK(std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) { + return w.opt_key == "enable_mixed_color_sublayer"; + }) == 1); + } + + SECTION("sublayer mixing on") { + config.set_deserialize_strict({{"enable_mixed_color_sublayer", "1"}}); + Print print; + Model model; + init_print({cube(20)}, print, model, config); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } + + SECTION("gradient flag off") { + config.set_deserialize_strict({{"filament_mixed_gradient", "0,0,0"}}); + Print print; + Model model; + init_print({cube(20)}, print, model, config); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } + + SECTION("mixed slot not used") { + config.set_deserialize_strict({ + {"outer_wall_filament_id", "0"}, + {"inner_wall_filament_id", "0"}, + {"sparse_infill_filament_id", "0"}, + {"internal_solid_filament_id", "0"}, + {"top_surface_filament_id", "0"}, + {"bottom_surface_filament_id", "0"}, + }); + Print print; + Model model; + const std::vector> overrides{{{ "extruder", "1" }}}; + init_print(std::vector{cube(20)}, print, model, config, &overrides); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } +} From 1631d3cf01a296a4a0c3ee00f940c2ecb5c912e6 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 24 Aug 2026 17:30:28 +0800 Subject: [PATCH 115/138] fix flatpak build --- scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index 94c98121ec..d081e2f997 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -276,6 +276,12 @@ modules: sha256: 27b72ba2d5ff3d0a9814ad40d4cb88f8dc89a35491c0866d952473f8f9416b77 dest: external-packages/Draco + # Assimp 5.4.3 + - type: file + url: https://github.com/assimp/assimp/archive/refs/tags/v5.4.3.tar.gz + sha256: 66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb + dest: external-packages/Assimp + # OpenSSL 1.1.1w (GNOME SDK has 3.x; OrcaSlicer requires 1.1.x) - type: file url: https://github.com/openssl/openssl/archive/OpenSSL_1_1_1w.tar.gz From 0fa25acda81354be0c7fdfa9fc54a80b6fae977a Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 25 Aug 2026 00:47:25 +0800 Subject: [PATCH 116/138] Match OrcaSlicer's color theme in the color-mixing UI --- src/slic3r/GUI/ColorDecomposeDialog.cpp | 29 +- src/slic3r/GUI/GLCanvas3D.cpp | 8 +- src/slic3r/GUI/GradientCurveEditor.cpp | 14 +- src/slic3r/GUI/MixedFilamentDialog.cpp | 88 ++--- src/slic3r/GUI/MixedFilamentDialog.hpp | 4 - src/slic3r/GUI/PlateSettingsDialog.cpp | 2 +- src/slic3r/GUI/Plater.cpp | 14 +- src/slic3r/GUI/TextureImportDialog.cpp | 427 ++++++++++-------------- src/slic3r/GUI/TextureImportDialog.hpp | 23 +- src/slic3r/GUI/Widgets/DropDown.cpp | 7 +- 10 files changed, 264 insertions(+), 352 deletions(-) diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp index c52d4f4380..ad4594f948 100644 --- a/src/slic3r/GUI/ColorDecomposeDialog.cpp +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -26,7 +26,7 @@ namespace Slic3r { namespace GUI { -static const wxColour COLOR_BRAND("#00AE42"); +static const wxColour COLOR_BRAND("#009688"); static const wxColour COLOR_BORDER_NORMAL("#EEEEEE"); static const wxColour COLOR_BG_CARD("#F8F8F8"); static const wxColour COLOR_LABEL_GREY("#ACACAC"); @@ -394,7 +394,7 @@ wxPanel* ColorDecomposeDialog::create_mode_card(wxWindow* parent, DecomposeMode auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); auto* title_label = new wxStaticText(card, wxID_ANY, title); title_label->SetFont(Label::Body_14); - title_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#909090"))); + title_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6A6A"))); match_parent_bg(title_label, StateColor::darkModeColorFor(COLOR_BG_CARD)); title_sizer->Add(title_label, 1, wxALIGN_CENTER_VERTICAL); @@ -523,7 +523,7 @@ wxBoxSizer* ColorDecomposeDialog::create_mode_selection_section() m_no_card_hint = new wxStaticText(this, wxID_ANY, _L("At least two filaments of the same material type are required for decomposition")); m_no_card_hint->SetFont(Label::Body_13); - m_no_card_hint->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#909090"))); + m_no_card_hint->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6A6A"))); m_no_card_hint->Wrap(FromDIP(400)); m_no_card_hint->Hide(); sizer->Add(m_no_card_hint, 0, wxTOP, FromDIP(8)); @@ -536,7 +536,7 @@ wxBoxSizer* ColorDecomposeDialog::create_mode_selection_section() wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); m_limit_warning_text = new wxStaticText(m_limit_warning_panel, wxID_ANY, wxEmptyString); m_limit_warning_text->SetFont(Label::Body_13); - m_limit_warning_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D32F2F"))); + m_limit_warning_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D01B1B"))); m_limit_warning_text->Wrap(FromDIP(400)); warning_sizer->Add(warn_bmp, 0, wxALIGN_TOP | wxRIGHT, FromDIP(6)); warning_sizer->Add(m_limit_warning_text, 1, wxEXPAND); @@ -553,22 +553,23 @@ wxBoxSizer* ColorDecomposeDialog::create_button_panel() sizer->AddStretchSpacer(); m_btn_cancel = new Button(this, _L("Cancel")); - m_btn_cancel->SetBackgroundColor(StateColor::darkModeColorFor(*wxWHITE)); - m_btn_cancel->SetBorderColor(StateColor::darkModeColorFor(wxColour("#CECECE"))); - m_btn_cancel->SetTextColor(StateColor::darkModeColorFor(wxColour("#262E30"))); + m_btn_cancel->SetBackgroundColor(*wxWHITE); + m_btn_cancel->SetBorderColor(wxColour("#CECECE")); + m_btn_cancel->SetTextColor(COLOR_TEXT_DARK); m_btn_cancel->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); m_btn_ok = new Button(this, _L("OK")); m_btn_ok->SetBackgroundColor(StateColor( - std::make_pair(wxColour("#C2C2C2"), (int) StateColor::Disabled), - std::make_pair(wxColour("#00AE42"), (int) StateColor::Normal))); + std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), + std::make_pair(wxColour(0, 137, 123), (int) StateColor::Pressed), + std::make_pair(COLOR_BRAND, (int) StateColor::Normal))); m_btn_ok->SetBorderColor(StateColor( - std::make_pair(wxColour("#C2C2C2"), (int) StateColor::Disabled), - std::make_pair(wxColour("#00AE42"), (int) StateColor::Normal))); - m_btn_ok->SetTextColor(StateColor( - std::make_pair(*wxWHITE, (int) StateColor::Disabled), - std::make_pair(*wxWHITE, (int) StateColor::Normal))); + std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), + std::make_pair(COLOR_BRAND, (int) StateColor::Normal))); + // Off-by-one white: plain #FFFFFF is a dark-mode key and would repaint the + // label as the window background on the accent fill. + m_btn_ok->SetTextColor(wxColour("#FFFFFE")); m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 6d03f992cd..34279369a1 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -9751,7 +9751,13 @@ void GLCanvas3D::_render_paint_toolbar() const const float text_offset_y = 4.0f * em_unit * f_scale; for (int i = 0; i < extruder_num; i++) { - decode_color(colors[i], rgba); + // A gradient slot's swatch shows its fade instead of the blended colour in `colors`, so the + // labels take their contrast from the colour printed at the middle of the fade they sit on. + if (i < (int) gradient_ramps.size() && !gradient_ramps[i].empty()) { + const wxColour& c = gradient_ramps[i][gradient_ramps[i].size() / 2]; + rgba = ColorRGBA(c.Red(), c.Green(), c.Blue(), c.Alpha()); + } else + decode_color(colors[i], rgba); float gray = 0.299 * rgba.r_uchar() + 0.587 * rgba.g_uchar() + 0.114 * rgba.b_uchar(); ImVec4 text_color = gray < 80 ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0, 0, 0, 1.0f); diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index 678fee0efc..5b1073d231 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -39,12 +39,13 @@ constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP // Light-mode design tokens. Resolved through StateColor::darkModeColorFor() // at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> -// #818183, #262E30 -> #EFEFF0, *wxWHITE -> #2D2D31). Don't read these directly in paint; -// always go through the resolved locals declared at the top of on_paint(). +// #818183, #262E30 -> #EFEFF0, #ACACAC -> #65656A, *wxWHITE -> #2D2D31). Don't read these +// directly in paint; always go through the resolved locals declared at the top of on_paint(). const wxColour kGridColor (238, 238, 238); // #EEEEEE grey 300 const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 +const wxColour kOutlineColor(172, 172, 172); // #ACACAC dimmed elements // LAB (DeltaE76) threshold for "curve color is too close to the background": below it the curve // gets a subtle outline so it does not visually vanish, otherwise it is drawn plain. Looser than @@ -321,6 +322,9 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) const wxColour label_muted = StateColor::darkModeColorFor(kLabelMuted); const wxColour label_strong = StateColor::darkModeColorFor(kLabelStrong); const wxColour point_fill = StateColor::darkModeColorFor(*wxWHITE); + // Softer than axis_color: the curve outline only has to lift the curve off the + // background, it must not compete with the structural axis / grid. + const wxColour outline_color = StateColor::darkModeColorFor(kOutlineColor); wxAutoBufferedPaintDC raw_dc(this); raw_dc.SetBackground(wxBrush(bg)); @@ -462,12 +466,6 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) // Outline only when the curve color is perceptually close to the background; otherwise // the plain filament color reads fine and the extra stroke would look heavy. - // Outline tone is intentionally softer than axis_color so it disambiguates the curve - // from the bg without competing with the structural axis/grid: light mode uses a pale - // grey, dark mode uses a slightly-above-bg grey (gDarkColors has no entry for these). - const wxColour outline_color = wxGetApp().dark_mode() - ? wxColour(90, 90, 94) // > bg #2B2B2B, < axis #818183 - : wxColour(200, 200, 200); // > grid #EEEEEE, < axis #6B6B6B auto needs_outline = [&](const wxColour& c) { return calc_color_distance(c, bg) < kBgSimilarThreshold; }; diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index a947f0ed6c..10144a5003 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -35,6 +35,9 @@ namespace GUI { static constexpr int MAX_COMPONENTS = 3; static constexpr int MIN_COMPONENT_RATIO = 10; +// Section headings and the placeholder text share one muted tone; light key, resolved at each use. +static const wxColour COLOR_LABEL_MUTED("#6B6A6A"); + // Lightweight self-painting label used for both dual-color and triple-color // ratio percentage display. Hover shows a rounded-rect background; click // fires wxEVT_LEFT_DOWN which the owning dialog binds to start_ratio_editor. @@ -92,7 +95,7 @@ private: } dc.SetFont(GetFont()); - dc.SetTextForeground(m_hovered ? wxColour("#00AE42") + dc.SetTextForeground(m_hovered ? StateColor::darkModeColorFor(wxColour("#009688")) : StateColor::darkModeColorFor(wxColour("#262E30"))); wxSize ts = dc.GetTextExtent(m_text); int x = (sz.GetWidth() - ts.GetWidth()) / 2; @@ -132,12 +135,6 @@ MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, m_result.ratios = {50, 50}; build_ui(); wxGetApp().UpdateDlgDarkUI(this); - - wxImage img; - if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_twocolor.png")), wxBITMAP_TYPE_PNG)) - m_preview_bmp_two = wxBitmap(img); - if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_threecolor.png")), wxBITMAP_TYPE_PNG)) - m_preview_bmp_three = wxBitmap(img); } MixedFilamentDialog::~MixedFilamentDialog() @@ -180,12 +177,6 @@ MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, } build_ui(); wxGetApp().UpdateDlgDarkUI(this); - - wxImage img; - if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_twocolor.png")), wxBITMAP_TYPE_PNG)) - m_preview_bmp_two = wxBitmap(img); - if (img.LoadFile(from_u8(Slic3r::var("mixed_filament_preview_threecolor.png")), wxBITMAP_TYPE_PNG)) - m_preview_bmp_three = wxBitmap(img); } void MixedFilamentDialog::on_dpi_changed(const wxRect&) @@ -609,13 +600,7 @@ void MixedFilamentDialog::commit_ratio_editor_from_background(wxMouseEvent& e) void MixedFilamentDialog::build_ui() { - const wxColour mc_bg = StateColor::darkModeColorFor(*wxWHITE); - const wxColour mc_bg_sub = StateColor::darkModeColorFor(wxColour("#F8F8F8")); - const wxColour mc_border = StateColor::darkModeColorFor(wxColour("#CECECE")); - const wxColour mc_text = StateColor::darkModeColorFor(wxColour("#262E30")); - const wxColour mc_dim_text = StateColor::darkModeColorFor(wxColour("#ACACAC")); - - SetBackgroundColour(mc_bg); + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); Bind(wxEVT_LEFT_DOWN, &MixedFilamentDialog::commit_ratio_editor_from_background, this); SetSize(FromDIP(439), FromDIP(580)); @@ -727,7 +712,7 @@ wxBoxSizer* MixedFilamentDialog::create_preview_panel() sizer->Add(m_preview_canvas, 0, wxALIGN_CENTER); auto* label = new wxStaticText(this, wxID_ANY, _L("Effect Preview")); - label->SetForegroundColour(wxColour("#909090")); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); label->SetFont(::Label::Body_13); sizer->Add(label, 0, wxALIGN_CENTER | wxTOP, FromDIP(4)); @@ -803,7 +788,7 @@ wxBoxSizer* MixedFilamentDialog::create_material_selection() sizer->Add(m_summary_panel, 0, wxEXPAND); auto* sel_label = new wxStaticText(this, wxID_ANY, _L("Select Mixed Materials")); - sel_label->SetForegroundColour(wxColour("#909090")); + sel_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); sel_label->SetFont(::Label::Body_12); sizer->Add(sel_label, 0, wxTOP, FromDIP(6)); @@ -821,7 +806,10 @@ wxBoxSizer* MixedFilamentDialog::create_material_selection() m_btn_add_material = new Button(this, _L("+ Add Material")); m_btn_add_material->SetBackgroundColor(wxColour("#F8F8F8")); m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); - m_btn_add_material->SetTextColor(wxColour("#262E30")); + // The disabled tone rides on the StateColor so Enable() alone repaints it, the way m_btn_ok does. + m_btn_add_material->SetTextColor(StateColor( + std::make_pair(wxColour("#ACACAC"), (int) StateColor::Disabled), + std::make_pair(wxColour("#262E30"), (int) StateColor::Normal))); m_btn_add_material->SetMinSize(wxSize(-1, FromDIP(24))); m_btn_add_material->SetCursor(wxCursor(wxCURSOR_HAND)); m_btn_add_material->EnableTooltipEvenDisabled(); @@ -848,7 +836,7 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() auto* sizer = new wxBoxSizer(wxVERTICAL); auto* ratio_label = new wxStaticText(this, wxID_ANY, _L("Ratio")); - ratio_label->SetForegroundColour(wxColour("#909090")); + ratio_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); ratio_label->SetFont(::Label::Body_12); sizer->Add(ratio_label, 0, wxBOTTOM, FromDIP(4)); @@ -870,7 +858,9 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() } int div_x = (int)(ratio(1) / 100.0 * sz.GetWidth()); - dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour(80, 80, 80)), FromDIP(4))); + // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over + // blended filament colour, so it has to keep its contrast against data rather than chrome. + dc.SetPen(wxPen(wxColour(80, 80, 80), FromDIP(4))); dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); dc.SetPen(wxPen(*wxWHITE, FromDIP(2))); dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); @@ -1081,7 +1071,7 @@ wxBoxSizer* MixedFilamentDialog::create_triangle_picker() int top_label_y = std::max(0, (int)(v0.y - ts0.GetHeight() - FromDIP(4))); dc.SetFont(::Label::Body_12); - dc.SetTextForeground(wxColour("#909090")); + dc.SetTextForeground(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); dc.DrawText(_L("Ratio"), FromDIP(2), top_label_y); // Position the real RatioLabelPanel children @@ -1247,7 +1237,7 @@ wxBoxSizer* MixedFilamentDialog::create_recommendation_grid() auto* rec_line = new wxPanel(this, wxID_ANY); rec_line->SetMinSize(wxSize(-1, 1)); - rec_line->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#DFDFDF"))); + rec_line->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#EEEEEE"))); title_sizer->Add(rec_line, 1, wxALIGN_CENTER_VERTICAL); outer->Add(title_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); @@ -1386,9 +1376,14 @@ wxBoxSizer* MixedFilamentDialog::create_button_panel() m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); m_btn_ok = new Button(this, _L("OK")); - m_btn_ok->SetBackgroundColor(wxColour("#00AE42")); - m_btn_ok->SetBorderColor(wxColour("#00AE42")); - m_btn_ok->SetTextColor(*wxWHITE); + m_btn_ok->SetBackgroundColor(StateColor( + std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), + std::make_pair(wxColour(0, 137, 123), (int) StateColor::Pressed), + std::make_pair(wxColour("#009688"), (int) StateColor::Normal))); + m_btn_ok->SetBorderColor(StateColor( + std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), + std::make_pair(wxColour("#009688"), (int) StateColor::Normal))); + m_btn_ok->SetTextColor(wxColour("#FFFFFE")); m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); @@ -1721,15 +1716,15 @@ void MixedFilamentDialog::paint_warning_panel(wxPaintEvent&) dc.SetPen(*wxTRANSPARENT_PEN); dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); - dc.SetBrush(wxBrush(wxColour(255, 245, 245))); - dc.SetPen(wxPen(wxColour("#E84C4C"), 1)); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#F8F8F8")))); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#D01B1B")), 1)); dc.DrawRoundedRectangle(0, 0, sz.GetWidth(), sz.GetHeight(), FromDIP(4)); int x = FromDIP(10); int cy = sz.GetHeight() / 2; int icon_r = FromDIP(7); - dc.SetBrush(wxBrush(wxColour("#E84C4C"))); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#D01B1B")))); dc.SetPen(*wxTRANSPARENT_PEN); dc.DrawCircle(x + icon_r, cy, icon_r); dc.SetFont(::Label::Body_10); @@ -1741,7 +1736,7 @@ void MixedFilamentDialog::paint_warning_panel(wxPaintEvent&) if (m_type_mismatch_msg.empty()) return; dc.SetFont(::Label::Body_12); - dc.SetTextForeground(wxColour("#E84C4C")); + dc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#D01B1B"))); wxString msg = m_type_mismatch_msg; int avail_w = sz.GetWidth() - x - FromDIP(10); wxSize ts = dc.GetTextExtent(msg); @@ -1812,20 +1807,14 @@ void MixedFilamentDialog::update_ok_button_state() } bool can_confirm = !has_type_mismatch && !has_unselected; + // Enable() alone repaints the button: its StateColor carries the disabled grey. m_btn_ok->Enable(can_confirm); - if (has_unselected) { - m_btn_ok->SetBackgroundColor(wxColour("#CECECE")); - m_btn_ok->SetBorderColor(wxColour("#CECECE")); + if (has_unselected) m_btn_ok->SetToolTip(_L("Please select a filament for all components")); - } else if (has_type_mismatch) { - m_btn_ok->SetBackgroundColor(wxColour("#CECECE")); - m_btn_ok->SetBorderColor(wxColour("#CECECE")); + else if (has_type_mismatch) m_btn_ok->SetToolTip(_L("Cannot mix different filament types")); - } else { - m_btn_ok->SetBackgroundColor(wxColour("#00AE42")); - m_btn_ok->SetBorderColor(wxColour("#00AE42")); + else m_btn_ok->SetToolTip(wxEmptyString); - } if (m_warning_panel) { m_warning_panel->Show(has_type_mismatch); @@ -1946,15 +1935,8 @@ void MixedFilamentDialog::update_component_count_ui() if (m_btn_add_material) { bool can_add = (num_components() < (size_t)MAX_COMPONENTS && m_physical_colors.size() > num_components()); m_btn_add_material->Enable(can_add); - if (can_add) { - m_btn_add_material->SetTextColor(wxColour("#262E30")); - m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); - m_btn_add_material->SetToolTip(wxEmptyString); - } else { - m_btn_add_material->SetTextColor(wxColour("#CECECE")); - m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); - m_btn_add_material->SetToolTip(is_three ? _L("Maximum 3 materials for mixing") : _L("Maximum number of components reached")); - } + m_btn_add_material->SetToolTip(can_add ? wxString() + : (is_three ? _L("Maximum 3 materials for mixing") : _L("Maximum number of components reached"))); } if (m_btn_remove_material) { diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp index 6085f77873..ea8ac5ad16 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.hpp +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -158,10 +158,6 @@ private: wxScrolledWindow* m_recommendation_scroll{nullptr}; wxWrapSizer* m_recommendation_grid{nullptr}; - // Cached preview bitmaps (loaded once at construction) - wxBitmap m_preview_bmp_two; - wxBitmap m_preview_bmp_three; - // Drag state. The ratio bar and the triangle picker capture the mouse // independently, so they must not share a flag: a mouse-up on one would // otherwise clear the other's flag and skip its ReleaseMouse(). diff --git a/src/slic3r/GUI/PlateSettingsDialog.cpp b/src/slic3r/GUI/PlateSettingsDialog.cpp index bc81335d61..07c955ef01 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.cpp +++ b/src/slic3r/GUI/PlateSettingsDialog.cpp @@ -486,7 +486,7 @@ PlateSettingsDialog::PlateSettingsDialog(wxWindow* parent, const wxString& title wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); auto *warn_text = new wxStaticText(this, wxID_ANY, _L("The filament list contains mixed filaments. Custom filament sequence will not take effect.")); - warn_text->SetForegroundColour(wxColour(255, 111, 0)); + warn_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); warn_text->SetFont(Label::Body_12); warn_text->Wrap(FromDIP(300)); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 8b0fcc3902..0772b3ae11 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3148,12 +3148,12 @@ Sidebar::Sidebar(Plater *parent) // 4) Warning bar for mixes whose components were deleted or whose types disagree. p->m_panel_mixed_warning = new wxPanel(p->scrolled, wxID_ANY); - p->m_panel_mixed_warning->SetBackgroundColour(wxColour("#FDE8E8")); + p->m_panel_mixed_warning->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); { auto* warn_sizer = new wxBoxSizer(wxHORIZONTAL); p->m_text_mixed_warning = new wxStaticText(p->m_panel_mixed_warning, wxID_ANY, _L("Mixed filament has invalid or mismatched components. Please re-edit affected entries.")); - p->m_text_mixed_warning->SetForegroundColour(wxColour("#D32F2F")); + p->m_text_mixed_warning->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D01B1B"))); p->m_text_mixed_warning->SetFont(::Label::Body_12); p->m_text_mixed_warning->Wrap(FromDIP(360)); warn_sizer->Add(p->m_text_mixed_warning, 1, wxALL, FromDIP(6)); @@ -3999,12 +3999,12 @@ void Sidebar::update_mixed_filament_list() physical_colors.push_back(colours_opt->values[i]); } - auto make_swatch_panel = [this, mc_text](wxWindow* parent, const wxColour& col, unsigned int num) -> wxPanel* { + auto make_swatch_panel = [this](wxWindow* parent, const wxColour& col, unsigned int num) -> wxPanel* { int swatch_sz = FromDIP(20); auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); bool is_dark = wxGetApp().dark_mode(); - panel->Bind(wxEVT_PAINT, [panel, col, num, mc_text, is_dark](wxPaintEvent&) { + panel->Bind(wxEVT_PAINT, [panel, col, num, is_dark](wxPaintEvent&) { wxPaintDC dc(panel); wxSize sz = panel->GetClientSize(); dc.SetBackground(wxBrush(col)); @@ -4110,7 +4110,7 @@ void Sidebar::update_mixed_filament_list() wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); grad_panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); grad_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); - grad_panel->Bind(wxEVT_PAINT, [grad_panel, gradient_ramp, mix_num, mc_text](wxPaintEvent&) { + grad_panel->Bind(wxEVT_PAINT, [grad_panel, gradient_ramp, mix_num](wxPaintEvent&) { wxBufferedPaintDC dc(grad_panel); wxSize sz = grad_panel->GetClientSize(); fill_gradient_ramp_rect(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), gradient_ramp); @@ -4119,7 +4119,7 @@ void Sidebar::update_mixed_filament_list() wxSize txt_sz = dc.GetTextExtent(txt); // The number sits at the swatch's middle, so take its contrast from the // colour printed at mid height rather than from either endpoint. - dc.SetTextForeground(gradient_ramp[gradient_ramp.size() / 2].GetLuminance() > 0.5 ? mc_text : *wxWHITE); + dc.SetTextForeground(gradient_ramp[gradient_ramp.size() / 2].GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, (sz.GetHeight() - txt_sz.GetHeight()) / 2); }); @@ -4251,7 +4251,7 @@ void Sidebar::update_mixed_filament_list() dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); wxString dash = wxT("\u2014"); wxSize dash_sz = dc.GetTextExtent(dash); - dc.SetTextForeground(wxColour("#909090")); + dc.SetTextForeground(mc_dim); dc.DrawText(dash, x + (cp_swatch_sz - dash_sz.GetWidth()) / 2, y_swatch + (cp_swatch_sz - dash_sz.GetHeight()) / 2); } diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index ef9beda4d0..1a24799a15 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -43,11 +43,6 @@ static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_NAME = "Bambu PLA Ba static bool is_dark() { return Slic3r::GUI::wxGetApp().dark_mode(); } -static wxColour dark_or(const wxColour& light, const wxColour& dark) -{ - return is_dark() ? dark : light; -} - static wxColour texture_import_gray9000() { return wxColour(38, 46, 48); @@ -58,9 +53,41 @@ static wxColour texture_import_text_colour() return StateColor::darkModeColorFor(texture_import_gray9000()); } +// StaticLine::SetLineColour stores the raw key and resolves it itself when it paints, so those +// sinks take SEPARATOR_COLOUR_KEY directly; only raw wx sinks need the resolved form below. +static constexpr const char* SEPARATOR_COLOUR_KEY = "#CECECE"; + static wxColour texture_import_separator_colour() { - return StateColor::darkModeColorFor(wxColour("#CECECE")); + return StateColor::darkModeColorFor(wxColour(SEPARATOR_COLOUR_KEY)); +} + +// Orca's confirm palette, applied here rather than through Button::SetStyle because these buttons +// keep custom pill geometry that SetStyle resets. The Disabled entries are load-bearing: without +// one, StateColor::colorForStates falls through to the Normal entry and a disabled button paints +// as a live accent button. +static void apply_accent_button_colours(Button* btn) +{ + btn->SetBackgroundColor(StateColor( + std::pair(wxColour("#CECECE"), StateColor::Disabled), + std::pair(wxColour(0, 137, 123), StateColor::Pressed), + std::pair(wxColour(38, 166, 154), StateColor::Hovered), + std::pair(wxColour(0, 150, 136), StateColor::Normal))); + btn->SetBorderColor(StateColor( + std::pair(wxColour("#CECECE"), StateColor::Disabled), + std::pair(wxColour(0, 150, 136), StateColor::Normal))); + btn->SetTextColor(StateColor( + std::pair(wxColour("#6B6B6A"), StateColor::Disabled), + std::pair(wxColour("#FFFFFE"), StateColor::Normal))); +} + +// The same button while the parameters behind it are dirty: still clickable, but reading as +// "what you see is not what this button would apply". +static void apply_muted_button_colours(Button* btn) +{ + btn->SetBackgroundColor(wxColour("#CECECE")); + btn->SetBorderColor(wxColour("#CECECE")); + btn->SetTextColor(wxColour("#6B6B6A")); } static wxFont texture_import_section_title_font(wxWindow* win) @@ -162,29 +189,16 @@ static wxString ellipsize_text(wxDC& dc, wxString text, int max_width) return text + ellipsis; } -static int draw_brand_icon_and_strip(wxDC& dc, wxWindow* win, wxString& name, int x, int cy) -{ - int icon_sz = win->FromDIP(16); - if (name.StartsWith("Bambu ")) { - name = name.Mid(6); - wxBitmap bmp = create_scaled_bitmap("BambuStudioBlack", win, 16); - if (bmp.IsOk()) - dc.DrawBitmap(bmp, x, cy - icon_sz / 2, true); - x += icon_sz + win->FromDIP(4); - } - return x; -} - // ============================================================ -// GreenSlider — thin track + green triangle thumb +// AccentSlider — thin track + accent-coloured triangle thumb // ============================================================ -class GreenSlider : public wxPanel { +class AccentSlider : public wxPanel { public: - GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, - const wxPoint& pos = wxDefaultPosition, - const wxSize& size = wxDefaultSize); - ~GreenSlider() override; + AccentSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize); + ~AccentSlider() override; int GetValue() const; void SetValue(int val); bool Enable(bool enable = true) override; @@ -197,8 +211,8 @@ private: bool m_dragging = false; }; -GreenSlider::GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, - const wxPoint& pos, const wxSize& size) +AccentSlider::AccentSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos, const wxSize& size) : wxPanel(parent, wxID_ANY, pos, size.IsFullySpecified() ? size : wxSize(-1, parent->FromDIP(24)), wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE) , m_value(std::clamp(value, minVal, maxVal)), m_min(minVal), m_max(maxVal) @@ -206,18 +220,18 @@ GreenSlider::GreenSlider(wxWindow* parent, int value, int minVal, int maxVal, SetBackgroundStyle(wxBG_STYLE_PAINT); SetMinSize(wxSize(-1, FromDIP(24))); - Bind(wxEVT_PAINT, &GreenSlider::OnPaint, this); + Bind(wxEVT_PAINT, &AccentSlider::OnPaint, this); Bind(wxEVT_SIZE, [this](wxSizeEvent& evt) { evt.Skip(); Refresh(); }); - Bind(wxEVT_LEFT_DOWN, &GreenSlider::OnMouse, this); - Bind(wxEVT_LEFT_UP, &GreenSlider::OnMouse, this); - Bind(wxEVT_MOTION, &GreenSlider::OnMouse, this); + Bind(wxEVT_LEFT_DOWN, &AccentSlider::OnMouse, this); + Bind(wxEVT_LEFT_UP, &AccentSlider::OnMouse, this); + Bind(wxEVT_MOTION, &AccentSlider::OnMouse, this); Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { m_dragging = false; }); } -GreenSlider::~GreenSlider() +AccentSlider::~AccentSlider() { // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it // still holds the capture wedges mouse input for the whole application. @@ -225,22 +239,22 @@ GreenSlider::~GreenSlider() ReleaseMouse(); } -int GreenSlider::GetValue() const { return m_value; } +int AccentSlider::GetValue() const { return m_value; } -void GreenSlider::SetValue(int val) +void AccentSlider::SetValue(int val) { val = std::clamp(val, m_min, m_max); if (val != m_value) { m_value = val; Refresh(); } } -bool GreenSlider::Enable(bool enable) +bool AccentSlider::Enable(bool enable) { bool ok = wxPanel::Enable(enable); Refresh(); return ok; } -int GreenSlider::xFromValue() const +int AccentSlider::xFromValue() const { wxSize sz = GetClientSize(); int margin = FromDIP(6); @@ -249,7 +263,7 @@ int GreenSlider::xFromValue() const return margin + (m_value - m_min) * track_w / (m_max - m_min); } -int GreenSlider::valueFromX(int x) const +int AccentSlider::valueFromX(int x) const { wxSize sz = GetClientSize(); int margin = FromDIP(6); @@ -259,7 +273,7 @@ int GreenSlider::valueFromX(int x) const return std::clamp(val, m_min, m_max); } -void GreenSlider::OnPaint(wxPaintEvent&) +void AccentSlider::OnPaint(wxPaintEvent&) { wxAutoBufferedPaintDC dc(this); wxSize sz = GetClientSize(); @@ -272,17 +286,15 @@ void GreenSlider::OnPaint(wxPaintEvent&) int ts = FromDIP(8); int pen_w = FromDIP(2); - wxColour greenClr = IsEnabled() ? wxColour(0, 174, 66) - : dark_or(wxColour(180, 180, 180), wxColour(90, 90, 96)); - wxColour grayClr = IsEnabled() ? dark_or(wxColour(200, 200, 200), wxColour(90, 90, 96)) - : dark_or(wxColour(220, 220, 220), wxColour(70, 70, 76)); + wxColour accent_clr = StateColor::darkModeColorFor(IsEnabled() ? wxColour("#009688") : wxColour("#ACACAC")); + wxColour track_clr = StateColor::darkModeColorFor(IsEnabled() ? wxColour("#CECECE") : wxColour("#DFDFDF")); int tx = xFromValue(); - dc.SetPen(wxPen(greenClr, pen_w)); + dc.SetPen(wxPen(accent_clr, pen_w)); dc.DrawLine(margin, track_y, tx, track_y); - dc.SetPen(wxPen(grayClr, pen_w)); + dc.SetPen(wxPen(track_clr, pen_w)); dc.DrawLine(tx, track_y, sz.x - margin, track_y); wxPoint tri[3] = { @@ -290,12 +302,12 @@ void GreenSlider::OnPaint(wxPaintEvent&) {tx - ts / 2, track_y + FromDIP(1) + ts}, {tx + ts / 2, track_y + FromDIP(1) + ts} }; - dc.SetBrush(wxBrush(greenClr)); + dc.SetBrush(wxBrush(accent_clr)); dc.SetPen(*wxTRANSPARENT_PEN); dc.DrawPolygon(3, tri); } -void GreenSlider::OnMouse(wxMouseEvent& evt) +void AccentSlider::OnMouse(wxMouseEvent& evt) { if (!IsEnabled()) return; @@ -516,7 +528,7 @@ public: , m_on_close(std::move(on_close)) , m_display_numbers(std::move(display_numbers)) { - wxColour pop_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + wxColour pop_bg = StateColor::darkModeColorFor(*wxWHITE); SetBackgroundColour(pop_bg); m_content = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); @@ -528,7 +540,7 @@ public: const int row_h = FromDIP(32); const int pad = FromDIP(8); const int max_visible_rows = 10; - const wxColour header_clr = dark_or(wxColour(0xAC, 0xAC, 0xAC), wxColour(0x81, 0x81, 0x83)); + const wxColour header_clr = StateColor::darkModeColorFor(wxColour("#ACACAC")); auto add_section_header = [&](const wxString& label) { auto* hdr = new wxStaticText(m_content, wxID_ANY, label); @@ -538,7 +550,7 @@ public: hdr->SetForegroundColour(header_clr); outer->Add(hdr, 0, wxLEFT | wxRIGHT | wxTOP, pad); auto* line = new StaticLine(m_content); - line->SetLineColour(texture_import_separator_colour()); + line->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); outer->Add(line, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); }; @@ -577,8 +589,9 @@ public: add_label->SetFont(af); decompose_label->SetFont(af); const bool add_enabled = !m_can_add_filament || m_can_add_filament(); - add_label->SetForegroundColour(add_enabled ? wxColour(0x00, 0xAE, 0x42) : header_clr); - decompose_label->SetForegroundColour(add_enabled ? wxColour(0x00, 0xAE, 0x42) : header_clr); + const wxColour action_clr = StateColor::darkModeColorFor(wxColour("#009688")); + add_label->SetForegroundColour(add_enabled ? action_clr : header_clr); + decompose_label->SetForegroundColour(add_enabled ? action_clr : header_clr); add_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); decompose_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); if (!add_enabled) @@ -634,11 +647,11 @@ public: top_sizer->AddSpacer(FromDIP(4)); auto* sep_line = new StaticLine(this); - sep_line->SetLineColour(texture_import_separator_colour()); + sep_line->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); top_sizer->Add(sep_line, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); top_sizer->Add(decompose_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); auto* sep_line2 = new StaticLine(this); - sep_line2->SetLineColour(texture_import_separator_colour()); + sep_line2->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); top_sizer->Add(sep_line2, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); top_sizer->Add(add_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); SetSizerAndFit(top_sizer); @@ -676,8 +689,8 @@ private: wxPanel* create_item_row(size_t idx, int row_h) { - wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); - wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); wxColour name_fg = texture_import_text_colour(); wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); @@ -731,15 +744,14 @@ private: dc.DrawText(ns, sq_x + (sq - tsz.x) / 2, sq_y + (sq - tsz.y) / 2); } - // Brand icon + material name + // Material name { wxFont mf = p->GetFont(); mf.SetPointSize(10); dc.SetFont(mf); dc.SetTextForeground(name_fg); - wxString display = name_str; - int tx = draw_brand_icon_and_strip(dc, p, display, sq_x + sq + gap1, sz.y / 2); - display = ellipsize_text(dc, display, sz.x - tx - p->FromDIP(4)); + int tx = sq_x + sq + gap1; + wxString display = ellipsize_text(dc, name_str, sz.x - tx - p->FromDIP(4)); wxSize tsz = dc.GetTextExtent(display); if (!display.empty()) dc.DrawText(display, tx, (sz.y - tsz.y) / 2); @@ -772,10 +784,9 @@ private: wxPanel* create_mixed_item_row(const TextureFilamentEntry& entry, int row_h) { - wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); - wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); wxColour name_fg = texture_import_text_colour(); - wxColour plus_fg = dark_or(wxColour(38, 46, 48), wxColour(0xE6, 0xE6, 0xE8)); const int idx = entry.dialog_index; wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); @@ -784,7 +795,7 @@ private: row->SetCursor(wxCursor(wxCURSOR_HAND)); row->SetToolTip(entry.name.empty() ? wxString::Format("Filament %d", display_number(idx)) : filament_name_to_wx_string(entry.name)); - row->Bind(wxEVT_PAINT, [this, entry, idx, row_bg, hover_bg, name_fg, plus_fg](wxPaintEvent& e) { + row->Bind(wxEVT_PAINT, [this, entry, idx, row_bg, hover_bg, name_fg](wxPaintEvent& e) { auto* p = static_cast(e.GetEventObject()); wxAutoBufferedPaintDC dc(p); wxSize sz = p->GetClientSize(); @@ -803,7 +814,7 @@ private: for (size_t ci = 0; ci < entry.mixed_components.size() && ci < entry.mixed_ratios.size(); ++ci) { if (ci > 0) { - dc.SetTextForeground(plus_fg); + dc.SetTextForeground(name_fg); wxString plus = "+"; wxSize psz = dc.GetTextExtent(plus); dc.DrawText(plus, x, (sz.y - psz.y) / 2); @@ -907,7 +918,7 @@ public: , m_on_select(std::move(on_select)) , m_on_close(std::move(on_close)) { - wxColour pop_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + wxColour pop_bg = StateColor::darkModeColorFor(*wxWHITE); SetBackgroundColour(pop_bg); auto* content = new wxPanel(this, wxID_ANY); @@ -939,10 +950,10 @@ private: wxPanel* create_item_row(wxWindow* parent, TextureAutoMixMode mode, int row_h) { - wxColour row_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); - wxColour hover_bg = dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)); + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); wxColour text_fg = texture_import_text_colour(); - wxColour green = wxColour(0, 174, 66); + wxColour accent = StateColor::darkModeColorFor(wxColour("#009688")); wxPanel* row = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h), wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); @@ -951,7 +962,7 @@ private: row->SetCursor(wxCursor(wxCURSOR_HAND)); const int row_idx = mode == TextureAutoMixMode::CMYW ? 0 : 1; - row->Bind(wxEVT_PAINT, [this, row_bg, hover_bg, text_fg, green, mode, row_idx](wxPaintEvent& e) { + row->Bind(wxEVT_PAINT, [this, row_bg, hover_bg, text_fg, accent, mode, row_idx](wxPaintEvent& e) { auto* p = static_cast(e.GetEventObject()); wxAutoBufferedPaintDC dc(p); wxSize sz = p->GetClientSize(); @@ -975,7 +986,7 @@ private: check_font.SetPointSize(12); check_font.MakeBold(); dc.SetFont(check_font); - dc.SetTextForeground(green); + dc.SetTextForeground(accent); wxString check = wxString::FromUTF8("✓"); wxSize csz = dc.GetTextExtent(check); dc.DrawText(check, sz.x - p->FromDIP(16) - csz.x, (sz.y - csz.y) / 2); @@ -1286,13 +1297,13 @@ void TexturePreviewCanvas::upload_reset_icon_textures() return; if (!m_reset_icon_tex) - m_reset_icon_tex = upload_reset_icon_texture("fit_camera"); + m_reset_icon_tex = upload_reset_icon_texture("canvas_zoom"); if (!m_reset_icon_hover_tex) - m_reset_icon_hover_tex = upload_reset_icon_texture("fit_camera_hover"); + m_reset_icon_hover_tex = upload_reset_icon_texture("canvas_zoom_hover"); if (!m_reset_icon_dark_tex) - m_reset_icon_dark_tex = upload_reset_icon_texture("fit_camera_dark"); + m_reset_icon_dark_tex = upload_reset_icon_texture("canvas_zoom_dark"); if (!m_reset_icon_dark_hover_tex) - m_reset_icon_dark_hover_tex = upload_reset_icon_texture("fit_camera_dark_hover"); + m_reset_icon_dark_hover_tex = upload_reset_icon_texture("canvas_zoom_dark_hover"); } bool TexturePreviewCanvas::handle_reset_overlay_mouse(wxMouseEvent& evt) @@ -1475,10 +1486,9 @@ void TexturePreviewCanvas::render() wxSize viewport_sz = gl_viewport_size(this, sz); glViewport(0, 0, viewport_sz.x, viewport_sz.y); - if (is_dark()) - glClearColor(0.24f, 0.24f, 0.27f, 1.0f); - else - glClearColor(0.933f, 0.933f, 0.933f, 1.0f); + // Same palette key as the preview container, so canvas and frame cannot drift apart. + const wxColour clear_clr = StateColor::darkModeColorFor(wxColour("#EEEEEE")); + glClearColor(clear_clr.Red() / 255.f, clear_clr.Green() / 255.f, clear_clr.Blue() / 255.f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glMatrixMode(GL_PROJECTION); @@ -1886,14 +1896,14 @@ int TextureImportDialog::ShowModal() void TextureImportDialog::build_ui() { - const wxColour dialog_bg = dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)); + const wxColour dialog_bg = StateColor::darkModeColorFor(*wxWHITE); SetBackgroundColour(dialog_bg); - SetForegroundColour(dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0))); + SetForegroundColour(StateColor::darkModeColorFor(wxColour("#323A3D"))); wxBoxSizer* root_sizer = new wxBoxSizer(wxVERTICAL); auto line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1)); - line_top->SetBackgroundColour(dark_or(wxColour(166, 169, 170), wxColour(80, 80, 86))); + line_top->SetBackgroundColour(texture_import_separator_colour()); root_sizer->Add(line_top, 0, wxEXPAND); wxBoxSizer* main_sizer = new wxBoxSizer(wxHORIZONTAL); @@ -1936,8 +1946,8 @@ void TextureImportDialog::build_ui() void TextureImportDialog::build_preview_panel(wxWindow* parent, wxSizer* sizer) { - wxColour preview_bg = dark_or(wxColour(238, 238, 238), wxColour(0x3E, 0x3E, 0x45)); - wxColour preview_bd = dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)); + wxColour preview_bg = StateColor::darkModeColorFor(wxColour("#EEEEEE")); + wxColour preview_bd = texture_import_separator_colour(); wxPanel* preview_container = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); preview_container->SetBackgroundColour(preview_bg); @@ -2044,7 +2054,7 @@ void TextureImportDialog::build_preview_panel(wxWindow* parent, wxSizer* sizer) void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) { - wxColour label_fg = dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0)); + wxColour label_fg = StateColor::darkModeColorFor(wxColour("#323A3D")); wxBoxSizer* color_header_sizer = new wxBoxSizer(wxHORIZONTAL); wxStaticText* lbl_colors = new wxStaticText(parent, wxID_ANY, _L("Color Count")); @@ -2063,18 +2073,18 @@ void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) { StateColor preset_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed | StateColor::Checked), - std::pair(wxColour(61, 203, 115), StateColor::Hovered | StateColor::Checked), - std::pair(wxColour(0, 174, 66), StateColor::Checked), - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), - std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + std::pair(wxColour(0, 137, 123), StateColor::Pressed | StateColor::Checked), + std::pair(wxColour(38, 166, 154), StateColor::Hovered | StateColor::Checked), + std::pair(wxColour(0, 150, 136), StateColor::Checked), + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); StateColor preset_bd( - std::pair(wxColour(0, 174, 66), StateColor::Checked), - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); + std::pair(wxColour(0, 150, 136), StateColor::Checked), + std::pair(wxColour("#CECECE"), StateColor::Normal)); StateColor preset_text( - std::pair(wxColour(255, 255, 255), StateColor::Checked), - std::pair(dark_or(wxColour(50, 58, 61), wxColour(0xEF, 0xEF, 0xF0)), StateColor::Normal)); + std::pair(wxColour("#FFFFFE"), StateColor::Checked), + std::pair(wxColour("#323A3D"), StateColor::Normal)); for (auto* btn : {m_btn_color_4, m_btn_color_8, m_btn_color_16}) { btn->SetCornerRadius(FromDIP(12)); @@ -2093,7 +2103,7 @@ void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) sizer->Add(color_header_sizer, 0, wxBOTTOM, FromDIP(4)); wxBoxSizer* color_slider_sizer = new wxBoxSizer(wxHORIZONTAL); - m_color_slider = new GreenSlider(parent, m_param_color_count, 1, (int)max_filament_count()); + m_color_slider = new AccentSlider(parent, m_param_color_count, 1, (int)max_filament_count()); m_color_spin = new SpinInput(parent, wxString::Format("%d", m_param_color_count), wxEmptyString, wxDefaultPosition, wxSize(FromDIP(60), FromDIP(28)), @@ -2113,7 +2123,7 @@ void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) sizer->Add(lbl_smooth, 0, wxBOTTOM, FromDIP(4)); wxBoxSizer* smooth_sizer = new wxBoxSizer(wxHORIZONTAL); - m_smooth_slider = new GreenSlider(parent, m_param_smooth, 0, 10); + m_smooth_slider = new AccentSlider(parent, m_param_smooth, 0, 10); m_smooth_spin = new SpinInput(parent, wxString::Format("%d", m_param_smooth), wxEmptyString, wxDefaultPosition, wxSize(FromDIP(60), FromDIP(28)), @@ -2132,25 +2142,23 @@ void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) { StateColor btn_bg_white( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), - std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); - StateColor btn_bd_green( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor btn_text_green( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour btn_bd_accent = wxColour(0, 150, 136); + const wxColour btn_text_accent = wxColour(0, 150, 136); m_btn_color_auto->SetCornerRadius(FromDIP(12)); m_btn_color_auto->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); m_btn_color_auto->SetBackgroundColor(btn_bg_white); - m_btn_color_auto->SetBorderColor(btn_bd_green); - m_btn_color_auto->SetTextColor(btn_text_green); + m_btn_color_auto->SetBorderColor(btn_bd_accent); + m_btn_color_auto->SetTextColor(btn_text_accent); m_btn_apply->SetCornerRadius(FromDIP(12)); m_btn_apply->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); m_btn_apply->SetBackgroundColor(btn_bg_white); - m_btn_apply->SetBorderColor(btn_bd_green); - m_btn_apply->SetTextColor(btn_text_green); + m_btn_apply->SetBorderColor(btn_bd_accent); + m_btn_apply->SetTextColor(btn_text_accent); } // Defer attaching the Auto/Apply tooltips until the dialog has actually @@ -2179,19 +2187,19 @@ void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) m_hint_label = new wxStaticText(parent, wxID_ANY, _L("Reminder: parameters changed, click Apply to take effect")); - m_hint_label->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_hint_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); m_hint_label->SetFont(texture_import_section_title_font(parent)); m_hint_label->Hide(); sizer->Add(m_hint_label, 0, wxBOTTOM, FromDIP(4)); auto* mapping_separator = new StaticLine(parent); - mapping_separator->SetLineColour(texture_import_separator_colour()); + mapping_separator->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); sizer->Add(mapping_separator, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); } void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) { - wxColour secondary_fg = dark_or(wxColour(107, 107, 107), wxColour(0x81, 0x81, 0x83)); + wxColour secondary_fg = StateColor::darkModeColorFor(wxColour("#6B6B6B")); wxBoxSizer* header_sizer = new wxBoxSizer(wxHORIZONTAL); @@ -2206,9 +2214,9 @@ void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) m_btn_mix_reset->SetPaddingSize(wxSize(FromDIP(2), FromDIP(2))); { StateColor reset_bg( - std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)), StateColor::Pressed), - std::pair(dark_or(wxColour(248, 248, 248), wxColour(0x35, 0x35, 0x3A)), StateColor::Hovered), - std::pair(dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); + std::pair(wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); m_btn_mix_reset->SetBackgroundColor(reset_bg); m_btn_mix_reset->SetBorderColor(StateColor()); } @@ -2232,13 +2240,11 @@ void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) m_btn_auto_mix->SetMinSize(wxSize(FromDIP(178), FromDIP(28))); { StateColor btn_bg( - std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x3C, 0x3C, 0x42)), StateColor::Pressed), - std::pair(dark_or(wxColour(248, 248, 248), wxColour(0x35, 0x35, 0x3A)), StateColor::Hovered), - std::pair(dark_or(*wxWHITE, wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); - StateColor btn_bd( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor btn_text( - std::pair(texture_import_text_colour(), StateColor::Normal)); + std::pair(wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour btn_bd = wxColour("#CECECE"); + const wxColour btn_text = texture_import_gray9000(); m_btn_auto_mix->SetBackgroundColor(btn_bg); m_btn_auto_mix->SetBorderColor(btn_bd); m_btn_auto_mix->SetTextColor(btn_text); @@ -2269,7 +2275,7 @@ void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) m_mapping_scroll = new wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(300))); m_mapping_scroll->SetScrollRate(0, FromDIP(10)); - m_mapping_scroll->SetBackgroundColour(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31))); + m_mapping_scroll->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); m_mapping_scroll->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); m_mapping_sizer = new wxBoxSizer(wxVERTICAL); @@ -2284,7 +2290,7 @@ void TextureImportDialog::build_bottom_buttons(wxSizer* sizer) wxString::Format( _L("The project supports up to %d filaments. Extra filaments will be discarded."), (int)max_filament_count())); - m_drop_warning_label->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_drop_warning_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); m_drop_warning_label->SetFont(texture_import_section_title_font(this)); m_drop_warning_label->Hide(); sizer->Add(m_drop_warning_label, 0, wxALIGN_LEFT | wxBOTTOM, FromDIP(4)); @@ -2297,13 +2303,11 @@ void TextureImportDialog::build_bottom_buttons(wxSizer* sizer) m_btn_skip->SetMinSize(wxSize(FromDIP(136), FromDIP(40))); { StateColor skip_bg( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Pressed), - std::pair(dark_or(wxColour(238, 238, 238), wxColour(0x4C, 0x4C, 0x55)), StateColor::Hovered), - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x2D, 0x2D, 0x31)), StateColor::Normal)); - StateColor skip_bd( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor skip_text( - std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour skip_bd = wxColour("#CECECE"); + const wxColour skip_text = wxColour("#6B6B6A"); m_btn_skip->SetBackgroundColor(skip_bg); m_btn_skip->SetBorderColor(skip_bd); m_btn_skip->SetTextColor(skip_text); @@ -2313,19 +2317,7 @@ void TextureImportDialog::build_bottom_buttons(wxSizer* sizer) m_btn_ok->SetId(wxID_OK); m_btn_ok->SetCornerRadius(FromDIP(20)); m_btn_ok->SetMinSize(wxSize(FromDIP(156), FromDIP(40))); - { - StateColor ok_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed), - std::pair(wxColour(61, 203, 115), StateColor::Hovered), - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_bd( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_text( - std::pair(wxColour("#FFFFFE"), StateColor::Normal)); - m_btn_ok->SetBackgroundColor(ok_bg); - m_btn_ok->SetBorderColor(ok_bd); - m_btn_ok->SetTextColor(ok_text); - } + apply_accent_button_colours(m_btn_ok); btn_sizer->AddStretchSpacer(); btn_sizer->Add(m_btn_skip, 0, wxRIGHT, FromDIP(16)); @@ -2372,36 +2364,10 @@ void TextureImportDialog::update_ui_for_state() m_preview_canvas->set_computing_overlay(computing); - if (ready && valid && is_params_dirty()) { - m_btn_ok->Enable(true); - StateColor gray_bg( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor gray_bd( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor gray_text( - std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); - m_btn_ok->SetBackgroundColor(gray_bg); - m_btn_ok->SetBorderColor(gray_bd); - m_btn_ok->SetTextColor(gray_text); - m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); - if (m_hint_label) m_hint_label->Show(); - } else if (ready && valid) { - StateColor ok_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed), - std::pair(wxColour(61, 203, 115), StateColor::Hovered), - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_bd( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_text( - std::pair(wxColour("#FFFFFE"), StateColor::Normal)); - m_btn_ok->SetBackgroundColor(ok_bg); - m_btn_ok->SetBorderColor(ok_bd); - m_btn_ok->SetTextColor(ok_text); - m_btn_ok->UnsetToolTip(); - if (m_hint_label) m_hint_label->Hide(); - } else { - if (m_hint_label) m_hint_label->Hide(); - } + if (ready && valid) + style_confirm_button(is_params_dirty()); + else if (m_hint_label) + m_hint_label->Hide(); m_btn_ok->Refresh(); Layout(); @@ -2687,33 +2653,16 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) _L("Mesh repair"), wxYES_NO | wxICON_WARNING | wxYES_DEFAULT); dlg.SetButtonLabel(wxID_YES, _L("Import without repair")); dlg.SetButtonLabel(wxID_NO, _L("Repair and import"), true); - StateColor primary_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed), - std::pair(wxColour(61, 203, 115), StateColor::Hovered), - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor primary_bd( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor primary_text( - std::pair(wxColour("#FFFFFE"), StateColor::Normal)); - StateColor secondary_bg( - std::pair(wxColour("#CECECE"), StateColor::Pressed), - std::pair(wxColour("#EEEEEE"), StateColor::Hovered), - std::pair(*wxWHITE, StateColor::Normal)); - StateColor secondary_bd( - std::pair(texture_import_gray9000(), StateColor::Normal)); - StateColor secondary_text( - std::pair(texture_import_gray9000(), StateColor::Normal)); + // "Repair and import" is the recommended action here, so the accent moves off the default YES + // button onto NO. MsgDialog::add_button already styled both as ButtonType::Choice, so restyling + // with the same type swaps only the palette and leaves the geometry alone. if (auto* yes_btn = dynamic_cast(dlg.FindWindow(wxID_YES))) { + yes_btn->SetStyle(ButtonStyle::Regular, ButtonType::Choice); yes_btn->SetMinSize(wxSize(FromDIP(180), FromDIP(24))); - yes_btn->SetBackgroundColor(secondary_bg); - yes_btn->SetBorderColor(secondary_bd); - yes_btn->SetTextColor(secondary_text); } if (auto* no_btn = dynamic_cast(dlg.FindWindow(wxID_NO))) { + no_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); no_btn->SetMinSize(wxSize(FromDIP(160), FromDIP(24))); - no_btn->SetBackgroundColor(primary_bg); - no_btn->SetBorderColor(primary_bd); - no_btn->SetTextColor(primary_text); } dlg.Layout(); dlg.Fit(); @@ -3780,12 +3729,12 @@ void TextureImportDialog::rebuild_mapping_rows() return wxString::Format("Filament %d", display_number(idx)); }; - const wxColour dash_clr = dark_or(wxColour(179, 179, 179), wxColour(100, 100, 106)); + const wxColour dash_clr = StateColor::darkModeColorFor(wxColour("#ACACAC")); const wxColour hex_fg = texture_import_text_colour(); - const wxColour card_bg = dark_or(wxColour(235, 235, 235), wxColour(0x3C, 0x3C, 0x42)); - const wxColour card_bd = dark_or(wxColour(224, 224, 224), wxColour(0x46, 0x46, 0x4C)); + const wxColour card_bg = StateColor::darkModeColorFor(wxColour("#E8E8E8")); + const wxColour card_bd = StateColor::darkModeColorFor(wxColour("#DBDBDB")); const wxColour name_fg = texture_import_text_colour(); - const wxColour chev_clr = dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)); + const wxColour chev_clr = StateColor::darkModeColorFor(wxColour("#6B6B6A")); m_mapping_rows.resize(m_current_matches.size()); for (size_t ci = 0; ci < m_current_matches.size(); ++ci) { @@ -4003,14 +3952,14 @@ void TextureImportDialog::rebuild_mapping_rows() dc.DrawText(num_str, sq_x + (sq - nsz.x) / 2, sq_y + (sq - nsz.y) / 2); } - // Brand icon + material name + // Material name { wxFont name_font = p->GetFont(); name_font.SetPointSize(9); dc.SetFont(name_font); dc.SetTextForeground(name_fg); wxString name_str = get_filament_label(fil_idx); - int text_x = draw_brand_icon_and_strip(dc, p, name_str, sq_x + sq + p->FromDIP(8), sz.y / 2); + int text_x = sq_x + sq + p->FromDIP(8); int max_text_w = sz.x - text_x - p->FromDIP(24); if (max_text_w > 0) { name_str = ellipsize_text(dc, name_str, max_text_w); @@ -4097,7 +4046,7 @@ void TextureImportDialog::set_smooth_value(int value, bool update_spin) update_confirm_button_state(); } -void TextureImportDialog::preview_spin_text_value(SpinInput* spin, GreenSlider* slider, int& param, +void TextureImportDialog::preview_spin_text_value(SpinInput* spin, AccentSlider* slider, int& param, int min_value, int max_value, const wxString& text, std::function on_value_changed) { @@ -4204,30 +4153,22 @@ void TextureImportDialog::highlight_view_button(int view_index) { Button* btns[] = { m_btn_view_original, m_btn_view_multicolor }; - StateColor active_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed), - std::pair(wxColour(61, 203, 115), StateColor::Hovered), - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor active_bd( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor active_text( - std::pair(wxColour(255, 255, 255), StateColor::Normal)); - + // The inactive pill lies on m_tab_panel, which is preview_bg (#EEEEEE -> #4C4C55), and has to + // read as raised above that strip in both themes — so its fill steps away from the strip in + // opposite directions. gDarkColors pairs one light tone with one dark tone and cannot express + // an inversion, so the two are picked here the way filament_swatch_border_colour() does. + const bool dark_pill = is_dark(); StateColor inactive_bg( - std::pair(dark_or(wxColour(245, 245, 245), wxColour(0x5C, 0x5C, 0x64)), StateColor::Pressed), - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x66, 0x66, 0x6E)), StateColor::Hovered), - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor inactive_bd( - std::pair(dark_or(wxColour(255, 255, 255), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor inactive_text( - std::pair(dark_or(wxColour(104, 104, 104), wxColour(0xD0, 0xD0, 0xD2)), StateColor::Normal)); + std::pair(dark_pill ? wxColour(0x5C, 0x5C, 0x64) : wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(dark_pill ? wxColour(0x66, 0x66, 0x6E) : wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(dark_pill ? wxColour(0x54, 0x54, 0x5B) : *wxWHITE, StateColor::Normal)); + const wxColour inactive_bd = dark_pill ? wxColour(0x54, 0x54, 0x5B) : *wxWHITE; + const wxColour inactive_text = wxColour("#6B6B6A"); for (int i = 0; i < 2; ++i) { if (!btns[i]) continue; if (i == view_index) { - btns[i]->SetBackgroundColor(active_bg); - btns[i]->SetBorderColor(active_bd); - btns[i]->SetTextColor(active_text); + apply_accent_button_colours(btns[i]); } else { btns[i]->SetBackgroundColor(inactive_bg); btns[i]->SetBorderColor(inactive_bd); @@ -4298,42 +4239,28 @@ void TextureImportDialog::update_confirm_button_state() return; } - bool dirty = is_params_dirty(); - m_btn_ok->Enable(true); - - if (dirty) { - StateColor gray_bg( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor gray_bd( - std::pair(dark_or(wxColour(206, 206, 206), wxColour(0x54, 0x54, 0x5B)), StateColor::Normal)); - StateColor gray_text( - std::pair(dark_or(wxColour(107, 107, 107), wxColour(0xB3, 0xB3, 0xB5)), StateColor::Normal)); - m_btn_ok->SetBackgroundColor(gray_bg); - m_btn_ok->SetBorderColor(gray_bd); - m_btn_ok->SetTextColor(gray_text); - m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); - if (m_hint_label) m_hint_label->Show(); - } else { - StateColor ok_bg( - std::pair(wxColour(27, 136, 68), StateColor::Pressed), - std::pair(wxColour(61, 203, 115), StateColor::Hovered), - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_bd( - std::pair(wxColour(0, 174, 66), StateColor::Normal)); - StateColor ok_text( - std::pair(wxColour(255, 255, 255), StateColor::Normal)); - m_btn_ok->SetBackgroundColor(ok_bg); - m_btn_ok->SetBorderColor(ok_bd); - m_btn_ok->SetTextColor(ok_text); - m_btn_ok->UnsetToolTip(); - if (m_hint_label) m_hint_label->Hide(); - } + style_confirm_button(is_params_dirty()); m_btn_ok->Refresh(); Layout(); } +// Both state updaters land here: the Confirm button reads as accent only while it would apply +// exactly what the preview shows. +void TextureImportDialog::style_confirm_button(bool dirty) +{ + if (dirty) { + apply_muted_button_colours(m_btn_ok); + m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); + } else { + apply_accent_button_colours(m_btn_ok); + m_btn_ok->UnsetToolTip(); + } + if (m_hint_label) + m_hint_label->Show(dirty); +} + void TextureImportDialog::on_ok_clicked(wxCommandEvent&) { if (m_state != TextureImportState::Ready || !has_valid_result() || is_params_dirty()) diff --git a/src/slic3r/GUI/TextureImportDialog.hpp b/src/slic3r/GUI/TextureImportDialog.hpp index 3d7ba43c31..960bac6145 100644 --- a/src/slic3r/GUI/TextureImportDialog.hpp +++ b/src/slic3r/GUI/TextureImportDialog.hpp @@ -27,7 +27,7 @@ #include #include -class GreenSlider; +class AccentSlider; namespace Slic3r { namespace GUI { @@ -299,7 +299,7 @@ private: void set_color_count_value(int value, bool update_spin); void set_smooth_value(int value, bool update_spin); - void preview_spin_text_value(SpinInput* spin, GreenSlider* slider, int& param, + void preview_spin_text_value(SpinInput* spin, AccentSlider* slider, int& param, int min_value, int max_value, const wxString& text, std::function on_value_changed = {}); void update_color_count_preset_buttons(); @@ -307,6 +307,7 @@ private: bool has_valid_result() const; bool is_params_dirty() const; void update_confirm_button_state(); + void style_confirm_button(bool dirty); Slic3r::TexturedMesh m_textured_mesh; std::vector m_filament_color_strs; // existing + virtual @@ -351,15 +352,15 @@ private: Slic3r::TexturePaintingSettings::MeshRepairDecision m_mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::Ask; - Button* m_btn_color_4 = nullptr; - Button* m_btn_color_8 = nullptr; - Button* m_btn_color_16 = nullptr; - Button* m_btn_color_auto = nullptr; - GreenSlider* m_color_slider = nullptr; - SpinInput* m_color_spin = nullptr; - GreenSlider* m_smooth_slider = nullptr; - SpinInput* m_smooth_spin = nullptr; - Button* m_btn_apply = nullptr; + Button* m_btn_color_4 = nullptr; + Button* m_btn_color_8 = nullptr; + Button* m_btn_color_16 = nullptr; + Button* m_btn_color_auto = nullptr; + AccentSlider* m_color_slider = nullptr; + SpinInput* m_color_spin = nullptr; + AccentSlider* m_smooth_slider = nullptr; + SpinInput* m_smooth_spin = nullptr; + Button* m_btn_apply = nullptr; wxCheckBox* m_auto_merge_cb = nullptr; Button* m_btn_auto_mix = nullptr; diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index aae8bccf9e..cd4d5edff8 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -360,8 +360,6 @@ void DropDown::render(wxDC &dc) for (int i = 0; i < items.size(); ++i) { auto &item = items[i]; int states2 = states; - // Dimmed items render greyed out but stay selectable, so they cannot reuse the disabled state. - bool is_dimmed = (item.style & DD_ITEM_STYLE_DIMMED) != 0; if ((item.style & DD_ITEM_STYLE_DISABLED) != 0) states2 &= ~StateColor::Enabled; // Skip by group @@ -429,7 +427,10 @@ void DropDown::render(wxDC &dc) } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); - dc.SetTextForeground(is_dimmed ? wxColour(0xCE, 0xCE, 0xCE) : text_color.colorForStates(states2)); + // Dimmed items stay selectable, so they only borrow the disabled text tone rather + // than taking the disabled state itself. + const int text_states = (item.style & DD_ITEM_STYLE_DIMMED) ? (states2 & ~StateColor::Enabled) : states2; + dc.SetTextForeground(text_color.colorForStates(text_states)); dc.DrawText(text, pt); if (group.IsEmpty() && !item.group_key.IsEmpty()) { auto szBmp = arrow_bitmap.GetBmpSize(); From 524fd5e9c0b9a9ab18d080790730795061a9f5b0 Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:37:47 +0200 Subject: [PATCH 117/138] Fix: resolve 23 MSVC compiler warnings (#15280) * fix: resolve MSVC compiler warnings and build error C4101 - unreferenced local variables: - STEP.cpp, FilamentGroup.cpp: remove unused catch variable 'e' - GLGizmoMeasure.cpp: remove unused 'direction_on_model' - PartPlate.cpp: remove unused 'origin1, origin2' - DevStatus.cpp: suppress unused 'e' via (void)e C4005 - macro redefinition: - Wrap NOMINMAX defines in #ifndef guards (OrcaSlicer.cpp, Preset.cpp, SupportTreeBuilder.cpp, OpenVDBUtils.cpp, GUI.cpp) - Remove conflicting DESIGN_INPUT_SIZE redefine in DownloadProgressDialog.cpp C4172 - return address of local/temporary: - Config.cpp: return static const double instead of temporary 0 C4996 - deprecated API usage: - ImGuiWrapper.cpp: use GetText().Length() instead of GetTextLength() - OrcaCloudServiceAgent.cpp: replace deprecated wxPATH_NORM_ALL with explicit flags matching old default behavior - ASCIIFolding.cpp: replace deprecated std::wstring_convert/codecvt_utf8 with boost::locale::conv::utf_to_utf (already used in same function) C2440 - build error from deprecated wxTipWindow constructor: - Button.hpp/cpp: replace raw wxTipWindow* with wxTipWindow::Ref (weak reference). Ref auto-nulls when the tip window closes, eliminating the manual Bind(wxEVT_DESTROY) handler. delete uses operator->() to access the raw pointer since Ref is non-owning * fix: avoid duplicate GetText() call in ImGuiWrapper clipboard handler Capture wxTextDataObject::GetText() result in a local variable instead of calling it twice (for .Length() check and into_u8()). GetText() returns wxString by value, so this avoids an extra allocation/copy. * fix: resolve MSVC compiler warnings (code review fixes) * fix: resolve MSVC compiler warnings (code review fixes) --- src/OrcaSlicer.cpp | 2 ++ src/OrcaSlicer_app_msvc.cpp | 2 ++ src/libslic3r/Config.cpp | 3 ++- src/libslic3r/FilamentGroup.cpp | 2 +- src/libslic3r/Format/STEP.cpp | 2 +- src/libslic3r/OpenVDBUtils.cpp | 2 ++ src/libslic3r/Preset.cpp | 2 ++ src/libslic3r/SLA/SupportTreeBuilder.cpp | 2 ++ src/slic3r/GUI/DeviceCore/DevStatus.cpp | 1 + src/slic3r/GUI/DownloadProgressDialog.cpp | 2 -- src/slic3r/GUI/GUI.cpp | 2 ++ src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp | 1 - src/slic3r/GUI/ImGuiWrapper.cpp | 5 +++-- src/slic3r/GUI/PartPlate.cpp | 2 -- src/slic3r/GUI/SysInfoDialog.cpp | 2 ++ src/slic3r/GUI/Widgets/Button.cpp | 9 +++++---- src/slic3r/GUI/Widgets/Button.hpp | 5 ++--- src/slic3r/Utils/ASCIIFolding.cpp | 6 ++---- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 8 ++++---- 19 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 71d6ffde80..8176e9f444 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -3,7 +3,9 @@ #define _WIN32_WINNT 0x0502 // The standard Windows includes. #define WIN32_LEAN_AND_MEAN + #ifndef NOMINMAX #define NOMINMAX + #endif #include #include #include diff --git a/src/OrcaSlicer_app_msvc.cpp b/src/OrcaSlicer_app_msvc.cpp index b1e498f4e4..35568a9cfa 100644 --- a/src/OrcaSlicer_app_msvc.cpp +++ b/src/OrcaSlicer_app_msvc.cpp @@ -2,7 +2,9 @@ #define _WIN32_WINNT 0x0502 // The standard Windows includes. #define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX #define NOMINMAX +#endif #include #include #include diff --git a/src/libslic3r/Config.cpp b/src/libslic3r/Config.cpp index a43f659be6..242e4bb146 100644 --- a/src/libslic3r/Config.cpp +++ b/src/libslic3r/Config.cpp @@ -2031,7 +2031,8 @@ const double& DynamicConfig::opt_float(const t_config_option_key &opt_key, unsig return opt_floats_nullable->get_at(idx); } else { assert(false); - return 0; + static const double zero = 0.0; + return zero; } } diff --git a/src/libslic3r/FilamentGroup.cpp b/src/libslic3r/FilamentGroup.cpp index 97da94652c..07a4cc2449 100644 --- a/src/libslic3r/FilamentGroup.cpp +++ b/src/libslic3r/FilamentGroup.cpp @@ -1021,7 +1021,7 @@ namespace Slic3r if (FGMode::MatchMode == ctx.group_info.mode) return calc_filament_group_for_match(cost); } - catch (const FilamentGroupException& e) { + catch (const FilamentGroupException&) { } return calc_filament_group_for_flush(cost); diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index f82ced7d86..a5c3bb49a2 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -712,7 +712,7 @@ unsigned int Step::get_triangle_num(double linear_deflection, double angle_defle return 0; } } - } catch(const Exception &e) { + } catch(const Exception &) { return 0; } diff --git a/src/libslic3r/OpenVDBUtils.cpp b/src/libslic3r/OpenVDBUtils.cpp index 72c7668a45..c72607f14f 100644 --- a/src/libslic3r/OpenVDBUtils.cpp +++ b/src/libslic3r/OpenVDBUtils.cpp @@ -1,4 +1,6 @@ +#ifndef NOMINMAX #define NOMINMAX +#endif #include "OpenVDBUtils.hpp" #ifdef _MSC_VER diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 1334bd4e7a..f48f151357 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -8,7 +8,9 @@ #ifdef _MSC_VER #define WIN32_LEAN_AND_MEAN + #ifndef NOMINMAX #define NOMINMAX + #endif #include #endif /* _MSC_VER */ diff --git a/src/libslic3r/SLA/SupportTreeBuilder.cpp b/src/libslic3r/SLA/SupportTreeBuilder.cpp index 86339d2acf..4080c4fc3f 100644 --- a/src/libslic3r/SLA/SupportTreeBuilder.cpp +++ b/src/libslic3r/SLA/SupportTreeBuilder.cpp @@ -1,4 +1,6 @@ +#ifndef NOMINMAX #define NOMINMAX +#endif #include #include diff --git a/src/slic3r/GUI/DeviceCore/DevStatus.cpp b/src/slic3r/GUI/DeviceCore/DevStatus.cpp index 26d2bc4ceb..e37a0f9abc 100644 --- a/src/slic3r/GUI/DeviceCore/DevStatus.cpp +++ b/src/slic3r/GUI/DeviceCore/DevStatus.cpp @@ -27,6 +27,7 @@ void DevStatus::ParseStatus(const nlohmann::json& print_jj) #else BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": get exception=" << e.what(); #endif + (void)e; // suppress C4101 when BBL_RELEASE_TO_PUBLIC } } diff --git a/src/slic3r/GUI/DownloadProgressDialog.cpp b/src/slic3r/GUI/DownloadProgressDialog.cpp index 9bc0d90e5e..1c5ff4c3d7 100644 --- a/src/slic3r/GUI/DownloadProgressDialog.cpp +++ b/src/slic3r/GUI/DownloadProgressDialog.cpp @@ -26,8 +26,6 @@ #include "Widgets/HyperLink.hpp" // ORCA -#define DESIGN_INPUT_SIZE wxSize(FromDIP(100), -1) - namespace Slic3r { namespace GUI { diff --git a/src/slic3r/GUI/GUI.cpp b/src/slic3r/GUI/GUI.cpp index 29f8fc9749..78a511c90c 100644 --- a/src/slic3r/GUI/GUI.cpp +++ b/src/slic3r/GUI/GUI.cpp @@ -18,7 +18,9 @@ #import #elif _WIN32 #define WIN32_LEAN_AND_MEAN +#ifndef NOMINMAX #define NOMINMAX +#endif #include #include "boost/nowide/convert.hpp" #endif diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp index 0a3936a81b..e21498163a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp @@ -597,7 +597,6 @@ void GLGizmoMeasure::on_render() } } Vec3d position_on_model; - Vec3d direction_on_model; size_t model_facet_idx = -1; double closest_hit_distance = std::numeric_limits::max(); { diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index d46e8ed31b..0d127f524b 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -3332,8 +3332,9 @@ const char* ImGuiWrapper::clipboard_get(void* user_data) wxTextDataObject data; wxTheClipboard->GetData(data); - if (data.GetTextLength() > 0) { - self->m_clipboard_text = into_u8(data.GetText()); + const wxString text = data.GetText(); + if (text.Length() > 0) { + self->m_clipboard_text = into_u8(text); res = self->m_clipboard_text.c_str(); } } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 910c761c06..5e8ee31363 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -4445,8 +4445,6 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini //this may be happened after machine changed void PartPlateList::reset_size(int width, int depth, int height, bool reload_objects, bool update_shapes) { - Vec3d origin1, origin2; - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(":before size: plate_width %1%, plate_depth %2%, plate_height %3%") % m_plate_width % m_plate_depth % m_plate_height; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(":after size: plate_width %1%, plate_depth %2%, plate_height %3%") % width % depth % height; if ((m_plate_width != width) || (m_plate_depth != depth) || (m_plate_height != height)) diff --git a/src/slic3r/GUI/SysInfoDialog.cpp b/src/slic3r/GUI/SysInfoDialog.cpp index 933cfb4d7c..585767d318 100644 --- a/src/slic3r/GUI/SysInfoDialog.cpp +++ b/src/slic3r/GUI/SysInfoDialog.cpp @@ -21,7 +21,9 @@ #ifdef _WIN32 // The standard Windows includes. #define WIN32_LEAN_AND_MEAN + #ifndef NOMINMAX #define NOMINMAX + #endif #include #include #endif /* _WIN32 */ diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index 74ed2cbadd..5a5bd89403 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -503,8 +503,8 @@ void Button::OnParentMotion(wxMouseEvent& event) { if (!tipWindow) { - tipWindow = new wxTipWindow(this, tip); - tipWindow->Bind(wxEVT_DESTROY, [this](wxEvent& event) { this->tipWindow = nullptr;}); + tipWindow = wxTipWindow::New(this, tip); + if (!tipWindow) return event.Skip(); tipWindow->Enable(false); } @@ -522,7 +522,8 @@ void Button::OnParentMotion(wxMouseEvent& event) { if (tipWindow) { - delete tipWindow; + tipWindow->Dismiss(); + tipWindow->Destroy(); tipWindow = nullptr; } } @@ -543,7 +544,7 @@ void Button::OnParentLeave(wxMouseEvent& event) if (!screen_rect.Contains(pos)) { tipWindow->Dismiss(); - delete tipWindow; + tipWindow->Destroy(); tipWindow = nullptr; } } diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index c98d583c34..2991edd425 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -3,6 +3,7 @@ #include "../wxExtensions.hpp" #include "StaticBox.hpp" +#include class ButtonProps { @@ -27,9 +28,9 @@ enum class ButtonType{ Expanded , // Font14 Semi-Rounded For full length buttons. ex. buttons in static box }; -class wxTipWindow; class Button : public StaticBox { + wxTipWindow::Ref tipWindow; wxRect textSize; wxSize minSize; // set by outer wxSize paddingSize; @@ -43,8 +44,6 @@ class Button : public StaticBox bool isCenter = true; bool vertical = false; - wxTipWindow* tipWindow = nullptr; - static const int buttonWidth = 200; static const int buttonHeight = 50; diff --git a/src/slic3r/Utils/ASCIIFolding.cpp b/src/slic3r/Utils/ASCIIFolding.cpp index 0eb02a5f8c..016c30fcde 100644 --- a/src/slic3r/Utils/ASCIIFolding.cpp +++ b/src/slic3r/Utils/ASCIIFolding.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include namespace Slic3r { @@ -1953,8 +1952,7 @@ std::string fold_utf8_to_ascii(const std::string &src, bool is_convert_for_filen for (wchar_t c : wstr) fold_to_ascii(c, out); if (is_convert_for_filename) { - std::wstring_convert> converter; - auto dstStr = converter.to_bytes(dst); + auto dstStr = boost::locale::conv::utf_to_utf(dst.c_str(), dst.c_str() + dst.size()); std::size_t found = dstStr.find_last_of("/\\"); if (found != std::string::npos) { @@ -1964,7 +1962,7 @@ std::string fold_utf8_to_ascii(const std::string &src, bool is_convert_for_filen std::string newFileName = regex_replace(filename, reg, ""); dstStr = dir + "\\" + newFileName; } - dst = converter.from_bytes(dstStr); + dst = boost::locale::conv::utf_to_utf(dstStr.c_str(), dstStr.c_str() + dstStr.size()); } return boost::locale::conv::utf_to_utf(dst.c_str(), dst.c_str() + dst.size()); diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index a372ab5b7c..4419395b4d 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -572,7 +572,7 @@ int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir) { config_dir = cfg_dir; wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), secret_constants::USER_SECRET_FILENAME); - fallback.Normalize(); + fallback.MakeAbsolute(); secret_fallback_path = fallback.GetFullPath().ToStdString(); return BAMBU_NETWORK_SUCCESS; } @@ -1564,7 +1564,7 @@ void OrcaCloudServiceAgent::persist_user_secret(const std::string& secret) return; } wxFileName path(wxString::FromUTF8(secret_fallback_path.c_str())); - path.Normalize(); + path.MakeAbsolute(); if (!wxFileName::DirExists(path.GetPath())) { wxFileName::Mkdir(path.GetPath(), wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL); } @@ -2487,7 +2487,7 @@ void OrcaCloudServiceAgent::compute_fallback_path() if (wxTheApp == nullptr) return; wxFileName fallback(wxStandardPaths::Get().GetUserDataDir(), "orca_refresh_token.sec"); - fallback.Normalize(); + fallback.MakeAbsolute(); secret_fallback_path = fallback.GetFullPath().ToStdString(); } @@ -3581,7 +3581,7 @@ std::string OrcaCloudServiceAgent::token_lock_path() const if (config_dir.empty()) return {}; wxFileName lock(wxString::FromUTF8(config_dir.c_str()), "orca_refresh_token.lock"); - lock.Normalize(); + lock.MakeAbsolute(); return lock.GetFullPath().ToStdString(); } From ce75a66e7cc0288fb4347b1f619e039a19f625fb Mon Sep 17 00:00:00 2001 From: yw4z Date: Mon, 24 Aug 2026 21:38:51 +0300 Subject: [PATCH 118/138] fix duplicate decompose menu item --- src/slic3r/GUI/GUI_Factories.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 9ee74d742f..f83b13a5b5 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1685,7 +1685,9 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men [filaments_cnt]() { return filaments_cnt > 1; }, m_parent); // Decompose a target colour into a printable mix of the loaded filaments. Placed before the - // Delete entry below so Orca's "delete last" ordering is preserved (BBS appends it after). + const int decompose_id = menu->FindItem(_L("Decompose Color")); + if (decompose_id != wxNOT_FOUND) + menu->Destroy(decompose_id); append_menu_item( menu, wxID_ANY, _L("Decompose Color"), "", [](wxCommandEvent&) { plater()->sidebar().decompose_filament_color(kSidebarContextMenuFilamentId); }, "", nullptr, From a3231aa723ebcf2a6d94025c17b2d0aa4d77ecb9 Mon Sep 17 00:00:00 2001 From: yw4z Date: Mon, 24 Aug 2026 22:02:47 +0300 Subject: [PATCH 119/138] rebuild menus from scratch to remove duplicate item check and match "delete" item order --- src/slic3r/GUI/GUI_Factories.cpp | 21 ++++++++------------- src/slic3r/GUI/Plater.cpp | 13 ++++++++----- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index f83b13a5b5..be407a270f 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1656,16 +1656,16 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men { wxMenu *menu = &m_filament_action_menu; - if (init) { + // ORCA rebuild menu everytime instead checking existing of every item then deleting + while (menu->GetMenuItemCount() > 0) + menu->Destroy(menu->FindItemByPosition(0)); + + //if (init) { // append_menu_item( menu, wxID_ANY, _L("Edit"), "", [](wxCommandEvent&) { plater()->sidebar().edit_filament(); }, "", nullptr, []() { return true; }, m_parent); - } - - const int item_id = menu->FindItem(_L("Merge with")); - if (item_id != wxNOT_FOUND) - menu->Destroy(item_id); + //} wxMenu* sub_menu = new wxMenu(); std::vector icons = get_extruder_color_icons(true); @@ -1685,19 +1685,14 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men [filaments_cnt]() { return filaments_cnt > 1; }, m_parent); // Decompose a target colour into a printable mix of the loaded filaments. Placed before the - const int decompose_id = menu->FindItem(_L("Decompose Color")); - if (decompose_id != wxNOT_FOUND) - menu->Destroy(decompose_id); append_menu_item( menu, wxID_ANY, _L("Decompose Color"), "", [](wxCommandEvent&) { plater()->sidebar().decompose_filament_color(kSidebarContextMenuFilamentId); }, "", nullptr, []() { return plater()->sidebar().combos_filament().size() >= 2; }, m_parent); - // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS - const int delete_id = menu->FindItem(_L("Delete")); - if (delete_id != wxNOT_FOUND) - menu->Destroy(delete_id); + menu->AppendSeparator(); // ORCA use seperator for reducing accidental clicks to delete + // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS append_menu_item( menu, wxID_ANY, _L("Delete"), _L("Delete this filament"), [](wxCommandEvent&) { plater()->sidebar().delete_filament(-2); }, "", nullptr, diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 0772b3ae11..e30853f94a 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -4308,11 +4308,6 @@ void Sidebar::update_mixed_filament_list() edit_mixed_filament(panel_idx); }, edit_item->GetId()); - auto* del_item = menu.Append(wxID_ANY, _L("Delete")); - menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { - delete_mixed_filament_at(panel_idx); - }, del_item->GetId()); - wxMenu* sub_menu = new wxMenu(); std::vector icons = get_extruder_color_icons(true); int filaments_cnt = icons.size(); @@ -4345,6 +4340,14 @@ void Sidebar::update_mixed_filament_list() else delete sub_menu; + menu.AppendSeparator(); // ORCA use seperator for reducing accidental clicks to delete + + // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS + auto* del_item = menu.Append(wxID_ANY, _L("Delete")); + menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { + delete_mixed_filament_at(panel_idx); + }, del_item->GetId()); + PopupMenu(&menu); }); combo_and_btn_sizer->Add(menu_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); From fbe4cdff2306a0e6fb92f64823f33b10dad730c7 Mon Sep 17 00:00:00 2001 From: yw4z Date: Mon, 24 Aug 2026 23:07:11 +0300 Subject: [PATCH 120/138] fix mixed filaments area cannot be hidden --- src/slic3r/GUI/Plater.cpp | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index e30853f94a..b3a873a0bc 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -737,6 +737,9 @@ struct Sidebar::priv ScalableButton * m_bpButton_ams_filament; ScalableButton * m_bpButton_set_filament; int m_menu_filament_id = -1; + + wxPanel* m_filament_area_wrapper; + wxScrolledWindow* m_panel_filament_content; // Mixed-color filament section. Sits directly under the physical filament list in @@ -2896,7 +2899,7 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_filament_title->SetBackgroundColor(title_bg); p->m_panel_filament_title->SetBackgroundColor2(0xF1F1F1); p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { - if (!p || !p->m_panel_filament_content || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn) + if (!p || !p->m_filament_area_wrapper || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn) return; // ORCA exclude area of del button from titlebar collapse/expand feature to fix undesired collapse when user spams del filament button // also block fold/unfold feature when user clicks to spacing between icons @@ -2907,8 +2910,8 @@ Sidebar::Sidebar(Plater *parent) else if (ams_btn->IsShown()) exclude_pt = ams_btn->GetPosition().x; if (e.GetPosition().x > exclude_pt) return; - bool isShown = p->m_panel_filament_content->IsShown(); - p->m_panel_filament_content->Show(!isShown); + bool isShown = p->m_filament_area_wrapper->IsShown(); + p->m_filament_area_wrapper->Show(!isShown); p->m_panel_filament_separator->Show(isShown); m_scrolled_sizer->Layout(); @@ -3022,8 +3025,13 @@ Sidebar::Sidebar(Plater *parent) bSizer39->Add(set_btn, 0, wxALIGN_CENTER | wxLEFT, FromDIP(SidebarProps::WideSpacing())); bSizer39->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); + // ---- Wrapper panel for collapse/expand of all filament content ---- + p->m_filament_area_wrapper = new wxPanel(p->scrolled, wxID_ANY); + p->m_filament_area_wrapper->SetBackgroundColour(*wxWHITE); + auto* wrapper_sizer = new wxBoxSizer(wxVERTICAL); + // add filament content - p->m_panel_filament_content = new wxScrolledWindow( p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + p->m_panel_filament_content = new wxScrolledWindow(p->m_filament_area_wrapper, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); p->m_panel_filament_content->SetScrollbars(0, 100, 1, 2); p->m_panel_filament_content->SetScrollRate(0, 5); //p->m_panel_filament_content->SetMaxSize(wxSize{-1, FromDIP(174)}); @@ -3051,7 +3059,7 @@ Sidebar::Sidebar(Plater *parent) update_filaments_area_height(); // ORCA - scrolled_sizer->Add(p->m_panel_filament_content, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(SidebarProps::ContentMarginV())); // ORCA use vertical margin on parent otherwise it shows scrollbar even on 1 filament + wrapper_sizer->Add(p->m_panel_filament_content, 0, wxEXPAND); // ---- Mixed-color filament section ---- // A mixed filament is a virtual slot realized from 2-3 physical filaments at slicing time. @@ -3059,7 +3067,7 @@ Sidebar::Sidebar(Plater *parent) // filament setup looks exactly as before. { // 1) "+ Add Mixed Filament" button, shown only while no mixed filament exists yet. - p->m_btn_add_mixed_filament = new wxPanel(p->scrolled, wxID_ANY); + p->m_btn_add_mixed_filament = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); p->m_btn_add_mixed_filament->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); p->m_btn_add_mixed_filament->SetMinSize(wxSize(-1, FromDIP(23))); { @@ -3081,10 +3089,10 @@ Sidebar::Sidebar(Plater *parent) add_label->Bind(wxEVT_LEFT_UP, on_click); icon_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); } - scrolled_sizer->Add(p->m_btn_add_mixed_filament, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(8)); + wrapper_sizer->Add(p->m_btn_add_mixed_filament, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(8)); // 2) Title row with add / remove buttons, shown once a mixed filament exists. - p->m_panel_mixed_title = new wxPanel(p->scrolled, wxID_ANY); + p->m_panel_mixed_title = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); p->m_panel_mixed_title->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); { auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); @@ -3112,11 +3120,11 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_mixed_title->SetSizer(title_sizer); } - scrolled_sizer->Add(p->m_panel_mixed_title, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(8)); + wrapper_sizer->Add(p->m_panel_mixed_title, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(8)); // 3) Mixed filament rows, in their own scroll area so a long mixed list does not // push the physical filament list off screen. - p->m_mixed_scroll_area = new wxScrolledWindow(p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_mixed_scroll_area = new wxScrolledWindow(p->m_filament_area_wrapper, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); p->m_mixed_scroll_area->SetScrollbars(0, 100, 1, 2); p->m_mixed_scroll_area->SetScrollRate(0, 5); p->m_mixed_scroll_area->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); @@ -3144,10 +3152,10 @@ Sidebar::Sidebar(Plater *parent) p->m_mixed_scroll_area->SetVirtualSize(w, p->m_mixed_scroll_area->GetVirtualSize().GetHeight()); e.Skip(); }); - scrolled_sizer->Add(p->m_mixed_scroll_area, 0, wxEXPAND, 0); + wrapper_sizer->Add(p->m_mixed_scroll_area, 0, wxEXPAND, 0); // 4) Warning bar for mixes whose components were deleted or whose types disagree. - p->m_panel_mixed_warning = new wxPanel(p->scrolled, wxID_ANY); + p->m_panel_mixed_warning = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); p->m_panel_mixed_warning->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); { auto* warn_sizer = new wxBoxSizer(wxHORIZONTAL); @@ -3159,7 +3167,7 @@ Sidebar::Sidebar(Plater *parent) warn_sizer->Add(p->m_text_mixed_warning, 1, wxALL, FromDIP(6)); p->m_panel_mixed_warning->SetSizer(warn_sizer); } - scrolled_sizer->Add(p->m_panel_mixed_warning, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); + wrapper_sizer->Add(p->m_panel_mixed_warning, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); // Hidden until update_mixed_filament_list() decides otherwise. p->m_btn_add_mixed_filament->Hide(); @@ -3169,6 +3177,11 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_mixed_warning->Hide(); } // ---- End mixed-color filament section ---- + + p->m_filament_area_wrapper->SetSizer(wrapper_sizer); + p->m_filament_area_wrapper->Layout(); + scrolled_sizer->Add(p->m_filament_area_wrapper, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(SidebarProps::ContentMarginV())); // ORCA use vertical margin on parent otherwise it shows scrollbar even on 1 filament + // ---- End filament area ---- } { From 2f9ef86e97659babbde991a3c73fead4c17559be Mon Sep 17 00:00:00 2001 From: yw4z Date: Tue, 25 Aug 2026 00:04:14 +0300 Subject: [PATCH 121/138] match style of dialog buttons --- src/slic3r/GUI/ColorDecomposeDialog.cpp | 17 ++--------------- src/slic3r/GUI/MixedFilamentDialog.cpp | 15 ++------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp index ad4594f948..2176de110e 100644 --- a/src/slic3r/GUI/ColorDecomposeDialog.cpp +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -553,24 +553,11 @@ wxBoxSizer* ColorDecomposeDialog::create_button_panel() sizer->AddStretchSpacer(); m_btn_cancel = new Button(this, _L("Cancel")); - m_btn_cancel->SetBackgroundColor(*wxWHITE); - m_btn_cancel->SetBorderColor(wxColour("#CECECE")); - m_btn_cancel->SetTextColor(COLOR_TEXT_DARK); - m_btn_cancel->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_cancel->SetStyle(ButtonStyle::Regular, ButtonType::Choice); m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); m_btn_ok = new Button(this, _L("OK")); - m_btn_ok->SetBackgroundColor(StateColor( - std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), - std::make_pair(wxColour(0, 137, 123), (int) StateColor::Pressed), - std::make_pair(COLOR_BRAND, (int) StateColor::Normal))); - m_btn_ok->SetBorderColor(StateColor( - std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), - std::make_pair(COLOR_BRAND, (int) StateColor::Normal))); - // Off-by-one white: plain #FFFFFF is a dark-mode key and would repaint the - // label as the window background on the accent fill. - m_btn_ok->SetTextColor(wxColour("#FFFFFE")); - m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_ok->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 10144a5003..902c12d27b 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -1369,22 +1369,11 @@ wxBoxSizer* MixedFilamentDialog::create_button_panel() auto* sizer = new wxBoxSizer(wxHORIZONTAL); m_btn_cancel = new Button(this, _L("Cancel")); - m_btn_cancel->SetBackgroundColor(*wxWHITE); - m_btn_cancel->SetBorderColor(wxColour("#CECECE")); - m_btn_cancel->SetTextColor(wxColour("#262E30")); - m_btn_cancel->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_cancel->SetStyle(ButtonStyle::Regular, ButtonType::Choice); m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); m_btn_ok = new Button(this, _L("OK")); - m_btn_ok->SetBackgroundColor(StateColor( - std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), - std::make_pair(wxColour(0, 137, 123), (int) StateColor::Pressed), - std::make_pair(wxColour("#009688"), (int) StateColor::Normal))); - m_btn_ok->SetBorderColor(StateColor( - std::make_pair(wxColour("#CECECE"), (int) StateColor::Disabled), - std::make_pair(wxColour("#009688"), (int) StateColor::Normal))); - m_btn_ok->SetTextColor(wxColour("#FFFFFE")); - m_btn_ok->SetMinSize(wxSize(FromDIP(55), FromDIP(24))); + m_btn_ok->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); sizer->Add(m_btn_cancel, 0, wxRIGHT, FromDIP(12)); From 56f9edc572dc2d3df35b1e913bf7a9a19225fb53 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 25 Aug 2026 06:18:48 -0500 Subject: [PATCH 122/138] build: mark missing overrides and drop unused lambda captures (1,156 clang warnings) (#15334) * chore: mark every declaration that overrides a base virtual clang-cl reports 42 member functions across 28 files that override a base virtual without being marked `override`, inside classes that already mark their other overrides. That is every occurrence of -Winconsistent-missing-override in the tree, so the category drops to zero and -Werror=inconsistent-missing-override becomes available as a guard against it coming back. Behaviour is unchanged. Each keyword goes only where clang had already resolved the declaration to a base virtual, so it records what the compiler already worked out and cannot affect overload resolution or dispatch. If any of these signatures had not really overridden a base method, the build would have failed rather than warned. Where a declaration already carried `virtual` it is left alone and the keyword appended, matching the surrounding declarations. Plain `override` is used rather than the wxWidgets `wxOVERRIDE` macro, which wx/defs.h defines as `override` beneath a comment marking it obsolete, and which the rest of src/slic3r already avoids by 1742 occurrences to 113. A full clang-cl build takes -Winconsistent-missing-override from 1,146 warning lines to 0. Those 42 declarations produce that many lines because a header is re-diagnosed in every translation unit that includes it. CalibrationWizardStartPage.hpp alone accounts for 336 of them from 4 declarations. * chore: drop unused lambda captures in GUI/Widgets clang-cl reports 10 lambda captures in src/slic3r/GUI/Widgets that are never read. Removing them changes nothing at runtime. Every capture removed is `this` or a raw pointer. clang does not report a capture whose type has a non-trivial destructor, since such a capture can be held purely for its effect on an object's lifetime, so nothing that owns or extends a lifetime is touched. The std::weak_ptr captured beside the removed `this` in MultiNozzleSync.cpp stays. This clears the category in GUI/Widgets only. A full clang-cl build takes -Wunused-lambda-capture from 312 warning lines to 302, leaving 235 sites in other directories for a follow-up. --- src/libslic3r/Print.hpp | 4 ++-- src/slic3r/GUI/CalibrationWizardSavePage.hpp | 2 +- src/slic3r/GUI/CalibrationWizardStartPage.hpp | 8 ++++---- src/slic3r/GUI/Field.hpp | 4 ++-- src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp | 2 +- src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp | 2 +- src/slic3r/GUI/Gizmos/GLGizmoMove.hpp | 2 +- src/slic3r/GUI/Gizmos/GLGizmoScale.hpp | 2 +- src/slic3r/GUI/Preferences.cpp | 2 +- src/slic3r/GUI/PrintHostDialogs.hpp | 4 ++-- src/slic3r/GUI/SelectMachine.hpp | 2 +- src/slic3r/GUI/SendToPrinter.hpp | 2 +- src/slic3r/GUI/SyncAmsInfoDialog.hpp | 2 +- src/slic3r/GUI/Tab.hpp | 4 ++-- src/slic3r/GUI/TabButton.hpp | 2 +- src/slic3r/GUI/Tabbook.hpp | 2 +- src/slic3r/GUI/UnsavedChangesDialog.hpp | 2 +- src/slic3r/GUI/Widgets/MultiNozzleSync.cpp | 14 +++++++------- src/slic3r/GUI/Widgets/MultiNozzleSync.hpp | 2 +- src/slic3r/GUI/Widgets/ProgressBar.hpp | 2 +- src/slic3r/GUI/Widgets/ProgressDialog.hpp | 2 +- src/slic3r/GUI/Widgets/SideButton.hpp | 4 ++-- src/slic3r/GUI/Widgets/SpinInput.cpp | 2 +- src/slic3r/GUI/Widgets/TabCtrl.hpp | 2 +- src/slic3r/GUI/Widgets/TempInput.cpp | 2 +- src/slic3r/GUI/Widgets/TempInput.hpp | 4 ++-- src/slic3r/GUI/Widgets/TextInput.cpp | 2 +- src/slic3r/GUI/Widgets/TextInput.hpp | 4 ++-- src/slic3r/GUI/Widgets/WebView.cpp | 2 +- src/slic3r/Utils/CrealityPrint.hpp | 4 ++-- src/slic3r/Utils/ElegooLink.hpp | 6 +++--- src/slic3r/Utils/Obico.hpp | 4 ++-- 32 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index b38a0ca058..4744426510 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -930,8 +930,8 @@ public: // If preview_data is not null, the preview_data is filled in for the G-code visualization (not used by the command line Slic3r). std::string export_gcode(const std::string& path_template, GCodeProcessorResult* result, ThumbnailsGeneratorCallback thumbnail_cb = nullptr); //return 0 means successful - int export_cached_data(const std::string& dir_path, bool with_space=false); - int load_cached_data(const std::string& directory); + int export_cached_data(const std::string& dir_path, bool with_space=false) override; + int load_cached_data(const std::string& directory) override; // methods for handling state bool is_step_done(PrintStep step) const { return Inherited::is_step_done(step); } diff --git a/src/slic3r/GUI/CalibrationWizardSavePage.hpp b/src/slic3r/GUI/CalibrationWizardSavePage.hpp index 4726cb1230..eb15720e96 100644 --- a/src/slic3r/GUI/CalibrationWizardSavePage.hpp +++ b/src/slic3r/GUI/CalibrationWizardSavePage.hpp @@ -193,7 +193,7 @@ public: void show_panels(CalibrationMethod method, const PrinterSeries printer_ser); - void on_device_connected(MachineObject* obj); + void on_device_connected(MachineObject* obj) override; void update(MachineObject* obj) override; diff --git a/src/slic3r/GUI/CalibrationWizardStartPage.hpp b/src/slic3r/GUI/CalibrationWizardStartPage.hpp index 0e893bce10..026ce187ac 100644 --- a/src/slic3r/GUI/CalibrationWizardStartPage.hpp +++ b/src/slic3r/GUI/CalibrationWizardStartPage.hpp @@ -48,8 +48,8 @@ public: void create_page(wxWindow* parent); - void on_reset_page(); - void on_device_connected(MachineObject* obj); + void on_reset_page() override; + void on_device_connected(MachineObject* obj) override; void msw_rescale() override; }; @@ -63,8 +63,8 @@ public: long style = wxTAB_TRAVERSAL); void create_page(wxWindow* parent); - void on_reset_page(); - void on_device_connected(MachineObject* obj); + void on_reset_page() override; + void on_device_connected(MachineObject* obj) override; void msw_rescale() override; }; diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index e57a569561..5d5d549427 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -385,7 +385,7 @@ public: wxWindow* window{ nullptr }; void BUILD() override; /// Propagate value from field to the OptionGroupe and Config after kill_focus/ENTER - void propagate_value() ; + void propagate_value() override; void set_value(const std::string& value, bool change_event = false) { m_disable_change_event = !change_event; @@ -440,7 +440,7 @@ public: wxWindow* window{ nullptr }; void BUILD() override; // Propagate value from field to the OptionGroupe and Config after kill_focus/ENTER - void propagate_value(); + void propagate_value() override; /* Under OSX: wxBitmapComboBox->GetWindowStyle() returns some weard value, * so let use a flag, which has TRUE value for a control without wxCB_READONLY style diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp index 4e531e6acc..cd3bc53cbd 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp @@ -85,7 +85,7 @@ public: void update_model_object(); //ClippingPlane get_sla_clipping_plane() const; - bool is_selection_rectangle_dragging() const { return m_selection_rectangle.is_dragging(); } + bool is_selection_rectangle_dragging() const override { return m_selection_rectangle.is_dragging(); } bool wants_enter_leave_snapshots() const override { return true; } std::string get_gizmo_entering_text() const override { return _u8L("Entering Brim Ears"); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp index 9c36be5cd9..3ba613295d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp @@ -75,7 +75,7 @@ protected: virtual void on_render() override; virtual void on_set_state() override; virtual CommonGizmosDataID on_get_requirements() const override; - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; void on_load(cereal::BinaryInputArchive &ar) override; void on_save(cereal::BinaryOutputArchive &ar) const override; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp index df3abdddc7..fecc8abf1c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp @@ -67,7 +67,7 @@ protected: void on_register_raycasters_for_picking() override; void on_unregister_raycasters_for_picking() override; //BBS: GUI refactor: add object manipulation - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; private: double calc_projection(const UpdateData& data) const; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp b/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp index 6b46a596ba..3bfb63ff7a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp @@ -89,7 +89,7 @@ protected: virtual void on_register_raycasters_for_picking() override; virtual void on_unregister_raycasters_for_picking() override; //BBS: GUI refactor: add object manipulation - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; private: void render_grabbers_connection(unsigned int id_1, unsigned int id_2, const ColorRGBA& color); diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index ca115b9773..8f3ac17f7c 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -79,7 +79,7 @@ public: Bind(wxEVT_LEFT_DOWN, &WikiLabel::OnLeftDown, this); } - void SetLabel(const wxString& label) + void SetLabel(const wxString& label) override { m_label = label; m_last_wrap_width = -1; // force re-wrap diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 988d4c8171..6f55c0d953 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -163,7 +163,7 @@ public: BedType bedType() const { return m_BedType; } virtual void init() override; - virtual std::map extendedInfo() const + virtual std::map extendedInfo() const override { return {{"bedType", std::to_string(static_cast(m_BedType))}, {"timeLapse", std::to_string(m_timeLapse)}, @@ -200,7 +200,7 @@ public: PrintHost* printhost); virtual void init() override; - virtual std::map extendedInfo() const; + virtual std::map extendedInfo() const override; private: static constexpr const char* CONFIG_KEY_ENABLESELFTEST = "crealityprint_enable_self_test"; diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index fd326f7c85..46d6adf4f4 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -522,7 +522,7 @@ public: bool is_timeout(); int update_print_required_data(Slic3r::DynamicPrintConfig config, Slic3r::Model model, Slic3r::PlateDataPtrs plate_data_list, std::string file_name, std::string file_path); void set_print_type(PrintFromType type) {m_print_type = type;}; - bool Show(bool show); + bool Show(bool show) override; void show_init(); bool do_ams_mapping(MachineObject *obj_,bool use_ams); bool get_ams_mapping_result(std::string& mapping_array_str, std::string& mapping_array_str2, std::string& ams_mapping_info) const; diff --git a/src/slic3r/GUI/SendToPrinter.hpp b/src/slic3r/GUI/SendToPrinter.hpp index 14493a1f20..87948b28c3 100644 --- a/src/slic3r/GUI/SendToPrinter.hpp +++ b/src/slic3r/GUI/SendToPrinter.hpp @@ -180,7 +180,7 @@ public: SendToPrinterDialog(Plater *plater = nullptr); ~SendToPrinterDialog(); - bool Show(bool show); + bool Show(bool show) override; bool is_timeout(); void on_rename_click(wxCommandEvent& event); void on_rename_enter(); diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.hpp b/src/slic3r/GUI/SyncAmsInfoDialog.hpp index 8ff8f18aff..248ca4032c 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.hpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.hpp @@ -371,7 +371,7 @@ public: }; FinishSyncAmsDialog(InputInfo &input_info); ~FinishSyncAmsDialog() override; - void deal_ok(); + void deal_ok() override; void update_info(InputInfo& info); bool Layout() override; diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 19eb0b849d..5a098d52a4 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -515,13 +515,13 @@ public: bool has_key(std::string const &key); protected: - virtual void activate_selected_page(std::function throw_if_canceled); + virtual void activate_selected_page(std::function throw_if_canceled) override; virtual void on_value_change(const std::string& opt_key, const boost::any& value) override; virtual void notify_changed(ObjectBase * object) = 0; - virtual void reload_config(); + virtual void reload_config() override; virtual void update_custom_dirty(std::vector &dirty_options, std::vector &nonsys_options) override; diff --git a/src/slic3r/GUI/TabButton.hpp b/src/slic3r/GUI/TabButton.hpp index 7accf248c4..05ce1c6bd3 100644 --- a/src/slic3r/GUI/TabButton.hpp +++ b/src/slic3r/GUI/TabButton.hpp @@ -40,7 +40,7 @@ public: void SetBitmap(ScalableBitmap &bitmap); - bool Enable(bool enable = true); + bool Enable(bool enable = true) override; void Rescale(); diff --git a/src/slic3r/GUI/Tabbook.hpp b/src/slic3r/GUI/Tabbook.hpp index 7f10e9dd8d..87fd215327 100644 --- a/src/slic3r/GUI/Tabbook.hpp +++ b/src/slic3r/GUI/Tabbook.hpp @@ -166,7 +166,7 @@ public: return true; } - bool RemovePage(size_t n) + bool RemovePage(size_t n) override { if (!wxBookCtrlBase::RemovePage(n)) return false; diff --git a/src/slic3r/GUI/UnsavedChangesDialog.hpp b/src/slic3r/GUI/UnsavedChangesDialog.hpp index b25e852c6b..fc6b8043f4 100644 --- a/src/slic3r/GUI/UnsavedChangesDialog.hpp +++ b/src/slic3r/GUI/UnsavedChangesDialog.hpp @@ -343,7 +343,7 @@ public: UnsavedChangesDialog(const wxString &caption, const wxString &header, DynamicConfig *config, int from, int to, bool left_to_right, NozzleVolumeType nozzle); ~UnsavedChangesDialog() override = default; - int ShowModal(); + int ShowModal() override; void build(Preset::Type type, PresetCollection *dependent_presets, const std::string &new_selected_preset, const wxString &header = ""); void update(Preset::Type type, PresetCollection* dependent_presets, const std::string& new_selected_preset, const wxString& header); diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp index 05857c6d0d..518bda1d19 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp @@ -630,7 +630,7 @@ NozzleListTable::NozzleListTable(wxWindow* parent) : wxPanel(parent,wxID_ANY,wxD SetSizer(sizer); Layout(); - m_web_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this,sizer](wxWebViewEvent& evt) { + m_web_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this](wxWebViewEvent& evt) { std::string message = evt.GetString().ToStdString(); BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << "Received message: " << message; try { @@ -1168,8 +1168,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Ignore")); m_confirm_btn->SetLabel(_L("Refresh")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, ignore_opt](auto& e) {ignore_opt(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, ignore_opt](auto& e) {ignore_opt(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); } else if (has_unknown) { m_cancel_btn->Show(); @@ -1178,8 +1178,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Ignore")); m_confirm_btn->SetLabel(_L("Refresh")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, ignore_opt](auto& e) {ignore_opt(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, ignore_opt](auto& e) {ignore_opt(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); } else if (has_unreliable) { m_cancel_btn->Show(); @@ -1188,8 +1188,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Refresh")); m_confirm_btn->SetLabel(_L("Confirm")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, trust_cmd](auto& e) {trust_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, trust_cmd](auto& e) {trust_cmd(); }); } else { diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp index ab56663928..3af524c2fc 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp @@ -167,7 +167,7 @@ class MultiNozzleSyncDialog : public DPIDialog { public: MultiNozzleSyncDialog(wxWindow* parent, std::weak_ptr rack); - virtual void on_dpi_changed(const wxRect& suggested_rect) {}; + virtual void on_dpi_changed(const wxRect& suggested_rect) override {}; std::vector GetNozzleOptions(const std::vector& group_infos); std::optional GetSelectedOption() { diff --git a/src/slic3r/GUI/Widgets/ProgressBar.hpp b/src/slic3r/GUI/Widgets/ProgressBar.hpp index 38dda6c8d2..40ddb8e4be 100644 --- a/src/slic3r/GUI/Widgets/ProgressBar.hpp +++ b/src/slic3r/GUI/Widgets/ProgressBar.hpp @@ -56,7 +56,7 @@ protected: void paintEvent(wxPaintEvent &evt); void render(wxDC &dc); void doRender(wxDC &dc); - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; diff --git a/src/slic3r/GUI/Widgets/ProgressDialog.hpp b/src/slic3r/GUI/Widgets/ProgressDialog.hpp index 597ec7f802..bb770298a9 100644 --- a/src/slic3r/GUI/Widgets/ProgressDialog.hpp +++ b/src/slic3r/GUI/Widgets/ProgressDialog.hpp @@ -33,7 +33,7 @@ public: void OnPaint(wxPaintEvent &evt); virtual ~ProgressDialog(); - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; bool Create(const wxString &title, const wxString &message, int maximum = 100, wxWindow *parent = NULL, int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE); virtual bool Update(int value, const wxString &newmsg = wxEmptyString, bool *skip = NULL); diff --git a/src/slic3r/GUI/Widgets/SideButton.hpp b/src/slic3r/GUI/Widgets/SideButton.hpp index 4f8d893f93..894a7d8727 100644 --- a/src/slic3r/GUI/Widgets/SideButton.hpp +++ b/src/slic3r/GUI/Widgets/SideButton.hpp @@ -31,7 +31,7 @@ public: void SetLayoutStyle(int style); - void SetLabel(const wxString& label); + void SetLabel(const wxString& label) override; bool SetForegroundColour(wxColour const & colour) override; @@ -47,7 +47,7 @@ public: void SetBackgroundColor(StateColor const &color); - bool Enable(bool enable = true); + bool Enable(bool enable = true) override; void Rescale(); diff --git a/src/slic3r/GUI/Widgets/SpinInput.cpp b/src/slic3r/GUI/Widgets/SpinInput.cpp index fba5a45233..010794c85f 100644 --- a/src/slic3r/GUI/Widgets/SpinInput.cpp +++ b/src/slic3r/GUI/Widgets/SpinInput.cpp @@ -75,7 +75,7 @@ void SpinInput::Create(wxWindow *parent, text_ctrl->Bind(wxEVT_KILL_FOCUS, &SpinInput::onTextLostFocus, this); text_ctrl->Bind(wxEVT_TEXT_ENTER, &SpinInput::onTextEnter, this); text_ctrl->Bind(wxEVT_KEY_DOWN, &SpinInput::keyPressed, this); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu button_inc = createButton(true); button_dec = createButton(false); delta = 0; diff --git a/src/slic3r/GUI/Widgets/TabCtrl.hpp b/src/slic3r/GUI/Widgets/TabCtrl.hpp index a25f332fb3..04b5b8e24e 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.hpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.hpp @@ -63,7 +63,7 @@ public: bool IsVisible(unsigned int item) const; private: - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; #ifdef __WIN32__ WXLRESULT MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) override; diff --git a/src/slic3r/GUI/Widgets/TempInput.cpp b/src/slic3r/GUI/Widgets/TempInput.cpp index 6a9809252a..6705378dac 100644 --- a/src/slic3r/GUI/Widgets/TempInput.cpp +++ b/src/slic3r/GUI/Widgets/TempInput.cpp @@ -134,7 +134,7 @@ void TempInput::Create(wxWindow *parent, wxString text, wxString label, wxString } } }); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu text_ctrl->Bind(wxEVT_LEFT_DOWN, [this](auto &e) { if (m_read_only) { return; diff --git a/src/slic3r/GUI/Widgets/TempInput.hpp b/src/slic3r/GUI/Widgets/TempInput.hpp index c306ba59cc..f281a1ea6e 100644 --- a/src/slic3r/GUI/Widgets/TempInput.hpp +++ b/src/slic3r/GUI/Widgets/TempInput.hpp @@ -107,7 +107,7 @@ public: wxString GetTagTemp() { return text_ctrl->GetValue(); } wxString GetCurrTemp() { return GetLabel(); } int get_max_temp() { return max_temp; } - void SetLabel(const wxString &label); + void SetLabel(const wxString &label) override; void SetTextColor(StateColor const &color); @@ -128,7 +128,7 @@ public: void ReSetOnChanging() { m_on_changing = false; } protected: - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; void DoSetToolTipText(wxString const &tip) override; diff --git a/src/slic3r/GUI/Widgets/TextInput.cpp b/src/slic3r/GUI/Widgets/TextInput.cpp index 49605e048d..c4a6f59a8b 100644 --- a/src/slic3r/GUI/Widgets/TextInput.cpp +++ b/src/slic3r/GUI/Widgets/TextInput.cpp @@ -85,7 +85,7 @@ void TextInput::Create(wxWindow * parent, e.SetId(GetId()); ProcessEventLocally(e); }); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu if (!icon.IsEmpty()) { this->icon = ScalableBitmap(this, icon.ToStdString(), 16); } diff --git a/src/slic3r/GUI/Widgets/TextInput.hpp b/src/slic3r/GUI/Widgets/TextInput.hpp index 9aca7037c4..b3cdf9d1b8 100644 --- a/src/slic3r/GUI/Widgets/TextInput.hpp +++ b/src/slic3r/GUI/Widgets/TextInput.hpp @@ -46,7 +46,7 @@ public: // Only meant to be used by inspector, not public API int GetCornerRadius() const { return static_cast(radius); } - void SetLabel(const wxString& label); + void SetLabel(const wxString& label) override; void SetStaticTips(const wxString& tips, const wxBitmap& bitmap); @@ -73,7 +73,7 @@ protected: virtual void OnEdit() {} virtual void DoSetSize( - int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; void DoSetToolTipText(wxString const &tip) override; diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp index 36800dcf47..e281d97407 100644 --- a/src/slic3r/GUI/Widgets/WebView.cpp +++ b/src/slic3r/GUI/Widgets/WebView.cpp @@ -104,7 +104,7 @@ DWORD DownloadAndInstallWV2RT() { class WebViewEdge : public wxWebViewEdge { public: - bool SetUserAgent(const wxString &userAgent) + bool SetUserAgent(const wxString &userAgent) override { bool dark = userAgent.Contains("dark"); SetColorScheme(dark ? COREWEBVIEW2_PREFERRED_COLOR_SCHEME_DARK : COREWEBVIEW2_PREFERRED_COLOR_SCHEME_LIGHT); diff --git a/src/slic3r/Utils/CrealityPrint.hpp b/src/slic3r/Utils/CrealityPrint.hpp index ddb2054420..3b5287f382 100644 --- a/src/slic3r/Utils/CrealityPrint.hpp +++ b/src/slic3r/Utils/CrealityPrint.hpp @@ -21,14 +21,14 @@ public: ~CrealityPrint() override = default; const char* get_name() const override; - virtual bool can_test() const { return true; }; + virtual bool can_test() const override { return true; }; std::string get_host() const override; bool has_auto_discovery() const override { return true; } wxString get_test_ok_msg() const override; wxString get_test_failed_msg(wxString& msg) const override; virtual bool test(wxString& curl_msg) const override; - PrintHostPostUploadActions get_post_upload_actions() const; + PrintHostPostUploadActions get_post_upload_actions() const override; bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; bool supports_multi_color_print() const; std::string query_boxes_info() const; diff --git a/src/slic3r/Utils/ElegooLink.hpp b/src/slic3r/Utils/ElegooLink.hpp index eb1ca7ba26..a60d2de1b3 100644 --- a/src/slic3r/Utils/ElegooLink.hpp +++ b/src/slic3r/Utils/ElegooLink.hpp @@ -32,10 +32,10 @@ public: PrintHostPostUploadActions get_post_upload_actions() const override; protected: #ifdef WIN32 - virtual bool upload_inner_with_resolved_ip(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn, const boost::asio::ip::address& resolved_addr) const; + virtual bool upload_inner_with_resolved_ip(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn, const boost::asio::ip::address& resolved_addr) const override; #endif - virtual bool validate_version_text(const boost::optional &version_text) const; - virtual bool upload_inner_with_host(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const; + virtual bool validate_version_text(const boost::optional &version_text) const override; + virtual bool upload_inner_with_host(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; #ifdef WIN32 virtual bool test_with_resolved_ip(wxString& curl_msg) const override; diff --git a/src/slic3r/Utils/Obico.hpp b/src/slic3r/Utils/Obico.hpp index 9fd3d50f6b..f262d204bd 100644 --- a/src/slic3r/Utils/Obico.hpp +++ b/src/slic3r/Utils/Obico.hpp @@ -20,7 +20,7 @@ public: ~Obico() override = default; const char* get_name() const override; - virtual bool can_test() const { return true; }; + virtual bool can_test() const override { return true; }; bool has_auto_discovery() const override { return false; } bool is_cloud() const override { return true; } bool get_login_url(wxString& auth_url) const override; @@ -30,7 +30,7 @@ public: wxString get_test_failed_msg(wxString& msg) const override; virtual bool test(wxString& curl_msg) const override; bool get_printers(wxArrayString& printers) const override; - PrintHostPostUploadActions get_post_upload_actions() const; + PrintHostPostUploadActions get_post_upload_actions() const override; bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; protected: From bfe5f7e63cadabff98cbda747b1ee1b4e1ed8f5e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 25 Aug 2026 21:35:43 +0800 Subject: [PATCH 123/138] fix text error --- tests/libslic3r/test_triangle_selector.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/libslic3r/test_triangle_selector.cpp b/tests/libslic3r/test_triangle_selector.cpp index 0bdc639626..fd2ab9efa8 100644 --- a/tests/libslic3r/test_triangle_selector.cpp +++ b/tests/libslic3r/test_triangle_selector.cpp @@ -108,8 +108,9 @@ TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSe })); // get_triangle_as_string emits the nibbles most significant first, so read the hex backwards. + const std::string hex = c.hex; std::vector bitstream; - for (auto it = std::string(c.hex).rbegin(); it != std::string(c.hex).rend(); ++it) { + for (auto it = hex.rbegin(); it != hex.rend(); ++it) { const int nibble = *it >= 'A' ? (*it - 'A' + 10) : (*it - '0'); for (int bit = 0; bit < 4; ++bit) bitstream.push_back((nibble >> bit) & 1); From 265ae16160f304327193522e08ae09fe5459af97 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 25 Aug 2026 10:13:45 -0500 Subject: [PATCH 124/138] chore: ignore CMakeUserPresets.json (#15354) CMake reads CMakeUserPresets.json for developer-local presets, and its documentation states the file should not be checked into version control: https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html#introduction It is the preset equivalent of CMakeLists.txt.user, ignored on the line above. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4d3ccb5c7b..cdcd1c90b4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ Build Build.bat /build*/ CMakeLists.txt.user +CMakeUserPresets.json **/CMakeLists.txt.autosave deps/build* MYMETA.json From a5223279acc9cf3952296aab7d0728345b482676 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Tue, 25 Aug 2026 12:46:34 -0300 Subject: [PATCH 125/138] Fix uneven corner rounding in multiline infill (#15352) * Skip straight-run splits in corner smoothing Teach `CornerSmoother` to treat vertices that only continue a straight segment as part of the same leg instead of rounding them as corners. The smoother now keeps a three-point window so it can emit a corner only once both adjoining legs are known, which avoids unnecessary corner processing while preserving real turns such as hairpins. * Add regression test for split-leg smoothing Adds a FillCornerSmoothing regression test covering polylines with an extra collinear vertex in a straight run. The test ensures corner smoothing treats split and unsplit geometry identically, preventing inconsistent rounding radii in triangular/grid infill paths. --- src/libslic3r/Fill/FillCornerSmoothing.cpp | 16 +++++ src/libslic3r/Fill/FillCornerSmoothing.hpp | 59 +++++++++++++------ .../libslic3r/test_fill_corner_smoothing.cpp | 21 +++++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/libslic3r/Fill/FillCornerSmoothing.cpp b/src/libslic3r/Fill/FillCornerSmoothing.cpp index 2af9f6bb9c..dbce39d572 100644 --- a/src/libslic3r/Fill/FillCornerSmoothing.cpp +++ b/src/libslic3r/Fill/FillCornerSmoothing.cpp @@ -108,6 +108,22 @@ const std::vector& CornerSmoother::curve_coefficients( return m_cached_coefficients; } +bool CornerSmoother::is_on_straight_run(const Vec2d &previous, const Vec2d &vertex, const Vec2d &next) +{ + const Vec2d incoming_leg = vertex - previous; + const Vec2d outgoing_leg = next - vertex; + const double incoming_length = incoming_leg.norm(); + const double outgoing_length = outgoing_leg.norm(); + // A vertex repeating one of its neighbours carries no direction of its own. + if (incoming_length < EPSILON || outgoing_length < EPSILON) + return true; + + const Vec2d incoming = incoming_leg / incoming_length; + const Vec2d outgoing = outgoing_leg / outgoing_length; + return incoming.dot(outgoing) > 0. && + std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x()) < EPSILON; +} + void CornerSmoother::round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next) { m_corner_points.clear(); diff --git a/src/libslic3r/Fill/FillCornerSmoothing.hpp b/src/libslic3r/Fill/FillCornerSmoothing.hpp index 1852fc4c67..7f2ead229a 100644 --- a/src/libslic3r/Fill/FillCornerSmoothing.hpp +++ b/src/libslic3r/Fill/FillCornerSmoothing.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -47,36 +48,57 @@ public: template void push(const Vec2d &point, Emit &emit) { - if (m_pending == 0) { + if (m_held == 0) { + // The first point of a path is an end, not a corner, and stays where it is. emit(point); - m_previous = point; - } else if (m_pending > 1) { - round_corner(m_previous, m_corner, point); - for (const Vec2d &corner_point : m_corner_points) - emit(corner_point); - m_previous = m_corner; + m_window[m_held++] = point; + return; } - m_corner = point; - m_pending = std::min(m_pending + 1, 2); + if (m_held > 1 && is_on_straight_run(m_window[m_held - 2], m_window[m_held - 1], point)) { + // The newest vertex only splits a straight leg, so the leg runs on to this point instead. + m_window[m_held - 1] = point; + return; + } + if (m_held < 3) { + m_window[m_held++] = point; + return; + } + // Both legs of the middle vertex are complete now, so its curve can no longer grow. + emit_corner(m_window[0], m_window[1], m_window[2], emit); + m_window[0] = m_window[1]; + m_window[1] = m_window[2]; + m_window[2] = point; } // Emits the last point of the path and prepares the smoother for a new one. template void flush(Emit &emit) { - if (m_pending > 1) - emit(m_corner); - m_pending = 0; + if (m_held > 2) + emit_corner(m_window[0], m_window[1], m_window[2], emit); + if (m_held > 1) + emit(m_window[m_held - 1]); + m_held = 0; } private: + template void emit_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next, Emit &emit) + { + round_corner(previous, corner, next); + for (const Vec2d &corner_point : m_corner_points) + emit(corner_point); + } + + // Tells a vertex that only continues a straight leg (or repeats its predecessor) from a corner. + // A path doubling back on itself is not one, that vertex is a hairpin and stays where it is. + static bool is_on_straight_run(const Vec2d &previous, const Vec2d &vertex, const Vec2d &next); // Fills m_corner_points with the points replacing the corner vertex. void round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next); // Flattens the canonical corner curve of the given size and turn into coordinates of the // (incoming, outgoing) basis of the corner. Cached, as an infill path repeats the same corner. const std::vector& curve_coefficients(double corner_distance, const Vec2d &incoming, const Vec2d &outgoing); - // Fraction of the shorter adjoining segment consumed on each side of a corner. Half of a segment - // is the maximum, otherwise the curves of two adjacent corners would overlap. + // Fraction of the shorter adjoining leg consumed on each side of a corner. Half of a leg is the + // maximum, otherwise the curves of two adjacent corners would overlap. const double m_corner_distance_ratio; const double m_tolerance; const double m_max_corner_distance; @@ -88,10 +110,11 @@ private: double m_cached_cosine { 0. }; bool m_has_cached_coefficients { false }; - Vec2d m_previous { Vec2d::Zero() }; - Vec2d m_corner { Vec2d::Zero() }; - // Number of points held back: none, the first point of a path, or a corner candidate. - int m_pending { 0 }; + // The corners seen last, kept free of vertices that merely split a straight leg. The middle one + // is rounded once the third arrives, which is what makes its outgoing leg final. + std::array m_window { Vec2d::Zero(), Vec2d::Zero(), Vec2d::Zero() }; + // How many of them are filled in. + int m_held { 0 }; }; // Rounds the corners of already scaled paths in place. Paths of less than three points are left alone. diff --git a/tests/libslic3r/test_fill_corner_smoothing.cpp b/tests/libslic3r/test_fill_corner_smoothing.cpp index f2c25e816d..a9e752f250 100644 --- a/tests/libslic3r/test_fill_corner_smoothing.cpp +++ b/tests/libslic3r/test_fill_corner_smoothing.cpp @@ -171,3 +171,24 @@ TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", REQUIRE(retrace.front() == sharp.front()); REQUIRE(retrace.back() == sharp.back()); } + +TEST_CASE("Corner smoothing ignores vertices splitting a straight leg", "[FillCornerSmoothing][Regression]") +{ + // The triangular and grid infills emit a vertex halfway along the straight run joining two of + // their corners. Measuring the legs up to that vertex instead of up to the next corner let the + // rounding reach only half as far there as it did into the very same run elsewhere in the + // pattern, so geometrically identical corners came out rounded to different radii. + const Polyline plain{ Point::new_scale(0., 20.), Point::new_scale(10., 0.), + Point::new_scale(20., 0.), Point::new_scale(30., 20.) }; + Polyline split = plain; + split.points.insert(split.points.begin() + 2, Point::new_scale(15., 0.)); + + Polyline smooth_plain = plain; + smooth_polyline_corners(smooth_plain, 1., tolerance); + Polyline smooth_split = split; + smooth_polyline_corners(smooth_split, 1., tolerance); + + REQUIRE(smooth_split.points == smooth_plain.points); + // Both corners reach the middle of the 10mm run they share, which the extra vertex sat on. + REQUIRE(contains(smooth_plain, Point::new_scale(15., 0.))); +} From ea4a4a60f12ace34687cb2d8e1015679979791c1 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Tue, 25 Aug 2026 16:18:45 -0300 Subject: [PATCH 126/138] Refresh dynamic filament list on mixed slot changes (#15375) Call update_dynamic_filament_list() alongside update_mixed_filament_list() in two places: after editing a mixed filament slot and when the filament count doesn't change (e.g., adding a mixed/virtual slot). This ensures per-feature filament lists reflect the updated blended colour and type without requiring a full filament count change. --- src/slic3r/GUI/Plater.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index b3a873a0bc..79ebd38716 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -4914,7 +4914,10 @@ void Sidebar::edit_mixed_filament(size_t panel_idx) if (multi_colour_opt && cfg_idx < multi_colour_opt->values.size()) multi_colour_opt->values[cfg_idx] = blended; + // The edited slot keeps its index, so nothing else refreshes the per-feature filament + // lists - and its blended colour and type are what they show for it. update_mixed_filament_list(); + update_dynamic_filament_list(); wxGetApp().plater()->update_project_dirty_from_presets(); wxPostEvent(this, SimpleEvent(EVT_SCHEDULE_BACKGROUND_PROCESS, this)); } @@ -5286,8 +5289,11 @@ void Sidebar::on_filament_count_change(size_t num_filaments) if (num_physical == choices.size()) { // The ctor pre-creates one combo, so a single-filament project hits this guard before // any layout pass has sized the scroll areas; refresh them here as well. + // Adding a mixed slot also lands here, since only the virtual count changed, so the + // per-feature filament lists - which do list mixed slots - have to be refreshed too. recalc_filament_scroll_sizes(); update_mixed_filament_list(); + update_dynamic_filament_list(); return; } From 24967b543ade957e061e52159f557c37134d43cb Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:31:09 +0200 Subject: [PATCH 127/138] Fix contour cleanup across coplanar triangles (#15366) * Fix contour cleanup across coplanar triangles Avoid generic collinear simplification after slicing. Skip only junctions created by shared edges between coplanar faces so contours stay stable without altering shallow geometry. Fixes #15364 * Fix contour cleanup across coplanar triangles (code review fixes) --------- Co-authored-by: Ian Bassi --- src/libslic3r/TriangleMeshSlicer.cpp | 125 +++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index c403a6bd92..417ca354d3 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -146,6 +146,85 @@ public: using IntersectionLines = std::vector; +// Orca: A planar face is commonly represented by multiple triangles. A slicing plane then crosses +// their shared edges and creates intermediate 2D points which are not part of the model contour. +// Track only edges whose two incident triangles lie in the same geometric plane within the slicing +// coordinate precision, so those artificial junctions can be omitted without simplifying genuine, +// nearly-collinear geometry. +using CoplanarEdges = std::vector; + +static CoplanarEdges coplanar_edges(const indexed_triangle_set &mesh, const std::vector &face_edge_ids, + const Transform3d &trafo) +{ + struct FacePlane { + Vec3d origin { Vec3d::Zero() }; + Vec3d normal { Vec3d::Zero() }; + bool valid { false }; + }; + + // Orca: Edge IDs are dense but may include boundary edges referenced by just one face. + int num_edges = 0; + for (const Vec3i32 &edge_ids : face_edge_ids) + num_edges = std::max(num_edges, edge_ids.maxCoeff() + 1); + + CoplanarEdges coplanar(num_edges, false); + std::vector first_face(num_edges, -1); + std::vector first_face_edge(num_edges, -1); + std::vector face_planes(face_edge_ids.size()); + std::vector face_plane_computed(face_edge_ids.size(), false); + auto transformed_vertex = [&mesh, &trafo](int vertex_idx) { + return trafo * mesh.vertices[vertex_idx].cast(); + }; + // Orca: Compute planes lazily. The single-plane slicer masks most faces, so eagerly calculating + // every plane would defeat part of that optimization. + auto face_plane = [&mesh, &face_planes, &face_plane_computed, &transformed_vertex](int face_idx) -> const FacePlane& { + if (! face_plane_computed[face_idx]) { + const Vec3i32 &face = mesh.indices[face_idx]; + const Vec3d a = transformed_vertex(face(0)); + const Vec3d b = transformed_vertex(face(1)); + const Vec3d c = transformed_vertex(face(2)); + FacePlane &plane = face_planes[face_idx]; + plane.origin = a; + plane.normal = (b - a).cross(c - a); + const double normal_length = plane.normal.norm(); + if (normal_length > 0.) { + plane.normal /= normal_length; + plane.valid = true; + } + face_plane_computed[face_idx] = true; + } + return face_planes[face_idx]; + }; + const double plane_distance_tolerance = SCALING_FACTOR; + for (int face_idx = 0; face_idx < int(face_edge_ids.size()); ++ face_idx) { + for (int edge_idx = 0; edge_idx < 3; ++ edge_idx) { + const int edge_id = face_edge_ids[face_idx](edge_idx); + if (edge_id < 0) + continue; + if (first_face[edge_id] == -1) { + first_face[edge_id] = face_idx; + first_face_edge[edge_id] = edge_idx; + } else { + const int first_face_idx = first_face[edge_id]; + const FacePlane &first_plane = face_plane(first_face_idx); + const FacePlane &second_plane = face_plane(face_idx); + const int first_opposite_idx = mesh.indices[first_face_idx]((first_face_edge[edge_id] + 2) % 3); + const int second_opposite_idx = mesh.indices[face_idx]((edge_idx + 2) % 3); + const Vec3d first_opposite = transformed_vertex(first_opposite_idx); + const Vec3d second_opposite = transformed_vertex(second_opposite_idx); + // Orca: A shared edge guarantees that the planes intersect, but not that they coincide. + // Check both opposite vertices against the neighboring plane using one coord_t as the + // distance tolerance. The normal dot product only preserves face orientation; it does + // not classify a shallow angle as coplanar (see #15364). + coplanar[edge_id] = first_plane.valid && second_plane.valid && first_plane.normal.dot(second_plane.normal) > 0. && + std::abs(first_plane.normal.dot(second_opposite - first_plane.origin)) <= plane_distance_tolerance && + std::abs(second_plane.normal.dot(first_opposite - second_plane.origin)) <= plane_distance_tolerance; + } + } + } + return coplanar; +} + enum class FacetSliceType { NoSlice = 0, Slicing = 1, @@ -1057,7 +1136,8 @@ struct OpenPolyline { // called by make_loops() to connect sliced triangles into closed loops and open polylines by the triangle connectivity. // Only connects segments crossing triangles of the same orientation. -static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polygons &loops, std::vector &open_polylines) +static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, const CoplanarEdges &coplanar_edges, + Polygons &loops, std::vector &open_polylines) { // Build a map of lines by edge_a_id and a_id. std::vector by_edge_a_id; @@ -1134,6 +1214,11 @@ static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polyg (first_line->a_id != -1 && first_line->a_id == last_line->b_id)) { // The current loop is complete. Add it to the output. assert(first_line->a == last_line->b); + // Orca: The seed point is also a triangle junction. Handle it explicitly because it + // is never visited through the next_line branch below when the loop closes. + if (first_line->edge_a_id >= 0 && first_line->edge_a_id < int(coplanar_edges.size()) && + coplanar_edges[first_line->edge_a_id]) + loop_pts.erase(loop_pts.begin()); loops.emplace_back(std::move(loop_pts)); #ifdef SLIC3R_TRIANGLEMESH_DEBUG printf(" Discovered %s polygon of %d points\n", (p.is_counter_clockwise() ? "ccw" : "cw"), (int)p.points.size()); @@ -1153,7 +1238,12 @@ static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polyg next_line->a.x, next_line->a.y, next_line->b.x, next_line->b.y); */ assert(last_line->b == next_line->a); - loop_pts.emplace_back(next_line->a); + // Orca: Skip only junctions introduced by triangulating one planar face. Unlike a generic + // collinearity cleanup, this preserves intentional shallow corners used when comparing + // adjacent layers for bridges and overhang perimeters (see #15364). + if (next_line->edge_a_id < 0 || next_line->edge_a_id >= int(coplanar_edges.size()) || + ! coplanar_edges[next_line->edge_a_id]) + loop_pts.emplace_back(next_line->a); last_line = next_line; next_line->set_skip(); } @@ -1382,7 +1472,8 @@ static void chain_open_polylines_close_gaps(std::vector &open_poly static Polygons make_loops( // Lines will have their flags modified. - IntersectionLines &lines) + IntersectionLines &lines, + const CoplanarEdges &coplanar_edges) { Polygons loops; #if 0 @@ -1412,7 +1503,7 @@ static Polygons make_loops( #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ std::vector open_polylines; - chain_lines_by_triangle_connectivity(lines, loops, open_polylines); + chain_lines_by_triangle_connectivity(lines, coplanar_edges, loops, open_polylines); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { @@ -1484,6 +1575,7 @@ template static std::vector make_loops( // Lines will have their flags modified. std::vector &lines, + const CoplanarEdges &coplanar_edges, const MeshSlicingParams ¶ms, ThrowOnCancel throw_on_cancel) { @@ -1491,20 +1583,13 @@ static std::vector make_loops( layers.resize(lines.size()); tbb::parallel_for( tbb::blocked_range(0, lines.size()), - [&lines, &layers, ¶ms, throw_on_cancel](const tbb::blocked_range &range) { + [&lines, &layers, &coplanar_edges, ¶ms, throw_on_cancel](const tbb::blocked_range &range) { for (size_t line_idx = range.begin(); line_idx < range.end(); ++ line_idx) { if ((line_idx & 0x0ffff) == 0) throw_on_cancel(); Polygons &polygons = layers[line_idx]; - polygons = make_loops(lines[line_idx]); - - // Orca: A planar quad represented by two triangles contributes a point where the - // slicing plane crosses the shared diagonal. After rounding to coord_t this - // point may be very slightly off the otherwise straight contour edge. Apart - // from being redundant, such points make the subsequent contour - // simplification depend on the slice height (and may move seam candidates). - remove_collinear(polygons); + polygons = make_loops(lines[line_idx], coplanar_edges); auto this_mode = line_idx < params.slicing_mode_normal_below_layer ? params.mode_below : params.mode; if (! polygons.empty()) { @@ -1633,7 +1718,7 @@ static std::vector make_slab_loops( #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ Polygons &loops = layers[line_idx]; std::vector open_polylines; - chain_lines_by_triangle_connectivity(in, loops, open_polylines); + chain_lines_by_triangle_connectivity(in, {}, loops, open_polylines); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { SVG svg(debug_out_path("make_slab_loops-out-%d-%d-%s.svg", iRun, line_idx, ProjectionFromTop ? "top" : "bottom").c_str(), bbox_svg); @@ -1673,7 +1758,7 @@ static ExPolygons make_expolygons_simple(std::vector &lines) ExPolygons slices; Polygons holes; - for (Polygon &loop : make_loops(lines)) + for (Polygon &loop : make_loops(lines, {})) if (loop.area() >= 0.) slices.emplace_back(std::move(loop)); else @@ -1878,6 +1963,7 @@ std::vector slice_mesh( BOOST_LOG_TRIVIAL(debug) << "slice_mesh to polygons"; std::vector lines; + CoplanarEdges coplanar; { //FIXME facets_edges is likely not needed and quite costly to calculate. @@ -1885,6 +1971,8 @@ std::vector slice_mesh( // However facets_edges assigns a single edge ID to two triangles only, thus when factoring facets_edges out, one will have // to make sure that no code relies on it. std::vector face_edge_ids = its_face_edge_ids(mesh); + // Orca: Keep the coplanarity classification aligned with the edge IDs used to chain this slice. + coplanar = coplanar_edges(mesh, face_edge_ids, params.trafo); if (zs.size() <= 1) { // It likely is not worthwile to copy the vertices. Apply the transformation in place. if (is_identity(params.trafo)) { @@ -1906,7 +1994,7 @@ std::vector slice_mesh( throw_on_cancel(); - std::vector layers = make_loops(lines, params, throw_on_cancel); + std::vector layers = make_loops(lines, coplanar, params, throw_on_cancel); #ifdef SLIC3R_DEBUG { @@ -1952,6 +2040,7 @@ Polygons slice_mesh( const MeshSlicingParams ¶ms) { std::vector lines; + CoplanarEdges coplanar; { bool trafo_identity = is_identity(params.trafo); @@ -1987,6 +2076,8 @@ Polygons slice_mesh( // 3) Calculate face neighbors for just the faces in face_mask. std::vector face_edge_ids = its_face_edge_ids(mesh, face_mask); + // Orca: The single-plane path has its own masked edge-ID space, so classify that space separately. + coplanar = coplanar_edges(mesh, face_edge_ids, params.trafo); // 4) Slice "face_mask" triangles, collect line segments. // It likely is not worthwile to copy the vertices. Apply the transformation in place. @@ -2002,7 +2093,7 @@ Polygons slice_mesh( } // 5) Chain the line segments. - std::vector layers = make_loops(lines, params, [](){}); + std::vector layers = make_loops(lines, coplanar, params, [](){}); assert(layers.size() == 1); return layers.front(); } From 1e4b48c54833086b9f68f1914fb621b30165a1e2 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Wed, 26 Aug 2026 05:38:52 -0500 Subject: [PATCH 128/138] build: clear 227 warnings - dead private fields, malformed comments (#15376) build: drop dead private fields, close malformed comments (227 warnings) Clears 227 of the clang-cl warnings tracked in #15374, taking a full Windows build from 1,491 to 1,264. Five of the six changes are in headers, which are re-diagnosed in every translation unit that includes them, so the count is large for a 14-line diff. Tabbook.hpp: delete two private fields, unread since the 2022 import. m_parent also shadowed wxWindowBase::m_parent. GUI_Utils.hpp: the wxEVT_SYS_COLOUR_CHANGED lambda body is empty on Windows, so its `this` capture is unused there. (void) this; leaves the handler bound, which is what stops the event propagating. DevFirmware.h: mark m_owner [[maybe_unused]]. The class is never instantiated, and the file tracks BambuStudio, so this is the smallest divergence. Eight DeviceTab/ files, AMSItem.cpp and SelectMachine.cpp: block comments malformed so that they read as a nested /*. No behavior change. -Wcomment goes to zero, and only the three intended categories move. --- src/slic3r/GUI/DeviceCore/DevFirmware.h | 2 +- src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp | 2 +- src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp | 2 +- src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h | 2 +- src/slic3r/GUI/GUI_Utils.hpp | 3 +++ src/slic3r/GUI/SelectMachine.cpp | 2 +- src/slic3r/GUI/Tabbook.hpp | 3 --- src/slic3r/GUI/Widgets/AMSItem.cpp | 3 --- 13 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevFirmware.h b/src/slic3r/GUI/DeviceCore/DevFirmware.h index 9dae603702..5b0ea986a2 100644 --- a/src/slic3r/GUI/DeviceCore/DevFirmware.h +++ b/src/slic3r/GUI/DeviceCore/DevFirmware.h @@ -64,7 +64,7 @@ public: DevFirmware(MachineObject* obj) : m_owner(obj) {} private: - MachineObject* m_owner = nullptr; + [[maybe_unused]] MachineObject* m_owner = nullptr; }; } // namespace Slic3r \ No newline at end of file diff --git a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp index 103584602f..a258a2178a 100644 --- a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp +++ b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp @@ -2,7 +2,7 @@ /* File: uiAMSBestPositionPopup.hpp * Description: The popup with suggest best ams position * -//**********************************************************/ +************************************************************/ #include "uiAMSBestPositionPopup.hpp" diff --git a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp index 427a5c6a73..18dd3c4301 100644 --- a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp +++ b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp @@ -2,7 +2,7 @@ /* File: uiAMSBestPositionPopup.hpp * Description: The popup with suggest best ams position * -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/Widgets/AMSItem.hpp" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp index a62277a858..1e40a71150 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp @@ -6,7 +6,7 @@ * \n class wgtDeviceNozzleRackNozzleItem; * \n class wgtDeviceNozzleRackToolHead; * \n class wgtDeviceNozzleRackPos; -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleRack.h" #include "wgtDeviceNozzleRackUpdate.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h index fe12b8bc50..385fa6be48 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h @@ -6,7 +6,7 @@ * \n class wgtDeviceNozzleRackNozzleItem; * \n class wgtDeviceNozzleRackToolHead; * \n class wgtDeviceNozzleRackPos; -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/DeviceCore/DevNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp index 2750ad6323..fac14e31d9 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp @@ -3,7 +3,7 @@ * Description: The panel with rack updating * * \n class wgtDeviceNozzleRackUpdate -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleRackUpdate.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h index 0fa07fd63a..8275638831 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h @@ -3,7 +3,7 @@ * Description: The panel for updating hotends * * \n class wgtDeviceNozzleRackUpdate -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/DeviceCore/DevNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp index 0d61cfd144..c383815f8c 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp @@ -3,7 +3,7 @@ * Description: The panel to select nozzle * * \n class wgtDeviceNozzleSelect; -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleSelect.h" #include "wgtDeviceNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h index 3ff866f3a1..729d24a03d 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h @@ -3,7 +3,7 @@ * Description: The panel to select nozzle * * \n class wgtDeviceNozzleSelect; -//**********************************************************/ +************************************************************/ #pragma once diff --git a/src/slic3r/GUI/GUI_Utils.hpp b/src/slic3r/GUI/GUI_Utils.hpp index c93c40b066..85790516ee 100644 --- a/src/slic3r/GUI/GUI_Utils.hpp +++ b/src/slic3r/GUI/GUI_Utils.hpp @@ -155,6 +155,9 @@ public: update_dark_config(); on_sys_color_changed(); event.Skip(); +#else + // Not calling Skip() is what stops the event propagating on Windows. + (void) this; #endif // __WINDOWS__ }); diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 486be04243..2411967b0d 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -2847,7 +2847,7 @@ void SelectMachineDialog::on_ok_btn(wxCommandEvent &event) }); // STUDIO-9580 - /* use warning color if there are warning and normal messages* / + /* use warning color if there are warning and normal messages*/ /* use indexes if there are several messages*/ /* add header and ending if there are several messages or has none block warnings*/ if (confirm_text.size() > 1 || !is_printing_block) diff --git a/src/slic3r/GUI/Tabbook.hpp b/src/slic3r/GUI/Tabbook.hpp index 87fd215327..b1301a5c23 100644 --- a/src/slic3r/GUI/Tabbook.hpp +++ b/src/slic3r/GUI/Tabbook.hpp @@ -36,7 +36,6 @@ public: TabButton* pageButton; private: - wxWindow* m_parent; wxFlexGridSizer* m_buttons_sizer; wxBoxSizer* m_sizer; ScalableBitmap m_arrow_img; @@ -400,8 +399,6 @@ private: unsigned m_showTimeout, m_hideTimeout; - TabButtonsListCtrl *m_ctrl{nullptr}; - }; //#endif // _WIN32 #endif // slic3r_Tabbook_hpp_ diff --git a/src/slic3r/GUI/Widgets/AMSItem.cpp b/src/slic3r/GUI/Widgets/AMSItem.cpp index b6f335b580..27241450cb 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.cpp +++ b/src/slic3r/GUI/Widgets/AMSItem.cpp @@ -2083,9 +2083,6 @@ void AMSRoad::OnPassRoad(std::vector prord_list) } } -/* - - /************************************************* Description:AMSRoadUpPart **************************************************/ From 9dc9b4247520b8fe016738955ead7b9406dddcc3 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Wed, 26 Aug 2026 07:39:23 -0300 Subject: [PATCH 129/138] Pass closure state to fuzzy skin (#15378) Update perimeter traversal to pass each extrusion's closed/open state into `apply_fuzzy_skin`. This lets fuzzy skin logic distinguish contours from closed loops when processing perimeters. Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> --- src/libslic3r/PerimeterGenerator.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 659a3a7038..9037118f0c 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -408,7 +408,7 @@ static ExtrusionEntityCollection traverse_extrusions(const PerimeterGenerator& p ExtrusionRole role = is_external ? erExternalPerimeter : erPerimeter; const bool is_contour = !extrusion->is_closed || pg_extrusion.is_contour; - apply_fuzzy_skin(extrusion, perimeter_generator, is_contour); + apply_fuzzy_skin(extrusion, perimeter_generator, is_contour, extrusion->is_closed); ExtrusionPaths paths; // detect overhanging/bridging perimeters From 5552ed6cf1383a58321b2196317fe0c69a78b1a6 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 26 Aug 2026 19:06:33 +0800 Subject: [PATCH 130/138] 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. --- src/libslic3r/PresetBundle.cpp | 84 ++------ src/libslic3r/PresetBundle.hpp | 7 +- src/slic3r/GUI/GUI_App.cpp | 14 +- src/slic3r/GUI/Plater.cpp | 11 +- src/slic3r/GUI/Tab.cpp | 32 +-- .../libslic3r/test_preset_bundle_loading.cpp | 186 ++++++++++++++++++ 6 files changed, 246 insertions(+), 88 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index f92bb354ee..53524887a9 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -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]; } -// BBS -void PresetBundle::set_num_filaments(unsigned int n, std::vector 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("filament_colour"); - ConfigOptionStrings *filament_multi_color = project_config.option("filament_multi_colour"); - ConfigOptionStrings* filament_color_type = project_config.option("filament_colour_type"); - ConfigOptionInts* filament_map = project_config.option("filament_map"); - ConfigOptionInts* filament_nozzle_map = project_config.option("filament_nozzle_map"); - ConfigOptionInts* filament_volume_map = project_config.option("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(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("filament_is_mixed")) - opt->values.resize(n, false); - if (auto* opt = project_config.option("filament_mixed_components")) - opt->values.resize(n, std::string{}); - if (auto* opt = project_config.option("filament_mixed_sublayer_ratios")) - opt->values.resize(n, std::string{}); - if (auto* opt = project_config.option("filament_mixed_gradient")) - opt->values.resize(n, false); - if (auto* opt = project_config.option("filament_mixed_gradient_range")) - opt->values.resize(n, std::string{}); - if (auto* opt = project_config.option("filament_mixed_gradient_curve")) - opt->values.resize(n, std::string{}); - if (auto* opt = project_config.option("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) { 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("filament_nozzle_map"); ConfigOptionInts* filament_volume_map = project_config.option("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); // Sync filament multi colour 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); //BBS set new filament color to new_color - if (old_filament_count < n) { - if (!new_color.empty()) { - for (unsigned i = old_filament_count; i < n; i++) { - filament_color->values[i] = new_color; - filament_multi_color->values[i] = new_color; - filament_color_type->values[i] = "1"; // default color type - } + if (!new_color.empty()) { + for (size_t i = old_slot_count; i < n; i++) { + filament_color->values[i] = new_color; + filament_multi_color->values[i] = new_color; + 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)); } +// 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("filament_is_mixed"); + return opt == nullptr ? filament_presets.size() + : size_t(std::count(opt->values.begin(), opt->values.end(), false)); +} + std::vector PresetBundle::physical_filament_config_indices() const { std::vector indices; diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index c3e7dd4441..6e7e07b26e 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -326,8 +326,9 @@ public: // Export selections (current print, current filaments, current printer) into config.ini void export_selections(AppConfig &config); - // BBS - void set_num_filaments(unsigned int n, std::vector new_colors); + // n is the total slot count, and growth appends at the raw tail - which is where the mixed + // 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 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 // their own, so any resize driven by the printer's extruder count has to add this on top. 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); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 738af5e24c..223829f435 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -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("nozzle_diameter"); if (nozzle_diameter) { // 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 - // a just-loaded project, and update_extruder_count() would then strip the facets painted - // with them. - preset_bundle->set_num_filaments(nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments()); + // nozzle of their own and the count has to allow for them. Only ever grow: this sizes + // the list so the combo boxes have something to bind to, and set_num_filaments() trims + // at the raw tail, so shrinking here would eat the mixes rather than the surplus + // 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); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 79ebd38716..7d3fc4307f 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -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 // 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. - size_t total = wxGetApp().preset_bundle->filament_presets.size(); - size_t insert_pos = p->combos_filament.size(); + // Count off filament_is_mixed, not filament_presets or the combos: the extruder-count spinner + // reaches this before the sidebar has rebuilt, and update_multi_material_filament_presets() + // 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); 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. // No mixed slots -> insert_pos == total -> every rotate below is a no-op. diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index dc7ccb7284..ef5ff29af1 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -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 if (opt_key == "extruders_count" && !m_config->opt_bool("single_extruder_multi_material")){ - auto num_extruder = boost::any_cast(value); - int old_filament_size = wxGetApp().preset_bundle->filament_presets.size(); - std::vector new_colors; - for (int i = old_filament_size; i < num_extruder; ++i) { - wxColour new_col = Plater::get_next_color_for_filament(); - std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); - new_colors.push_back(new_color); + const size_t num_extruder = boost::any_cast(value); + auto *bundle = wxGetApp().preset_bundle; + Sidebar &sidebar = wxGetApp().plater()->sidebar(); + // A tool changer feeds filament N from nozzle N, so the extruder count sizes the physical + // run only; mixed slots are virtual and keep the tail. Go one slot at a time through the + // sidebar's own +/- calls: they insert ahead of the mixed tail and renumber filament ids, + // 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 diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index ea05ec0cf5..55c18bfa9e 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -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); } } + +// 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("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("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("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.printers.get_edited_preset().config.option("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("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("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("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("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("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("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("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("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 }; + p.config.option("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("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("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("filament_is_mixed")->values = + { false, false, false, false, false, true }; + bundle.project_config.option("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("filament_mixed_components")->values[5] == "1,2"); + } +} From 142c63ab0e4a22c9be18d67752f9779d75c98bf5 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Wed, 26 Aug 2026 17:14:30 -0500 Subject: [PATCH 131/138] build: clear 143 -Woverloaded-virtual warnings in GUI widgets (#15377) --- .../GUI/CalibrationWizardPresetPage.cpp | 2 +- src/slic3r/GUI/Plater.cpp | 4 +-- src/slic3r/GUI/Widgets/AMSControl.cpp | 6 ++-- src/slic3r/GUI/Widgets/AMSItem.cpp | 35 ++++++++----------- src/slic3r/GUI/Widgets/AMSItem.hpp | 14 ++++---- src/slic3r/GUI/Widgets/ScrolledWindow.cpp | 11 ------ src/slic3r/GUI/Widgets/ScrolledWindow.hpp | 1 - 7 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp index db97354ad1..5267715439 100644 --- a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp +++ b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp @@ -1015,7 +1015,7 @@ wxBoxSizer* CalibrationPresetPage::create_ams_items_sizer(MachineObject* obj, wx auto ams_items_sizer = new wxBoxSizer(wxHORIZONTAL); for (auto &info : ams_info) { auto preview_ams_item = new AMSPreview(ams_preview_panel, wxID_ANY, info, info.ams_type); - preview_ams_item->Update(info); + preview_ams_item->UpdateInfo(info); preview_ams_item->Open(); ams_preview_list.push_back(preview_ams_item); std::string ams_id = preview_ams_item->get_ams_id(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 7d3fc4307f..e1a98cdbf8 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -1467,12 +1467,12 @@ void ExtruderGroup::update_ams() size_t left = 4; size_t index = 0; for (size_t i = i4; i < ams_n4 && left > 0; ++i, ++index, left -= 2) { - ams[index]->Update(i < ams_4.size() ? ams_4[i] : info4); + ams[index]->UpdateInfo(i < ams_4.size() ? ams_4[i] : info4); ams[index]->Refresh(); ams[index]->Open(); } for (size_t i = i1; i < ams_n1 && left > 0; ++i, ++index, --left) { - ams[index]->Update(i < ams_1.size() ? ams_1[i] : info1); + ams[index]->UpdateInfo(i < ams_1.size() ? ams_1[i] : info1); ams[index]->Refresh(); ams[index]->Open(); } diff --git a/src/slic3r/GUI/Widgets/AMSControl.cpp b/src/slic3r/GUI/Widgets/AMSControl.cpp index efcca12a05..41f14b6a11 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.cpp +++ b/src/slic3r/GUI/Widgets/AMSControl.cpp @@ -984,7 +984,7 @@ void AMSControl::UpdateAms(const std::string &series_name, if (cans->get_ams_id() == std::to_string(VIRTUAL_TRAY_MAIN_ID) || cans->get_ams_id() == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) { for (auto ifo : m_ext_info) { if (ifo.ams_id == ams_id) { - cans->Update(ifo); + cans->UpdateInfo(ifo); cans->show_sn_value(m_ams_model == AMSModel::AMS_LITE ? false : true); } } @@ -992,7 +992,7 @@ void AMSControl::UpdateAms(const std::string &series_name, else{ for (auto ifo : m_ams_info) { if (ifo.ams_id == ams_id) { - cans->Update(ifo); + cans->UpdateInfo(ifo); cans->show_sn_value(m_ams_model == AMSModel::AMS_LITE ? false : true); } } @@ -1015,7 +1015,7 @@ void AMSControl::UpdateAms(const std::string &series_name, std::string id = ams_prv.second->get_ams_id(); auto item = m_ams_item_list.find(id); if (item != m_ams_item_list.end()) - { ams_prv.second->Update(item->second->get_ams_info()); + { ams_prv.second->UpdateInfo(item->second->get_ams_info()); } } } diff --git a/src/slic3r/GUI/Widgets/AMSItem.cpp b/src/slic3r/GUI/Widgets/AMSItem.cpp index 27241450cb..f99e5f49fd 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.cpp +++ b/src/slic3r/GUI/Widgets/AMSItem.cpp @@ -325,7 +325,7 @@ AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, wxString can_id, Ca m_can_id = can_id.ToStdString(); create(parent, wxID_ANY, pos, size); - Update(ams_id, info); + UpdateInfo(ams_id, info); } AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, int can_id, Caninfo info, const wxPoint &pos, const wxSize &size) : AMSrefresh() @@ -333,7 +333,7 @@ AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, int can_id, Caninfo m_can_id = wxString::Format("%d", can_id).ToStdString(); create(parent, wxID_ANY, pos, size); - Update(ams_id, info); + UpdateInfo(ams_id, info); } AMSrefresh::~AMSrefresh() @@ -482,7 +482,7 @@ void AMSrefresh::paintEvent(wxPaintEvent &evt) dc.DrawText(m_refresh_id, pot); } -void AMSrefresh::Update(std::string ams_id, Caninfo info) +void AMSrefresh::UpdateInfo(std::string ams_id, Caninfo info) { if (m_ams_id == ams_id && m_info == info) { @@ -945,7 +945,7 @@ AMSLib::AMSLib(wxWindow *parent, std::string ams_idx, Caninfo info, AMSModelOrig Bind(wxEVT_LEAVE_WINDOW, &AMSLib::on_leave_window, this); Bind(wxEVT_LEFT_DOWN, &AMSLib::on_left_down, this); - Update(info, ams_idx, false); + UpdateInfo(info, ams_idx, false); } AMSLib::~AMSLib() @@ -1730,7 +1730,7 @@ void AMSLib::on_pass_road(bool pass) } } -void AMSLib::Update(Caninfo info, std::string ams_idx, bool refresh) +void AMSLib::UpdateInfo(Caninfo info, std::string ams_idx, bool refresh) { DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); if (!dev) return; @@ -1868,7 +1868,7 @@ AMSRoad::AMSRoad(wxWindow *parent, wxWindowID id, Caninfo info, int canindex, in void AMSRoad::create(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size) { wxWindow::Create(parent, id, pos, size); } -void AMSRoad::Update(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan) +void AMSRoad::UpdateInfo(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan) { m_amsinfo = amsinfo; m_info = info; @@ -2121,7 +2121,7 @@ void AMSRoadUpPart::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, Refresh(); } -void AMSRoadUpPart::Update(AMSinfo amsinfo) +void AMSRoadUpPart::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo != amsinfo) { @@ -2613,7 +2613,7 @@ void AMSPreview::Close() Hide(); } -void AMSPreview::Update(AMSinfo amsinfo) +void AMSPreview::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo == amsinfo) { @@ -2951,7 +2951,7 @@ AMSHumidity::AMSHumidity(wxWindow* parent, wxWindowID id, AMSinfo info, const wx } }); - Update(info); + UpdateInfo(info); } void AMSHumidity::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size) { @@ -2960,7 +2960,7 @@ void AMSHumidity::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, co } -void AMSHumidity::Update(AMSinfo amsinfo) +void AMSHumidity::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo != amsinfo) { @@ -3377,7 +3377,7 @@ void AmsItem::AddLiteCan(Caninfo caninfo, int canindex, wxGridSizer* sizer) //m_can_road_list[caninfo.can_id] = m_panel_road; } -void AmsItem::Update(AMSinfo info) +void AmsItem::UpdateInfo(AMSinfo info) { if (m_info == info) { @@ -3389,7 +3389,7 @@ void AmsItem::Update(AMSinfo info) if (m_humidity) { - m_humidity->Update(m_info); + m_humidity->UpdateInfo(m_info); } for (int i = 0; i < m_can_count; i++) { @@ -3398,7 +3398,7 @@ void AmsItem::Update(AMSinfo info) auto refresh = it->second; if (refresh != nullptr){ - refresh->Update(info.ams_id, info.cans[i]); + refresh->UpdateInfo(info.ams_id, info.cans[i]); refresh->Show(); } } @@ -3407,7 +3407,7 @@ void AmsItem::Update(AMSinfo info) AMSLib* lib = m_can_lib_list[std::to_string(i)]; if (lib != nullptr){ if (i < m_can_count){ - lib->Update(info.cans[i], info.ams_id); + lib->UpdateInfo(info.cans[i], info.ams_id); lib->Show(); } else{ @@ -3416,12 +3416,7 @@ void AmsItem::Update(AMSinfo info) } } if (m_panel_road != nullptr){ - m_panel_road->Update(m_info); - } - - if (true || m_ams_model == AMSModel::GENERIC_AMS) { - /*m_panel_road->Update(m_info, info.cans[0]); - m_panel_road->Show();*/ + m_panel_road->UpdateInfo(m_info); } Layout(); diff --git a/src/slic3r/GUI/Widgets/AMSItem.hpp b/src/slic3r/GUI/Widgets/AMSItem.hpp index bed57e7d39..d7dc26a741 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.hpp +++ b/src/slic3r/GUI/Widgets/AMSItem.hpp @@ -312,7 +312,7 @@ public: ~AMSrefresh(); public: - void Update(std::string ams_id, Caninfo info); + void UpdateInfo(std::string ams_id, Caninfo info); std::string GetCanId() const { return m_info.can_id; }; @@ -492,7 +492,7 @@ public: AMSModel m_ams_model; AMSModelOriginType m_ext_type = { AMSModelOriginType::GENERIC_EXT }; - void Update(Caninfo info, std::string ams_idx, bool refresh = true); + void UpdateInfo(Caninfo info, std::string ams_idx, bool refresh = true); void UnableSelected() { m_unable_selected = true; }; void EableSelected() { m_unable_selected = false; }; void OnSelected(); @@ -581,7 +581,7 @@ public: double m_radius = {4}; wxColour m_road_def_color; wxColour m_road_color; - void Update(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan); + void UpdateInfo(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan); std::vector ams_humidity_img; @@ -614,7 +614,7 @@ public: void create(wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize); public: - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); void OnVamsLoading(bool load, wxColour col = AMS_CONTROL_GRAY500); void SetPassRoadColour(wxColour col); @@ -715,7 +715,7 @@ public: void Open(); void Close(); - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); void create(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size); void OnEnterWindow(wxMouseEvent &evt); void OnLeaveWindow(wxMouseEvent &evt); @@ -768,7 +768,7 @@ public: int m_canindex = { 0 }; bool m_selected = { false }; double m_radius = { 12 }; - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); std::vector ams_humidity_imgs; std::vector ams_humidity_dark_imgs; @@ -801,7 +801,7 @@ public: AmsItem(wxWindow *parent, AMSinfo info, AMSModel model, AMSPanelPos pos); ~AmsItem(); - void Update(AMSinfo info); + void UpdateInfo(AMSinfo info); void create(wxWindow *parent); void AddCan(Caninfo caninfo, int canindex, int maxcan, wxBoxSizer* sizer); void AddLiteCan(Caninfo caninfo, int canindex, wxGridSizer* sizer); diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp index d570b9f764..6aa6f5b600 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp @@ -110,17 +110,6 @@ void ScrolledWindow::SetTipColor(wxColour color) if (m_bottomScrollbar) m_bottomScrollbar->SetTipColor(color); } -void ScrolledWindow::Refresh() -{ - // m_rightScrollbar->SetViewStart(0); - // m_rightScrollbar->Refresh(); - // m_rightScrollbar->Update(); - // m_userPanel->Refresh(); - // m_bottomScrollbar->SetViewStart(0); - // m_rightScrollbar->Refresh(); - // m_bottomScrollbar->Refresh(); -} - void ScrolledWindow::SetBackgroundColour(wxColour color) { wxWindow::SetBackgroundColour(color); diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp index 56d54aade3..38409a19d4 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp @@ -15,7 +15,6 @@ public: ScrolledWindow(wxWindow *parent, wxWindowID id, wxPoint position, wxSize size, long style, int marginWidth = 0, int scrollbarWidth = 4, int tipLength = 0); void OnMouseWheel(wxMouseEvent &event); void SetTipColor(wxColour color); - void Refresh(); void SetBackgroundColour(wxColour color); void SetMarginColor(wxColour color); From cbd1bf2c37adda529489eab1101164be7879e498 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Thu, 27 Aug 2026 06:06:50 -0500 Subject: [PATCH 132/138] build: clear 295 more -Woverloaded-virtual warnings in GUI widgets (#15394) build: clear 295 -Woverloaded-virtual warnings in GUI widgets Turns three hidden base virtuals into real overrides, clearing 295 of the 553 -Woverloaded-virtual warnings and taking a full clang-cl build from 1,264 to 969. Part of #15374. Search.hpp: SearchDialog::Popup and SearchObjectDialog::Popup took a wxPoint that neither body ever read, hiding the virtual wxPopupTransientWindow::Popup(wxWindow*). Both bodies clear the input, call the base, set focus and refill the list, and SearchObjectDialog also guards re-entry, so hiding meant none of that ran when the window was popped through a base pointer. They now override and forward focus. LabeledStaticBox::SetFont and ScrolledWindow::SetBackgroundColour hid their base virtuals the same way, so the label metrics recompute and the child colour propagation only ran for callers holding the concrete type. Both now override. Marking a member override makes clang flag every other unmarked override in the same class, so seven sibling declarations needed the keyword too. Left unmarked they were worth 481 warnings, which would have made this a net loss. MSWDismissUnfocusedPopup is declared only inside #ifdef __WXMSW__ in wx/popupwin.h, so off Windows there is no base virtual to override and the keyword would not compile. Both the declarations and the definitions are guarded, which is how wxWidgets itself declares MSWWindowProc in wx/nativewin.h and how this repo already handles it in BBLTopbar, MainFrame, Button, ComboBox and TabCtrl. ScrolledWindow's constructor left m_userPanel and m_scroll_win uninitialised unless the style requested a vertical scrollbar, while SetBackgroundColour dereferences both. No caller hits that today since every instantiation passes wxVSCROLL, but the override widens who can reach them, so they are now initialised alongside their siblings. Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> --- src/slic3r/GUI/Search.cpp | 12 ++++++++---- src/slic3r/GUI/Search.hpp | 20 ++++++++++++-------- src/slic3r/GUI/Widgets/LabeledStaticBox.cpp | 3 ++- src/slic3r/GUI/Widgets/LabeledStaticBox.hpp | 2 +- src/slic3r/GUI/Widgets/ScrolledWindow.cpp | 7 +++++-- src/slic3r/GUI/Widgets/ScrolledWindow.hpp | 4 ++-- 6 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/slic3r/GUI/Search.cpp b/src/slic3r/GUI/Search.cpp index 1d5cf0b9e5..f8fc51ed02 100644 --- a/src/slic3r/GUI/Search.cpp +++ b/src/slic3r/GUI/Search.cpp @@ -681,7 +681,7 @@ SearchDialog::SearchDialog(OptionsSearcher *searcher, Preset::Type type, wxWindo SearchDialog::~SearchDialog() {} -void SearchDialog::Popup(wxPoint position /*= wxDefaultPosition*/) +void SearchDialog::Popup(wxWindow *focus /*= nullptr*/) { /* const std::string& line = searcher->search_string(); search_line->SetValue(line.empty() ? default_string : from_u8(line)); @@ -696,17 +696,19 @@ void SearchDialog::Popup(wxPoint position /*= wxDefaultPosition*/) search_line2->SetValue(wxString("")); //const std::string &line = searcher->search_string(); //searcher->search(into_u8(line), true); - PopupWindow::Popup(); + PopupWindow::Popup(focus); search_line2->SetFocus(); update_list(); } +#ifdef __WXMSW__ void SearchDialog::MSWDismissUnfocusedPopup() { Dismiss(); OnDismiss(); } +#endif // __WXMSW__ void SearchDialog::OnDismiss() { } @@ -926,7 +928,7 @@ SearchObjectDialog::SearchObjectDialog(GUI::ObjectList* object_list, wxWindow* p SearchObjectDialog::~SearchObjectDialog() {} -void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) +void SearchObjectDialog::Popup(wxWindow *focus /*= nullptr*/) { if (m_is_dismissing || this->IsShown()) { return; @@ -937,7 +939,7 @@ void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) // dropdown list, otherwise the text input won't be usable m_object_list->SetFocus(); #endif - PopupWindow::Popup(); + PopupWindow::Popup(focus); search_line2->SetFocus(); m_object_list->assembly_plate_object_name(); @@ -945,11 +947,13 @@ void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) update_list(); } +#ifdef __WXMSW__ void SearchObjectDialog::MSWDismissUnfocusedPopup() { Dismiss(); OnDismiss(); } +#endif // __WXMSW__ void SearchObjectDialog::OnDismiss() {} diff --git a/src/slic3r/GUI/Search.hpp b/src/slic3r/GUI/Search.hpp index bdb4da83c4..4ae43dbca0 100644 --- a/src/slic3r/GUI/Search.hpp +++ b/src/slic3r/GUI/Search.hpp @@ -216,10 +216,12 @@ public: SearchDialog(OptionsSearcher *searcher, Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *search_btn); ~SearchDialog(); - void MSWDismissUnfocusedPopup(); - void Popup(wxPoint position = wxDefaultPosition); - void OnDismiss(); - void Dismiss(); +#ifdef __WXMSW__ + void MSWDismissUnfocusedPopup() override; +#endif // __WXMSW__ + void Popup(wxWindow *focus = nullptr) override; + void OnDismiss() override; + void Dismiss() override; void Die(); void msw_rescale(); @@ -260,10 +262,12 @@ public: SearchObjectDialog(GUI::ObjectList* object_list, wxWindow* parent, TextInput* input); ~SearchObjectDialog(); - void MSWDismissUnfocusedPopup(); - void Popup(wxPoint position = wxDefaultPosition); - void OnDismiss(); - void Dismiss(); +#ifdef __WXMSW__ + void MSWDismissUnfocusedPopup() override; +#endif // __WXMSW__ + void Popup(wxWindow *focus = nullptr) override; + void OnDismiss() override; + void Dismiss() override; void Die(); void OnInputText(wxCommandEvent& event); diff --git a/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp b/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp index c8e054593f..a11839a8b8 100644 --- a/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp +++ b/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp @@ -98,7 +98,7 @@ void LabeledStaticBox::SetBorderColor(StateColor const &color) Refresh(); } -void LabeledStaticBox::SetFont(wxFont set_font) +bool LabeledStaticBox::SetFont(const wxFont &set_font) { m_font = set_font; @@ -109,6 +109,7 @@ void LabeledStaticBox::SetFont(wxFont set_font) m_label_width = tW; Refresh(); + return true; } bool LabeledStaticBox::Enable(bool enable) diff --git a/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp b/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp index f42175ae05..d3e7f2efce 100644 --- a/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp +++ b/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp @@ -42,7 +42,7 @@ public: void SetBorderColor(StateColor const &color); - void SetFont(wxFont set_font); + bool SetFont(const wxFont &set_font) override; bool Enable(bool enable) override; diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp index 6aa6f5b600..90922f93f1 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp @@ -21,6 +21,8 @@ ScrolledWindow::ScrolledWindow(wxWindow *parent, wxWindowID id, wxPoint position m_bottomScrollbar = NULL; m_verticalSplitter = NULL; m_horizontalSplitter = NULL; + m_userPanel = NULL; + m_scroll_win = NULL; m_marginWidth = marginWidth; @@ -110,12 +112,13 @@ void ScrolledWindow::SetTipColor(wxColour color) if (m_bottomScrollbar) m_bottomScrollbar->SetTipColor(color); } -void ScrolledWindow::SetBackgroundColour(wxColour color) +bool ScrolledWindow::SetBackgroundColour(const wxColour &color) { - wxWindow::SetBackgroundColour(color); + const bool result = wxWindow::SetBackgroundColour(color); m_verticalSplitter->SetBackgroundColour(color); m_userPanel->SetBackgroundColour(color); m_scroll_win->SetBackgroundColour(color); + return result; } void ScrolledWindow::SetMarginColor(wxColour color) diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp index 38409a19d4..5c2bc2f9e5 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp @@ -15,7 +15,7 @@ public: ScrolledWindow(wxWindow *parent, wxWindowID id, wxPoint position, wxSize size, long style, int marginWidth = 0, int scrollbarWidth = 4, int tipLength = 0); void OnMouseWheel(wxMouseEvent &event); void SetTipColor(wxColour color); - void SetBackgroundColour(wxColour color); + bool SetBackgroundColour(const wxColour &color) override; void SetMarginColor(wxColour color); void SetScrollbarColor(wxColour color); @@ -26,7 +26,7 @@ public: // wxSplitterWindow* GetVerticalSplitter() { return m_verticalSplitter; } // wxSplitterWindow* GetHorizontalSplitter() { return m_horizontalSplitter; } bool IsBothDirections() { return m_bothDirections; } - virtual void SetScrollbars(int pixelsPerUnitX, int pixelsPerUnitY, int noUnitsX, int noUnitsY, int xPos = 0, int yPos = 0, bool noRefresh = false); + virtual void SetScrollbars(int pixelsPerUnitX, int pixelsPerUnitY, int noUnitsX, int noUnitsY, int xPos = 0, int yPos = 0, bool noRefresh = false) override; private: wxPanel * m_userPanel; // the panel targeted by the scrolled window From 6fdd4945c19348cc5fc9ed9ae2f26f22a778786b Mon Sep 17 00:00:00 2001 From: schneider007 Date: Thu, 27 Aug 2026 19:16:34 +0800 Subject: [PATCH 133/138] Fix bug: centroid calculation (#15399) --- src/libslic3r/AABBTreeIndirect.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/AABBTreeIndirect.hpp b/src/libslic3r/AABBTreeIndirect.hpp index 035982d876..483ca8efc4 100644 --- a/src/libslic3r/AABBTreeIndirect.hpp +++ b/src/libslic3r/AABBTreeIndirect.hpp @@ -229,7 +229,7 @@ public: m_bbox(bbox.min - Point(SCALED_EPSILON, SCALED_EPSILON), bbox.max + Point(SCALED_EPSILON, SCALED_EPSILON)) {} size_t idx() const { return m_idx; } const BoundingBox& bbox() const { return m_bbox; } - Point centroid() const { return (m_bbox.min() + m_bbox.max() / 2); } + Point centroid() const { return (m_bbox.min() + m_bbox.max()) / 2; } private: size_t m_idx; BoundingBox m_bbox; From 6d1584844e873169580b2aaf946bc9ceae0547d8 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Thu, 27 Aug 2026 17:06:06 -0500 Subject: [PATCH 134/138] fix: STEP part names with accented characters import as numbers (clears 6 warnings) (#15406) --- src/libslic3r/Format/OBJ.cpp | 4 +- src/libslic3r/Format/STEP.cpp | 32 +- tests/data/utf8_part_names.step | 1207 +++++++++++++++++++++++++++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_step.cpp | 93 +++ tests/test_utils.hpp | 21 +- 6 files changed, 1344 insertions(+), 14 deletions(-) create mode 100644 tests/data/utf8_part_names.step create mode 100644 tests/libslic3r/test_step.cpp diff --git a/src/libslic3r/Format/OBJ.cpp b/src/libslic3r/Format/OBJ.cpp index 50826924f4..e066925a98 100644 --- a/src/libslic3r/Format/OBJ.cpp +++ b/src/libslic3r/Format/OBJ.cpp @@ -55,9 +55,9 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s boost::filesystem::path temp_mtl_path(mtl_file); mtl_path = temp_mtl_path; } - auto _mtl_path = mtl_name_is_path ? mtl_abs_path.string().c_str() : mtl_path.string().c_str(); + const std::string _mtl_path = (mtl_name_is_path ? mtl_abs_path : mtl_path).string(); if (boost::filesystem::exists(mtl_name_is_path ? mtl_abs_path : mtl_path)) { - if (!ObjParser::mtlparse(_mtl_path, mtl_data)) { + if (!ObjParser::mtlparse(_mtl_path.c_str(), mtl_data)) { BOOST_LOG_TRIVIAL(error) << "load_obj:load_mtl: failed to parse " << _mtl_path; message = _L("load mtl in obj: failed to parse"); return false; diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index a5c3bb49a2..9cfd3b5cc8 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -111,14 +111,19 @@ bool StepPreProcessor::isUtf8File(const char* path) bool StepPreProcessor::isUtf8(const std::string str) { size_t num = 0; - int i = 0; + size_t i = 0; while (i < str.length()) { - if ((str[i] & 0x80) == 0x00) { + const unsigned char lead = static_cast(str[i]); + if ((lead & 0x80) == 0x00) { i++; - } else if ((num = preNum(str[i])) > 2) { + // preNum() counts the leading 1 bits, and a multi-byte sequence is 2 to 4 + // bytes long, so anything outside that range is not a lead byte. + } else if ((num = preNum(lead)) >= 2 && num <= 4) { + if (i + num > str.length()) + return false; i++; - for (int j = 0; j < num - 1; j++) { - if ((str[i] & 0xc0) != 0x80) + for (size_t j = 0; j < num - 1; j++) { + if ((static_cast(str[i]) & 0xc0) != 0x80) return false; i++; } @@ -132,15 +137,20 @@ bool StepPreProcessor::isUtf8(const std::string str) bool StepPreProcessor::isGBK(const std::string str) { size_t i = 0; while (i < str.length()) { - if (str[i] <= 0x7f) { + // char is signed here, so every byte compares <= 0x7f unless widened first. + const unsigned char lead = static_cast(str[i]); + if (lead <= 0x7f) { i++; continue; } else { - if (str[i] >= 0x81 && - str[i] <= 0xfe && - str[i + 1] >= 0x40 && - str[i + 1] <= 0xfe && - str[i + 1] != 0xf7) { + if (i + 1 >= str.length()) + return false; + const unsigned char trail = static_cast(str[i + 1]); + if (lead >= 0x81 && + lead <= 0xfe && + trail >= 0x40 && + trail <= 0xfe && + trail != 0xf7) { i += 2; continue; } diff --git a/tests/data/utf8_part_names.step b/tests/data/utf8_part_names.step new file mode 100644 index 0000000000..c6d41898ad --- /dev/null +++ b/tests/data/utf8_part_names.step @@ -0,0 +1,1207 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('Open CASCADE Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-27T11:50:16',('Author'),( + 'Open CASCADE'),'Open CASCADE STEP processor 7.6','Open CASCADE 7.6' + ,'Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('pièce','pièce','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#345); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#137,#237,#284,#331,#338)); +#17 = ADVANCED_FACE('',(#18),#32,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#55,#83,#111)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(0.,0.,0.)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(0.,0.,6.)); +#26 = SURFACE_CURVE('',#27,(#31,#43),.PCURVE_S1.); +#27 = LINE('',#28,#29); +#28 = CARTESIAN_POINT('',(0.,0.,0.)); +#29 = VECTOR('',#30,1.); +#30 = DIRECTION('',(0.,0.,1.)); +#31 = PCURVE('',#32,#37); +#32 = PLANE('',#33); +#33 = AXIS2_PLACEMENT_3D('',#34,#35,#36); +#34 = CARTESIAN_POINT('',(0.,0.,0.)); +#35 = DIRECTION('',(1.,0.,-0.)); +#36 = DIRECTION('',(0.,0.,1.)); +#37 = DEFINITIONAL_REPRESENTATION('',(#38),#42); +#38 = LINE('',#39,#40); +#39 = CARTESIAN_POINT('',(0.,0.)); +#40 = VECTOR('',#41,1.); +#41 = DIRECTION('',(1.,0.)); +#42 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#43 = PCURVE('',#44,#49); +#44 = PLANE('',#45); +#45 = AXIS2_PLACEMENT_3D('',#46,#47,#48); +#46 = CARTESIAN_POINT('',(0.,0.,0.)); +#47 = DIRECTION('',(-0.,1.,0.)); +#48 = DIRECTION('',(0.,0.,1.)); +#49 = DEFINITIONAL_REPRESENTATION('',(#50),#54); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(0.,0.)); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(1.,0.)); +#54 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#55 = ORIENTED_EDGE('',*,*,#56,.T.); +#56 = EDGE_CURVE('',#22,#57,#59,.T.); +#57 = VERTEX_POINT('',#58); +#58 = CARTESIAN_POINT('',(0.,12.,0.)); +#59 = SURFACE_CURVE('',#60,(#64,#71),.PCURVE_S1.); +#60 = LINE('',#61,#62); +#61 = CARTESIAN_POINT('',(0.,0.,0.)); +#62 = VECTOR('',#63,1.); +#63 = DIRECTION('',(-0.,1.,0.)); +#64 = PCURVE('',#32,#65); +#65 = DEFINITIONAL_REPRESENTATION('',(#66),#70); +#66 = LINE('',#67,#68); +#67 = CARTESIAN_POINT('',(0.,0.)); +#68 = VECTOR('',#69,1.); +#69 = DIRECTION('',(0.,-1.)); +#70 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#71 = PCURVE('',#72,#77); +#72 = PLANE('',#73); +#73 = AXIS2_PLACEMENT_3D('',#74,#75,#76); +#74 = CARTESIAN_POINT('',(0.,0.,0.)); +#75 = DIRECTION('',(0.,0.,1.)); +#76 = DIRECTION('',(1.,0.,-0.)); +#77 = DEFINITIONAL_REPRESENTATION('',(#78),#82); +#78 = LINE('',#79,#80); +#79 = CARTESIAN_POINT('',(0.,0.)); +#80 = VECTOR('',#81,1.); +#81 = DIRECTION('',(0.,1.)); +#82 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#83 = ORIENTED_EDGE('',*,*,#84,.T.); +#84 = EDGE_CURVE('',#57,#85,#87,.T.); +#85 = VERTEX_POINT('',#86); +#86 = CARTESIAN_POINT('',(0.,12.,6.)); +#87 = SURFACE_CURVE('',#88,(#92,#99),.PCURVE_S1.); +#88 = LINE('',#89,#90); +#89 = CARTESIAN_POINT('',(0.,12.,0.)); +#90 = VECTOR('',#91,1.); +#91 = DIRECTION('',(0.,0.,1.)); +#92 = PCURVE('',#32,#93); +#93 = DEFINITIONAL_REPRESENTATION('',(#94),#98); +#94 = LINE('',#95,#96); +#95 = CARTESIAN_POINT('',(0.,-12.)); +#96 = VECTOR('',#97,1.); +#97 = DIRECTION('',(1.,0.)); +#98 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#99 = PCURVE('',#100,#105); +#100 = PLANE('',#101); +#101 = AXIS2_PLACEMENT_3D('',#102,#103,#104); +#102 = CARTESIAN_POINT('',(0.,12.,0.)); +#103 = DIRECTION('',(-0.,1.,0.)); +#104 = DIRECTION('',(0.,0.,1.)); +#105 = DEFINITIONAL_REPRESENTATION('',(#106),#110); +#106 = LINE('',#107,#108); +#107 = CARTESIAN_POINT('',(0.,0.)); +#108 = VECTOR('',#109,1.); +#109 = DIRECTION('',(1.,0.)); +#110 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#111 = ORIENTED_EDGE('',*,*,#112,.F.); +#112 = EDGE_CURVE('',#24,#85,#113,.T.); +#113 = SURFACE_CURVE('',#114,(#118,#125),.PCURVE_S1.); +#114 = LINE('',#115,#116); +#115 = CARTESIAN_POINT('',(0.,0.,6.)); +#116 = VECTOR('',#117,1.); +#117 = DIRECTION('',(-0.,1.,0.)); +#118 = PCURVE('',#32,#119); +#119 = DEFINITIONAL_REPRESENTATION('',(#120),#124); +#120 = LINE('',#121,#122); +#121 = CARTESIAN_POINT('',(6.,0.)); +#122 = VECTOR('',#123,1.); +#123 = DIRECTION('',(0.,-1.)); +#124 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#125 = PCURVE('',#126,#131); +#126 = PLANE('',#127); +#127 = AXIS2_PLACEMENT_3D('',#128,#129,#130); +#128 = CARTESIAN_POINT('',(0.,0.,6.)); +#129 = DIRECTION('',(0.,0.,1.)); +#130 = DIRECTION('',(1.,0.,-0.)); +#131 = DEFINITIONAL_REPRESENTATION('',(#132),#136); +#132 = LINE('',#133,#134); +#133 = CARTESIAN_POINT('',(0.,0.)); +#134 = VECTOR('',#135,1.); +#135 = DIRECTION('',(0.,1.)); +#136 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#137 = ADVANCED_FACE('',(#138),#152,.T.); +#138 = FACE_BOUND('',#139,.T.); +#139 = EDGE_LOOP('',(#140,#170,#193,#216)); +#140 = ORIENTED_EDGE('',*,*,#141,.F.); +#141 = EDGE_CURVE('',#142,#144,#146,.T.); +#142 = VERTEX_POINT('',#143); +#143 = CARTESIAN_POINT('',(20.,0.,0.)); +#144 = VERTEX_POINT('',#145); +#145 = CARTESIAN_POINT('',(20.,0.,6.)); +#146 = SURFACE_CURVE('',#147,(#151,#163),.PCURVE_S1.); +#147 = LINE('',#148,#149); +#148 = CARTESIAN_POINT('',(20.,0.,0.)); +#149 = VECTOR('',#150,1.); +#150 = DIRECTION('',(0.,0.,1.)); +#151 = PCURVE('',#152,#157); +#152 = PLANE('',#153); +#153 = AXIS2_PLACEMENT_3D('',#154,#155,#156); +#154 = CARTESIAN_POINT('',(20.,0.,0.)); +#155 = DIRECTION('',(1.,0.,-0.)); +#156 = DIRECTION('',(0.,0.,1.)); +#157 = DEFINITIONAL_REPRESENTATION('',(#158),#162); +#158 = LINE('',#159,#160); +#159 = CARTESIAN_POINT('',(0.,0.)); +#160 = VECTOR('',#161,1.); +#161 = DIRECTION('',(1.,0.)); +#162 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#163 = PCURVE('',#44,#164); +#164 = DEFINITIONAL_REPRESENTATION('',(#165),#169); +#165 = LINE('',#166,#167); +#166 = CARTESIAN_POINT('',(0.,20.)); +#167 = VECTOR('',#168,1.); +#168 = DIRECTION('',(1.,0.)); +#169 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#170 = ORIENTED_EDGE('',*,*,#171,.T.); +#171 = EDGE_CURVE('',#142,#172,#174,.T.); +#172 = VERTEX_POINT('',#173); +#173 = CARTESIAN_POINT('',(20.,12.,0.)); +#174 = SURFACE_CURVE('',#175,(#179,#186),.PCURVE_S1.); +#175 = LINE('',#176,#177); +#176 = CARTESIAN_POINT('',(20.,0.,0.)); +#177 = VECTOR('',#178,1.); +#178 = DIRECTION('',(-0.,1.,0.)); +#179 = PCURVE('',#152,#180); +#180 = DEFINITIONAL_REPRESENTATION('',(#181),#185); +#181 = LINE('',#182,#183); +#182 = CARTESIAN_POINT('',(0.,0.)); +#183 = VECTOR('',#184,1.); +#184 = DIRECTION('',(0.,-1.)); +#185 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#186 = PCURVE('',#72,#187); +#187 = DEFINITIONAL_REPRESENTATION('',(#188),#192); +#188 = LINE('',#189,#190); +#189 = CARTESIAN_POINT('',(20.,0.)); +#190 = VECTOR('',#191,1.); +#191 = DIRECTION('',(0.,1.)); +#192 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#193 = ORIENTED_EDGE('',*,*,#194,.T.); +#194 = EDGE_CURVE('',#172,#195,#197,.T.); +#195 = VERTEX_POINT('',#196); +#196 = CARTESIAN_POINT('',(20.,12.,6.)); +#197 = SURFACE_CURVE('',#198,(#202,#209),.PCURVE_S1.); +#198 = LINE('',#199,#200); +#199 = CARTESIAN_POINT('',(20.,12.,0.)); +#200 = VECTOR('',#201,1.); +#201 = DIRECTION('',(0.,0.,1.)); +#202 = PCURVE('',#152,#203); +#203 = DEFINITIONAL_REPRESENTATION('',(#204),#208); +#204 = LINE('',#205,#206); +#205 = CARTESIAN_POINT('',(0.,-12.)); +#206 = VECTOR('',#207,1.); +#207 = DIRECTION('',(1.,0.)); +#208 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#209 = PCURVE('',#100,#210); +#210 = DEFINITIONAL_REPRESENTATION('',(#211),#215); +#211 = LINE('',#212,#213); +#212 = CARTESIAN_POINT('',(0.,20.)); +#213 = VECTOR('',#214,1.); +#214 = DIRECTION('',(1.,0.)); +#215 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#216 = ORIENTED_EDGE('',*,*,#217,.F.); +#217 = EDGE_CURVE('',#144,#195,#218,.T.); +#218 = SURFACE_CURVE('',#219,(#223,#230),.PCURVE_S1.); +#219 = LINE('',#220,#221); +#220 = CARTESIAN_POINT('',(20.,0.,6.)); +#221 = VECTOR('',#222,1.); +#222 = DIRECTION('',(-0.,1.,0.)); +#223 = PCURVE('',#152,#224); +#224 = DEFINITIONAL_REPRESENTATION('',(#225),#229); +#225 = LINE('',#226,#227); +#226 = CARTESIAN_POINT('',(6.,0.)); +#227 = VECTOR('',#228,1.); +#228 = DIRECTION('',(0.,-1.)); +#229 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#230 = PCURVE('',#126,#231); +#231 = DEFINITIONAL_REPRESENTATION('',(#232),#236); +#232 = LINE('',#233,#234); +#233 = CARTESIAN_POINT('',(20.,0.)); +#234 = VECTOR('',#235,1.); +#235 = DIRECTION('',(0.,1.)); +#236 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#237 = ADVANCED_FACE('',(#238),#44,.F.); +#238 = FACE_BOUND('',#239,.F.); +#239 = EDGE_LOOP('',(#240,#261,#262,#283)); +#240 = ORIENTED_EDGE('',*,*,#241,.F.); +#241 = EDGE_CURVE('',#22,#142,#242,.T.); +#242 = SURFACE_CURVE('',#243,(#247,#254),.PCURVE_S1.); +#243 = LINE('',#244,#245); +#244 = CARTESIAN_POINT('',(0.,0.,0.)); +#245 = VECTOR('',#246,1.); +#246 = DIRECTION('',(1.,0.,-0.)); +#247 = PCURVE('',#44,#248); +#248 = DEFINITIONAL_REPRESENTATION('',(#249),#253); +#249 = LINE('',#250,#251); +#250 = CARTESIAN_POINT('',(0.,0.)); +#251 = VECTOR('',#252,1.); +#252 = DIRECTION('',(0.,1.)); +#253 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#254 = PCURVE('',#72,#255); +#255 = DEFINITIONAL_REPRESENTATION('',(#256),#260); +#256 = LINE('',#257,#258); +#257 = CARTESIAN_POINT('',(0.,0.)); +#258 = VECTOR('',#259,1.); +#259 = DIRECTION('',(1.,0.)); +#260 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#261 = ORIENTED_EDGE('',*,*,#21,.T.); +#262 = ORIENTED_EDGE('',*,*,#263,.T.); +#263 = EDGE_CURVE('',#24,#144,#264,.T.); +#264 = SURFACE_CURVE('',#265,(#269,#276),.PCURVE_S1.); +#265 = LINE('',#266,#267); +#266 = CARTESIAN_POINT('',(0.,0.,6.)); +#267 = VECTOR('',#268,1.); +#268 = DIRECTION('',(1.,0.,-0.)); +#269 = PCURVE('',#44,#270); +#270 = DEFINITIONAL_REPRESENTATION('',(#271),#275); +#271 = LINE('',#272,#273); +#272 = CARTESIAN_POINT('',(6.,0.)); +#273 = VECTOR('',#274,1.); +#274 = DIRECTION('',(0.,1.)); +#275 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#276 = PCURVE('',#126,#277); +#277 = DEFINITIONAL_REPRESENTATION('',(#278),#282); +#278 = LINE('',#279,#280); +#279 = CARTESIAN_POINT('',(0.,0.)); +#280 = VECTOR('',#281,1.); +#281 = DIRECTION('',(1.,0.)); +#282 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#283 = ORIENTED_EDGE('',*,*,#141,.F.); +#284 = ADVANCED_FACE('',(#285),#100,.T.); +#285 = FACE_BOUND('',#286,.T.); +#286 = EDGE_LOOP('',(#287,#308,#309,#330)); +#287 = ORIENTED_EDGE('',*,*,#288,.F.); +#288 = EDGE_CURVE('',#57,#172,#289,.T.); +#289 = SURFACE_CURVE('',#290,(#294,#301),.PCURVE_S1.); +#290 = LINE('',#291,#292); +#291 = CARTESIAN_POINT('',(0.,12.,0.)); +#292 = VECTOR('',#293,1.); +#293 = DIRECTION('',(1.,0.,-0.)); +#294 = PCURVE('',#100,#295); +#295 = DEFINITIONAL_REPRESENTATION('',(#296),#300); +#296 = LINE('',#297,#298); +#297 = CARTESIAN_POINT('',(0.,0.)); +#298 = VECTOR('',#299,1.); +#299 = DIRECTION('',(0.,1.)); +#300 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#301 = PCURVE('',#72,#302); +#302 = DEFINITIONAL_REPRESENTATION('',(#303),#307); +#303 = LINE('',#304,#305); +#304 = CARTESIAN_POINT('',(0.,12.)); +#305 = VECTOR('',#306,1.); +#306 = DIRECTION('',(1.,0.)); +#307 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#308 = ORIENTED_EDGE('',*,*,#84,.T.); +#309 = ORIENTED_EDGE('',*,*,#310,.T.); +#310 = EDGE_CURVE('',#85,#195,#311,.T.); +#311 = SURFACE_CURVE('',#312,(#316,#323),.PCURVE_S1.); +#312 = LINE('',#313,#314); +#313 = CARTESIAN_POINT('',(0.,12.,6.)); +#314 = VECTOR('',#315,1.); +#315 = DIRECTION('',(1.,0.,-0.)); +#316 = PCURVE('',#100,#317); +#317 = DEFINITIONAL_REPRESENTATION('',(#318),#322); +#318 = LINE('',#319,#320); +#319 = CARTESIAN_POINT('',(6.,0.)); +#320 = VECTOR('',#321,1.); +#321 = DIRECTION('',(0.,1.)); +#322 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#323 = PCURVE('',#126,#324); +#324 = DEFINITIONAL_REPRESENTATION('',(#325),#329); +#325 = LINE('',#326,#327); +#326 = CARTESIAN_POINT('',(0.,12.)); +#327 = VECTOR('',#328,1.); +#328 = DIRECTION('',(1.,0.)); +#329 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#330 = ORIENTED_EDGE('',*,*,#194,.F.); +#331 = ADVANCED_FACE('',(#332),#72,.F.); +#332 = FACE_BOUND('',#333,.F.); +#333 = EDGE_LOOP('',(#334,#335,#336,#337)); +#334 = ORIENTED_EDGE('',*,*,#56,.F.); +#335 = ORIENTED_EDGE('',*,*,#241,.T.); +#336 = ORIENTED_EDGE('',*,*,#171,.T.); +#337 = ORIENTED_EDGE('',*,*,#288,.F.); +#338 = ADVANCED_FACE('',(#339),#126,.T.); +#339 = FACE_BOUND('',#340,.T.); +#340 = EDGE_LOOP('',(#341,#342,#343,#344)); +#341 = ORIENTED_EDGE('',*,*,#112,.F.); +#342 = ORIENTED_EDGE('',*,*,#263,.T.); +#343 = ORIENTED_EDGE('',*,*,#217,.T.); +#344 = ORIENTED_EDGE('',*,*,#310,.F.); +#345 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#349)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#346,#347,#348)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#346 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#347 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#348 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#349 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#346, + 'distance_accuracy_value','confusion accuracy'); +#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +#351 = SHAPE_DEFINITION_REPRESENTATION(#352,#358); +#352 = PRODUCT_DEFINITION_SHAPE('','',#353); +#353 = PRODUCT_DEFINITION('design','',#354,#357); +#354 = PRODUCT_DEFINITION_FORMATION('','',#355); +#355 = PRODUCT('Gehäuse','Gehäuse','',(#356)); +#356 = PRODUCT_CONTEXT('',#2,'mechanical'); +#357 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#358 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#359),#689); +#359 = MANIFOLD_SOLID_BREP('',#360); +#360 = CLOSED_SHELL('',(#361,#481,#581,#628,#675,#682)); +#361 = ADVANCED_FACE('',(#362),#376,.F.); +#362 = FACE_BOUND('',#363,.F.); +#363 = EDGE_LOOP('',(#364,#399,#427,#455)); +#364 = ORIENTED_EDGE('',*,*,#365,.F.); +#365 = EDGE_CURVE('',#366,#368,#370,.T.); +#366 = VERTEX_POINT('',#367); +#367 = CARTESIAN_POINT('',(25.,0.,0.)); +#368 = VERTEX_POINT('',#369); +#369 = CARTESIAN_POINT('',(25.,0.,6.)); +#370 = SURFACE_CURVE('',#371,(#375,#387),.PCURVE_S1.); +#371 = LINE('',#372,#373); +#372 = CARTESIAN_POINT('',(25.,0.,0.)); +#373 = VECTOR('',#374,1.); +#374 = DIRECTION('',(0.,0.,1.)); +#375 = PCURVE('',#376,#381); +#376 = PLANE('',#377); +#377 = AXIS2_PLACEMENT_3D('',#378,#379,#380); +#378 = CARTESIAN_POINT('',(25.,0.,0.)); +#379 = DIRECTION('',(1.,0.,-0.)); +#380 = DIRECTION('',(0.,0.,1.)); +#381 = DEFINITIONAL_REPRESENTATION('',(#382),#386); +#382 = LINE('',#383,#384); +#383 = CARTESIAN_POINT('',(0.,0.)); +#384 = VECTOR('',#385,1.); +#385 = DIRECTION('',(1.,0.)); +#386 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#387 = PCURVE('',#388,#393); +#388 = PLANE('',#389); +#389 = AXIS2_PLACEMENT_3D('',#390,#391,#392); +#390 = CARTESIAN_POINT('',(25.,0.,0.)); +#391 = DIRECTION('',(-0.,1.,0.)); +#392 = DIRECTION('',(0.,0.,1.)); +#393 = DEFINITIONAL_REPRESENTATION('',(#394),#398); +#394 = LINE('',#395,#396); +#395 = CARTESIAN_POINT('',(0.,0.)); +#396 = VECTOR('',#397,1.); +#397 = DIRECTION('',(1.,0.)); +#398 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#399 = ORIENTED_EDGE('',*,*,#400,.T.); +#400 = EDGE_CURVE('',#366,#401,#403,.T.); +#401 = VERTEX_POINT('',#402); +#402 = CARTESIAN_POINT('',(25.,12.,0.)); +#403 = SURFACE_CURVE('',#404,(#408,#415),.PCURVE_S1.); +#404 = LINE('',#405,#406); +#405 = CARTESIAN_POINT('',(25.,0.,0.)); +#406 = VECTOR('',#407,1.); +#407 = DIRECTION('',(-0.,1.,0.)); +#408 = PCURVE('',#376,#409); +#409 = DEFINITIONAL_REPRESENTATION('',(#410),#414); +#410 = LINE('',#411,#412); +#411 = CARTESIAN_POINT('',(0.,0.)); +#412 = VECTOR('',#413,1.); +#413 = DIRECTION('',(0.,-1.)); +#414 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#415 = PCURVE('',#416,#421); +#416 = PLANE('',#417); +#417 = AXIS2_PLACEMENT_3D('',#418,#419,#420); +#418 = CARTESIAN_POINT('',(25.,0.,0.)); +#419 = DIRECTION('',(0.,0.,1.)); +#420 = DIRECTION('',(1.,0.,-0.)); +#421 = DEFINITIONAL_REPRESENTATION('',(#422),#426); +#422 = LINE('',#423,#424); +#423 = CARTESIAN_POINT('',(0.,0.)); +#424 = VECTOR('',#425,1.); +#425 = DIRECTION('',(0.,1.)); +#426 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#427 = ORIENTED_EDGE('',*,*,#428,.T.); +#428 = EDGE_CURVE('',#401,#429,#431,.T.); +#429 = VERTEX_POINT('',#430); +#430 = CARTESIAN_POINT('',(25.,12.,6.)); +#431 = SURFACE_CURVE('',#432,(#436,#443),.PCURVE_S1.); +#432 = LINE('',#433,#434); +#433 = CARTESIAN_POINT('',(25.,12.,0.)); +#434 = VECTOR('',#435,1.); +#435 = DIRECTION('',(0.,0.,1.)); +#436 = PCURVE('',#376,#437); +#437 = DEFINITIONAL_REPRESENTATION('',(#438),#442); +#438 = LINE('',#439,#440); +#439 = CARTESIAN_POINT('',(0.,-12.)); +#440 = VECTOR('',#441,1.); +#441 = DIRECTION('',(1.,0.)); +#442 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#443 = PCURVE('',#444,#449); +#444 = PLANE('',#445); +#445 = AXIS2_PLACEMENT_3D('',#446,#447,#448); +#446 = CARTESIAN_POINT('',(25.,12.,0.)); +#447 = DIRECTION('',(-0.,1.,0.)); +#448 = DIRECTION('',(0.,0.,1.)); +#449 = DEFINITIONAL_REPRESENTATION('',(#450),#454); +#450 = LINE('',#451,#452); +#451 = CARTESIAN_POINT('',(0.,0.)); +#452 = VECTOR('',#453,1.); +#453 = DIRECTION('',(1.,0.)); +#454 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#455 = ORIENTED_EDGE('',*,*,#456,.F.); +#456 = EDGE_CURVE('',#368,#429,#457,.T.); +#457 = SURFACE_CURVE('',#458,(#462,#469),.PCURVE_S1.); +#458 = LINE('',#459,#460); +#459 = CARTESIAN_POINT('',(25.,0.,6.)); +#460 = VECTOR('',#461,1.); +#461 = DIRECTION('',(-0.,1.,0.)); +#462 = PCURVE('',#376,#463); +#463 = DEFINITIONAL_REPRESENTATION('',(#464),#468); +#464 = LINE('',#465,#466); +#465 = CARTESIAN_POINT('',(6.,0.)); +#466 = VECTOR('',#467,1.); +#467 = DIRECTION('',(0.,-1.)); +#468 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#469 = PCURVE('',#470,#475); +#470 = PLANE('',#471); +#471 = AXIS2_PLACEMENT_3D('',#472,#473,#474); +#472 = CARTESIAN_POINT('',(25.,0.,6.)); +#473 = DIRECTION('',(0.,0.,1.)); +#474 = DIRECTION('',(1.,0.,-0.)); +#475 = DEFINITIONAL_REPRESENTATION('',(#476),#480); +#476 = LINE('',#477,#478); +#477 = CARTESIAN_POINT('',(0.,0.)); +#478 = VECTOR('',#479,1.); +#479 = DIRECTION('',(0.,1.)); +#480 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#481 = ADVANCED_FACE('',(#482),#496,.T.); +#482 = FACE_BOUND('',#483,.T.); +#483 = EDGE_LOOP('',(#484,#514,#537,#560)); +#484 = ORIENTED_EDGE('',*,*,#485,.F.); +#485 = EDGE_CURVE('',#486,#488,#490,.T.); +#486 = VERTEX_POINT('',#487); +#487 = CARTESIAN_POINT('',(41.,0.,0.)); +#488 = VERTEX_POINT('',#489); +#489 = CARTESIAN_POINT('',(41.,0.,6.)); +#490 = SURFACE_CURVE('',#491,(#495,#507),.PCURVE_S1.); +#491 = LINE('',#492,#493); +#492 = CARTESIAN_POINT('',(41.,0.,0.)); +#493 = VECTOR('',#494,1.); +#494 = DIRECTION('',(0.,0.,1.)); +#495 = PCURVE('',#496,#501); +#496 = PLANE('',#497); +#497 = AXIS2_PLACEMENT_3D('',#498,#499,#500); +#498 = CARTESIAN_POINT('',(41.,0.,0.)); +#499 = DIRECTION('',(1.,0.,-0.)); +#500 = DIRECTION('',(0.,0.,1.)); +#501 = DEFINITIONAL_REPRESENTATION('',(#502),#506); +#502 = LINE('',#503,#504); +#503 = CARTESIAN_POINT('',(0.,0.)); +#504 = VECTOR('',#505,1.); +#505 = DIRECTION('',(1.,0.)); +#506 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#507 = PCURVE('',#388,#508); +#508 = DEFINITIONAL_REPRESENTATION('',(#509),#513); +#509 = LINE('',#510,#511); +#510 = CARTESIAN_POINT('',(0.,16.)); +#511 = VECTOR('',#512,1.); +#512 = DIRECTION('',(1.,0.)); +#513 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#514 = ORIENTED_EDGE('',*,*,#515,.T.); +#515 = EDGE_CURVE('',#486,#516,#518,.T.); +#516 = VERTEX_POINT('',#517); +#517 = CARTESIAN_POINT('',(41.,12.,0.)); +#518 = SURFACE_CURVE('',#519,(#523,#530),.PCURVE_S1.); +#519 = LINE('',#520,#521); +#520 = CARTESIAN_POINT('',(41.,0.,0.)); +#521 = VECTOR('',#522,1.); +#522 = DIRECTION('',(-0.,1.,0.)); +#523 = PCURVE('',#496,#524); +#524 = DEFINITIONAL_REPRESENTATION('',(#525),#529); +#525 = LINE('',#526,#527); +#526 = CARTESIAN_POINT('',(0.,0.)); +#527 = VECTOR('',#528,1.); +#528 = DIRECTION('',(0.,-1.)); +#529 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#530 = PCURVE('',#416,#531); +#531 = DEFINITIONAL_REPRESENTATION('',(#532),#536); +#532 = LINE('',#533,#534); +#533 = CARTESIAN_POINT('',(16.,0.)); +#534 = VECTOR('',#535,1.); +#535 = DIRECTION('',(0.,1.)); +#536 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#537 = ORIENTED_EDGE('',*,*,#538,.T.); +#538 = EDGE_CURVE('',#516,#539,#541,.T.); +#539 = VERTEX_POINT('',#540); +#540 = CARTESIAN_POINT('',(41.,12.,6.)); +#541 = SURFACE_CURVE('',#542,(#546,#553),.PCURVE_S1.); +#542 = LINE('',#543,#544); +#543 = CARTESIAN_POINT('',(41.,12.,0.)); +#544 = VECTOR('',#545,1.); +#545 = DIRECTION('',(0.,0.,1.)); +#546 = PCURVE('',#496,#547); +#547 = DEFINITIONAL_REPRESENTATION('',(#548),#552); +#548 = LINE('',#549,#550); +#549 = CARTESIAN_POINT('',(0.,-12.)); +#550 = VECTOR('',#551,1.); +#551 = DIRECTION('',(1.,0.)); +#552 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#553 = PCURVE('',#444,#554); +#554 = DEFINITIONAL_REPRESENTATION('',(#555),#559); +#555 = LINE('',#556,#557); +#556 = CARTESIAN_POINT('',(0.,16.)); +#557 = VECTOR('',#558,1.); +#558 = DIRECTION('',(1.,0.)); +#559 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#560 = ORIENTED_EDGE('',*,*,#561,.F.); +#561 = EDGE_CURVE('',#488,#539,#562,.T.); +#562 = SURFACE_CURVE('',#563,(#567,#574),.PCURVE_S1.); +#563 = LINE('',#564,#565); +#564 = CARTESIAN_POINT('',(41.,0.,6.)); +#565 = VECTOR('',#566,1.); +#566 = DIRECTION('',(-0.,1.,0.)); +#567 = PCURVE('',#496,#568); +#568 = DEFINITIONAL_REPRESENTATION('',(#569),#573); +#569 = LINE('',#570,#571); +#570 = CARTESIAN_POINT('',(6.,0.)); +#571 = VECTOR('',#572,1.); +#572 = DIRECTION('',(0.,-1.)); +#573 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#574 = PCURVE('',#470,#575); +#575 = DEFINITIONAL_REPRESENTATION('',(#576),#580); +#576 = LINE('',#577,#578); +#577 = CARTESIAN_POINT('',(16.,0.)); +#578 = VECTOR('',#579,1.); +#579 = DIRECTION('',(0.,1.)); +#580 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#581 = ADVANCED_FACE('',(#582),#388,.F.); +#582 = FACE_BOUND('',#583,.F.); +#583 = EDGE_LOOP('',(#584,#605,#606,#627)); +#584 = ORIENTED_EDGE('',*,*,#585,.F.); +#585 = EDGE_CURVE('',#366,#486,#586,.T.); +#586 = SURFACE_CURVE('',#587,(#591,#598),.PCURVE_S1.); +#587 = LINE('',#588,#589); +#588 = CARTESIAN_POINT('',(25.,0.,0.)); +#589 = VECTOR('',#590,1.); +#590 = DIRECTION('',(1.,0.,-0.)); +#591 = PCURVE('',#388,#592); +#592 = DEFINITIONAL_REPRESENTATION('',(#593),#597); +#593 = LINE('',#594,#595); +#594 = CARTESIAN_POINT('',(0.,0.)); +#595 = VECTOR('',#596,1.); +#596 = DIRECTION('',(0.,1.)); +#597 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#598 = PCURVE('',#416,#599); +#599 = DEFINITIONAL_REPRESENTATION('',(#600),#604); +#600 = LINE('',#601,#602); +#601 = CARTESIAN_POINT('',(0.,0.)); +#602 = VECTOR('',#603,1.); +#603 = DIRECTION('',(1.,0.)); +#604 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#605 = ORIENTED_EDGE('',*,*,#365,.T.); +#606 = ORIENTED_EDGE('',*,*,#607,.T.); +#607 = EDGE_CURVE('',#368,#488,#608,.T.); +#608 = SURFACE_CURVE('',#609,(#613,#620),.PCURVE_S1.); +#609 = LINE('',#610,#611); +#610 = CARTESIAN_POINT('',(25.,0.,6.)); +#611 = VECTOR('',#612,1.); +#612 = DIRECTION('',(1.,0.,-0.)); +#613 = PCURVE('',#388,#614); +#614 = DEFINITIONAL_REPRESENTATION('',(#615),#619); +#615 = LINE('',#616,#617); +#616 = CARTESIAN_POINT('',(6.,0.)); +#617 = VECTOR('',#618,1.); +#618 = DIRECTION('',(0.,1.)); +#619 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#620 = PCURVE('',#470,#621); +#621 = DEFINITIONAL_REPRESENTATION('',(#622),#626); +#622 = LINE('',#623,#624); +#623 = CARTESIAN_POINT('',(0.,0.)); +#624 = VECTOR('',#625,1.); +#625 = DIRECTION('',(1.,0.)); +#626 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#627 = ORIENTED_EDGE('',*,*,#485,.F.); +#628 = ADVANCED_FACE('',(#629),#444,.T.); +#629 = FACE_BOUND('',#630,.T.); +#630 = EDGE_LOOP('',(#631,#652,#653,#674)); +#631 = ORIENTED_EDGE('',*,*,#632,.F.); +#632 = EDGE_CURVE('',#401,#516,#633,.T.); +#633 = SURFACE_CURVE('',#634,(#638,#645),.PCURVE_S1.); +#634 = LINE('',#635,#636); +#635 = CARTESIAN_POINT('',(25.,12.,0.)); +#636 = VECTOR('',#637,1.); +#637 = DIRECTION('',(1.,0.,-0.)); +#638 = PCURVE('',#444,#639); +#639 = DEFINITIONAL_REPRESENTATION('',(#640),#644); +#640 = LINE('',#641,#642); +#641 = CARTESIAN_POINT('',(0.,0.)); +#642 = VECTOR('',#643,1.); +#643 = DIRECTION('',(0.,1.)); +#644 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#645 = PCURVE('',#416,#646); +#646 = DEFINITIONAL_REPRESENTATION('',(#647),#651); +#647 = LINE('',#648,#649); +#648 = CARTESIAN_POINT('',(0.,12.)); +#649 = VECTOR('',#650,1.); +#650 = DIRECTION('',(1.,0.)); +#651 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#652 = ORIENTED_EDGE('',*,*,#428,.T.); +#653 = ORIENTED_EDGE('',*,*,#654,.T.); +#654 = EDGE_CURVE('',#429,#539,#655,.T.); +#655 = SURFACE_CURVE('',#656,(#660,#667),.PCURVE_S1.); +#656 = LINE('',#657,#658); +#657 = CARTESIAN_POINT('',(25.,12.,6.)); +#658 = VECTOR('',#659,1.); +#659 = DIRECTION('',(1.,0.,-0.)); +#660 = PCURVE('',#444,#661); +#661 = DEFINITIONAL_REPRESENTATION('',(#662),#666); +#662 = LINE('',#663,#664); +#663 = CARTESIAN_POINT('',(6.,0.)); +#664 = VECTOR('',#665,1.); +#665 = DIRECTION('',(0.,1.)); +#666 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#667 = PCURVE('',#470,#668); +#668 = DEFINITIONAL_REPRESENTATION('',(#669),#673); +#669 = LINE('',#670,#671); +#670 = CARTESIAN_POINT('',(0.,12.)); +#671 = VECTOR('',#672,1.); +#672 = DIRECTION('',(1.,0.)); +#673 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#674 = ORIENTED_EDGE('',*,*,#538,.F.); +#675 = ADVANCED_FACE('',(#676),#416,.F.); +#676 = FACE_BOUND('',#677,.F.); +#677 = EDGE_LOOP('',(#678,#679,#680,#681)); +#678 = ORIENTED_EDGE('',*,*,#400,.F.); +#679 = ORIENTED_EDGE('',*,*,#585,.T.); +#680 = ORIENTED_EDGE('',*,*,#515,.T.); +#681 = ORIENTED_EDGE('',*,*,#632,.F.); +#682 = ADVANCED_FACE('',(#683),#470,.T.); +#683 = FACE_BOUND('',#684,.T.); +#684 = EDGE_LOOP('',(#685,#686,#687,#688)); +#685 = ORIENTED_EDGE('',*,*,#456,.F.); +#686 = ORIENTED_EDGE('',*,*,#607,.T.); +#687 = ORIENTED_EDGE('',*,*,#561,.T.); +#688 = ORIENTED_EDGE('',*,*,#654,.F.); +#689 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#693)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#690,#691,#692)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#690 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#691 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#692 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#693 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#690, + 'distance_accuracy_value','confusion accuracy'); +#694 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#355)); +#695 = SHAPE_DEFINITION_REPRESENTATION(#696,#702); +#696 = PRODUCT_DEFINITION_SHAPE('','',#697); +#697 = PRODUCT_DEFINITION('design','',#698,#701); +#698 = PRODUCT_DEFINITION_FORMATION('','',#699); +#699 = PRODUCT('bracket','bracket','',(#700)); +#700 = PRODUCT_CONTEXT('',#2,'mechanical'); +#701 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#702 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#703),#1033); +#703 = MANIFOLD_SOLID_BREP('',#704); +#704 = CLOSED_SHELL('',(#705,#825,#925,#972,#1019,#1026)); +#705 = ADVANCED_FACE('',(#706),#720,.F.); +#706 = FACE_BOUND('',#707,.F.); +#707 = EDGE_LOOP('',(#708,#743,#771,#799)); +#708 = ORIENTED_EDGE('',*,*,#709,.F.); +#709 = EDGE_CURVE('',#710,#712,#714,.T.); +#710 = VERTEX_POINT('',#711); +#711 = CARTESIAN_POINT('',(45.,0.,0.)); +#712 = VERTEX_POINT('',#713); +#713 = CARTESIAN_POINT('',(45.,0.,6.)); +#714 = SURFACE_CURVE('',#715,(#719,#731),.PCURVE_S1.); +#715 = LINE('',#716,#717); +#716 = CARTESIAN_POINT('',(45.,0.,0.)); +#717 = VECTOR('',#718,1.); +#718 = DIRECTION('',(0.,0.,1.)); +#719 = PCURVE('',#720,#725); +#720 = PLANE('',#721); +#721 = AXIS2_PLACEMENT_3D('',#722,#723,#724); +#722 = CARTESIAN_POINT('',(45.,0.,0.)); +#723 = DIRECTION('',(1.,0.,-0.)); +#724 = DIRECTION('',(0.,0.,1.)); +#725 = DEFINITIONAL_REPRESENTATION('',(#726),#730); +#726 = LINE('',#727,#728); +#727 = CARTESIAN_POINT('',(0.,0.)); +#728 = VECTOR('',#729,1.); +#729 = DIRECTION('',(1.,0.)); +#730 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#731 = PCURVE('',#732,#737); +#732 = PLANE('',#733); +#733 = AXIS2_PLACEMENT_3D('',#734,#735,#736); +#734 = CARTESIAN_POINT('',(45.,0.,0.)); +#735 = DIRECTION('',(-0.,1.,0.)); +#736 = DIRECTION('',(0.,0.,1.)); +#737 = DEFINITIONAL_REPRESENTATION('',(#738),#742); +#738 = LINE('',#739,#740); +#739 = CARTESIAN_POINT('',(0.,0.)); +#740 = VECTOR('',#741,1.); +#741 = DIRECTION('',(1.,0.)); +#742 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#743 = ORIENTED_EDGE('',*,*,#744,.T.); +#744 = EDGE_CURVE('',#710,#745,#747,.T.); +#745 = VERTEX_POINT('',#746); +#746 = CARTESIAN_POINT('',(45.,12.,0.)); +#747 = SURFACE_CURVE('',#748,(#752,#759),.PCURVE_S1.); +#748 = LINE('',#749,#750); +#749 = CARTESIAN_POINT('',(45.,0.,0.)); +#750 = VECTOR('',#751,1.); +#751 = DIRECTION('',(-0.,1.,0.)); +#752 = PCURVE('',#720,#753); +#753 = DEFINITIONAL_REPRESENTATION('',(#754),#758); +#754 = LINE('',#755,#756); +#755 = CARTESIAN_POINT('',(0.,0.)); +#756 = VECTOR('',#757,1.); +#757 = DIRECTION('',(0.,-1.)); +#758 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#759 = PCURVE('',#760,#765); +#760 = PLANE('',#761); +#761 = AXIS2_PLACEMENT_3D('',#762,#763,#764); +#762 = CARTESIAN_POINT('',(45.,0.,0.)); +#763 = DIRECTION('',(0.,0.,1.)); +#764 = DIRECTION('',(1.,0.,-0.)); +#765 = DEFINITIONAL_REPRESENTATION('',(#766),#770); +#766 = LINE('',#767,#768); +#767 = CARTESIAN_POINT('',(0.,0.)); +#768 = VECTOR('',#769,1.); +#769 = DIRECTION('',(0.,1.)); +#770 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#771 = ORIENTED_EDGE('',*,*,#772,.T.); +#772 = EDGE_CURVE('',#745,#773,#775,.T.); +#773 = VERTEX_POINT('',#774); +#774 = CARTESIAN_POINT('',(45.,12.,6.)); +#775 = SURFACE_CURVE('',#776,(#780,#787),.PCURVE_S1.); +#776 = LINE('',#777,#778); +#777 = CARTESIAN_POINT('',(45.,12.,0.)); +#778 = VECTOR('',#779,1.); +#779 = DIRECTION('',(0.,0.,1.)); +#780 = PCURVE('',#720,#781); +#781 = DEFINITIONAL_REPRESENTATION('',(#782),#786); +#782 = LINE('',#783,#784); +#783 = CARTESIAN_POINT('',(0.,-12.)); +#784 = VECTOR('',#785,1.); +#785 = DIRECTION('',(1.,0.)); +#786 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#787 = PCURVE('',#788,#793); +#788 = PLANE('',#789); +#789 = AXIS2_PLACEMENT_3D('',#790,#791,#792); +#790 = CARTESIAN_POINT('',(45.,12.,0.)); +#791 = DIRECTION('',(-0.,1.,0.)); +#792 = DIRECTION('',(0.,0.,1.)); +#793 = DEFINITIONAL_REPRESENTATION('',(#794),#798); +#794 = LINE('',#795,#796); +#795 = CARTESIAN_POINT('',(0.,0.)); +#796 = VECTOR('',#797,1.); +#797 = DIRECTION('',(1.,0.)); +#798 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#799 = ORIENTED_EDGE('',*,*,#800,.F.); +#800 = EDGE_CURVE('',#712,#773,#801,.T.); +#801 = SURFACE_CURVE('',#802,(#806,#813),.PCURVE_S1.); +#802 = LINE('',#803,#804); +#803 = CARTESIAN_POINT('',(45.,0.,6.)); +#804 = VECTOR('',#805,1.); +#805 = DIRECTION('',(-0.,1.,0.)); +#806 = PCURVE('',#720,#807); +#807 = DEFINITIONAL_REPRESENTATION('',(#808),#812); +#808 = LINE('',#809,#810); +#809 = CARTESIAN_POINT('',(6.,0.)); +#810 = VECTOR('',#811,1.); +#811 = DIRECTION('',(0.,-1.)); +#812 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#813 = PCURVE('',#814,#819); +#814 = PLANE('',#815); +#815 = AXIS2_PLACEMENT_3D('',#816,#817,#818); +#816 = CARTESIAN_POINT('',(45.,0.,6.)); +#817 = DIRECTION('',(0.,0.,1.)); +#818 = DIRECTION('',(1.,0.,-0.)); +#819 = DEFINITIONAL_REPRESENTATION('',(#820),#824); +#820 = LINE('',#821,#822); +#821 = CARTESIAN_POINT('',(0.,0.)); +#822 = VECTOR('',#823,1.); +#823 = DIRECTION('',(0.,1.)); +#824 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#825 = ADVANCED_FACE('',(#826),#840,.T.); +#826 = FACE_BOUND('',#827,.T.); +#827 = EDGE_LOOP('',(#828,#858,#881,#904)); +#828 = ORIENTED_EDGE('',*,*,#829,.F.); +#829 = EDGE_CURVE('',#830,#832,#834,.T.); +#830 = VERTEX_POINT('',#831); +#831 = CARTESIAN_POINT('',(57.,0.,0.)); +#832 = VERTEX_POINT('',#833); +#833 = CARTESIAN_POINT('',(57.,0.,6.)); +#834 = SURFACE_CURVE('',#835,(#839,#851),.PCURVE_S1.); +#835 = LINE('',#836,#837); +#836 = CARTESIAN_POINT('',(57.,0.,0.)); +#837 = VECTOR('',#838,1.); +#838 = DIRECTION('',(0.,0.,1.)); +#839 = PCURVE('',#840,#845); +#840 = PLANE('',#841); +#841 = AXIS2_PLACEMENT_3D('',#842,#843,#844); +#842 = CARTESIAN_POINT('',(57.,0.,0.)); +#843 = DIRECTION('',(1.,0.,-0.)); +#844 = DIRECTION('',(0.,0.,1.)); +#845 = DEFINITIONAL_REPRESENTATION('',(#846),#850); +#846 = LINE('',#847,#848); +#847 = CARTESIAN_POINT('',(0.,0.)); +#848 = VECTOR('',#849,1.); +#849 = DIRECTION('',(1.,0.)); +#850 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#851 = PCURVE('',#732,#852); +#852 = DEFINITIONAL_REPRESENTATION('',(#853),#857); +#853 = LINE('',#854,#855); +#854 = CARTESIAN_POINT('',(0.,12.)); +#855 = VECTOR('',#856,1.); +#856 = DIRECTION('',(1.,0.)); +#857 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#858 = ORIENTED_EDGE('',*,*,#859,.T.); +#859 = EDGE_CURVE('',#830,#860,#862,.T.); +#860 = VERTEX_POINT('',#861); +#861 = CARTESIAN_POINT('',(57.,12.,0.)); +#862 = SURFACE_CURVE('',#863,(#867,#874),.PCURVE_S1.); +#863 = LINE('',#864,#865); +#864 = CARTESIAN_POINT('',(57.,0.,0.)); +#865 = VECTOR('',#866,1.); +#866 = DIRECTION('',(-0.,1.,0.)); +#867 = PCURVE('',#840,#868); +#868 = DEFINITIONAL_REPRESENTATION('',(#869),#873); +#869 = LINE('',#870,#871); +#870 = CARTESIAN_POINT('',(0.,0.)); +#871 = VECTOR('',#872,1.); +#872 = DIRECTION('',(0.,-1.)); +#873 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#874 = PCURVE('',#760,#875); +#875 = DEFINITIONAL_REPRESENTATION('',(#876),#880); +#876 = LINE('',#877,#878); +#877 = CARTESIAN_POINT('',(12.,0.)); +#878 = VECTOR('',#879,1.); +#879 = DIRECTION('',(0.,1.)); +#880 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#881 = ORIENTED_EDGE('',*,*,#882,.T.); +#882 = EDGE_CURVE('',#860,#883,#885,.T.); +#883 = VERTEX_POINT('',#884); +#884 = CARTESIAN_POINT('',(57.,12.,6.)); +#885 = SURFACE_CURVE('',#886,(#890,#897),.PCURVE_S1.); +#886 = LINE('',#887,#888); +#887 = CARTESIAN_POINT('',(57.,12.,0.)); +#888 = VECTOR('',#889,1.); +#889 = DIRECTION('',(0.,0.,1.)); +#890 = PCURVE('',#840,#891); +#891 = DEFINITIONAL_REPRESENTATION('',(#892),#896); +#892 = LINE('',#893,#894); +#893 = CARTESIAN_POINT('',(0.,-12.)); +#894 = VECTOR('',#895,1.); +#895 = DIRECTION('',(1.,0.)); +#896 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#897 = PCURVE('',#788,#898); +#898 = DEFINITIONAL_REPRESENTATION('',(#899),#903); +#899 = LINE('',#900,#901); +#900 = CARTESIAN_POINT('',(0.,12.)); +#901 = VECTOR('',#902,1.); +#902 = DIRECTION('',(1.,0.)); +#903 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#904 = ORIENTED_EDGE('',*,*,#905,.F.); +#905 = EDGE_CURVE('',#832,#883,#906,.T.); +#906 = SURFACE_CURVE('',#907,(#911,#918),.PCURVE_S1.); +#907 = LINE('',#908,#909); +#908 = CARTESIAN_POINT('',(57.,0.,6.)); +#909 = VECTOR('',#910,1.); +#910 = DIRECTION('',(-0.,1.,0.)); +#911 = PCURVE('',#840,#912); +#912 = DEFINITIONAL_REPRESENTATION('',(#913),#917); +#913 = LINE('',#914,#915); +#914 = CARTESIAN_POINT('',(6.,0.)); +#915 = VECTOR('',#916,1.); +#916 = DIRECTION('',(0.,-1.)); +#917 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#918 = PCURVE('',#814,#919); +#919 = DEFINITIONAL_REPRESENTATION('',(#920),#924); +#920 = LINE('',#921,#922); +#921 = CARTESIAN_POINT('',(12.,0.)); +#922 = VECTOR('',#923,1.); +#923 = DIRECTION('',(0.,1.)); +#924 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#925 = ADVANCED_FACE('',(#926),#732,.F.); +#926 = FACE_BOUND('',#927,.F.); +#927 = EDGE_LOOP('',(#928,#949,#950,#971)); +#928 = ORIENTED_EDGE('',*,*,#929,.F.); +#929 = EDGE_CURVE('',#710,#830,#930,.T.); +#930 = SURFACE_CURVE('',#931,(#935,#942),.PCURVE_S1.); +#931 = LINE('',#932,#933); +#932 = CARTESIAN_POINT('',(45.,0.,0.)); +#933 = VECTOR('',#934,1.); +#934 = DIRECTION('',(1.,0.,-0.)); +#935 = PCURVE('',#732,#936); +#936 = DEFINITIONAL_REPRESENTATION('',(#937),#941); +#937 = LINE('',#938,#939); +#938 = CARTESIAN_POINT('',(0.,0.)); +#939 = VECTOR('',#940,1.); +#940 = DIRECTION('',(0.,1.)); +#941 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#942 = PCURVE('',#760,#943); +#943 = DEFINITIONAL_REPRESENTATION('',(#944),#948); +#944 = LINE('',#945,#946); +#945 = CARTESIAN_POINT('',(0.,0.)); +#946 = VECTOR('',#947,1.); +#947 = DIRECTION('',(1.,0.)); +#948 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#949 = ORIENTED_EDGE('',*,*,#709,.T.); +#950 = ORIENTED_EDGE('',*,*,#951,.T.); +#951 = EDGE_CURVE('',#712,#832,#952,.T.); +#952 = SURFACE_CURVE('',#953,(#957,#964),.PCURVE_S1.); +#953 = LINE('',#954,#955); +#954 = CARTESIAN_POINT('',(45.,0.,6.)); +#955 = VECTOR('',#956,1.); +#956 = DIRECTION('',(1.,0.,-0.)); +#957 = PCURVE('',#732,#958); +#958 = DEFINITIONAL_REPRESENTATION('',(#959),#963); +#959 = LINE('',#960,#961); +#960 = CARTESIAN_POINT('',(6.,0.)); +#961 = VECTOR('',#962,1.); +#962 = DIRECTION('',(0.,1.)); +#963 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#964 = PCURVE('',#814,#965); +#965 = DEFINITIONAL_REPRESENTATION('',(#966),#970); +#966 = LINE('',#967,#968); +#967 = CARTESIAN_POINT('',(0.,0.)); +#968 = VECTOR('',#969,1.); +#969 = DIRECTION('',(1.,0.)); +#970 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#971 = ORIENTED_EDGE('',*,*,#829,.F.); +#972 = ADVANCED_FACE('',(#973),#788,.T.); +#973 = FACE_BOUND('',#974,.T.); +#974 = EDGE_LOOP('',(#975,#996,#997,#1018)); +#975 = ORIENTED_EDGE('',*,*,#976,.F.); +#976 = EDGE_CURVE('',#745,#860,#977,.T.); +#977 = SURFACE_CURVE('',#978,(#982,#989),.PCURVE_S1.); +#978 = LINE('',#979,#980); +#979 = CARTESIAN_POINT('',(45.,12.,0.)); +#980 = VECTOR('',#981,1.); +#981 = DIRECTION('',(1.,0.,-0.)); +#982 = PCURVE('',#788,#983); +#983 = DEFINITIONAL_REPRESENTATION('',(#984),#988); +#984 = LINE('',#985,#986); +#985 = CARTESIAN_POINT('',(0.,0.)); +#986 = VECTOR('',#987,1.); +#987 = DIRECTION('',(0.,1.)); +#988 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#989 = PCURVE('',#760,#990); +#990 = DEFINITIONAL_REPRESENTATION('',(#991),#995); +#991 = LINE('',#992,#993); +#992 = CARTESIAN_POINT('',(0.,12.)); +#993 = VECTOR('',#994,1.); +#994 = DIRECTION('',(1.,0.)); +#995 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#996 = ORIENTED_EDGE('',*,*,#772,.T.); +#997 = ORIENTED_EDGE('',*,*,#998,.T.); +#998 = EDGE_CURVE('',#773,#883,#999,.T.); +#999 = SURFACE_CURVE('',#1000,(#1004,#1011),.PCURVE_S1.); +#1000 = LINE('',#1001,#1002); +#1001 = CARTESIAN_POINT('',(45.,12.,6.)); +#1002 = VECTOR('',#1003,1.); +#1003 = DIRECTION('',(1.,0.,-0.)); +#1004 = PCURVE('',#788,#1005); +#1005 = DEFINITIONAL_REPRESENTATION('',(#1006),#1010); +#1006 = LINE('',#1007,#1008); +#1007 = CARTESIAN_POINT('',(6.,0.)); +#1008 = VECTOR('',#1009,1.); +#1009 = DIRECTION('',(0.,1.)); +#1010 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1011 = PCURVE('',#814,#1012); +#1012 = DEFINITIONAL_REPRESENTATION('',(#1013),#1017); +#1013 = LINE('',#1014,#1015); +#1014 = CARTESIAN_POINT('',(0.,12.)); +#1015 = VECTOR('',#1016,1.); +#1016 = DIRECTION('',(1.,0.)); +#1017 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1018 = ORIENTED_EDGE('',*,*,#882,.F.); +#1019 = ADVANCED_FACE('',(#1020),#760,.F.); +#1020 = FACE_BOUND('',#1021,.F.); +#1021 = EDGE_LOOP('',(#1022,#1023,#1024,#1025)); +#1022 = ORIENTED_EDGE('',*,*,#744,.F.); +#1023 = ORIENTED_EDGE('',*,*,#929,.T.); +#1024 = ORIENTED_EDGE('',*,*,#859,.T.); +#1025 = ORIENTED_EDGE('',*,*,#976,.F.); +#1026 = ADVANCED_FACE('',(#1027),#814,.T.); +#1027 = FACE_BOUND('',#1028,.T.); +#1028 = EDGE_LOOP('',(#1029,#1030,#1031,#1032)); +#1029 = ORIENTED_EDGE('',*,*,#800,.F.); +#1030 = ORIENTED_EDGE('',*,*,#951,.T.); +#1031 = ORIENTED_EDGE('',*,*,#905,.T.); +#1032 = ORIENTED_EDGE('',*,*,#998,.F.); +#1033 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#1037)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#1034,#1035,#1036)) REPRESENTATION_CONTEXT +('Context #1','3D Context with UNIT and UNCERTAINTY') ); +#1034 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#1035 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#1036 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#1037 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1034, + 'distance_accuracy_value','confusion accuracy'); +#1038 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#699)); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index ef42a5e897..bf3981f519 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests test_mutable_polygon.cpp test_mutable_priority_queue.cpp test_nozzle_volume_type.cpp + test_step.cpp test_stl.cpp test_triangle_selector.cpp test_meshboolean.cpp diff --git a/tests/libslic3r/test_step.cpp b/tests/libslic3r/test_step.cpp new file mode 100644 index 0000000000..a2aa7218f1 --- /dev/null +++ b/tests/libslic3r/test_step.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/Format/STEP.hpp" +#include "test_utils.hpp" + +using namespace Slic3r; + +static void write_step_line(const std::string &path, const std::string &line) +{ + boost::nowide::ofstream file(path, std::ios::binary); + file << "ISO-10303-21;\n" << line << "\nEND-ISO-10303-21;\n"; +} + +// preprocess() hands back the input path unless it transcoded into a temporary. +static std::string preprocess_result(const std::string &line) +{ + ScopedSlic3rTemporaryDir scratch; + + ScopedTemporaryFile step(".step"); + write_step_line(step.string(), line); + + std::string output_path; + StepPreProcessor preprocessor; + REQUIRE(preprocessor.preprocess(step.string().c_str(), output_path)); + + return output_path == step.string() ? "untouched" : "transcoded"; +} + +// data/utf8_part_names.step is three boxes written by OCCT's own STEP writer, whose +// PRODUCT names were then patched to raw UTF-8. Most CAD exporters write non-ASCII names +// that way rather than in the \X2\ escape form. The third part is ASCII, as a control. +TEST_CASE("Part names with multi-byte UTF-8 survive import", "[Step]") +{ + // getNamedSolids() replaces a name that isUtf8() rejects with a running number. + const std::string path = TEST_DATA_DIR PATH_SEPARATOR "utf8_part_names.step"; + + Model model; + bool cancel = false; + Step step(path); // no isUtf8Fn, matching how Model::read_from_step builds it + + REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS); + REQUIRE(step.mesh(&model, cancel, false) == Step::Step_Status::MESH_SUCCESS); + + REQUIRE(model.objects.size() == 1); + const ModelObject *object = model.objects.front(); + REQUIRE(object->volumes.size() == 3); + // "ce" is split off, or the hex escape would swallow it as further hex digits. + CHECK(object->volumes[0]->name == "pi\xC3\xA8" "ce"); + CHECK(object->volumes[1]->name == "Geh\xC3\xA4use"); + CHECK(object->volumes[2]->name == "bracket"); +} + +TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]") +{ + CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9 + CHECK(StepPreProcessor::isUtf8("\xE4\xB8\xAD")); // U+4E2D + CHECK(StepPreProcessor::isUtf8("\xF0\x9F\x94\xA9")); // U+1F529 + CHECK_FALSE(StepPreProcessor::isUtf8("\x81\x30")); // 0x81 is not a lead byte + CHECK_FALSE(StepPreProcessor::isUtf8("\xC3")); // truncated sequence +} + +// The only caller of isGBK is preprocess(), which nothing calls today. +TEST_CASE("Encoding detection decides whether a step file is transcoded", "[Step]") +{ + SECTION("UTF-8, so left alone") + { + // A two byte sequence also satisfies every GBK range, so misdetecting it as + // not-UTF-8 sends it to be transcoded. + const std::string sequence = GENERATE(std::string("\xC3\xA9"), // U+00E9 + std::string("\xE4\xB8\xAD"), // U+4E2D + std::string("\xF0\x9F\x94\xA9")); // U+1F529 + + CHECK(preprocess_result("NAME('" + sequence + "');") == "untouched"); + } + + SECTION("neither UTF-8 nor GBK, so left alone") + { + // 0x81 is not a UTF-8 lead byte, and 0x30 is below the 0x40 floor for a GBK trail. + CHECK(preprocess_result("NAME('\x81\x30');") == "untouched"); + } + + SECTION("GBK, so transcoded") + { + // U+554A in GBK, whose lead byte is not valid UTF-8. Pins the other direction, + // since a detector that never reports GBK would pass every case above. + CHECK(preprocess_result("NAME('\xB0\xA1');") == "transcoded"); + } + + SECTION("plain ASCII, so left alone") { CHECK(preprocess_result("NAME('bracket');") == "untouched"); } +} diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 97e684fd6e..e3fbbe8fab 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -32,7 +33,7 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename) // --------------------------------------------------------------------------- // Owns a unique path under the system temp dir, "-[]" -// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below. +// (parallel-safe, cross-platform). Shared base for the RAII temp guards below. class ScopedTemporaryPath { public: @@ -70,6 +71,24 @@ public: ~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); } }; +// A temp directory that is also Slic3r::temporary_dir() for its lifetime. No test +// process sets that global, so code under test which writes there (for example +// StepPreProcessor::preprocess) lands at the filesystem root. Restored on scope exit +// even when an assertion throws, so it cannot leak into later tests. +class ScopedSlic3rTemporaryDir : public ScopedTemporaryDir +{ +public: + explicit ScopedSlic3rTemporaryDir(const std::string &prefix = "orca") + : ScopedTemporaryDir(prefix), m_previous(Slic3r::temporary_dir()) + { Slic3r::set_temporary_dir(string()); } + // Runs before ~ScopedTemporaryDir, so the setting goes back while the directory + // it names still exists. + ~ScopedSlic3rTemporaryDir() { Slic3r::set_temporary_dir(m_previous); } + +private: + const std::string m_previous; +}; + // --------------------------------------------------------------------------- // Debug-only test artifacts // From cc390f11ee822375b0d52e3f2d757331b725f3a0 Mon Sep 17 00:00:00 2001 From: Gabriel Monteiro Date: Fri, 28 Aug 2026 07:13:14 -0300 Subject: [PATCH 135/138] feat(build): build the missing dependencies from the main CMake configure (#15373) * fix(deps): build the dependencies from scratch with clang-cl Six dependencies fail once the superbuild compiles them with clang-cl instead of cl: - OpenSSL never goes through CMake. Its VC-WIN64A makefile only works with cl, and an unquoted clang-cl path with spaces produces no .obj files at all, so the lib step dies with LNK1181. Pin the upstream toolchain. - Boost.Container's bundled dlmalloc passes int* to the Interlocked API. cl warns, clang rejects it. - curl 7.75's configure probes rely on C laxness clang rejects. The results flip and nonblock.c ends up in the AmigaOS IoctlSocket branch. - OCCT installs RelWithDebInfo into bini/libi while find_package looks in lib. It also prepends -Wl,-s to the shared linker flags for every Clang build, which the MSVC-style linker gets as an argument it does not know. Both patched hunks sit inside if (MSVC) in the OCCT sources. - wxWidgets lands in lib/clang_x64_lib, so wxWidgetsConfig.cmake falls back to the layout that exists instead of assuming vc_x64_lib. It tries the derived path first, so a cl-built tree consumed by clang-cl keeps resolving the way it does today. The patch step also resets the one file it touches, so it can run again after an interrupted build or after the patch itself changed. - wxInspector goes through FindwxWidgets, which only searches lib/vc*_lib because _WX_TOOL is hardcoded to vc. It now gets the root and lib dir derived the same way wxWidgetsConfig.cmake derives them. Eigen is the seventh, and it breaks on the generator rather than the compiler. Its test, lapack and blas/testing subdirectories all call enable_language(Fortran), and they default to ON because the dependency configures as its own top-level project. Whether that hurts depends on what CMake finds: the Visual Studio generator supports no Fortran and finds nothing, clang-cl sits next to the LLVM toolset's flang and works, while MSVC with Ninja finds Strawberry Perl's MinGW gfortran, which this build already requires for OpenSSL, and hands it the MSVC-style /machine:x64 that MinGW's ld reads as a missing input file. The configure dies there and takes every dependency still in flight with it. Only the headers are consumed here, so the three subprojects are off. * fix(deps): honor the superbuild's generator and compiler in sub-builds orcaslicer_add_cmake_project pinned every dependency sub-build to the Visual Studio generator whenever MSVC was true, which is also true for clang-cl. That generator selects its compiler by toolset and ignores the CMAKE_C_COMPILER and CMAKE_CXX_COMPILER this file already forwards, so the dependencies were built with cl.exe no matter which generator or compiler the superbuild was given. Key the three affected decisions on the generator instead: which generator the sub-builds use, whether CMAKE_BUILD_TYPE is forwarded, and /m versus -j. A Visual Studio superbuild is unchanged, so the default path and CI behave exactly as they do today. build_release_vs.bat now accepts -l to select clang-cl, alongside the existing -x for Ninja, so the generator and the compiler can be chosen independently. On the Visual Studio generator -l reaches the slicer only, through the ClangCL toolset, because the dependency sub-builds have no toolset to inherit; a deps build in that combination says so rather than quietly using MSVC. * fix(deps): use upstream wxWidgets compiler layout fix The compiler-prefix layout fix now comes from SoftFever/Orca-deps-wxWidgets#7, so remove the duplicated local patch and apply step. * fix(deps): stop Assimp enabling ccache on the RC rule ASSIMP_BUILD_USE_CCACHE defaults on and applies the launcher through the global RULE_LAUNCH_COMPILE property, so it wraps the resource-compiler rule as well. Under Ninja that rule goes through cmcldeps, which does not survive being launched by ccache, and the build fails with clang-cl reporting /fo as a missing file. The superbuild already forwards CMAKE__COMPILER_LAUNCHER, which CMake applies per language and so keeps clear of the RC rule. --------- Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com> Co-authored-by: raistlin7447 --- build_release_vs.bat | 19 ++++++++++-- deps/Assimp/Assimp.cmake | 3 ++ deps/Boost/Boost.cmake | 10 +++++- deps/CMakeLists.txt | 14 +++++++-- deps/CURL/CURL.cmake | 14 +++++++++ deps/Eigen/Eigen.cmake | 15 +++++++++ deps/OCCT/0001-OCCT-fix.patch | 43 ++++++++++++++++++++++++++ deps/OpenSSL/OpenSSL.cmake | 16 ++++++++-- deps/wxInspector/wxInspector.cmake | 24 ++++++++++++++ deps/wxWidgets/0001-Clang-CL-fix.patch | 28 ----------------- deps/wxWidgets/wxWidgets.cmake | 1 - 11 files changed, 148 insertions(+), 39 deletions(-) delete mode 100644 deps/wxWidgets/0001-Clang-CL-fix.patch diff --git a/build_release_vs.bat b/build_release_vs.bat index 78419dadf5..a52d940455 100644 --- a/build_release_vs.bat +++ b/build_release_vs.bat @@ -20,6 +20,18 @@ for %%a in (%*) do ( if "%%a"=="-x" set USE_NINJA=1 ) +@REM Check for clang-cl option (-l). Combined with -x it also builds the deps with +@REM clang-cl; on the Visual Studio generator it applies to the slicer only, because +@REM the dependency sub-builds have no toolset to inherit and stay on MSVC. +set CLANG_ARG= +set TOOLSET_ARG= +for %%a in (%*) do ( + if "%%a"=="-l" ( + set CLANG_ARG=-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl + set TOOLSET_ARG=-T ClangCL + ) +) + @REM Check for unit-tests option ("tests") set BUILD_TESTS=OFF for %%a in (%*) do ( @@ -127,12 +139,13 @@ if "%1"=="slicer" ( GOTO :slicer ) echo "building deps.." +if defined CLANG_ARG if "%USE_NINJA%"=="0" echo Note: -l needs -x for the dependencies; building them with MSVC. echo on REM Set minimum CMake policy to avoid <3.5 errors set CMAKE_POLICY_VERSION_MINIMUM=3.5 if "%USE_NINJA%"=="1" ( - cmake ../ -G %CMAKE_GENERATOR% -DCMAKE_BUILD_TYPE=%build_type% + cmake ../ -G %CMAKE_GENERATOR% %CLANG_ARG% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target deps ) else ( cmake ../ -G %CMAKE_GENERATOR% -A %arch% -DCMAKE_BUILD_TYPE=%build_type% @@ -151,10 +164,10 @@ cd %build_dir% echo on set CMAKE_POLICY_VERSION_MINIMUM=3.5 if "%USE_NINJA%"=="1" ( - cmake .. -G %CMAKE_GENERATOR% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% + cmake .. -G %CMAKE_GENERATOR% %CLANG_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target all ) else ( - cmake .. -G %CMAKE_GENERATOR% -A %arch% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% + cmake .. -G %CMAKE_GENERATOR% -A %arch% %TOOLSET_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target ALL_BUILD -- -m ) @echo off diff --git a/deps/Assimp/Assimp.cmake b/deps/Assimp/Assimp.cmake index 8b4de03b09..9b973a55d5 100644 --- a/deps/Assimp/Assimp.cmake +++ b/deps/Assimp/Assimp.cmake @@ -21,6 +21,9 @@ orcaslicer_add_cmake_project(Assimp URL ${_assimp_url} URL_HASH ${_assimp_hash} CMAKE_ARGS + # Assimp's ccache support sets the global RULE_LAUNCH_COMPILE, which breaks + # the Ninja RC rule. The superbuild forwards CMAKE__COMPILER_LAUNCHER. + -DASSIMP_BUILD_USE_CCACHE=OFF -DASSIMP_BUILD_TESTS=OFF -DASSIMP_BUILD_SAMPLES=OFF -DASSIMP_BUILD_ASSIMP_TOOLS=OFF diff --git a/deps/Boost/Boost.cmake b/deps/Boost/Boost.cmake index bdd801857e..08b62b9fb8 100644 --- a/deps/Boost/Boost.cmake +++ b/deps/Boost/Boost.cmake @@ -24,6 +24,13 @@ if (MSVC AND DEP_DEBUG) set(_options "FORWARD_CONFIG") endif () +# Boost.Container's bundled dlmalloc passes int* where the Win32 Interlocked API +# takes volatile long*; cl compiles that with a warning, clang errors out. +set(_boost_c_flags_line "") +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_boost_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-incompatible-pointer-types") +endif () + orcaslicer_add_cmake_project(Boost ${_options} URL "https://github.com/boostorg/boost/releases/download/boost-1.84.0/boost-1.84.0.tar.gz" @@ -38,6 +45,7 @@ orcaslicer_add_cmake_project(Boost "${_context_abi_line}" "${_context_arch_line}" "${_context_impl_line}" + "${_boost_c_flags_line}" ) -set(DEP_Boost_DEPENDS ZLIB) \ No newline at end of file +set(DEP_Boost_DEPENDS ZLIB) diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 8f4bc2a215..c95cdf8d73 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -157,8 +157,16 @@ endif () function(orcaslicer_add_cmake_project projectname) cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND" "CMAKE_ARGS" ${ARGN}) + # MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the + # generator. A non-Visual-Studio superbuild passes its own generator down, and with + # it the CMAKE_C_COMPILER / CMAKE_CXX_COMPILER forwarded below. + set(_dep_msvc_gen FALSE) + if (MSVC AND CMAKE_GENERATOR MATCHES "Visual Studio") + set(_dep_msvc_gen TRUE) + endif () + set(_configs_line -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}) - if (_is_multi OR MSVC) + if (_is_multi OR _dep_msvc_gen) if (P_ARGS_FORWARD_CONFIG) set(_configs_line -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}) elseif (ORCA_INCLUDE_DEBUG_INFO AND NOT DEP_DEBUG) @@ -174,7 +182,7 @@ function(orcaslicer_add_cmake_project projectname) set(_target_config "Release") endif() - if (MSVC) + if (_dep_msvc_gen) set(_gen CMAKE_GENERATOR "${DEP_MSVC_GEN}" CMAKE_GENERATOR_PLATFORM "${DEP_PLATFORM}") else() set(_gen "") @@ -182,7 +190,7 @@ function(orcaslicer_add_cmake_project projectname) if ($ENV{CMAKE_BUILD_PARALLEL_LEVEL}) set(_build_j "") # assume environment will control --build parallel setting - elseif(MSVC) + elseif(_dep_msvc_gen) set(_build_j "/m") else() set(_build_j "-j${NPROC}") diff --git a/deps/CURL/CURL.cmake b/deps/CURL/CURL.cmake index a5ae1b9d00..3d649dfdea 100644 --- a/deps/CURL/CURL.cmake +++ b/deps/CURL/CURL.cmake @@ -56,6 +56,18 @@ else() set(_curl_static ON) endif() +# curl 7.75's configure probes and code rely on C laxness cl allows but clang +# errors on (implicit function declarations, int* vs u_long* in ioctlsocket), +# which flips probe results and misconfigures nonblock.c into the AmigaOS +# IoctlSocket branch. Relax both diagnostics so the probes behave like cl, and +# pin the camel-case probes off since they only "pass" by implicit declaration. +set(_curl_c_flags_line "") +set(_curl_probe_overrides "") +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_curl_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-implicit-function-declaration -Wno-incompatible-pointer-types") + set(_curl_probe_overrides -DHAVE_IOCTLSOCKET_CAMEL=0 -DHAVE_IOCTLSOCKET_CAMEL_FIONBIO=0) +endif () + orcaslicer_add_cmake_project(CURL # GIT_REPOSITORY https://github.com/curl/curl.git # GIT_TAG curl-7_75_0 @@ -69,6 +81,8 @@ orcaslicer_add_cmake_project(CURL -DBUILD_CURL_EXE:BOOL=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DCURL_STATICLIB=${_curl_static} + "${_curl_c_flags_line}" + ${_curl_probe_overrides} ${_curl_platform_flags} ) diff --git a/deps/Eigen/Eigen.cmake b/deps/Eigen/Eigen.cmake index 599976debb..2a9cc7105c 100644 --- a/deps/Eigen/Eigen.cmake +++ b/deps/Eigen/Eigen.cmake @@ -7,5 +7,20 @@ orcaslicer_add_cmake_project(Eigen URL https://gitlab.com/libeigen/eigen/-/archive/5.0.1/eigen-5.0.1.zip URL_HASH SHA256=0dbb1f9e3aaad66f352c03227d8c983f6f0b49e0b07e71a7300f4abcc01aee12 CMAKE_ARGS "${_eigen_extra_flags}" + # Only the headers are consumed here. Everything below builds nothing we + # use, and all three enable_language(Fortran): test/CMakeLists.txt:9, + # lapack/CMakeLists.txt:6 and blas/testing/CMakeLists.txt:2. They default + # to ON because the dependency configures as its own top-level project. + # + # Whether that probe is harmless depends on what CMake finds. The Visual + # Studio generator supports no Fortran, so it finds nothing; clang-cl sits + # next to the LLVM toolset's flang, which works. MSVC with Ninja finds + # Strawberry Perl's MinGW gfortran instead, which the deps build already + # requires for OpenSSL, and hands it the MSVC-style /machine:x64 that + # MinGW's ld reads as a missing input file. The configure dies there and + # takes the rest of the superbuild with it. + -DEIGEN_BUILD_TESTING=OFF + -DEIGEN_BUILD_BLAS=OFF + -DEIGEN_BUILD_LAPACK=OFF DEPENDS dep_Boost dep_GMP dep_MPFR ) diff --git a/deps/OCCT/0001-OCCT-fix.patch b/deps/OCCT/0001-OCCT-fix.patch index 27f5db7e0f..d251cc7ab6 100644 --- a/deps/OCCT/0001-OCCT-fix.patch +++ b/deps/OCCT/0001-OCCT-fix.patch @@ -1,3 +1,20 @@ +diff --git a/adm/cmake/occt_defs_flags.cmake b/adm/cmake/occt_defs_flags.cmake +index 00000000..00000001 100644 +--- a/adm/cmake/occt_defs_flags.cmake ++++ b/adm/cmake/occt_defs_flags.cmake +@@ -134,7 +134,11 @@ + set (CMAKE_CXX_FLAGS "-std=c++0x ${CMAKE_CXX_FLAGS}") + endif() + # Optimize size of binaries +- set (CMAKE_SHARED_LINKER_FLAGS "-Wl,-s ${CMAKE_SHARED_LINKER_FLAGS}") ++ # clang-cl reports the Clang compiler ID, and OCCT builds shared on Windows, ++ # where the MSVC-style linker gets this flag as an argument it does not know. ++ if (NOT WIN32) ++ set (CMAKE_SHARED_LINKER_FLAGS "-Wl,-s ${CMAKE_SHARED_LINKER_FLAGS}") ++ endif() + elseif(MINGW) + add_definitions(-D_WIN32_WINNT=0x0601) + # _WIN32_WINNT=0x0601 (use Windows 7 SDK) diff --git a/CMakeLists.txt b/CMakeLists.txt index d98acc0f..28eb8eb4 100644 --- a/CMakeLists.txt @@ -168,6 +185,32 @@ index d98acc0f..28eb8eb4 100644 endforeach() if (BUILD_SAMPLES_QT) +diff --git a/adm/cmake/occt_macros.cmake b/adm/cmake/occt_macros.cmake +index 224c96b1..8c94a1c5 100644 +--- a/adm/cmake/occt_macros.cmake ++++ b/adm/cmake/occt_macros.cmake +@@ -608,7 +608,7 @@ macro (OCCT_INSERT_CODE_FOR_TARGET) + install(CODE "if (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Rr][Ee][Ll][Ee][Aa][Ss][Ee])$\") + set (OCCT_INSTALL_BIN_LETTER \"\") + elseif (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Rr][Ee][Ll][Ww][Ii][Tt][Hh][Dd][Ee][Bb][Ii][Nn][Ff][Oo])$\") +- set (OCCT_INSTALL_BIN_LETTER \"i\") ++ set (OCCT_INSTALL_BIN_LETTER \"\") + elseif (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Dd][Ee][Bb][Uu][Gg])$\") + set (OCCT_INSTALL_BIN_LETTER \"d\") + endif()") +diff --git a/adm/cmake/occt_toolkit.cmake b/adm/cmake/occt_toolkit.cmake +index 550e0e2f..7ac1a3b8 100644 +--- a/adm/cmake/occt_toolkit.cmake ++++ b/adm/cmake/occt_toolkit.cmake +@@ -241,7 +241,7 @@ + else() + set (aReleasePdbConf) + endif() +- install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/bin\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb ++ install (FILES $ + CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo + DESTINATION "${INSTALL_DIR_BIN}\${OCCT_INSTALL_BIN_LETTER}") + endif() diff --git a/src/Font/Font_FTFont.cxx b/src/Font/Font_FTFont.cxx index 5ae9899f..0a17372b 100644 --- a/src/Font/Font_FTFont.cxx diff --git a/deps/OpenSSL/OpenSSL.cmake b/deps/OpenSSL/OpenSSL.cmake index e43997265b..ddeb680052 100644 --- a/deps/OpenSSL/OpenSSL.cmake +++ b/deps/OpenSSL/OpenSSL.cmake @@ -17,10 +17,20 @@ else() endif() if(WIN32) - set(_conf_cmd perl Configure ) + set(_openssl_msvc_env CC=cl CXX=cl RC=rc CL=/FS) + # OpenSSL's perl Configure honors the CC environment variable, but the + # VC-WIN64A makefile only works with cl (an unquoted clang-cl path with + # spaces, e.g. exported by CLion, silently produces no .obj files and the + # lib step fails with LNK1181). Pin the upstream toolchain. + # Keep rc.exe resolved from the MSVC developer environment as well. The + # absolute Windows SDK path contains spaces and OpenSSL 1.1.1 writes it to + # the generated nmake file without quoting, which skips .res generation. + # /FS serializes access to OpenSSL's shared generated PDB when cl is + # driven through nmake from a Ninja configure step. + set(_conf_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} perl Configure ) set(_cross_comp_prefix_line "") - set(_make_cmd nmake) - set(_install_cmd nmake install_sw ) + set(_make_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} nmake) + set(_install_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} nmake install_sw ) else() if(APPLE) set(_conf_cmd export MACOSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} && ./Configure -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}) diff --git a/deps/wxInspector/wxInspector.cmake b/deps/wxInspector/wxInspector.cmake index 97c3810809..4a5b28407f 100644 --- a/deps/wxInspector/wxInspector.cmake +++ b/deps/wxInspector/wxInspector.cmake @@ -1,3 +1,26 @@ +# wxInspector finds wxWidgets through CMake's FindwxWidgets module, which only +# searches lib/vc*_lib because _WX_TOOL is hardcoded to "vc". A superbuild driven +# by clang-cl installs wxWidgets into lib/clang_x64_lib, so hand the module the +# directory wxWidgets actually used, derived the same way wxWidgetsConfig.cmake +# derives it. +set(_wxinspector_wx_hints "") +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(_wx_compiler_prefix "clang") + else () + set(_wx_compiler_prefix "vc") + endif () + set(_wx_arch_suffix "") + if (CMAKE_GENERATOR_PLATFORM AND NOT CMAKE_GENERATOR_PLATFORM STREQUAL "Win32") + string(TOLOWER "_${CMAKE_GENERATOR_PLATFORM}" _wx_arch_suffix) + elseif (CMAKE_SIZEOF_VOID_P EQUAL 8) + set(_wx_arch_suffix "_x64") + endif () + set(_wxinspector_wx_hints + "-DwxWidgets_ROOT_DIR=${DESTDIR}" + "-DwxWidgets_LIB_DIR=${DESTDIR}/lib/${_wx_compiler_prefix}${_wx_arch_suffix}_lib") +endif () + orcaslicer_add_cmake_project( wxInspector URL https://github.com/Noisyfox/wxInspector/archive/refs/tags/v1.0.0.zip @@ -6,6 +29,7 @@ orcaslicer_add_cmake_project( CMAKE_ARGS -DCMAKE_CXX_FLAGS="-DwxDEBUG_LEVEL=0" -DCMAKE_POSITION_INDEPENDENT_CODE=ON + ${_wxinspector_wx_hints} ) if (MSVC) diff --git a/deps/wxWidgets/0001-Clang-CL-fix.patch b/deps/wxWidgets/0001-Clang-CL-fix.patch deleted file mode 100644 index 23bf23b3f4..0000000000 --- a/deps/wxWidgets/0001-Clang-CL-fix.patch +++ /dev/null @@ -1,28 +0,0 @@ ---- - build/cmake/wxWidgetsConfig.cmake.in | 10 +++++++++- - 1 file changed, 10 insertions(+), 1 deletion(-) - -diff --git a/build/cmake/wxWidgetsConfig.cmake.in b/build/cmake/wxWidgetsConfig.cmake.in -index 1a83f36..70ad8a4 100644 ---- a/build/cmake/wxWidgetsConfig.cmake.in -+++ b/build/cmake/wxWidgetsConfig.cmake.in -@@ -58,7 +58,16 @@ if(WIN32_MSVC_NAMING) - endif() - endif() - --include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") -+if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") -+ if (CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64" OR CMAKE_VS_PLATFORM_NAME STREQUAL "ARM64" OR CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|arm64|aarch64)$") -+ set(_wx_clang_msvc_lib_dir "vc_arm64_lib") -+ else() -+ set(_wx_clang_msvc_lib_dir "vc_x64_lib") -+ endif() -+ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/${_wx_clang_msvc_lib_dir}/@PROJECT_NAME@Targets.cmake") -+else() -+ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") -+endif() - - macro(wx_inherit_property source dest name) - # property name without _ --- -2.43.0 diff --git a/deps/wxWidgets/wxWidgets.cmake b/deps/wxWidgets/wxWidgets.cmake index 07bb31d8be..1e2cc85f78 100644 --- a/deps/wxWidgets/wxWidgets.cmake +++ b/deps/wxWidgets/wxWidgets.cmake @@ -28,7 +28,6 @@ orcaslicer_add_cmake_project( GIT_SHALLOW ON GIT_SUBMODULES 3rdparty/catch 3rdparty/pcre 3rdparty/libwebp DEPENDS ${PNG_PKG} ${ZLIB_PKG} ${EXPAT_PKG} ${JPEG_PKG} - PATCH_COMMAND git apply --verbose --ignore-space-change --whitespace=fix ${CMAKE_CURRENT_LIST_DIR}/0001-Clang-CL-fix.patch CMAKE_ARGS -DwxBUILD_PRECOMP=ON ${_wx_toolkit} From db29f570bd2f77742ab04e0bb8f0aa55237bd70a Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Fri, 28 Aug 2026 06:05:59 -0500 Subject: [PATCH 136/138] build: clear 50 warnings - pessimizing moves and null checks that cannot fail (#15408) * build: remove std::move that blocks copy elision std::move wrapped around a temporary, or around a local being returned, stops the compiler constructing it in place. Each edit is the fix clang suggests, which is to delete the std::move call and keep its argument. Three of the 39 sites save a move, the two return std::move(local) in Print.cpp and TreeSupport.cpp:2749. The rest are equivalent either way and match how the codebase already writes this elsewhere. Clears 39 -Wpessimizing-move warnings. * build: drop null checks on references and this A reference cannot be bound to null and this cannot be null, so the compiler folds these conditions to true and drops the guard. Seven are if (&bitmap && bitmap.IsOk()), where IsOk() already does the work; two test this directly. The guarded code runs either way, so removing the dead operand changes nothing. Clears 11 -Wundefined-bool-conversion warnings. --- src/libslic3r/ArcFitter.cpp | 12 ++++----- src/libslic3r/Clipper2Utils.cpp | 16 +++++------ src/libslic3r/Format/STEP.cpp | 2 +- src/libslic3r/Format/bbs_3mf.cpp | 2 +- src/libslic3r/Format/svg.cpp | 2 +- src/libslic3r/GCode.cpp | 2 +- src/libslic3r/MeshBoolean.cpp | 4 +-- src/libslic3r/Model.cpp | 2 +- src/libslic3r/Print.cpp | 4 +-- src/libslic3r/PrintObject.cpp | 2 +- src/libslic3r/Shape/TextShape.cpp | 2 +- src/libslic3r/Support/TreeSupport.cpp | 28 ++++++++++---------- src/slic3r/GUI/ImageDPIFrame.cpp | 2 +- src/slic3r/GUI/Plater.cpp | 2 +- src/slic3r/GUI/Printer/PrinterFileSystem.cpp | 4 +-- src/slic3r/GUI/SelectMachine.cpp | 2 +- src/slic3r/GUI/SendToPrinter.cpp | 20 +++++++------- src/slic3r/GUI/Widgets/AxisCtrlButton.cpp | 2 +- src/slic3r/GUI/Widgets/ComboBox.cpp | 8 +++--- src/slic3r/Utils/PresetUpdater.cpp | 2 +- 20 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/libslic3r/ArcFitter.cpp b/src/libslic3r/ArcFitter.cpp index cdfd708b10..46dd12931e 100644 --- a/src/libslic3r/ArcFitter.cpp +++ b/src/libslic3r/ArcFitter.cpp @@ -57,24 +57,24 @@ void ArcFitter::do_arc_fitting(const Points& points, std::vector 2) { //BBS: althought current point_stack can't be fit as arc, //but previous must can be fit if removing the top in stack, so save last arc - result.emplace_back(std::move(PathFittingData{ front_index, + result.emplace_back(PathFittingData{ front_index, back_index - 1, last_arc.direction == ArcDirection::Arc_Dir_CCW ? EMovePathType::Arc_move_ccw : EMovePathType::Arc_move_cw, - last_arc })); + last_arc }); } else { //BBS: save the first segment as line move when 3 point-line can't be fit as arc move if (result.empty() || result.back().path_type != EMovePathType::Linear_move) - result.emplace_back(std::move(PathFittingData{front_index, front_index + 1, EMovePathType::Linear_move, ArcSegment()})); + result.emplace_back(PathFittingData{front_index, front_index + 1, EMovePathType::Linear_move, ArcSegment()}); else if(result.back().path_type == EMovePathType::Linear_move) result.back().end_point_index = front_index + 1; } @@ -87,7 +87,7 @@ void ArcFitter::do_arc_fitting(const Points& points, std::vectorNbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } // BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 5391f9ba3d..b0cbb1fd50 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -8963,7 +8963,7 @@ private: BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " inital and interval = " << m_interval; m_next_backup = boost::get_system_time() + boost::posix_time::seconds(m_interval); boost::unique_lock lock(m_mutex); - m_thread = std::move(boost::thread(boost::ref(*this))); + m_thread = boost::thread(boost::ref(*this)); } ~_BBS_Backup_Manager() { diff --git a/src/libslic3r/Format/svg.cpp b/src/libslic3r/Format/svg.cpp index 7bfd73b987..7b720e62ef 100644 --- a/src/libslic3r/Format/svg.cpp +++ b/src/libslic3r/Format/svg.cpp @@ -352,7 +352,7 @@ bool load_svg(const char *path, Model *model, std::string &message) for (Standard_Integer aNodeIter = 1; aNodeIter <= aTriangulation->NbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } // BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index f28918f05d..e947be47a1 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -9079,7 +9079,7 @@ bool GCode::needs_retraction(const Polyline &travel, ExtrusionRole role, LiftTyp continue; Polygons temp; - temp.emplace_back(std::move(instance_bbox.polygon())); + temp.emplace_back(instance_bbox.polygon()); if (intersection_pl(travel, temp).empty()) continue; diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 8b50681370..7497f92abf 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -352,7 +352,7 @@ void segment(CGALMesh& src, std::vector& dst, double smoothing_alpha = //} //else { - dst.emplace_back(std::move(CGALMesh(out))); + dst.emplace_back(CGALMesh(out)); } } //if (mesh_merged.is_empty() == false) { @@ -371,7 +371,7 @@ std::vector segment(const TriangleMesh& src, double smoothing_alph std::vector out_meshes; for (auto& outf_cgal_mesh: out_cgal_meshes) { - out_meshes.emplace_back(std::move(cgal_to_triangle_mesh(outf_cgal_mesh.m))); + out_meshes.emplace_back(cgal_to_triangle_mesh(outf_cgal_mesh.m)); } return out_meshes; diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 600c46e7f5..bbc3aa80f3 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -261,7 +261,7 @@ static void add_textured_mesh_to_model(Model& model, const TexturedMesh& tex_mes its_remove_degenerate_faces(its); its_compactify_vertices(its); - model.add_object(object_name.c_str(), input_file.c_str(), std::move(TriangleMesh(std::move(its)))); + model.add_object(object_name.c_str(), input_file.c_str(), TriangleMesh(std::move(its))); } Model Model::read_from_file(const std::string& input_file, diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 1bc1015477..40764342fb 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -3790,7 +3790,7 @@ std::vector Print::get_extruder_printable_polygons() const Polygons ploys = {Polygon::new_scale(e_printable_area)}; extruder_printable_polys.emplace_back(ploys); } - return std::move(extruder_printable_polys); + return extruder_printable_polys; } std::vector Print::get_extruder_unprintable_polygons() const @@ -3803,7 +3803,7 @@ std::vector Print::get_extruder_unprintable_polygons() const Polygons ploys = diff(printable_poly, Polygon::new_scale(e_printable_area)); extruder_unprintable_polys.emplace_back(ploys); } - return std::move(extruder_unprintable_polys); + return extruder_unprintable_polys; } size_t Print::get_extruder_id(unsigned int filament_id) const diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 8368de1a4f..7bc18f6b86 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -906,7 +906,7 @@ void PrintObject::detect_overhangs_for_lift() Layer& lower_layer = *layer.lower_layer; ExPolygons overhangs = diff_ex(layer.lslices, offset_ex(lower_layer.lslices, scale_(min_overlap))); - layer.loverhangs = std::move(offset2_ex(overhangs, -0.1f * scale_(line_width), 0.1f * scale_(line_width))); + layer.loverhangs = offset2_ex(overhangs, -0.1f * scale_(line_width), 0.1f * scale_(line_width)); layer.loverhangs_bbox = get_extents(layer.loverhangs); } }); diff --git a/src/libslic3r/Shape/TextShape.cpp b/src/libslic3r/Shape/TextShape.cpp index dce731af19..4f32b9d857 100644 --- a/src/libslic3r/Shape/TextShape.cpp +++ b/src/libslic3r/Shape/TextShape.cpp @@ -199,7 +199,7 @@ static void MakeMesh(TopoDS_Shape& theSolid, TriangleMesh& theMesh) for (Standard_Integer aNodeIter = 1; aNodeIter <= aTriangulation->NbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } //BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index d4a43a4767..de08870216 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -842,7 +842,7 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) // normal overhang ExPolygons lower_layer_offseted = offset_ex(lower_polys, support_offset_scaled, SUPPORT_SURFACES_OFFSET_PARAMETERS); - overhangs_all_layers[layer_nr] = std::move(diff_ex(curr_polys, lower_layer_offseted)); + overhangs_all_layers[layer_nr] = diff_ex(curr_polys, lower_layer_offseted); double duration{ std::chrono::duration_cast(clock_::now() - t0).count() }; if (duration > 30 || overhangs_all_layers[layer_nr].size() > 100) { @@ -1396,7 +1396,7 @@ void TreeSupport::generate_toolpaths() raft_areas.push_back(expoly); } - raft_areas = std::move(offset_ex(raft_areas, scale_(object_config.raft_first_layer_expansion))); + raft_areas = offset_ex(raft_areas, scale_(object_config.raft_first_layer_expansion)); size_t layer_nr = 0; for (; layer_nr < m_slicing_params.base_raft_layers; layer_nr++) { @@ -1522,9 +1522,9 @@ void TreeSupport::generate_toolpaths() erSupportMaterialInterface : erSupportMaterial; make_perimeter_and_inner_brim(ts_layer->support_fills.entities, poly, wall_count, flow, brim_role); - polys = std::move(offset_ex(poly, -flow.scaled_spacing())); + polys = offset_ex(poly, -flow.scaled_spacing()); } else if (area_group.type == SupportLayer::Roof1stLayer) { - polys = std::move(offset_ex(poly, 0.5*support_flow.scaled_width())); + polys = offset_ex(poly, 0.5*support_flow.scaled_width()); } else { polys.push_back(poly); @@ -2269,7 +2269,7 @@ void TreeSupport::draw_circles() // Inside the gap: remove only the part overlapping the contact surface, keep the rest. if (bottom_gap_height > EPSILON && layer_bottom_z < band_gap_top - EPSILON) { any_gap_cleared = true; - comp_poly = std::move(diff_ex(comp_poly, band.surfaces)); + comp_poly = diff_ex(comp_poly, band.surfaces); } // Overlaps interface band @@ -2304,7 +2304,7 @@ void TreeSupport::draw_circles() ExPolygons comp_interface = band_ex.empty() ? ExPolygons {} : intersection_ex(comp_poly, band_ex); if (!comp_interface.empty()) { append(new_floor_areas, comp_interface); - comp_poly = std::move(diff_ex(comp_poly, offset_ex(comp_interface, 10))); + comp_poly = diff_ex(comp_poly, offset_ex(comp_interface, 10)); } } @@ -2396,7 +2396,7 @@ void TreeSupport::draw_circles() ts_layer->lslices.emplace_back(*expoly); } - ts_layer->lslices = std::move(union_ex(ts_layer->lslices)); + ts_layer->lslices = union_ex(ts_layer->lslices); //Must update bounding box which is used in avoid crossing perimeter ts_layer->lslices_bboxes.clear(); ts_layer->lslices_bboxes.reserve(ts_layer->lslices.size()); @@ -2474,7 +2474,7 @@ void TreeSupport::draw_circles() if (global_lightning_infill) { //search overhangs globally - overhang = std::move(diff_ex(offset_ex(base_areas_lower, -2.0 * scale_(support_extrusion_width)), base_areas)); + overhang = diff_ex(offset_ex(base_areas_lower, -2.0 * scale_(support_extrusion_width)), base_areas); } else { @@ -2485,13 +2485,13 @@ void TreeSupport::draw_circles() Polygon rev_hole = hole; rev_hole.make_counter_clockwise(); ExPolygons ex_hole; - ex_hole.emplace_back(std::move(ExPolygon(rev_hole))); + ex_hole.emplace_back(ExPolygon(rev_hole)); for (auto& other_area : base_areas) //if (&other_area != &base_area) - ex_hole = std::move(diff_ex(ex_hole, other_area)); - overhang = std::move(union_ex(overhang, ex_hole)); + ex_hole = diff_ex(ex_hole, other_area); + overhang = union_ex(overhang, ex_hole); } - overhang = std::move(intersection_ex(overhang, offset_ex(base_areas_lower, -0.5 * scale_(support_extrusion_width)))); + overhang = intersection_ex(overhang, offset_ex(base_areas_lower, -0.5 * scale_(support_extrusion_width))); } overhangs.emplace_back(to_polygons(overhang)); @@ -2746,7 +2746,7 @@ void TreeSupport::drop_nodes() m_object->print()->set_status(60 + int(10 * (1 - float(layer_nr) / contact_nodes.size())), _u8L("Generating support"));// (boost::format(_u8L("Support: propagate branches at layer %d")) % layer_nr).str()); - Polygons layer_contours = std::move(m_ts_data->get_contours_with_holes(obj_layer_nr)); + Polygons layer_contours = m_ts_data->get_contours_with_holes(obj_layer_nr); //std::unordered_map& mst_line_x_layer_contour_cache = m_mst_line_x_layer_contour_caches[layer_nr]; tbb::concurrent_unordered_map mst_line_x_layer_contour_cache; auto is_line_cut_by_contour = [&mst_line_x_layer_contour_cache,&layer_contours](Point a, Point b) @@ -3763,7 +3763,7 @@ const ExPolygons& TreeSupportData::calculate_avoidance(const RadiusLayerPair& ke } const ExPolygons &collision = get_collision(radius, layer_nr); avoidance_areas.insert(avoidance_areas.end(), collision.begin(), collision.end()); - avoidance_areas = std::move(union_ex(avoidance_areas)); + avoidance_areas = union_ex(avoidance_areas); auto ret = m_avoidance_cache.insert({key, std::move(avoidance_areas)}); //assert(ret.second); return ret.first->second; diff --git a/src/slic3r/GUI/ImageDPIFrame.cpp b/src/slic3r/GUI/ImageDPIFrame.cpp index 2133f18784..8dad9c44da 100644 --- a/src/slic3r/GUI/ImageDPIFrame.cpp +++ b/src/slic3r/GUI/ImageDPIFrame.cpp @@ -74,7 +74,7 @@ bool ImageDPIFrame::Show(bool show) } void ImageDPIFrame::set_bitmap(const wxBitmap &bit_map) { - if (&bit_map && bit_map.IsOk()) { + if (bit_map.IsOk()) { m_bitmap->SetBitmap(bit_map); } } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index e1a98cdbf8..13c0169131 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -3870,7 +3870,7 @@ bool Sidebar::reset_bed_type_combox_choices(bool is_sidebar_init) } } m_last_combo_bedtype_count = p->combo_printer_bed->GetCount(); - if (!is_sidebar_init && &p->plater->get_partplate_list()) { + if (!is_sidebar_init) { p->plater->get_partplate_list().check_all_plate_local_bed_type(m_cur_combox_bed_types); } return true; diff --git a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp index 8ecbe87391..0b280fe923 100644 --- a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp +++ b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp @@ -662,10 +662,10 @@ PrinterFileSystem::File const &PrinterFileSystem::GetFile(size_t index, bool &se void PrinterFileSystem::Attached() { boost::unique_lock lock(m_mutex); - m_recv_thread = std::move(boost::thread([w = weak_from_this()] { + m_recv_thread = boost::thread([w = weak_from_this()] { boost::shared_ptr s = w.lock(); if (s) s->RecvMessageThread(); - })); + }); } void PrinterFileSystem::Start() diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 2411967b0d..ac56e35ac3 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -483,7 +483,7 @@ SelectMachineDialog::SelectMachineDialog(Plater *plater) m_link_edit_nozzle->Bind(wxEVT_LEFT_DOWN, [this](auto &e) { - if (this && this->m_is_in_sending_mode) { + if (m_is_in_sending_mode) { return; } diff --git a/src/slic3r/GUI/SendToPrinter.cpp b/src/slic3r/GUI/SendToPrinter.cpp index 17da4b66a0..a63d96ffce 100644 --- a/src/slic3r/GUI/SendToPrinter.cpp +++ b/src/slic3r/GUI/SendToPrinter.cpp @@ -978,18 +978,16 @@ void SendToPrinterDialog::on_ok(wxCommandEvent &event) m_send_job->on_check_ip_address_fail([this, token = std::weak_ptr(m_token)](int result) { CallAfter([token, this] { if (token.expired()) { return; } - if (this) { - SendFailedConfirm sfcDlg; - auto res = sfcDlg.ShowModal(); - m_status_bar->cancel(); + SendFailedConfirm sfcDlg; + auto res = sfcDlg.ShowModal(); + m_status_bar->cancel(); - if (res == wxYES) { - wxQueueEvent(m_button_ensure, new wxCommandEvent(wxEVT_BUTTON)); - } else if (res == wxAPPLY) { - wxCommandEvent *evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS); - wxQueueEvent(this, evt); - wxGetApp().show_ip_address_enter_dialog(); - } + if (res == wxYES) { + wxQueueEvent(m_button_ensure, new wxCommandEvent(wxEVT_BUTTON)); + } else if (res == wxAPPLY) { + wxCommandEvent *evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS); + wxQueueEvent(this, evt); + wxGetApp().show_ip_address_enter_dialog(); } }); }); diff --git a/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp b/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp index ee0608ffbe..535abfcaf2 100644 --- a/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp +++ b/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp @@ -124,7 +124,7 @@ void AxisCtrlButton::SetInnerBackgroundColor(StateColor const& color) void AxisCtrlButton::SetBitmap(ScalableBitmap &bmp) { - if (&bmp && (& bmp.bmp()) && (bmp.bmp().IsOk())) { + if (bmp.bmp().IsOk()) { m_icon = bmp; } } diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index b6f6d42450..08687f3cf6 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -208,7 +208,7 @@ bool ComboBox::SetFont(wxFont const& font) int ComboBox::Append(const wxString &item, const wxBitmap &bitmap, int style) { - if (&bitmap && bitmap.IsOk()) { + if (bitmap.IsOk()) { return Append(item, bitmap, nullptr, style); } return Append(item, wxNullBitmap, nullptr, style); @@ -219,7 +219,7 @@ int ComboBox::Append(const wxString &text, void * clientData, int style) { - if (&bitmap && bitmap.IsOk()) { + if (bitmap.IsOk()) { return Append(text, bitmap, wxString{}, clientData, style); } return Append(text, wxNullBitmap, wxString{}, clientData, style); @@ -237,7 +237,7 @@ int ComboBox::Append(const wxString &text, void *clientData, int style) { - auto valid_bit_map = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + auto valid_bit_map = bitmap.IsOk() ? bitmap : wxNullBitmap; Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, clientData, group_key, group_label}; item.style = style; items.push_back(item); @@ -333,7 +333,7 @@ wxBitmap ComboBox::GetItemBitmap(unsigned int n) { return items[n].icon; } void ComboBox::SetItemBitmap(unsigned int n, wxBitmap const &bitmap) { if (n >= items.size()) return; - items[n].icon = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + items[n].icon = bitmap.IsOk() ? bitmap : wxNullBitmap; drop.Invalidate(); } diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 06808e253d..56a2b66b49 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -1198,7 +1198,7 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version version.config_version = cache_ver; version.comment = description; // Orca: update vendor.json - updates.updates.emplace_back(std::move(file_path), std::move(path_in_vendor.string()), std::move(version), vendor_name, changelog, "", force_update, false); + updates.updates.emplace_back(std::move(file_path), path_in_vendor.string(), std::move(version), vendor_name, changelog, "", force_update, false); //Orca: update vendor folder updates.updates.emplace_back(cache_profile_path / vendor_name, vendor_path / vendor_name, Version(), vendor_name, "", "", force_update, true); } else { From 05b3c9053ed5a576fbcf9919bbaacf222685dc75 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Sat, 29 Aug 2026 12:48:05 -0500 Subject: [PATCH 137/138] fix: Cyrillic and other non-Latin text shows as question marks or garbage (#15419) --- src/slic3r/GUI/ImGuiWrapper.cpp | 44 +++++++++++++++++++++++++-------- src/slic3r/GUI/Plater.cpp | 2 +- src/slic3r/GUI/Tab.cpp | 2 +- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index 5850161a3e..eeaadd0cbd 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -2482,6 +2482,19 @@ static const ImWchar ranges_keyboard_shortcuts[] = }; #endif // __APPLE__ +// Names drawn through the atlas come from file names and CAD data, not from the UI language. +// GetGlyphRangesDefault() already gives every language the CJK ideographs, which is why a +// Chinese file name renders under an English UI; these are the alphabetic scripts it omits. +// Codepoints the font lacks are skipped at build time, so only existing glyphs cost anything. +static const ImWchar ranges_language_independent[] = +{ + 0x0100, 0x024F, // Latin Extended-A and Extended-B + 0x0370, 0x03FF, // Greek and Coptic + 0x0400, 0x04FF, // Cyrillic + 0x1E00, 0x1EFF, // Latin Extended Additional (Vietnamese) + 0, +}; + std::vector ImGuiWrapper::load_svg(const std::string& bitmap_name, unsigned target_width, unsigned target_height, unsigned *outwidth, unsigned *outheight) { @@ -2792,6 +2805,7 @@ void ImGuiWrapper::init_font(bool compress) ImFontAtlas::GlyphRangesBuilder builder; builder.AddRanges(m_glyph_ranges); builder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesDefault()); + builder.AddRanges(ranges_language_independent); #ifdef __APPLE__ if (m_font_cjk) // Apple keyboard shortcuts are only contained in the CJK fonts. @@ -2813,12 +2827,17 @@ void ImGuiWrapper::init_font(bool compress) // Orca: temp fix for Korean font auto font_name_regular = "HarmonyOS_Sans_SC_Regular.ttf"; auto font_name_bold = "HarmonyOS_Sans_SC_Bold.ttf"; + // The Korean and Thai fonts cover their own script and little else, so they need the + // default font merged in behind them to reach the full range. + bool needs_glyph_fallback = false; if(m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesKorean()) { font_name_regular = "NanumGothic-Regular.ttf"; font_name_bold = "NanumGothic-Bold.ttf"; + needs_glyph_fallback = true; } else if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { font_name_regular = "Sarabun-Medium.ttf"; font_name_bold = "Sarabun-SemiBold.ttf"; + needs_glyph_fallback = true; } default_font = io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/" + font_name_regular).c_str(), m_font_size, &cfg, ranges.Data); if (default_font == nullptr) { @@ -2828,11 +2847,12 @@ void ImGuiWrapper::init_font(bool compress) } } - if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { + // A merged font only supplies glyphs the font ahead of it lacks, so this fills the gaps + // without restyling anything the script font already covers. + if (needs_glyph_fallback) { ImFontConfig fallback_cfg = cfg; fallback_cfg.MergeMode = true; - static constexpr ImWchar celsius_range[] = { 0x2103, 0x2103, 0 }; - io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf").c_str(), m_font_size, &fallback_cfg, celsius_range); + io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf").c_str(), m_font_size, &fallback_cfg, ranges.Data); } bold_font = io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/" + font_name_bold).c_str(), m_font_size, &cfg, ranges.Data); @@ -2841,11 +2861,10 @@ void ImGuiWrapper::init_font(bool compress) if (bold_font == nullptr) { throw Slic3r::RuntimeError("ImGui: Could not load deafult font"); } } - if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { + if (needs_glyph_fallback) { ImFontConfig fallback_cfg = cfg; fallback_cfg.MergeMode = true; - static constexpr ImWchar celsius_range[] = { 0x2103, 0x2103, 0 }; - io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Bold.ttf").c_str(), m_font_size, &fallback_cfg, celsius_range); + io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Bold.ttf").c_str(), m_font_size, &fallback_cfg, ranges.Data); } if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { @@ -2897,13 +2916,18 @@ void ImGuiWrapper::init_font(bool compress) glsafe(::glGetIntegerv(GL_MAX_TEXTURE_SIZE, &gl_max_tex_size)); constexpr int max_retries = 6; for (int attempt = 0; attempt < max_retries && io.Fonts->TexHeight > gl_max_tex_size; ++attempt) { - io.Fonts->TexDesiredWidth = (io.Fonts->TexDesiredWidth > 0 ? io.Fonts->TexDesiredWidth : io.Fonts->TexWidth) * 2; + const int width = io.Fonts->TexDesiredWidth > 0 ? io.Fonts->TexDesiredWidth : io.Fonts->TexWidth; + // Both dimensions share the same limit, so widening past it would only trade an + // illegal height for an illegal width. + if (width * 2 > gl_max_tex_size) + break; + io.Fonts->TexDesiredWidth = width * 2; io.Fonts->Build(); } if (io.Fonts->TexHeight > gl_max_tex_size) { - // Shouldn't really happen - BOOST_LOG_TRIVIAL(error) << "Font atlas height " << io.Fonts->TexHeight - << " still exceeds GL_MAX_TEXTURE_SIZE (" << gl_max_tex_size << ")" + // Needs both a very large glyph set and a small GL_MAX_TEXTURE_SIZE. + BOOST_LOG_TRIVIAL(error) << "Font atlas " << io.Fonts->TexWidth << "x" << io.Fonts->TexHeight + << " does not fit GL_MAX_TEXTURE_SIZE (" << gl_max_tex_size << ")" << " after " << max_retries << " attempts; rendering may be incomplete"; } diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 13c0169131..2b1a558fe9 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -21635,7 +21635,7 @@ void Plater::show_object_info() auto mesh_errors = p->sidebar->obj_list()->get_mesh_errors_info(&info_manifold, &non_manifold_edges); if (non_manifold_edges > 0) { - info_manifold += into_u8("\n" + _L("Tips:") + "\n" + _L("Use \"Fix Model\" to repair the mesh.")); + info_manifold += "\n" + _L("Tips:") + "\n" + _L("Use \"Fix Model\" to repair the mesh."); } info_manifold = "" + info_manifold + ""; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index ef5ff29af1..0eb5c9281d 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -7530,7 +7530,7 @@ void Tab::delete_preset() for (auto &preset2 : *m_presets) if (preset2.inherits() == current_preset.name) { ++count; - presets += "\n - " + preset2.name; + presets += "\n - " + from_u8(preset2.name); } if (count > 0) { msg = _L("Presets inherited by other presets cannot be deleted!"); From 600f0f20bd4b972fc3cdd1c689b7671889c960c0 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sun, 30 Aug 2026 11:19:06 +0800 Subject: [PATCH 138/138] Copy the decoded frame instead of aliasing the decoder's buffer wxImage with static_data set stores the pointer and never copies it, so the frame handed to wxMediaCtrl3 aliased AVVideoDecoder::bits_. That buffer is rewritten by the next sws_scale with the mutex released, reallocated by bits_.resize() when the window grows, and freed outright when the decoder leaves PlayThread's loop body at end of stream, all while the GUI thread may be painting from it. Windows is unaffected either way, since toWxBitmap already copies the bits into GDI. --- src/slic3r/GUI/AVVideoDecoder.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index 4c951fdb61..d7b8432bd3 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -115,7 +115,10 @@ bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) if (result_h != size.GetHeight()) { return false; } - image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data(), true); + // Copy: the frame outlives this decoder and is painted by the GUI thread while the + // next sws_scale is already overwriting bits_, so it must own its pixels. The Windows + // path below needs no equivalent, wxBitmap copies the bits into GDI. + image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data(), true).Copy(); if (!image.IsOk()) { fprintf(stderr, "AVVideoDecoder: image not ok %dx%d\n", size.GetWidth(), size.GetHeight()); return false;