From 9463ff306c50a31998d5c760ec78a24d85fdc0c8 Mon Sep 17 00:00:00 2001 From: aceRage Date: Tue, 20 Oct 2026 08:03:08 -0500 Subject: [PATCH 1/4] Add a shared Bambu-style filament map for third-party send dialogs. Elegoo CC upload now uses MaterialItem colour cards and a MappingItem slot popup instead of a text combo, while the start-print slot_map is unchanged. Other hosts keep the existing rows; Bambu and Snapmaker are not switched over. Deft --- src/slic3r/CMakeLists.txt | 5 + src/slic3r/GUI/AmsMappingPopup.cpp | 43 +- src/slic3r/GUI/AmsMappingPopup.hpp | 5 + src/slic3r/GUI/Plater.cpp | 4 + src/slic3r/GUI/PrintHostDialogs.cpp | 86 +++- src/slic3r/GUI/PrintHostDialogs.hpp | 14 +- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 217 +++++++++ src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 104 +++++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 424 ++++++++++++++++++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 82 ++++ .../elegoo_canvas_mapping_tests.cpp | 125 ++++++ 11 files changed, 1094 insertions(+), 15 deletions(-) create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMap.cpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMap.hpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp create mode 100644 src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 4ce4b7f8a8d1..254e0da97c86 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -1111,6 +1111,10 @@ set(SLIC3R_GUI_SOURCES # Last on purpose: a new source in the middle of the list reshuffles the unity batches (HomeVaults.cpp # and HomeVendors.cpp share file-local names and must not land in one batch). GUI/FavoriteFilamentsUI.cpp + GUI/ThirdPartyFilamentMap.cpp + GUI/ThirdPartyFilamentMap.hpp + GUI/ThirdPartyFilamentMapPanel.cpp + GUI/ThirdPartyFilamentMapPanel.hpp ) add_subdirectory(GUI/DeviceTab) @@ -1281,6 +1285,7 @@ slic3r_unity_build(libslic3r_gui SKIP GUI/Tab.cpp # ::CheckBox vs GUI::CheckBox (Field.hpp) GUI/AMSMaterialsSetting.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) GUI/AmsMappingPopup.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) + GUI/ThirdPartyFilamentMapPanel.cpp # includes AmsMappingPopup.hpp (::CheckBox vs GUI::CheckBox) GUI/CalibrationWizardPage.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) GUI/ExtrusionCalibration.cpp # ::CheckBox members vs GUI::CheckBox (Field.hpp) ) diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index bbb3cfc7e608..796e0fc5c786 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -69,6 +69,22 @@ void MaterialItem::set_ams_info(wxColour col, wxString txt, int ctype, std::vect if (need_refresh) { Refresh();} } +void MaterialItem::set_dropdown_visible(bool visible) +{ + if (m_dropdown_visible == visible) + return; + m_dropdown_visible = visible; + Refresh(); +} + +void MaterialItem::set_nozzle_badge(const wxString &badge) +{ + if (m_nozzle_badge == badge) + return; + m_nozzle_badge = badge; + Refresh(); +} + void MaterialItem::disable() { if (IsEnabled()) { @@ -179,6 +195,19 @@ void MaterialItem::render(wxDC &dc) auto mapping_txt_size = dc.GetTextExtent(mapping_txt); dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); + + if (!m_nozzle_badge.empty()) { + const int d = FromDIP(16); + const wxPoint o(FromDIP(2), FromDIP(2)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(wxColour(0x26, 0x2E, 0x30))); + dc.DrawEllipse(o.x, o.y, d, d); + dc.SetFont(::Label::Body_10); + dc.SetTextForeground(*wxWHITE); + dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); + const wxSize ts = dc.GetTextExtent(m_nozzle_badge); + dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); + } } void MaterialItem::doRender(wxDC &dc) @@ -273,12 +302,14 @@ void MaterialItem::doRender(wxDC &dc) } #endif //arrow - if ( (acolor.Red() > 160 && acolor.Green() > 160 && acolor.Blue() > 160) && - (acolor.Red() < 180 && acolor.Green() < 180 && acolor.Blue() < 180)) { - dc.DrawBitmap(m_arraw_bitmap_white.bmp(), size.x - m_arraw_bitmap_white.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_white.GetBmpSize().y); - } - else { - dc.DrawBitmap(m_arraw_bitmap_gray.bmp(), size.x - m_arraw_bitmap_gray.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_gray.GetBmpSize().y); + if (m_dropdown_visible) { + if ( (acolor.Red() > 160 && acolor.Green() > 160 && acolor.Blue() > 160) && + (acolor.Red() < 180 && acolor.Green() < 180 && acolor.Blue() < 180)) { + dc.DrawBitmap(m_arraw_bitmap_white.bmp(), size.x - m_arraw_bitmap_white.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_white.GetBmpSize().y); + } + else { + dc.DrawBitmap(m_arraw_bitmap_gray.bmp(), size.x - m_arraw_bitmap_gray.GetBmpSize().x - FromDIP(7), size.y - m_arraw_bitmap_gray.GetBmpSize().y); + } } diff --git a/src/slic3r/GUI/AmsMappingPopup.hpp b/src/slic3r/GUI/AmsMappingPopup.hpp index ece65604c0c2..1796716aec50 100644 --- a/src/slic3r/GUI/AmsMappingPopup.hpp +++ b/src/slic3r/GUI/AmsMappingPopup.hpp @@ -86,9 +86,14 @@ class MaterialItem: public wxPanel bool m_selected {false}; bool m_warning{false}; + // Optional extras for ThirdPartyFilamentMapPanel. Defaults keep the Bambu send dialog unchanged. + bool m_dropdown_visible {true}; + wxString m_nozzle_badge; void msw_rescale(); void set_ams_info(wxColour col, wxString txt, int ctype=0, std::vector cols= std::vector()); + void set_dropdown_visible(bool visible); + void set_nozzle_badge(const wxString &badge); void disable(); void enable(); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 4dd074d2170e..64b829a0ccbb 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -24464,6 +24464,7 @@ static std::vector plate_filaments_for_send(int plate_idx) const ConfigOptionStrings* colours = bundle->project_config.option("filament_colour"); const ConfigOptionStrings* types = full.option("filament_type"); const ConfigOptionInts* temps = full.option("nozzle_temperature"); + const ConfigOptionInts* fil_map = bundle->project_config.option("filament_map"); std::map volumes; if (plate && plate->is_slice_result_valid() && plate->get_slice_result()) for (const auto& kv : plate->get_slice_result()->print_statistics.total_volumes_per_extruder) @@ -24474,6 +24475,9 @@ static std::vector plate_filaments_for_send(int plate_idx) f.colour = (colours && i < colours->values.size()) ? colours->values[i] : std::string(); f.type = (types && i < types->values.size()) ? types->values[i] : std::string(); f.nozzle_temp = (temps && i < temps->values.size()) ? temps->values[i] : 0; + // filament_map is 1-based (SelectMachine / Bambu dual-nozzle). -1 when the plate is single-nozzle. + if (fil_map && i < fil_map->values.size() && fil_map->values[i] > 0) + f.nozzle_id = fil_map->values[i] - 1; // The remembered CANVAS mapping keys on the preset as people know it: its alias, else its name. if (const Preset* fp = bundle->filaments.find_preset(bundle->filament_presets[i])) f.alias = fp->alias.empty() ? fp->name : fp->alias; diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index 2e9312e38f26..a4d3374eca48 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -492,6 +492,8 @@ std::string PrintHostSendDialog::filament_mapping() const // The QIDI Box switched off: the print runs from the extruder's own spool, nothing is mapped. if (m_qidi_use_box && !m_qidi_use_box->GetValue()) return {}; + if (m_filament_map) + return m_filament_map->filament_mapping(); std::string out; for (size_t i = 0; i < m_mapping_choices.size() && i < m_mapping_filament.size(); ++i) { const int sel = m_mapping_choices[i]->GetSelection(); @@ -580,8 +582,7 @@ void PrintHostSendDialog::build_device_ui() content_sizer->Add(m_device_note, 0, wxBOTTOM, VERT_SPACING); m_mapping_panel = new wxPanel(this, wxID_ANY); - m_mapping_sizer = new wxFlexGridSizer(3, FromDIP(4), FromDIP(8)); - m_mapping_sizer->AddGrowableCol(2, 1); + m_mapping_sizer = new wxBoxSizer(wxVERTICAL); m_mapping_panel->SetSizer(m_mapping_sizer); content_sizer->Add(m_mapping_panel, 0, wxEXPAND | wxBOTTOM, VERT_SPACING); m_mapping_note = new wxStaticText(this, wxID_ANY, wxEmptyString); @@ -670,6 +671,7 @@ void PrintHostSendDialog::rebuild_mapping_rows() { if (!m_mapping_panel || !m_mapping_sizer) return; + m_filament_map = nullptr; m_mapping_choices.clear(); m_mapping_filament.clear(); m_mapping_sizer->Clear(true); @@ -682,6 +684,15 @@ void PrintHostSendDialog::rebuild_mapping_rows() return; } + if (use_third_party_map_panel()) { + rebuild_third_party_map_rows(); + return; + } + + auto* grid = new wxFlexGridSizer(3, FromDIP(4), FromDIP(8)); + grid->AddGrowableCol(2, 1); + m_mapping_sizer->Add(grid, 0, wxEXPAND); + wxArrayString slot_labels; slot_labels.Add(_L("As sliced")); for (const PrintHostDevices::Slot& s : m_device_status.slots) { @@ -696,12 +707,12 @@ void PrintHostSendDialog::rebuild_mapping_rows() continue; auto* swatch = new wxPanel(m_mapping_panel, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); swatch->SetBackgroundColour(swatch_colour(f.colour)); - m_mapping_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL); + grid->Add(swatch, 0, wxALIGN_CENTER_VERTICAL); wxString name = format_wxstr(_L("Filament %1%"), f.index + 1); if (!f.type.empty()) name += " " + from_u8(f.type); - m_mapping_sizer->Add(new wxStaticText(m_mapping_panel, wxID_ANY, name), 0, wxALIGN_CENTER_VERTICAL); + grid->Add(new wxStaticText(m_mapping_panel, wxID_ANY, name), 0, wxALIGN_CENTER_VERTICAL); auto* choice = new wxChoice(m_mapping_panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, slot_labels); // Auto-match: the dialog's own choice when it has one (the Elegoo dialog's remembered @@ -715,7 +726,7 @@ void PrintHostSendDialog::rebuild_mapping_rows() m_device_status.qidi_box); choice->SetSelection(best); choice->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { update_mapping_note(); }); - m_mapping_sizer->Add(choice, 1, wxEXPAND); + grid->Add(choice, 1, wxEXPAND); m_mapping_choices.push_back(choice); m_mapping_filament.push_back(f.index); } @@ -725,8 +736,59 @@ void PrintHostSendDialog::rebuild_mapping_rows() update_mapping_note(); } +void PrintHostSendDialog::rebuild_third_party_map_rows() +{ + ThirdPartyFilamentMapModel model; + for (const SendPlateFilament& f : m_plate_filaments) { + if (!f.used) + continue; + ThirdPartySlicerFilament sf; + sf.index = f.index; + sf.colour = f.colour; + sf.type = f.type; + sf.name = f.alias; + sf.nozzle_id = f.nozzle_id; + model.filaments.push_back(std::move(sf)); + m_mapping_filament.push_back(f.index); + } + for (const PrintHostDevices::Slot& s : m_device_status.slots) { + ThirdPartyDeviceSlot ds; + ds.unit_id = s.unit; + ds.slot_id = s.tray >= 0 ? s.tray : s.index; + ds.label = s.unit >= 0 ? bambu_style_slot_label(s.unit, ds.slot_id) : bambu_style_slot_label(-1, s.index); + ds.colour = s.color; + ds.type = s.type; + ds.name = s.name; + ds.empty = !s.loaded; + ds.index = s.index; + model.slots.push_back(std::move(ds)); + } + model.read_only = third_party_map_read_only(); + model.mapping.resize(model.filaments.size(), -1); + for (size_t i = 0; i < model.filaments.size(); ++i) { + const SendPlateFilament* f = plate_filament(model.filaments[i].index); + if (!f) + continue; + const int preset_pos = preselect_slot(*f); + const bool preset_ok = preset_pos >= 0 && preset_pos < (int) m_device_status.slots.size(); + const int best = preset_ok ? preset_pos + 1 : + PrintHostDevices::auto_match_slot(m_device_status.slots, f->colour, f->type, + m_device_status.qidi_box); + model.mapping[i] = best - 1; + } + + m_filament_map = new ThirdPartyFilamentMapPanel(m_mapping_panel); + m_filament_map->set_model(std::move(model), [this]() { update_mapping_note(); }); + m_mapping_sizer->Add(m_filament_map, 0, wxEXPAND); + m_mapping_panel->Show(!m_mapping_filament.empty()); + m_mapping_panel->Layout(); + update_mapping_note(); +} + std::vector PrintHostSendDialog::mapping_choice_positions() const { + if (m_filament_map) + return m_filament_map->choice_positions(); std::vector out; for (const wxChoice* c : m_mapping_choices) out.push_back(c ? c->GetSelection() - 1 : -1); // 0 is "As sliced" @@ -741,11 +803,17 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } +wxString PrintHostSendDialog::mapping_warning() const +{ + return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); +} + void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; - const wxString text = m_mapping_choices.empty() ? wxString() : mapping_warning(); + const bool has_rows = m_filament_map || !m_mapping_choices.empty(); + const wxString text = has_rows ? mapping_warning() : wxString(); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -812,6 +880,8 @@ void PrintHostSendDialog::rebuild_qidi_options() void PrintHostSendDialog::update_mapping_enabled() { const bool box_on = !m_qidi_use_box || m_qidi_use_box->GetValue(); + if (m_filament_map) + m_filament_map->set_mapping_enabled(box_on); for (wxChoice* choice : m_mapping_choices) choice->Enable(box_on); } @@ -2333,7 +2403,7 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const wxString ElegooPrintHostSendDialog::mapping_warning() const { if (!m_device_status.canvas) - return wxString(); + return PrintHostSendDialog::mapping_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2358,6 +2428,8 @@ wxString ElegooPrintHostSendDialog::mapping_warning() const break; } } + if (const wxString extra = PrintHostSendDialog::mapping_warning(); !extra.IsEmpty()) + add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 3219d024d472..e1eac9e461b1 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -15,6 +15,7 @@ #include "../Utils/PrintHostDeviceStatus.hpp" #include "../Utils/ElegooCanvasMapping.hpp" #include "../Utils/Flashforge.hpp" +#include "ThirdPartyFilamentMapPanel.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/ProjectTask.hpp" class wxButton; @@ -41,6 +42,7 @@ struct SendPlateFilament bool used { false }; // this plate's slice result actually used it std::string alias; // its filament preset's alias (or name), for remembered mappings int nozzle_temp { 0 }; // its nozzle temperature, 0 = unknown + int nozzle_id { -1 }; // 0-based nozzle / toolhead; -1 = single-nozzle / unknown }; class PrintHostSendDialog : public GUI::MsgDialog @@ -102,6 +104,11 @@ class PrintHostSendDialog : public GUI::MsgDialog // cursor and a short timeout - the same shape as the devices dialog's Test button. void refresh_device_status(); void rebuild_mapping_rows(); + // Elegoo CC (and later CFS / MMU / ACE): Bambu-style MaterialItem rows instead of a text combo. + virtual bool use_third_party_map_panel() const { return false; } + // Creality K3 holders: show the two colours, no popup. + virtual bool third_party_map_read_only() const { return false; } + void rebuild_third_party_map_rows(); // The picked QIDI printer said it is printing or paused: Upload only, no start. bool print_blocked() const; // "Upload and Print" follows print_blocked(). Safe before the buttons exist. @@ -114,7 +121,8 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - virtual wxString mapping_warning() const { return wxString(); } + // The base implementation is the third-party panel's type/colour mismatch text. + virtual wxString mapping_warning() const; void update_mapping_note(); // The rows' choices as positions in m_device_status.slots, -1 = "As sliced"; and the plate // filament each row is for. @@ -134,7 +142,8 @@ class PrintHostSendDialog : public GUI::MsgDialog wxComboBox* m_combo_devices { nullptr }; wxStaticText* m_device_note { nullptr }; wxWindow* m_mapping_panel { nullptr }; - wxFlexGridSizer* m_mapping_sizer { nullptr }; + wxBoxSizer* m_mapping_sizer { nullptr }; + ThirdPartyFilamentMapPanel* m_filament_map { nullptr }; // Elegoo CC; null = wxChoice rows std::vector m_mapping_choices; // one per used plate filament std::vector m_mapping_filament; // its filament index // QIDI printers: QIDIStudio's print options, null where the device does not have them. @@ -268,6 +277,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog virtual std::map extendedInfo() const override; protected: + bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; wxString mapping_warning() const override; diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp new file mode 100644 index 000000000000..c8b14c7fb18b --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -0,0 +1,217 @@ +#include "ThirdPartyFilamentMap.hpp" + +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +char hex_digit(unsigned v) +{ + return v < 10 ? static_cast('0' + v) : static_cast('A' + (v - 10)); +} + +bool hex_nibble(char c, int& out) +{ + if (c >= '0' && c <= '9') { + out = c - '0'; + return true; + } + if (c >= 'a' && c <= 'f') { + out = c - 'a' + 10; + return true; + } + if (c >= 'A' && c <= 'F') { + out = c - 'A' + 10; + return true; + } + return false; +} + +bool rgb_of(const std::string& colour, int& r, int& g, int& b) +{ + const std::string hex = normalize_map_colour(colour); + if (hex.size() != 7) + return false; + int v[6]; + for (int i = 0; i < 6; ++i) { + if (!hex_nibble(hex[i + 1], v[i])) + return false; + } + r = v[0] * 16 + v[1]; + g = v[2] * 16 + v[3]; + b = v[4] * 16 + v[5]; + return true; +} + +std::string lower_copy(std::string s) +{ + for (char& c : s) + c = static_cast(std::tolower(static_cast(c))); + return s; +} + +} // namespace + +std::string normalize_map_colour(const std::string& colour) +{ + std::string s = colour; + if (!s.empty() && s[0] == '#') + s = s.substr(1); + if (s.size() < 6) + return {}; + int dummy; + for (int i = 0; i < 6; ++i) { + if (!hex_nibble(s[i], dummy)) + return {}; + } + std::string out = "#"; + out.resize(7); + out[0] = '#'; + for (int i = 0; i < 6; ++i) { + int n = 0; + hex_nibble(s[i], n); + out[i + 1] = hex_digit(static_cast(n)); + } + return out; +} + +std::string bambu_style_slot_label(int unit_id, int slot_id) +{ + const int slot = slot_id < 0 ? 0 : slot_id; + const int letter_i = slot % 4; + const char letter = static_cast('A' + letter_i); + if (unit_id < 0) + return std::string(1, letter); + return std::to_string(unit_id + 1) + letter; +} + +std::string slot_display_label(const ThirdPartyDeviceSlot& slot) +{ + if (!slot.label.empty()) + return slot.label; + return bambu_style_slot_label(slot.unit_id, slot.slot_id >= 0 ? slot.slot_id : slot.index); +} + +bool types_match(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return true; + return lower_copy(a) == lower_copy(b); +} + +double map_colour_distance(const std::string& a, const std::string& b) +{ + int ar, ag, ab, br, bg, bb; + if (!rgb_of(a, ar, ag, ab) || !rgb_of(b, br, bg, bb)) + return 0; + const double rm = (ar + br) / 2.0; + const double dr = ar - br, dg = ag - bg, db = ab - bb; + return (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db; +} + +bool colours_mismatch(const std::string& a, const std::string& b) +{ + if (normalize_map_colour(a).empty() || normalize_map_colour(b).empty()) + return false; + // Red vs a nearby red is a few thousand; red vs blue is tens of thousands. 8000 is + // "noticeably different" without flagging two spools of the same shade. + return map_colour_distance(a, b) > 8000.0; +} + +ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot) +{ + const bool type_bad = !slot.empty && !types_match(filament.type, slot.type); + const bool colour_bad = !slot.empty && colours_mismatch(filament.colour, slot.colour); + if (type_bad && colour_bad) + return ThirdPartyMapMismatch::TypeAndColour; + if (type_bad) + return ThirdPartyMapMismatch::Type; + if (colour_bad) + return ThirdPartyMapMismatch::Colour; + return ThirdPartyMapMismatch::None; +} + +ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row) +{ + if (filament_row >= model.filaments.size() || filament_row >= model.mapping.size()) + return ThirdPartyMapMismatch::None; + const int pos = model.mapping[filament_row]; + if (pos < 0 || pos >= (int) model.slots.size()) + return ThirdPartyMapMismatch::None; + return mismatch_for(model.filaments[filament_row], model.slots[pos]); +} + +std::vector distinct_nozzle_ids(const ThirdPartyFilamentMapModel& model) +{ + std::set ids; + for (const ThirdPartySlicerFilament& f : model.filaments) + if (f.nozzle_id >= 0) + ids.insert(f.nozzle_id); + for (const ThirdPartyDeviceSlot& s : model.slots) + if (s.nozzle_id >= 0) + ids.insert(s.nozzle_id); + return std::vector(ids.begin(), ids.end()); +} + +bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model) +{ + return distinct_nozzle_ids(model).size() > 1; +} + +std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model) +{ + const std::vector nozzles = distinct_nozzle_ids(model); + if (filament.nozzle_id >= 0 && nozzles.size() > 1) { + if (nozzles.size() == 2 && nozzles[0] == 0 && nozzles[1] == 1) { + // Bambu H2D: physical 0 is the right nozzle, 1 is the left. + if (filament.nozzle_id == 0) + return "R"; + if (filament.nozzle_id == 1) + return "L"; + } + return std::to_string(filament.nozzle_id + 1); + } + return std::to_string(filament.index + 1); +} + +std::vector group_slots(const std::vector& slots) +{ + std::vector groups; + for (size_t i = 0; i < slots.size(); ++i) { + const ThirdPartyDeviceSlot& s = slots[i]; + auto it = std::find_if(groups.begin(), groups.end(), [&](const ThirdPartySlotGroup& g) { + return g.unit_id == s.unit_id && g.nozzle_id == s.nozzle_id; + }); + if (it == groups.end()) { + ThirdPartySlotGroup g; + g.unit_id = s.unit_id; + g.nozzle_id = s.nozzle_id; + g.slot_positions.push_back((int) i); + groups.push_back(std::move(g)); + } else { + it->slot_positions.push_back((int) i); + } + } + return groups; +} + +std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model) +{ + std::string out; + for (size_t i = 0; i < model.filaments.size() && i < model.mapping.size(); ++i) { + const int pos = model.mapping[i]; + if (pos < 0 || pos >= (int) model.slots.size()) + continue; + if (!out.empty()) + out += ","; + out += std::to_string(model.filaments[i].index) + ":" + std::to_string(model.slots[pos].index); + } + return out; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp new file mode 100644 index 000000000000..073e69ddea9a --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_GUI_ThirdPartyFilamentMap_hpp_ +#define slic3r_GUI_ThirdPartyFilamentMap_hpp_ + +// Vendor-neutral filament → device-slot mapping for third-party send/upload dialogs +// (Elegoo CANVAS, Creality CFS, K3 holders, Happy Hare / AFC / QIDI Box, Anycubic ACE). +// +// wx-free and printer-protocol-free: the dialog fills this model, the panel shows it, and +// encode_filament_mapping() returns the same "filament:slot" string PrintHostSendDialog already +// sends. Bambu's SelectMachineDialog is unchanged. + +#include +#include + +namespace Slic3r { +namespace GUI { + +// One filament of the plate being sent. +struct ThirdPartySlicerFilament +{ + int index { 0 }; // 0-based slicer filament / T-number + std::string colour; // "#RRGGBB" or "#RRGGBBAA"; empty = unknown + std::string type; // "PLA" + std::string name; // preset alias or display name + int nozzle_id { -1 }; // 0-based nozzle / toolhead; -1 = none / single-nozzle +}; + +// One place on the device a filament can be fed from (AMS / CANVAS / MMU / CFS / ACE / box tray). +struct ThirdPartyDeviceSlot +{ + int unit_id { -1 }; // 0-based unit (AMS 1, CANVAS 1, CFS box 1); -1 = ungrouped + int slot_id { -1 }; // 0-based slot inside the unit (A=0) + std::string label; // shown on the tile; empty = bambu_style_slot_label() + std::string colour; // "#RRGGBB"; empty = unknown + std::string type; // "PLA"; empty = the device did not say + std::string name; // spool / material name + int nozzle_id { -1 }; // which nozzle this unit feeds; -1 = unspecified + bool empty { false }; + int index { 0 }; // protocol index (PrintHostDevices::Slot::index) +}; + +struct ThirdPartyFilamentMapModel +{ + std::vector filaments; // one row each, already filtered to used + std::vector slots; + // Parallel to filaments: position in `slots`, or -1 = "As sliced". + std::vector mapping; + bool read_only { false }; +}; + +struct ThirdPartySlotGroup +{ + int unit_id { -1 }; + int nozzle_id { -1 }; + std::vector slot_positions; // indices into model.slots +}; + +enum class ThirdPartyMapMismatch { + None = 0, + Type = 1, + Colour = 2, + TypeAndColour = 3, +}; + +// "#RRGGBB" upper case from "#RRGGBB", "#RRGGBBAA", "RRGGBB"; empty for anything else. +std::string normalize_map_colour(const std::string& colour); + +// Bambu-style unit+slot: unit 0 slot 0 → "1A", unit 0 slot 3 → "1D", unit 1 slot 0 → "2A". +// unit_id < 0: slot 0 → "A", slot 1 → "B", ... +std::string bambu_style_slot_label(int unit_id, int slot_id); + +// The label a tile should show: slot.label if set, else bambu_style_slot_label(). +std::string slot_display_label(const ThirdPartyDeviceSlot& slot); + +bool types_match(const std::string& a, const std::string& b); + +// Redmean, same formula PrintHostDevices::auto_match_slot uses. 0 when either colour is empty. +double map_colour_distance(const std::string& a, const std::string& b); + +// Noticeably different colours (redmean). False when either side has no colour. +bool colours_mismatch(const std::string& a, const std::string& b); + +ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot); +ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row); + +// Distinct nozzle ids on filaments or slots; more than one means a multi-nozzle / multi-toolhead printer. +std::vector distinct_nozzle_ids(const ThirdPartyFilamentMapModel& model); +bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model); + +// Badge on the slicer-filament colour box: "L"/"R" when the printer has exactly two nozzles +// numbered 0/1 (Bambu H2D: 0 = right, 1 = left), else the 1-based nozzle number, else the +// 1-based filament index on a single nozzle. +std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model); + +// Slots grouped by nozzle (when several) then unit, preserving first-seen order. +std::vector group_slots(const std::vector& slots); + +// "0:1,1:2" — filament index to Slot::index. Skips "As sliced" (-1) rows. Same shape as +// PrintHostSendDialog::filament_mapping(). +std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model); + +} // namespace GUI +} // namespace Slic3r + +#endif diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp new file mode 100644 index 000000000000..c1e90d96b04e --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -0,0 +1,424 @@ +#include "ThirdPartyFilamentMapPanel.hpp" + +#include "AmsMappingPopup.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/PopupWindow.hpp" +#include "format.hpp" + +#include +#include +#include +#include + +#include "Widgets/StateColor.hpp" + +#include + +namespace Slic3r { +namespace GUI { + +namespace { + +wxColour parse_slot_colour(const std::string& raw) +{ + std::string s = normalize_map_colour(raw); + if (s.empty()) + return wxColour(0xCE, 0xCE, 0xCE); + wxColour c; + if (c.Set(wxString::FromUTF8(s.c_str()))) + return c; + return wxColour(0xCE, 0xCE, 0xCE); +} + +wxString from_utf8(const std::string& s) +{ + return wxString::FromUTF8(s.c_str()); +} + +wxString group_title(const ThirdPartySlotGroup& g, bool multiple_nozzles) +{ + wxString title; + if (multiple_nozzles && g.nozzle_id >= 0) { + // Bambu AmsMapingPopup: physical 0 is the right nozzle, 1 is the left. + if (g.nozzle_id == 0) + title = _L("Right"); + else if (g.nozzle_id == 1) + title = _L("Left"); + else + title = format_wxstr(_L("Nozzle %1%"), g.nozzle_id + 1); + } + if (g.unit_id >= 0) { + const wxString unit = format_wxstr(_L("Unit %1%"), g.unit_id + 1); + title = title.empty() ? unit : title + " · " + unit; + } + return title; +} + +} // namespace + +class ThirdPartySlotPopup : public PopupWindow +{ +public: + ThirdPartySlotPopup(wxWindow* parent) + : PopupWindow(parent, wxBORDER_NONE) + { + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + Bind(wxEVT_PAINT, &ThirdPartySlotPopup::on_paint, this); + + m_sizer = new wxBoxSizer(wxVERTICAL); + + auto* title_panel = new wxPanel(this, wxID_ANY); + title_panel->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(0xF8, 0xF8, 0xF8))); + title_panel->SetMinSize(wxSize(-1, FromDIP(30))); + auto* title_sizer = new wxBoxSizer(wxVERTICAL); + auto* title = new wxStaticText(title_panel, wxID_ANY, _L("Device slots")); + title->SetForegroundColour(StateColor::darkModeColorFor(wxColour(0x32, 0x3A, 0x3D))); + title->SetFont(::Label::Head_13); + title_sizer->Add(title, 0, wxALIGN_CENTER | wxALL, FromDIP(6)); + title_panel->SetSizer(title_sizer); + + m_body = new wxBoxSizer(wxVERTICAL); + + m_as_sliced = new MappingItem(this); + m_as_sliced->SetSize(wxSize(FromDIP(48), FromDIP(60))); + m_as_sliced->SetMinSize(m_as_sliced->GetSize()); + m_as_sliced->SetMaxSize(m_as_sliced->GetSize()); + TrayData sliced; + sliced.type = TrayType::EMPTY; + sliced.colour = wxColour(0xCE, 0xCE, 0xCE); + m_as_sliced->set_tray_index("-"); + m_as_sliced->set_data(wxColour(0xCE, 0xCE, 0xCE), _L("As sliced"), sliced); + m_as_sliced->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_panel) + m_panel->pick_slot(m_filament_row, -1); + Dismiss(); + }); + + m_warning = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_warning->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_warning->SetFont(::Label::Body_12); + m_warning->Hide(); + + m_sizer->Add(title_panel, 0, wxEXPAND | wxALL, FromDIP(2)); + m_sizer->Add(m_body, 0, wxEXPAND | wxALL, FromDIP(4)); + auto* sliced_row = new wxBoxSizer(wxHORIZONTAL); + sliced_row->Add(m_as_sliced, 0, wxALIGN_CENTER | wxALL, FromDIP(4)); + m_sizer->Add(sliced_row, 0, wxALIGN_CENTER); + m_sizer->Add(m_warning, 0, wxEXPAND | wxALL, FromDIP(6)); + SetSizer(m_sizer); + } + + void set_panel(ThirdPartyFilamentMapPanel* panel) { m_panel = panel; } + + void update(const ThirdPartyFilamentMapModel& model, size_t filament_row) + { + m_filament_row = filament_row; + m_body->Clear(true); + m_tiles.clear(); + + const bool multi = has_multiple_nozzles(model); + const std::string tag_type = filament_row < model.filaments.size() ? model.filaments[filament_row].type : std::string(); + + bool any_type_mismatch = false; + const auto groups = group_slots(model.slots); + for (const ThirdPartySlotGroup& g : groups) { + const wxString title = group_title(g, multi); + if (!title.empty()) { + auto* t = new wxStaticText(this, wxID_ANY, title); + t->SetFont(::Label::Head_13); + t->SetForegroundColour(StateColor::darkModeColorFor(wxColour(0x32, 0x3A, 0x3D))); + m_body->Add(t, 0, wxALIGN_CENTER | wxTOP, FromDIP(4)); + } + + for (size_t chunk = 0; chunk < g.slot_positions.size(); chunk += 4) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + const size_t n = std::min(4, g.slot_positions.size() - chunk); + auto* container = new MappingContainer(this); + auto* inner = new wxBoxSizer(wxHORIZONTAL); + container->SetSizer(inner); + inner->Add(0, 0, 0, wxLEFT, FromDIP(6)); + for (size_t k = 0; k < n; ++k) { + const int pos = g.slot_positions[chunk + k]; + const ThirdPartyDeviceSlot& slot = model.slots[pos]; + MappingItem* item = new MappingItem(container); + item->SetSize(wxSize(FromDIP(48), FromDIP(60))); + item->SetMinSize(item->GetSize()); + item->SetMaxSize(item->GetSize()); + item->m_ams_id = slot.unit_id; + item->m_slot_id = slot.slot_id; + + TrayData td; + td.id = pos; + td.ams_id = slot.unit_id; + td.slot_id = slot.slot_id; + td.filament_type = slot.type; + td.name = slot.type.empty() ? slot.name : slot.type; + td.colour = parse_slot_colour(slot.colour); + if (slot.empty) + td.type = TrayType::EMPTY; + else if (slot.type.empty() && slot.colour.empty()) + td.type = TrayType::THIRD; + else + td.type = TrayType::NORMAL; + + const bool type_unmatch = !slot.empty && !tag_type.empty() && !types_match(tag_type, slot.type); + if (type_unmatch) + any_type_mismatch = true; + + const wxString idx = from_utf8(slot_display_label(slot)); + item->set_tray_index(idx); + if (td.type == TrayType::EMPTY) + item->set_data(wxColour(0xCE, 0xCE, 0xCE), "-", td); + else if (td.type == TrayType::THIRD) + item->set_data(wxColour(0xCE, 0xCE, 0xCE), "?", td); + else if (type_unmatch) + item->set_data(wxColour(0xEE, 0xEE, 0xEE), from_utf8(td.name), td, true); + else + item->set_data(td.colour, from_utf8(td.name), td); + + item->Bind(wxEVT_LEFT_DOWN, [this, pos](wxMouseEvent&) { + if (m_panel) + m_panel->pick_slot(m_filament_row, pos); + Dismiss(); + }); + inner->Add(item, 0, wxTOP, FromDIP(1)); + inner->Add(0, 0, 0, wxRIGHT, FromDIP(6)); + m_tiles.push_back(item); + } + container->Show(); + row->Add(container, 0, wxALIGN_CENTER); + m_body->Add(row, 0, wxALIGN_CENTER | wxALL, FromDIP(2)); + } + } + + if (any_type_mismatch) { + m_warning->SetLabel(_L("Note: slots of a different type are greyed. You can still pick them.")); + m_warning->Wrap(FromDIP(248)); + m_warning->Show(); + } else { + m_warning->Hide(); + } + + Layout(); + Fit(); + } + + void OnDismiss() override + { + if (m_panel) + m_panel->restore_after_popup(); + PopupWindow::OnDismiss(); + } + +private: + void on_paint(wxPaintEvent&) + { + wxPaintDC dc(this); + dc.SetPen(wxColour(0xAC, 0xAC, 0xAC)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRoundedRectangle(0, 0, GetSize().x, GetSize().y, 0); + } + + ThirdPartyFilamentMapPanel* m_panel { nullptr }; + size_t m_filament_row { 0 }; + wxBoxSizer* m_sizer { nullptr }; + wxBoxSizer* m_body { nullptr }; + MappingItem* m_as_sliced { nullptr }; + wxStaticText* m_warning { nullptr }; + std::vector m_tiles; +}; + +ThirdPartyFilamentMapPanel::ThirdPartyFilamentMapPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY) +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_wrap = new wxWrapSizer(wxHORIZONTAL); + auto* root = new wxBoxSizer(wxVERTICAL); + root->Add(m_wrap, 0, wxEXPAND); + SetSizer(root); + + m_popup = new ThirdPartySlotPopup(this); + m_popup->set_panel(this); +} + +ThirdPartyFilamentMapPanel::~ThirdPartyFilamentMapPanel() +{ + m_popup = nullptr; +} + +void ThirdPartyFilamentMapPanel::set_model(ThirdPartyFilamentMapModel model, ChangedFn on_changed) +{ + if (model.mapping.size() != model.filaments.size()) + model.mapping.resize(model.filaments.size(), -1); + m_model = std::move(model); + m_on_changed = std::move(on_changed); + rebuild_rows(); +} + +void ThirdPartyFilamentMapPanel::set_read_only(bool read_only) +{ + if (m_model.read_only == read_only) + return; + m_model.read_only = read_only; + rebuild_rows(); +} + +void ThirdPartyFilamentMapPanel::set_mapping_enabled(bool enabled) +{ + if (m_mapping_enabled == enabled) + return; + m_mapping_enabled = enabled; + for (MaterialItem* item : m_items) { + if (!item) + continue; + if (enabled) + item->enable(); + else + item->disable(); + } +} + +std::string ThirdPartyFilamentMapPanel::filament_mapping() const +{ + return encode_filament_mapping(m_model); +} + +wxString ThirdPartyFilamentMapPanel::mismatch_warning() const +{ + wxString text; + auto add = [&text](const wxString& line) { + if (!text.IsEmpty()) + text += "\n"; + text += line; + }; + for (size_t i = 0; i < m_model.filaments.size(); ++i) { + const ThirdPartyMapMismatch kind = mismatch_at(m_model, i); + if (kind == ThirdPartyMapMismatch::None) + continue; + const int n = m_model.filaments[i].index + 1; + if (kind == ThirdPartyMapMismatch::Type || kind == ThirdPartyMapMismatch::TypeAndColour) { + std::string slot_type; + const int pos = i < m_model.mapping.size() ? m_model.mapping[i] : -1; + if (pos >= 0 && pos < (int) m_model.slots.size()) + slot_type = m_model.slots[pos].type; + add(format_wxstr(_L("Filament %1%: its type (%2%) does not match the mapped slot (%3%)."), n, + m_model.filaments[i].type, slot_type)); + } + if (kind == ThirdPartyMapMismatch::Colour || kind == ThirdPartyMapMismatch::TypeAndColour) + add(format_wxstr(_L("Filament %1%: its colour does not match the mapped slot."), n)); + } + return text; +} + +void ThirdPartyFilamentMapPanel::msw_rescale() +{ + for (MaterialItem* item : m_items) + if (item) + item->msw_rescale(); + Layout(); +} + +void ThirdPartyFilamentMapPanel::sys_color_changed() +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + wxGetApp().UpdateDarkUI(this); + Refresh(); +} + +void ThirdPartyFilamentMapPanel::rebuild_rows() +{ + m_wrap->Clear(true); + m_items.clear(); + m_items.resize(m_model.filaments.size(), nullptr); + + for (size_t i = 0; i < m_model.filaments.size(); ++i) { + const ThirdPartySlicerFilament& f = m_model.filaments[i]; + auto* item = new MaterialItem(this, colour_of(f.colour), material_label(f)); + item->set_dropdown_visible(!m_model.read_only); + item->set_nozzle_badge(from_utf8(filament_badge_text(f, m_model))); + if (!m_mapping_enabled) + item->disable(); + m_items[i] = item; + apply_slot_to_item(i); + const size_t row = i; + item->Bind(wxEVT_LEFT_DOWN, [this, row](wxMouseEvent& e) { + if (m_model.read_only || !m_mapping_enabled) + return; + open_popup(row); + e.Skip(); + }); + m_wrap->Add(item, 0, wxALL, FromDIP(5)); + } + Layout(); +} + +void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) +{ + if (row >= m_items.size() || !m_items[row]) + return; + MaterialItem* item = m_items[row]; + const int pos = row < m_model.mapping.size() ? m_model.mapping[row] : -1; + if (pos < 0 || pos >= (int) m_model.slots.size()) { + item->set_ams_info(wxColour(0xCE, 0xCE, 0xCE), "-"); + item->on_normal(); + return; + } + const ThirdPartyDeviceSlot& slot = m_model.slots[pos]; + const wxColour col = slot.empty ? wxColour(0xCE, 0xCE, 0xCE) : colour_of(slot.colour); + item->set_ams_info(col, from_utf8(slot_display_label(slot))); + item->on_normal(); + if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) + item->on_warning(); +} + +void ThirdPartyFilamentMapPanel::open_popup(size_t row) +{ + if (!m_popup || row >= m_items.size() || !m_items[row]) + return; + MaterialItem* item = m_items[row]; + for (MaterialItem* other : m_items) + if (other) + other->on_normal(); + item->on_selected(); + + m_popup->update(m_model, row); + wxPoint pos = item->ClientToScreen(wxPoint(0, 0)); + pos.y += item->GetRect().height; + m_popup->Move(pos); + m_popup->Popup(); +} + +void ThirdPartyFilamentMapPanel::pick_slot(size_t row, int slot_pos) +{ + if (row >= m_model.mapping.size()) + return; + m_model.mapping[row] = slot_pos; + restore_after_popup(); + if (m_on_changed) + m_on_changed(); +} + +void ThirdPartyFilamentMapPanel::restore_after_popup() +{ + for (size_t i = 0; i < m_items.size(); ++i) + apply_slot_to_item(i); +} + +wxColour ThirdPartyFilamentMapPanel::colour_of(const std::string& raw) const +{ + return parse_slot_colour(raw); +} + +wxString ThirdPartyFilamentMapPanel::material_label(const ThirdPartySlicerFilament& f) const +{ + if (!f.type.empty()) + return from_utf8(f.type); + if (!f.name.empty()) + return from_utf8(f.name); + return format_wxstr("F%1%", f.index + 1); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp new file mode 100644 index 000000000000..f1df24f928f9 --- /dev/null +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -0,0 +1,82 @@ +#ifndef slic3r_GUI_ThirdPartyFilamentMapPanel_hpp_ +#define slic3r_GUI_ThirdPartyFilamentMapPanel_hpp_ + +#include "ThirdPartyFilamentMap.hpp" + +#include +#include +#include +#include + +class wxBoxSizer; +class wxWrapSizer; + +namespace Slic3r { +namespace GUI { + +class MaterialItem; +class ThirdPartySlotPopup; + +// Bambu-style mapping rows for a third-party send dialog: MaterialItem colour cards (slicer +// filament on top, device slot underneath) and a MappingItem tile popup grouped by unit +// (1A–1D). The Bambu SelectMachineDialog is not used and is not changed. +// +// ThirdPartyFilamentMapPanel* panel = new ThirdPartyFilamentMapPanel(parent); +// panel->set_model(model, [this]() { update_mapping_note(); }); +// std::string sent = panel->filament_mapping(); // "0:1,1:2" +// +// Read-only mode (Creality K3 holders): set_model() with model.read_only, or set_read_only(true) +// — no popup, no dropdown arrow, just the two colours. +class ThirdPartyFilamentMapPanel : public wxPanel +{ +public: + using ChangedFn = std::function; + + explicit ThirdPartyFilamentMapPanel(wxWindow* parent); + ~ThirdPartyFilamentMapPanel() override; + + // Replaces the rows. `on_changed` is called whenever a slot is picked (not in read-only). + void set_model(ThirdPartyFilamentMapModel model, ChangedFn on_changed = {}); + const ThirdPartyFilamentMapModel& model() const { return m_model; } + + void set_read_only(bool read_only); + bool is_read_only() const { return m_model.read_only; } + + // Grey out the cards and skip the popup (QIDI Box off / Use CFS off). + void set_mapping_enabled(bool enabled); + bool mapping_enabled() const { return m_mapping_enabled; } + + // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). + std::vector choice_positions() const { return m_model.mapping; } + // "0:1,1:2" — filament index to Slot::index. Empty when every row is "As sliced". + std::string filament_mapping() const; + + // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. + wxString mismatch_warning() const; + + void msw_rescale(); + void sys_color_changed(); + +private: + friend class ThirdPartySlotPopup; + + void rebuild_rows(); + void apply_slot_to_item(size_t row); + void open_popup(size_t row); + void pick_slot(size_t row, int slot_pos); // -1 = as sliced + void restore_after_popup(); + wxColour colour_of(const std::string& raw) const; + wxString material_label(const ThirdPartySlicerFilament& f) const; + + ThirdPartyFilamentMapModel m_model; + ChangedFn m_on_changed; + bool m_mapping_enabled { true }; + wxWrapSizer* m_wrap { nullptr }; + std::vector m_items; + ThirdPartySlotPopup* m_popup { nullptr }; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index c013e48ed188..fff1ab1664df 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -21,6 +21,7 @@ #include "slic3r/Utils/ElegooCanvasMapping.hpp" #include "slic3r/Utils/ElegooStartPrint.hpp" #include "slic3r/Utils/PrintHostDeviceStatus.hpp" +#include "slic3r/GUI/ThirdPartyFilamentMap.hpp" using namespace Slic3r; using ElegooStartPrint::SlotMapEntry; @@ -155,6 +156,8 @@ TEST_CASE("A CANVAS read becomes the send dialog's slots", "[ElegooCanvas]") CHECK(st.slots[0].min_temp == 190); CHECK(st.slots[0].max_temp == 230); CHECK(st.slots[0].label() == "Tray 1 PLA"); + CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[0].unit, st.slots[0].tray) == "1A"); + CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[3].unit, st.slots[3].tray) == "1D"); CHECK(! st.slots[2].loaded); CHECK(st.slots[2].label() == "Tray 3 (empty)"); CHECK(st.slots[3].name == "PLA"); @@ -320,3 +323,125 @@ TEST_CASE("Remembered CANVAS mappings follow the spool, not the tray number", "[ CHECK(ElegooCanvasMapping::load().empty()); } } + +TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + CHECK(bambu_style_slot_label(0, 0) == "1A"); + CHECK(bambu_style_slot_label(0, 1) == "1B"); + CHECK(bambu_style_slot_label(0, 3) == "1D"); + CHECK(bambu_style_slot_label(1, 0) == "2A"); + CHECK(bambu_style_slot_label(-1, 0) == "A"); + CHECK(bambu_style_slot_label(-1, 2) == "C"); + + ThirdPartyDeviceSlot a; + a.unit_id = 0; + a.slot_id = 0; + a.label = "custom"; + CHECK(slot_display_label(a) == "custom"); + a.label.clear(); + CHECK(slot_display_label(a) == "1A"); + + std::vector slots(6); + slots[0].unit_id = 0; + slots[0].slot_id = 0; + slots[1].unit_id = 0; + slots[1].slot_id = 1; + slots[2].unit_id = 1; + slots[2].slot_id = 0; + slots[2].nozzle_id = 1; + slots[3].unit_id = 1; + slots[3].slot_id = 1; + slots[3].nozzle_id = 1; + slots[4].unit_id = 0; + slots[4].slot_id = 2; + slots[5].unit_id = 0; + slots[5].slot_id = 3; + const auto groups = group_slots(slots); + REQUIRE(groups.size() == 2); + CHECK(groups[0].unit_id == 0); + CHECK(groups[0].slot_positions == std::vector{ 0, 1, 4, 5 }); + CHECK(groups[1].unit_id == 1); + CHECK(groups[1].nozzle_id == 1); + CHECK(groups[1].slot_positions == std::vector{ 2, 3 }); +} + +TEST_CASE("Third-party filament map: encode matches the send dialog's filament:slot string", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f0; + f0.index = 0; + ThirdPartySlicerFilament f1; + f1.index = 2; + model.filaments = { f0, f1 }; + ThirdPartyDeviceSlot s0; + s0.index = 5; + ThirdPartyDeviceSlot s1; + s1.index = 7; + model.slots = { s0, s1 }; + model.mapping = { 1, -1 }; + CHECK(encode_filament_mapping(model) == "0:7"); + model.mapping = { 0, 1 }; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); + model.mapping = { -1, -1 }; + CHECK(encode_filament_mapping(model).empty()); +} + +TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + CHECK(normalize_map_colour("#ff00aa") == "#FF00AA"); + CHECK(normalize_map_colour("00FF00BB") == "#00FF00"); + CHECK(normalize_map_colour("not-a-colour").empty()); + CHECK(types_match("PLA", "pla")); + CHECK(types_match("PLA", "")); + CHECK_FALSE(types_match("PLA", "PETG")); + CHECK_FALSE(colours_mismatch("#FF0000", "#EE0000")); + CHECK(colours_mismatch("#FF0000", "#0000FF")); + CHECK_FALSE(colours_mismatch("#FF0000", "")); + + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + ThirdPartyDeviceSlot s; + s.type = "PETG"; + s.colour = "#0000FF"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::TypeAndColour); + s.type = "PLA"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::Colour); + s.colour = "#FF0000"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); + s.empty = true; + s.type = "PETG"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); + + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament a; + a.index = 0; + a.nozzle_id = -1; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "1"); + a.index = 3; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "4"); + + ThirdPartySlicerFilament left; + left.nozzle_id = 1; + ThirdPartySlicerFilament right; + right.nozzle_id = 0; + model.filaments = { right, left }; + CHECK(has_multiple_nozzles(model)); + CHECK(filament_badge_text(right, model) == "R"); + CHECK(filament_badge_text(left, model) == "L"); + + ThirdPartySlicerFilament third; + third.nozzle_id = 2; + model.filaments = { right, left, third }; + CHECK(filament_badge_text(right, model) == "1"); + CHECK(filament_badge_text(third, model) == "3"); + + model.filaments = { right }; + CHECK_FALSE(has_multiple_nozzles(model)); +} From 2346b9c88b6d95076ba5b6681bf3b5be17c53fbc Mon Sep 17 00:00:00 2001 From: aceRage Date: Tue, 20 Oct 2026 08:03:12 -0500 Subject: [PATCH 2/4] Fix Russ review 1/3 on the third-party filament map. Call the panel's DPI/theme handlers from the send dialog, use dark-mode colours only on the new badge and panel paths, add the panel to gettext, label Elegoo warnings with the same 1A-1D tiles, shrink the badge so long types still fit, number unit-less slots 1..N, and keep read-only auto-match visual while encoding an empty mapping. Deft --- localization/i18n/list.txt | 1 + src/slic3r/GUI/AmsMappingPopup.cpp | 27 +++++-- src/slic3r/GUI/PrintHostDialogs.cpp | 81 ++++++++++++++----- src/slic3r/GUI/PrintHostDialogs.hpp | 8 +- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 8 +- src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 5 +- src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 67 ++++++++++++--- src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 3 +- .../elegoo_canvas_mapping_tests.cpp | 18 ++++- 9 files changed, 170 insertions(+), 48 deletions(-) diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index e240d8fc8a33..dc76bf9aaa5e 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -177,6 +177,7 @@ src/slic3r/GUI/CaliHistoryDialog.cpp src/slic3r/GUI/BonjourDialog.cpp src/slic3r/GUI/calib_dlg.cpp src/slic3r/GUI/PrintHostDialogs.cpp +src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.cpp src/slic3r/GUI/NetworkTestDialog.cpp src/slic3r/GUI/CreatePresetsDialog.cpp diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index 796e0fc5c786..cd7f8322d73c 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -12,9 +12,11 @@ #include "Widgets/ProgressDialog.hpp" #include "Widgets/RoundedRectangle.hpp" #include "Widgets/StaticBox.hpp" +#include "Widgets/StateColor.hpp" #include #include +#include #include #include #include @@ -176,8 +178,20 @@ void MaterialItem::render(wxDC &dc) dc.SetFont(::Label::Body_10); } - auto material_txt_size = dc.GetTextExtent(m_material_name); - dc.DrawText(m_material_name, wxPoint((MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2, (FromDIP(28) - material_txt_size.y) / 2)); + wxString material_txt = m_material_name; + auto material_txt_size = dc.GetTextExtent(material_txt); + int name_x = (MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2; + if (!m_nozzle_badge.empty()) { + // 12 DIP badge at top-left: long types (PETG-CF) on a 64-DIP tile must not sit under it. + const int name_x_pad = FromDIP(12) + FromDIP(4); + const int name_max_w = GetSize().x - name_x_pad - FromDIP(4); + if (material_txt_size.x > name_max_w) { + material_txt = wxControl::Ellipsize(material_txt, dc, wxELLIPSIZE_END, name_max_w); + material_txt_size = dc.GetTextExtent(material_txt); + } + name_x = std::max(name_x, name_x_pad); + } + dc.DrawText(material_txt, wxPoint(name_x, (FromDIP(28) - material_txt_size.y) / 2)); // mapping num dc.SetFont(::Label::Body_10); @@ -197,13 +211,14 @@ void MaterialItem::render(wxDC &dc) dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); if (!m_nozzle_badge.empty()) { - const int d = FromDIP(16); - const wxPoint o(FromDIP(2), FromDIP(2)); + const int d = FromDIP(12); + const wxPoint o(FromDIP(2), FromDIP(2)); + const wxColour badge_bg = StateColor::darkModeColorFor(wxColour(0x26, 0x2E, 0x30)); dc.SetPen(*wxTRANSPARENT_PEN); - dc.SetBrush(wxBrush(wxColour(0x26, 0x2E, 0x30))); + dc.SetBrush(wxBrush(badge_bg)); dc.DrawEllipse(o.x, o.y, d, d); dc.SetFont(::Label::Body_10); - dc.SetTextForeground(*wxWHITE); + dc.SetTextForeground(badge_bg.GetLuminance() < 0.6 ? *wxWHITE : wxColour(0x26, 0x2E, 0x30)); dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); const wxSize ts = dc.GetTextExtent(m_nozzle_badge); dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index a4d3374eca48..59f4541ca131 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -36,6 +36,7 @@ #include "NotificationManager.hpp" #include "ExtraRenderers.hpp" #include "format.hpp" +#include "Widgets/StateColor.hpp" #include "WebPreprintDialog.hpp" #include "../Utils/PrintHostDevices.hpp" #include "../Utils/PrintHostDeviceStatus.hpp" @@ -440,6 +441,24 @@ static FlashforgeMaterialMapWidget* as_ff_map_widget(wxWindow* window) } // namespace +static ThirdPartyDeviceSlot third_party_slot_from(const PrintHostDevices::Slot& s) +{ + ThirdPartyDeviceSlot ds; + ds.unit_id = s.unit; + ds.slot_id = s.tray >= 0 ? s.tray : s.index; + ds.colour = s.color; + ds.type = s.type; + ds.name = s.name; + ds.empty = !s.loaded; + ds.index = s.index; + return ds; +} + +static wxString third_party_slot_tile_label(const PrintHostDevices::Slot& s) +{ + return from_u8(slot_display_label(third_party_slot_from(s))); +} + static const char *CONFIG_KEY_PATH = "printhost_path"; static const char *CONFIG_KEY_GROUP = "printhost_group"; static const char* CONFIG_KEY_STORAGE = "printhost_storage"; @@ -751,20 +770,12 @@ void PrintHostSendDialog::rebuild_third_party_map_rows() model.filaments.push_back(std::move(sf)); m_mapping_filament.push_back(f.index); } - for (const PrintHostDevices::Slot& s : m_device_status.slots) { - ThirdPartyDeviceSlot ds; - ds.unit_id = s.unit; - ds.slot_id = s.tray >= 0 ? s.tray : s.index; - ds.label = s.unit >= 0 ? bambu_style_slot_label(s.unit, ds.slot_id) : bambu_style_slot_label(-1, s.index); - ds.colour = s.color; - ds.type = s.type; - ds.name = s.name; - ds.empty = !s.loaded; - ds.index = s.index; - model.slots.push_back(std::move(ds)); - } + for (const PrintHostDevices::Slot& s : m_device_status.slots) + model.slots.push_back(third_party_slot_from(s)); model.read_only = third_party_map_read_only(); model.mapping.resize(model.filaments.size(), -1); + // Always fill the visual auto-match, including read-only (K3): the cards show the match, but + // encode_filament_mapping() / filament_mapping() return empty when read_only. for (size_t i = 0; i < model.filaments.size(); ++i) { const SendPlateFilament* f = plate_filament(model.filaments[i].index); if (!f) @@ -803,17 +814,39 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } -wxString PrintHostSendDialog::mapping_warning() const +wxString PrintHostSendDialog::third_party_mismatch_warning() const { return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); } +void PrintHostSendDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + MsgDialog::on_dpi_changed(suggested_rect); + if (m_filament_map) + m_filament_map->msw_rescale(); + Layout(); + Fit(); + Refresh(); +} + +void PrintHostSendDialog::on_sys_color_changed() +{ + if (m_filament_map) + m_filament_map->sys_color_changed(); + if (m_mapping_note) + m_mapping_note->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#E8A33D"))); +#ifdef _WIN32 + wxGetApp().UpdateDlgDarkUI(this); +#endif + Refresh(); +} + void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; const bool has_rows = m_filament_map || !m_mapping_choices.empty(); - const wxString text = has_rows ? mapping_warning() : wxString(); + const wxString text = has_rows ? third_party_mismatch_warning() : wxString(); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -2400,10 +2433,10 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const return ElegooCanvasMapping::recall(m_memory, u, m_device_status.slots); } -wxString ElegooPrintHostSendDialog::mapping_warning() const +wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const { if (!m_device_status.canvas) - return PrintHostSendDialog::mapping_warning(); + return PrintHostSendDialog::third_party_mismatch_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2412,23 +2445,31 @@ wxString ElegooPrintHostSendDialog::mapping_warning() const text += "\n"; text += line; }; + auto slot_lbl = [this](int protocol_index) { + for (const PrintHostDevices::Slot& s : m_device_status.slots) + if (s.index == protocol_index) + return third_party_slot_tile_label(s); + return from_u8(bambu_style_slot_label(-1, protocol_index)); + }; for (const ElegooCanvasMapping::Warning& w : warnings) { switch (w.kind) { case ElegooCanvasMapping::WarningKind::MoreFilamentsThanTrays: add(_L("This plate uses more filaments than the CANVAS has loaded trays.")); break; case ElegooCanvasMapping::WarningKind::SharedTray: - add(format_wxstr(_L("Filament %1% prints from the same tray as another filament."), w.filament + 1)); + add(format_wxstr(_L("Filament %1% prints from the same slot (%2%) as another filament."), w.filament + 1, + slot_lbl(w.slot))); break; case ElegooCanvasMapping::WarningKind::TemperatureOutOfRange: - add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of tray %2%."), w.filament + 1, w.slot + 1)); + add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of %2%."), w.filament + 1, + slot_lbl(w.slot))); break; case ElegooCanvasMapping::WarningKind::EmptyTray: - add(format_wxstr(_L("Filament %1% is mapped to tray %2%, which is empty."), w.filament + 1, w.slot + 1)); + add(format_wxstr(_L("Filament %1% is mapped to %2%, which is empty."), w.filament + 1, slot_lbl(w.slot))); break; } } - if (const wxString extra = PrintHostSendDialog::mapping_warning(); !extra.IsEmpty()) + if (const wxString extra = PrintHostSendDialog::third_party_mismatch_warning(); !extra.IsEmpty()) add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index e1eac9e461b1..5e8d675592a5 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -121,8 +121,10 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - // The base implementation is the third-party panel's type/colour mismatch text. - virtual wxString mapping_warning() const; + // Named so vendor PRs (#622) can add their own mapping_warning() without colliding. + virtual wxString third_party_mismatch_warning() const; + void on_dpi_changed(const wxRect &suggested_rect) override; + void on_sys_color_changed() override; void update_mapping_note(); // The rows' choices as positions in m_device_status.slots, -1 = "As sliced"; and the plate // filament each row is for. @@ -279,7 +281,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog protected: bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; - wxString mapping_warning() const override; + wxString third_party_mismatch_warning() const override; private: // The plate's used filaments in the shape the CANVAS mapping code takes. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp index c8b14c7fb18b..3bfa9134003d 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -82,10 +82,10 @@ std::string normalize_map_colour(const std::string& colour) std::string bambu_style_slot_label(int unit_id, int slot_id) { const int slot = slot_id < 0 ? 0 : slot_id; - const int letter_i = slot % 4; - const char letter = static_cast('A' + letter_i); if (unit_id < 0) - return std::string(1, letter); + return std::to_string(slot + 1); + const int letter_i = slot % 4; + const char letter = static_cast('A' + letter_i); return std::to_string(unit_id + 1) + letter; } @@ -201,6 +201,8 @@ std::vector group_slots(const std::vector group_slots(const std::vector& slots); -// "0:1,1:2" — filament index to Slot::index. Skips "As sliced" (-1) rows. Same shape as +// "0:1,1:2" — filament index to Slot::index. Empty when model.read_only (the cards may still +// show an auto-match) or when every row is "As sliced". Same shape as // PrintHostSendDialog::filament_mapping(). std::string encode_filament_mapping(const ThirdPartyFilamentMapModel& model); diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp index c1e90d96b04e..115b9443dfc1 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -21,15 +21,30 @@ namespace GUI { namespace { +wxColour unknown_slot_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xCE, 0xCE, 0xCE)); +} + +wxColour warning_text_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xFF, 0x6F, 0x00)); +} + +wxColour popup_border_colour() +{ + return StateColor::darkModeColorFor(wxColour(0xAC, 0xAC, 0xAC)); +} + wxColour parse_slot_colour(const std::string& raw) { std::string s = normalize_map_colour(raw); if (s.empty()) - return wxColour(0xCE, 0xCE, 0xCE); + return unknown_slot_colour(); wxColour c; if (c.Set(wxString::FromUTF8(s.c_str()))) return c; - return wxColour(0xCE, 0xCE, 0xCE); + return unknown_slot_colour(); } wxString from_utf8(const std::string& s) @@ -87,9 +102,9 @@ class ThirdPartySlotPopup : public PopupWindow m_as_sliced->SetMaxSize(m_as_sliced->GetSize()); TrayData sliced; sliced.type = TrayType::EMPTY; - sliced.colour = wxColour(0xCE, 0xCE, 0xCE); + sliced.colour = unknown_slot_colour(); m_as_sliced->set_tray_index("-"); - m_as_sliced->set_data(wxColour(0xCE, 0xCE, 0xCE), _L("As sliced"), sliced); + m_as_sliced->set_data(unknown_slot_colour(), _L("As sliced"), sliced); m_as_sliced->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { if (m_panel) m_panel->pick_slot(m_filament_row, -1); @@ -97,7 +112,7 @@ class ThirdPartySlotPopup : public PopupWindow }); m_warning = new wxStaticText(this, wxID_ANY, wxEmptyString); - m_warning->SetForegroundColour(wxColour(0xFF, 0x6F, 0x00)); + m_warning->SetForegroundColour(warning_text_colour()); m_warning->SetFont(::Label::Body_12); m_warning->Hide(); @@ -170,9 +185,9 @@ class ThirdPartySlotPopup : public PopupWindow const wxString idx = from_utf8(slot_display_label(slot)); item->set_tray_index(idx); if (td.type == TrayType::EMPTY) - item->set_data(wxColour(0xCE, 0xCE, 0xCE), "-", td); + item->set_data(unknown_slot_colour(), "-", td); else if (td.type == TrayType::THIRD) - item->set_data(wxColour(0xCE, 0xCE, 0xCE), "?", td); + item->set_data(unknown_slot_colour(), "?", td); else if (type_unmatch) item->set_data(wxColour(0xEE, 0xEE, 0xEE), from_utf8(td.name), td, true); else @@ -212,11 +227,35 @@ class ThirdPartySlotPopup : public PopupWindow PopupWindow::OnDismiss(); } + void msw_rescale() + { + for (MappingItem* t : m_tiles) + if (t) + t->msw_rescale(); + if (m_as_sliced) + m_as_sliced->msw_rescale(); + Layout(); + } + + void sys_color_changed() + { + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + if (m_warning) + m_warning->SetForegroundColour(warning_text_colour()); + if (m_as_sliced) { + TrayData sliced; + sliced.type = TrayType::EMPTY; + sliced.colour = unknown_slot_colour(); + m_as_sliced->set_data(unknown_slot_colour(), _L("As sliced"), sliced); + } + Refresh(); + } + private: void on_paint(wxPaintEvent&) { wxPaintDC dc(this); - dc.SetPen(wxColour(0xAC, 0xAC, 0xAC)); + dc.SetPen(popup_border_colour()); dc.SetBrush(*wxTRANSPARENT_BRUSH); dc.DrawRoundedRectangle(0, 0, GetSize().x, GetSize().y, 0); } @@ -317,6 +356,8 @@ void ThirdPartyFilamentMapPanel::msw_rescale() for (MaterialItem* item : m_items) if (item) item->msw_rescale(); + if (m_popup) + m_popup->msw_rescale(); Layout(); } @@ -324,6 +365,10 @@ void ThirdPartyFilamentMapPanel::sys_color_changed() { SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); wxGetApp().UpdateDarkUI(this); + for (size_t i = 0; i < m_items.size(); ++i) + apply_slot_to_item(i); + if (m_popup) + m_popup->sys_color_changed(); Refresh(); } @@ -361,12 +406,12 @@ void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) MaterialItem* item = m_items[row]; const int pos = row < m_model.mapping.size() ? m_model.mapping[row] : -1; if (pos < 0 || pos >= (int) m_model.slots.size()) { - item->set_ams_info(wxColour(0xCE, 0xCE, 0xCE), "-"); + item->set_ams_info(unknown_slot_colour(), "-"); item->on_normal(); return; } const ThirdPartyDeviceSlot& slot = m_model.slots[pos]; - const wxColour col = slot.empty ? wxColour(0xCE, 0xCE, 0xCE) : colour_of(slot.colour); + const wxColour col = slot.empty ? unknown_slot_colour() : colour_of(slot.colour); item->set_ams_info(col, from_utf8(slot_display_label(slot))); item->on_normal(); if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) @@ -417,7 +462,7 @@ wxString ThirdPartyFilamentMapPanel::material_label(const ThirdPartySlicerFilame return from_utf8(f.type); if (!f.name.empty()) return from_utf8(f.name); - return format_wxstr("F%1%", f.index + 1); + return format_wxstr(_L("F%1%"), f.index + 1); } } // namespace GUI diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp index f1df24f928f9..c8e05b562a05 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -47,8 +47,9 @@ class ThirdPartyFilamentMapPanel : public wxPanel bool mapping_enabled() const { return m_mapping_enabled; } // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). + // Still filled in read-only (the visual auto-match); filament_mapping() is empty then. std::vector choice_positions() const { return m_model.mapping; } - // "0:1,1:2" — filament index to Slot::index. Empty when every row is "As sliced". + // "0:1,1:2" — filament index to Slot::index. Empty when read_only or every row is "As sliced". std::string filament_mapping() const; // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index fff1ab1664df..9cdfe806dbe8 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -331,8 +331,17 @@ TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[Th CHECK(bambu_style_slot_label(0, 1) == "1B"); CHECK(bambu_style_slot_label(0, 3) == "1D"); CHECK(bambu_style_slot_label(1, 0) == "2A"); - CHECK(bambu_style_slot_label(-1, 0) == "A"); - CHECK(bambu_style_slot_label(-1, 2) == "C"); + CHECK(bambu_style_slot_label(-1, 0) == "1"); + CHECK(bambu_style_slot_label(-1, 2) == "3"); + CHECK(bambu_style_slot_label(-1, 3) == "4"); + CHECK(bambu_style_slot_label(-1, 4) == "5"); + CHECK(bambu_style_slot_label(-1, 7) == "8"); + + ThirdPartyDeviceSlot unitless; + unitless.unit_id = -1; + unitless.slot_id = -1; + unitless.index = 5; + CHECK(slot_display_label(unitless) == "6"); ThirdPartyDeviceSlot a; a.unit_id = 0; @@ -386,6 +395,11 @@ TEST_CASE("Third-party filament map: encode matches the send dialog's filament:s CHECK(encode_filament_mapping(model) == "0:5,2:7"); model.mapping = { -1, -1 }; CHECK(encode_filament_mapping(model).empty()); + model.mapping = { 0, 1 }; + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + model.read_only = false; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); } TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") From 4997249fae6d209323800a4777430b42ffad60d7 Mon Sep 17 00:00:00 2001 From: aceRage Date: Tue, 20 Oct 2026 08:03:15 -0500 Subject: [PATCH 3/4] Keep mapping_warning() for vendor PRs; size the badge to its text. update_mapping_note combines mapping_warning() and third_party_mismatch_warning(). Read-only mismatch compares T-number identity, not the visual auto-match. The nozzle badge pill follows Body_10 extent so labels like 10 fit. Deft --- src/slic3r/GUI/AmsMappingPopup.cpp | 34 ++++++++++---- src/slic3r/GUI/PrintHostDialogs.cpp | 46 ++++++++++++++++--- src/slic3r/GUI/PrintHostDialogs.hpp | 7 ++- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 20 ++++++-- src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 4 ++ src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp | 4 +- src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp | 5 +- .../elegoo_canvas_mapping_tests.cpp | 31 +++++++++++++ 8 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index cd7f8322d73c..4a814874a37a 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -181,15 +181,27 @@ void MaterialItem::render(wxDC &dc) wxString material_txt = m_material_name; auto material_txt_size = dc.GetTextExtent(material_txt); int name_x = (MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2; + wxSize badge_ts(0, 0); + int badge_w = 0; + int badge_h = 0; if (!m_nozzle_badge.empty()) { - // 12 DIP badge at top-left: long types (PETG-CF) on a 64-DIP tile must not sit under it. - const int name_x_pad = FromDIP(12) + FromDIP(4); + // Pill sized to Body_10 text: a fixed 12 DIP circle clips "10" and is shorter than the glyph. + dc.SetFont(::Label::Body_10); + badge_ts = dc.GetTextExtent(m_nozzle_badge); + const int pad = FromDIP(2); + badge_w = badge_ts.x + 2 * pad; + badge_h = badge_ts.y + 2 * pad; + const int name_x_pad = badge_w + FromDIP(4); const int name_max_w = GetSize().x - name_x_pad - FromDIP(4); - if (material_txt_size.x > name_max_w) { + dc.SetFont(::Label::Body_13); + if (dc.GetTextExtent(material_txt).x > GetSize().x - 10) + dc.SetFont(::Label::Body_10); + if (dc.GetTextExtent(material_txt).x > name_max_w) { + dc.SetFont(::Label::Body_10); material_txt = wxControl::Ellipsize(material_txt, dc, wxELLIPSIZE_END, name_max_w); - material_txt_size = dc.GetTextExtent(material_txt); } - name_x = std::max(name_x, name_x_pad); + material_txt_size = dc.GetTextExtent(material_txt); + name_x = std::max((MATERIAL_ITEM_SIZE.x - material_txt_size.x) / 2, name_x_pad); } dc.DrawText(material_txt, wxPoint(name_x, (FromDIP(28) - material_txt_size.y) / 2)); @@ -211,17 +223,21 @@ void MaterialItem::render(wxDC &dc) dc.DrawText(mapping_txt, wxPoint((MATERIAL_ITEM_SIZE.x - mapping_txt_size.x) / 2, FromDIP(30) + (FromDIP(16) - mapping_txt_size.y) / 2)); if (!m_nozzle_badge.empty()) { - const int d = FromDIP(12); const wxPoint o(FromDIP(2), FromDIP(2)); const wxColour badge_bg = StateColor::darkModeColorFor(wxColour(0x26, 0x2E, 0x30)); dc.SetPen(*wxTRANSPARENT_PEN); dc.SetBrush(wxBrush(badge_bg)); - dc.DrawEllipse(o.x, o.y, d, d); dc.SetFont(::Label::Body_10); + if (badge_w <= 0 || badge_h <= 0) { + badge_ts = dc.GetTextExtent(m_nozzle_badge); + const int pad = FromDIP(2); + badge_w = badge_ts.x + 2 * pad; + badge_h = badge_ts.y + 2 * pad; + } + dc.DrawRoundedRectangle(o.x, o.y, badge_w, badge_h, std::min(badge_w, badge_h) / 2); dc.SetTextForeground(badge_bg.GetLuminance() < 0.6 ? *wxWHITE : wxColour(0x26, 0x2E, 0x30)); dc.SetBackgroundMode(wxBRUSHSTYLE_TRANSPARENT); - const wxSize ts = dc.GetTextExtent(m_nozzle_badge); - dc.DrawText(m_nozzle_badge, o.x + (d - ts.x) / 2, o.y + (d - ts.y) / 2); + dc.DrawText(m_nozzle_badge, o.x + (badge_w - badge_ts.x) / 2, o.y + (badge_h - badge_ts.y) / 2); } } diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index 59f4541ca131..4048f0336026 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -459,6 +459,36 @@ static wxString third_party_slot_tile_label(const PrintHostDevices::Slot& s) return from_u8(slot_display_label(third_party_slot_from(s))); } +static void append_unique_mapping_lines(wxString& out, const wxString& block) +{ + size_t start = 0; + while (start <= block.size()) { + const size_t nl = block.find('\n', start); + const wxString line = (nl == wxString::npos) ? block.Mid(start) : block.Mid(start, nl - start); + if (!line.IsEmpty()) { + bool seen = false; + size_t ostart = 0; + while (!seen && ostart <= out.size()) { + const size_t onl = out.find('\n', ostart); + const wxString existing = (onl == wxString::npos) ? out.Mid(ostart) : out.Mid(ostart, onl - ostart); + if (existing == line) + seen = true; + if (onl == wxString::npos) + break; + ostart = onl + 1; + } + if (!seen) { + if (!out.IsEmpty()) + out += "\n"; + out += line; + } + } + if (nl == wxString::npos) + break; + start = nl + 1; + } +} + static const char *CONFIG_KEY_PATH = "printhost_path"; static const char *CONFIG_KEY_GROUP = "printhost_group"; static const char* CONFIG_KEY_STORAGE = "printhost_storage"; @@ -814,6 +844,11 @@ const SendPlateFilament* PrintHostSendDialog::plate_filament(int index) const return nullptr; } +wxString PrintHostSendDialog::mapping_warning() const +{ + return wxString(); +} + wxString PrintHostSendDialog::third_party_mismatch_warning() const { return m_filament_map ? m_filament_map->mismatch_warning() : wxString(); @@ -845,8 +880,9 @@ void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; - const bool has_rows = m_filament_map || !m_mapping_choices.empty(); - const wxString text = has_rows ? third_party_mismatch_warning() : wxString(); + wxString text; + append_unique_mapping_lines(text, mapping_warning()); + append_unique_mapping_lines(text, third_party_mismatch_warning()); const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -2433,10 +2469,10 @@ int ElegooPrintHostSendDialog::preselect_slot(const SendPlateFilament& f) const return ElegooCanvasMapping::recall(m_memory, u, m_device_status.slots); } -wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const +wxString ElegooPrintHostSendDialog::mapping_warning() const { if (!m_device_status.canvas) - return PrintHostSendDialog::third_party_mismatch_warning(); + return PrintHostSendDialog::mapping_warning(); const auto used = used_filaments(); const auto warnings = ElegooCanvasMapping::warnings_for(used, mapping_choice_positions(), m_device_status.slots); wxString text; @@ -2469,8 +2505,6 @@ wxString ElegooPrintHostSendDialog::third_party_mismatch_warning() const break; } } - if (const wxString extra = PrintHostSendDialog::third_party_mismatch_warning(); !extra.IsEmpty()) - add(extra); return text; } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 5e8d675592a5..52e251329dd5 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -121,7 +121,10 @@ class PrintHostSendDialog : public GUI::MsgDialog // closest-colour rule pick. The Elegoo dialog answers with the remembered mapping. virtual int preselect_slot(const SendPlateFilament& f) const { return -1; } // One or more lines under the mapping table, refreshed whenever a choice changes; "" = none. - // Named so vendor PRs (#622) can add their own mapping_warning() without colliding. + // Vendor PRs (#616/#619/#620/#622) override this; keep the name and signature. + virtual wxString mapping_warning() const; + // Panel type/colour mismatch (identity slots when read-only). Combined with mapping_warning() + // in update_mapping_note(); vendor overrides of mapping_warning() still compile after rebase. virtual wxString third_party_mismatch_warning() const; void on_dpi_changed(const wxRect &suggested_rect) override; void on_sys_color_changed() override; @@ -281,7 +284,7 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog protected: bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; - wxString third_party_mismatch_warning() const override; + wxString mapping_warning() const override; private: // The plate's used filaments in the shape the CANVAS mapping code takes. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp index 3bfa9134003d..2aa6d44fce6e 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -135,11 +135,25 @@ ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, con return ThirdPartyMapMismatch::None; } +int warning_slot_position(const ThirdPartyFilamentMapModel& model, size_t filament_row) +{ + if (filament_row >= model.filaments.size()) + return -1; + if (model.read_only) { + const int t = model.filaments[filament_row].index; + for (size_t i = 0; i < model.slots.size(); ++i) + if (model.slots[i].index == t) + return (int) i; + return -1; + } + if (filament_row >= model.mapping.size()) + return -1; + return model.mapping[filament_row]; +} + ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row) { - if (filament_row >= model.filaments.size() || filament_row >= model.mapping.size()) - return ThirdPartyMapMismatch::None; - const int pos = model.mapping[filament_row]; + const int pos = warning_slot_position(model, filament_row); if (pos < 0 || pos >= (int) model.slots.size()) return ThirdPartyMapMismatch::None; return mismatch_for(model.filaments[filament_row], model.slots[pos]); diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp index f45780298c4a..80364f02b477 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp @@ -80,6 +80,10 @@ double map_colour_distance(const std::string& a, const std::string& b); bool colours_mismatch(const std::string& a, const std::string& b); ThirdPartyMapMismatch mismatch_for(const ThirdPartySlicerFilament& filament, const ThirdPartyDeviceSlot& slot); +// Slot compared for a warning that describes what will actually print. Interactive: mapping[row]. +// Read-only: the slot whose protocol index equals the filament T-number (identity); mapping[] may +// still hold a visual auto-match and is not sent. +int warning_slot_position(const ThirdPartyFilamentMapModel& model, size_t filament_row); ThirdPartyMapMismatch mismatch_at(const ThirdPartyFilamentMapModel& model, size_t filament_row); // Distinct nozzle ids on filaments or slots; more than one means a multi-nozzle / multi-toolhead printer. diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp index 115b9443dfc1..bc222bcc2f56 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.cpp @@ -339,7 +339,7 @@ wxString ThirdPartyFilamentMapPanel::mismatch_warning() const const int n = m_model.filaments[i].index + 1; if (kind == ThirdPartyMapMismatch::Type || kind == ThirdPartyMapMismatch::TypeAndColour) { std::string slot_type; - const int pos = i < m_model.mapping.size() ? m_model.mapping[i] : -1; + const int pos = warning_slot_position(m_model, i); if (pos >= 0 && pos < (int) m_model.slots.size()) slot_type = m_model.slots[pos].type; add(format_wxstr(_L("Filament %1%: its type (%2%) does not match the mapped slot (%3%)."), n, @@ -414,7 +414,7 @@ void ThirdPartyFilamentMapPanel::apply_slot_to_item(size_t row) const wxColour col = slot.empty ? unknown_slot_colour() : colour_of(slot.colour); item->set_ams_info(col, from_utf8(slot_display_label(slot))); item->on_normal(); - if (mismatch_at(m_model, row) != ThirdPartyMapMismatch::None) + if (mismatch_for(m_model.filaments[row], slot) != ThirdPartyMapMismatch::None) item->on_warning(); } diff --git a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp index c8e05b562a05..2d21279d5903 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMapPanel.hpp @@ -47,12 +47,15 @@ class ThirdPartyFilamentMapPanel : public wxPanel bool mapping_enabled() const { return m_mapping_enabled; } // Positions in model.slots, -1 = "As sliced". Same shape as PrintHostSendDialog::mapping_choice_positions(). - // Still filled in read-only (the visual auto-match); filament_mapping() is empty then. + // In read-only this is the visual auto-match only. The map that is sent is filament_mapping() + // (empty); mismatch_warning() compares each filament with its T-number slot (identity). std::vector choice_positions() const { return m_model.mapping; } // "0:1,1:2" — filament index to Slot::index. Empty when read_only or every row is "As sliced". + // Read-only consumers must take the sent map from here, not from choice_positions(). std::string filament_mapping() const; // Type / colour mismatch sentences for the dialog's mapping note; "" = none. Translated. + // Read-only: identity (T-number slot), not the visual auto-match in choice_positions(). wxString mismatch_warning() const; void msw_rescale(); diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index 9cdfe806dbe8..0b69b2982a73 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -402,6 +402,37 @@ TEST_CASE("Third-party filament map: encode matches the send dialog's filament:s CHECK(encode_filament_mapping(model) == "0:5,2:7"); } +TEST_CASE("Third-party filament map: read-only mismatch uses T-number identity, not auto-match", "[ThirdPartyFilamentMap]") +{ + using namespace Slic3r::GUI; + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + model.filaments = { f }; + + ThirdPartyDeviceSlot t0; + t0.index = 0; + t0.type = "PETG"; + t0.colour = "#0000FF"; + ThirdPartyDeviceSlot t1; + t1.index = 1; + t1.type = "PLA"; + t1.colour = "#FF0000"; + model.slots = { t0, t1 }; + model.mapping = { 1 }; // visual auto-match to the matching holder + + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::None); + CHECK(warning_slot_position(model, 0) == 1); + + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + CHECK(warning_slot_position(model, 0) == 0); + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::TypeAndColour); + CHECK(model.mapping == std::vector{ 1 }); +} + TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") { using namespace Slic3r::GUI; From db03bc4239f950d5c16f3b9244cfc0b8832b209a Mon Sep 17 00:00:00 2001 From: aceRage Date: Tue, 20 Oct 2026 08:10:34 -0500 Subject: [PATCH 4/4] Make the swatch map the send dialogs' picker; label slots by position Every third-party send dialog that gets named slots from its printer now shows the Bambu-style swatch map instead of the per-filament dropdown: Elegoo CANVAS, Snapmaker U1 toolheads and the QIDI Box. A host with no slots shows no rows, as before; Flashforge keeps its IFS dialog. Tiles are labelled by position, never by the printer's raw ids: CANVAS units in order of appearance and trays by their place in the unit (1A..1D, 2A..), so a 1-based tray_id no longer reads B, C, D, A; QIDI Box slot b*4+n is ; tool slots are T1..T4. The starting slot keeps the old rule (initial_slot_position): the remembered Elegoo mapping first, else the closest colour, or on a QIDI Box the filament's own material first. Nozzle badges only follow filament_map when the slots say which nozzle they feed, so a U1 shows filament numbers. The Elegoo warnings keep their old msgids and name the tray by its tile label. No rows means no warnings. The map's tests move to third_party_filament_map_tests.cpp and cover labels, auto-match and remembered mappings per host kind. Deft --- src/slic3r/GUI/PrintHostDialogs.cpp | 60 +-- src/slic3r/GUI/PrintHostDialogs.hpp | 9 +- src/slic3r/GUI/ThirdPartyFilamentMap.cpp | 54 ++- src/slic3r/GUI/ThirdPartyFilamentMap.hpp | 33 +- tests/slic3rutils/CMakeLists.txt | 1 + .../elegoo_canvas_mapping_tests.cpp | 170 -------- .../third_party_filament_map_tests.cpp | 409 ++++++++++++++++++ 7 files changed, 518 insertions(+), 218 deletions(-) create mode 100644 tests/slic3rutils/third_party_filament_map_tests.cpp diff --git a/src/slic3r/GUI/PrintHostDialogs.cpp b/src/slic3r/GUI/PrintHostDialogs.cpp index 4048f0336026..07627cdc7990 100644 --- a/src/slic3r/GUI/PrintHostDialogs.cpp +++ b/src/slic3r/GUI/PrintHostDialogs.cpp @@ -441,22 +441,14 @@ static FlashforgeMaterialMapWidget* as_ff_map_widget(wxWindow* window) } // namespace -static ThirdPartyDeviceSlot third_party_slot_from(const PrintHostDevices::Slot& s) +// The tile label ("1B", "T2") of the slot whose Slot::index is `protocol_index`, so a warning names +// the tray the way the swatches do. Positional (third_party_slots_from), never the raw tray id. +static wxString third_party_slot_tile_label(const PrintHostDevices::Status& st, int protocol_index) { - ThirdPartyDeviceSlot ds; - ds.unit_id = s.unit; - ds.slot_id = s.tray >= 0 ? s.tray : s.index; - ds.colour = s.color; - ds.type = s.type; - ds.name = s.name; - ds.empty = !s.loaded; - ds.index = s.index; - return ds; -} - -static wxString third_party_slot_tile_label(const PrintHostDevices::Slot& s) -{ - return from_u8(slot_display_label(third_party_slot_from(s))); + for (const ThirdPartyDeviceSlot& ds : third_party_slots_from(st.slots, st.qidi_box)) + if (ds.index == protocol_index) + return from_u8(slot_display_label(ds)); + return wxString::Format("%d", protocol_index + 1); } static void append_unique_mapping_lines(wxString& out, const wxString& block) @@ -800,8 +792,7 @@ void PrintHostSendDialog::rebuild_third_party_map_rows() model.filaments.push_back(std::move(sf)); m_mapping_filament.push_back(f.index); } - for (const PrintHostDevices::Slot& s : m_device_status.slots) - model.slots.push_back(third_party_slot_from(s)); + model.slots = third_party_slots_from(m_device_status.slots, m_device_status.qidi_box); model.read_only = third_party_map_read_only(); model.mapping.resize(model.filaments.size(), -1); // Always fill the visual auto-match, including read-only (K3): the cards show the match, but @@ -810,12 +801,10 @@ void PrintHostSendDialog::rebuild_third_party_map_rows() const SendPlateFilament* f = plate_filament(model.filaments[i].index); if (!f) continue; - const int preset_pos = preselect_slot(*f); - const bool preset_ok = preset_pos >= 0 && preset_pos < (int) m_device_status.slots.size(); - const int best = preset_ok ? preset_pos + 1 : - PrintHostDevices::auto_match_slot(m_device_status.slots, f->colour, f->type, - m_device_status.qidi_box); - model.mapping[i] = best - 1; + // The remembered slot first (Elegoo), else the closest colour - or, on a QIDI Box, the + // filament's own material first and never colour alone. Same rule as the wxChoice rows. + model.mapping[i] = initial_slot_position(m_device_status.slots, preselect_slot(*f), f->colour, f->type, + m_device_status.qidi_box); } m_filament_map = new ThirdPartyFilamentMapPanel(m_mapping_panel); @@ -880,9 +869,13 @@ void PrintHostSendDialog::update_mapping_note() { if (!m_mapping_note) return; + // No rows (the printer named no filaments): no warnings either. Overrides of mapping_warning() + // can then assume there is a mapping to judge. wxString text; - append_unique_mapping_lines(text, mapping_warning()); - append_unique_mapping_lines(text, third_party_mismatch_warning()); + if (!m_mapping_filament.empty()) { + append_unique_mapping_lines(text, mapping_warning()); + append_unique_mapping_lines(text, third_party_mismatch_warning()); + } const bool was = m_mapping_note->IsShown(); m_mapping_note->SetLabel(text); m_mapping_note->Wrap(CONTENT_WIDTH * wxGetApp().em_unit()); @@ -2481,27 +2474,22 @@ wxString ElegooPrintHostSendDialog::mapping_warning() const text += "\n"; text += line; }; - auto slot_lbl = [this](int protocol_index) { - for (const PrintHostDevices::Slot& s : m_device_status.slots) - if (s.index == protocol_index) - return third_party_slot_tile_label(s); - return from_u8(bambu_style_slot_label(-1, protocol_index)); - }; + // The msgids are the ones the wxChoice rows used, so existing translations still apply; the + // tray is named by its tile label ("1B") rather than the old flat number. + auto slot_lbl = [this](int protocol_index) { return third_party_slot_tile_label(m_device_status, protocol_index); }; for (const ElegooCanvasMapping::Warning& w : warnings) { switch (w.kind) { case ElegooCanvasMapping::WarningKind::MoreFilamentsThanTrays: add(_L("This plate uses more filaments than the CANVAS has loaded trays.")); break; case ElegooCanvasMapping::WarningKind::SharedTray: - add(format_wxstr(_L("Filament %1% prints from the same slot (%2%) as another filament."), w.filament + 1, - slot_lbl(w.slot))); + add(format_wxstr(_L("Filament %1% prints from the same tray as another filament."), w.filament + 1)); break; case ElegooCanvasMapping::WarningKind::TemperatureOutOfRange: - add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of %2%."), w.filament + 1, - slot_lbl(w.slot))); + add(format_wxstr(_L("Filament %1%: its nozzle temperature is outside the range of tray %2%."), w.filament + 1, slot_lbl(w.slot))); break; case ElegooCanvasMapping::WarningKind::EmptyTray: - add(format_wxstr(_L("Filament %1% is mapped to %2%, which is empty."), w.filament + 1, slot_lbl(w.slot))); + add(format_wxstr(_L("Filament %1% is mapped to tray %2%, which is empty."), w.filament + 1, slot_lbl(w.slot))); break; } } diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 52e251329dd5..3359c3ab5d8a 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -104,8 +104,10 @@ class PrintHostSendDialog : public GUI::MsgDialog // cursor and a short timeout - the same shape as the devices dialog's Test button. void refresh_device_status(); void rebuild_mapping_rows(); - // Elegoo CC (and later CFS / MMU / ACE): Bambu-style MaterialItem rows instead of a text combo. - virtual bool use_third_party_map_panel() const { return false; } + // Bambu-style MaterialItem swatches (ThirdPartyFilamentMapPanel) for every host that names its + // slots: Elegoo CANVAS, Snapmaker U1 toolheads, QIDI Box. A dialog that still wants the old + // wxChoice rows overrides this with false. No slots: no rows at all, as before. + virtual bool use_third_party_map_panel() const { return true; } // Creality K3 holders: show the two colours, no popup. virtual bool third_party_map_read_only() const { return false; } void rebuild_third_party_map_rows(); @@ -148,7 +150,7 @@ class PrintHostSendDialog : public GUI::MsgDialog wxStaticText* m_device_note { nullptr }; wxWindow* m_mapping_panel { nullptr }; wxBoxSizer* m_mapping_sizer { nullptr }; - ThirdPartyFilamentMapPanel* m_filament_map { nullptr }; // Elegoo CC; null = wxChoice rows + ThirdPartyFilamentMapPanel* m_filament_map { nullptr }; // the swatch rows; null = wxChoice rows or none std::vector m_mapping_choices; // one per used plate filament std::vector m_mapping_filament; // its filament index // QIDI printers: QIDIStudio's print options, null where the device does not have them. @@ -282,7 +284,6 @@ class ElegooPrintHostSendDialog : public PrintHostSendDialog virtual std::map extendedInfo() const override; protected: - bool use_third_party_map_panel() const override { return true; } int preselect_slot(const SendPlateFilament& f) const override; wxString mapping_warning() const override; diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp index 2aa6d44fce6e..40d922dda53a 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.cpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.cpp @@ -1,5 +1,7 @@ #include "ThirdPartyFilamentMap.hpp" +#include "../Utils/PrintHostDeviceStatus.hpp" + #include #include #include @@ -84,11 +86,55 @@ std::string bambu_style_slot_label(int unit_id, int slot_id) const int slot = slot_id < 0 ? 0 : slot_id; if (unit_id < 0) return std::to_string(slot + 1); - const int letter_i = slot % 4; - const char letter = static_cast('A' + letter_i); + if (slot >= 26) + return std::to_string(unit_id + 1) + "-" + std::to_string(slot + 1); + const char letter = static_cast('A' + slot); return std::to_string(unit_id + 1) + letter; } +std::vector third_party_slots_from(const std::vector& slots, bool qidi_box) +{ + std::vector out; + out.reserve(slots.size()); + std::vector raw_units; // raw Slot::unit in order of appearance; position = unit number + std::vector unit_counts; // trays seen so far per unit + for (const PrintHostDevices::Slot& s : slots) { + ThirdPartyDeviceSlot ds; + ds.colour = s.color; + ds.type = s.type; + ds.name = s.name; + ds.empty = !s.loaded; + ds.index = s.index; + if (s.unit >= 0) { + auto it = std::find(raw_units.begin(), raw_units.end(), s.unit); + int u = (int) (it - raw_units.begin()); + if (it == raw_units.end()) { + raw_units.push_back(s.unit); + unit_counts.push_back(0); + } + ds.unit_id = u; + ds.slot_id = unit_counts[u]++; + } else if (qidi_box && s.index >= 0) { + ds.unit_id = s.index / 4; + ds.slot_id = s.index % 4; + } else { + ds.unit_id = -1; + ds.slot_id = s.index; + ds.label = "T" + std::to_string(s.index + 1); + } + out.push_back(std::move(ds)); + } + return out; +} + +int initial_slot_position(const std::vector& slots, int remembered_pos, const std::string& colour, + const std::string& type, bool qidi_box) +{ + if (remembered_pos >= 0 && remembered_pos < (int) slots.size()) + return remembered_pos; + return PrintHostDevices::auto_match_slot(slots, colour, type, qidi_box) - 1; // 0 = "As sliced" +} + std::string slot_display_label(const ThirdPartyDeviceSlot& slot) { if (!slot.label.empty()) @@ -179,7 +225,9 @@ bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model) std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model) { const std::vector nozzles = distinct_nozzle_ids(model); - if (filament.nozzle_id >= 0 && nozzles.size() > 1) { + const bool slots_name_nozzles = std::any_of(model.slots.begin(), model.slots.end(), + [](const ThirdPartyDeviceSlot& s) { return s.nozzle_id >= 0; }); + if (filament.nozzle_id >= 0 && nozzles.size() > 1 && slots_name_nozzles) { if (nozzles.size() == 2 && nozzles[0] == 0 && nozzles[1] == 1) { // Bambu H2D: physical 0 is the right nozzle, 1 is the left. if (filament.nozzle_id == 0) diff --git a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp index 80364f02b477..f9c25d895c51 100644 --- a/src/slic3r/GUI/ThirdPartyFilamentMap.hpp +++ b/src/slic3r/GUI/ThirdPartyFilamentMap.hpp @@ -4,14 +4,17 @@ // Vendor-neutral filament → device-slot mapping for third-party send/upload dialogs // (Elegoo CANVAS, Creality CFS, K3 holders, Happy Hare / AFC / QIDI Box, Anycubic ACE). // -// wx-free and printer-protocol-free: the dialog fills this model, the panel shows it, and -// encode_filament_mapping() returns the same "filament:slot" string PrintHostSendDialog already -// sends. Bambu's SelectMachineDialog is unchanged. +// wx-free: the dialog fills this model (third_party_slots_from() turns the probe's slot list into +// tiles), the panel shows it, and encode_filament_mapping() returns the same "filament:slot" +// string PrintHostSendDialog already sends. Bambu's SelectMachineDialog is unchanged. #include #include namespace Slic3r { +namespace PrintHostDevices { +struct Slot; +} namespace GUI { // One filament of the plate being sent. @@ -65,9 +68,27 @@ enum class ThirdPartyMapMismatch { std::string normalize_map_colour(const std::string& colour); // Bambu-style unit+slot: unit 0 slot 0 → "1A", unit 0 slot 3 → "1D", unit 1 slot 0 → "2A". -// unit_id < 0: 1-based slot numbers ("1", "2", ...) so slots past four do not repeat A–D. +// Both are positions (0-based), never raw protocol ids. A unit with more than 26 slots falls +// back to "1-27". unit_id < 0: 1-based slot numbers ("1", "2", ...). std::string bambu_style_slot_label(int unit_id, int slot_id); +// The probe's slot list (PrintHostDevices::Status::slots) as tiles, labelled by position so a +// printer's own numbering (0- or 1-based tray ids) never shows: +// * grouped trays (Elegoo CANVAS, Slot::unit >= 0): units numbered in order of appearance, +// trays lettered by their position inside the unit: "1A".."1D", "2A"...; +// * a QIDI Box (qidi_box): Box slot b * 4 + n is "", the same "1A".."4D"; +// * tool slots (Snapmaker U1, Moonraker toolheads, Slot::unit < 0): "T1".."T4", as Slot::label() +// and the Device card name them. +// `index` keeps Slot::index, which is what the mapping string and the remembered mapping use. +std::vector third_party_slots_from(const std::vector& slots, bool qidi_box); + +// The slot a filament row starts on, as a position in `slots` (-1 = "As sliced"): the dialog's +// remembered slot (`remembered_pos`, Elegoo preselect_slot; -1 = none) when it is a position in +// `slots`, else PrintHostDevices::auto_match_slot - the closest colour, or a QIDI Box's +// material-first rule when `qidi_box`. +int initial_slot_position(const std::vector& slots, int remembered_pos, const std::string& colour, + const std::string& type, bool qidi_box); + // The label a tile should show: slot.label if set, else bambu_style_slot_label(). std::string slot_display_label(const ThirdPartyDeviceSlot& slot); @@ -92,7 +113,9 @@ bool has_multiple_nozzles(const ThirdPartyFilamentMapModel& model); // Badge on the slicer-filament colour box: "L"/"R" when the printer has exactly two nozzles // numbered 0/1 (Bambu H2D: 0 = right, 1 = left), else the 1-based nozzle number, else the -// 1-based filament index on a single nozzle. +// 1-based filament index on a single nozzle. Nozzle badges need the device's slots to say which +// nozzle they feed: on a printer whose slots are the toolheads (U1) the project's filament_map is +// not a nozzle the popup groups by, so the badge stays the filament number. std::string filament_badge_text(const ThirdPartySlicerFilament& filament, const ThirdPartyFilamentMapModel& model); // Slots grouped by nozzle (when several) then unit, preserving first-seen order. diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 09b21264b563..bbc3f6d674e8 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -34,6 +34,7 @@ add_executable(${_TEST_NAME}_tests elegoo_canvas_tests.cpp elegoo_start_print_tests.cpp elegoo_canvas_mapping_tests.cpp + third_party_filament_map_tests.cpp elegoo_sdcp_tests.cpp elegoo_camera_tests.cpp splash_animation_tests.cpp diff --git a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp index 0b69b2982a73..c013e48ed188 100644 --- a/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp +++ b/tests/slic3rutils/elegoo_canvas_mapping_tests.cpp @@ -21,7 +21,6 @@ #include "slic3r/Utils/ElegooCanvasMapping.hpp" #include "slic3r/Utils/ElegooStartPrint.hpp" #include "slic3r/Utils/PrintHostDeviceStatus.hpp" -#include "slic3r/GUI/ThirdPartyFilamentMap.hpp" using namespace Slic3r; using ElegooStartPrint::SlotMapEntry; @@ -156,8 +155,6 @@ TEST_CASE("A CANVAS read becomes the send dialog's slots", "[ElegooCanvas]") CHECK(st.slots[0].min_temp == 190); CHECK(st.slots[0].max_temp == 230); CHECK(st.slots[0].label() == "Tray 1 PLA"); - CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[0].unit, st.slots[0].tray) == "1A"); - CHECK(Slic3r::GUI::bambu_style_slot_label(st.slots[3].unit, st.slots[3].tray) == "1D"); CHECK(! st.slots[2].loaded); CHECK(st.slots[2].label() == "Tray 3 (empty)"); CHECK(st.slots[3].name == "PLA"); @@ -323,170 +320,3 @@ TEST_CASE("Remembered CANVAS mappings follow the spool, not the tray number", "[ CHECK(ElegooCanvasMapping::load().empty()); } } - -TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[ThirdPartyFilamentMap]") -{ - using namespace Slic3r::GUI; - CHECK(bambu_style_slot_label(0, 0) == "1A"); - CHECK(bambu_style_slot_label(0, 1) == "1B"); - CHECK(bambu_style_slot_label(0, 3) == "1D"); - CHECK(bambu_style_slot_label(1, 0) == "2A"); - CHECK(bambu_style_slot_label(-1, 0) == "1"); - CHECK(bambu_style_slot_label(-1, 2) == "3"); - CHECK(bambu_style_slot_label(-1, 3) == "4"); - CHECK(bambu_style_slot_label(-1, 4) == "5"); - CHECK(bambu_style_slot_label(-1, 7) == "8"); - - ThirdPartyDeviceSlot unitless; - unitless.unit_id = -1; - unitless.slot_id = -1; - unitless.index = 5; - CHECK(slot_display_label(unitless) == "6"); - - ThirdPartyDeviceSlot a; - a.unit_id = 0; - a.slot_id = 0; - a.label = "custom"; - CHECK(slot_display_label(a) == "custom"); - a.label.clear(); - CHECK(slot_display_label(a) == "1A"); - - std::vector slots(6); - slots[0].unit_id = 0; - slots[0].slot_id = 0; - slots[1].unit_id = 0; - slots[1].slot_id = 1; - slots[2].unit_id = 1; - slots[2].slot_id = 0; - slots[2].nozzle_id = 1; - slots[3].unit_id = 1; - slots[3].slot_id = 1; - slots[3].nozzle_id = 1; - slots[4].unit_id = 0; - slots[4].slot_id = 2; - slots[5].unit_id = 0; - slots[5].slot_id = 3; - const auto groups = group_slots(slots); - REQUIRE(groups.size() == 2); - CHECK(groups[0].unit_id == 0); - CHECK(groups[0].slot_positions == std::vector{ 0, 1, 4, 5 }); - CHECK(groups[1].unit_id == 1); - CHECK(groups[1].nozzle_id == 1); - CHECK(groups[1].slot_positions == std::vector{ 2, 3 }); -} - -TEST_CASE("Third-party filament map: encode matches the send dialog's filament:slot string", "[ThirdPartyFilamentMap]") -{ - using namespace Slic3r::GUI; - ThirdPartyFilamentMapModel model; - ThirdPartySlicerFilament f0; - f0.index = 0; - ThirdPartySlicerFilament f1; - f1.index = 2; - model.filaments = { f0, f1 }; - ThirdPartyDeviceSlot s0; - s0.index = 5; - ThirdPartyDeviceSlot s1; - s1.index = 7; - model.slots = { s0, s1 }; - model.mapping = { 1, -1 }; - CHECK(encode_filament_mapping(model) == "0:7"); - model.mapping = { 0, 1 }; - CHECK(encode_filament_mapping(model) == "0:5,2:7"); - model.mapping = { -1, -1 }; - CHECK(encode_filament_mapping(model).empty()); - model.mapping = { 0, 1 }; - model.read_only = true; - CHECK(encode_filament_mapping(model).empty()); - model.read_only = false; - CHECK(encode_filament_mapping(model) == "0:5,2:7"); -} - -TEST_CASE("Third-party filament map: read-only mismatch uses T-number identity, not auto-match", "[ThirdPartyFilamentMap]") -{ - using namespace Slic3r::GUI; - ThirdPartyFilamentMapModel model; - ThirdPartySlicerFilament f; - f.index = 0; - f.type = "PLA"; - f.colour = "#FF0000"; - model.filaments = { f }; - - ThirdPartyDeviceSlot t0; - t0.index = 0; - t0.type = "PETG"; - t0.colour = "#0000FF"; - ThirdPartyDeviceSlot t1; - t1.index = 1; - t1.type = "PLA"; - t1.colour = "#FF0000"; - model.slots = { t0, t1 }; - model.mapping = { 1 }; // visual auto-match to the matching holder - - CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::None); - CHECK(warning_slot_position(model, 0) == 1); - - model.read_only = true; - CHECK(encode_filament_mapping(model).empty()); - CHECK(warning_slot_position(model, 0) == 0); - CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::TypeAndColour); - CHECK(model.mapping == std::vector{ 1 }); -} - -TEST_CASE("Third-party filament map: type and colour mismatch, badges, colour parse", "[ThirdPartyFilamentMap]") -{ - using namespace Slic3r::GUI; - CHECK(normalize_map_colour("#ff00aa") == "#FF00AA"); - CHECK(normalize_map_colour("00FF00BB") == "#00FF00"); - CHECK(normalize_map_colour("not-a-colour").empty()); - CHECK(types_match("PLA", "pla")); - CHECK(types_match("PLA", "")); - CHECK_FALSE(types_match("PLA", "PETG")); - CHECK_FALSE(colours_mismatch("#FF0000", "#EE0000")); - CHECK(colours_mismatch("#FF0000", "#0000FF")); - CHECK_FALSE(colours_mismatch("#FF0000", "")); - - ThirdPartySlicerFilament f; - f.index = 0; - f.type = "PLA"; - f.colour = "#FF0000"; - ThirdPartyDeviceSlot s; - s.type = "PETG"; - s.colour = "#0000FF"; - CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::TypeAndColour); - s.type = "PLA"; - CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::Colour); - s.colour = "#FF0000"; - CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); - s.empty = true; - s.type = "PETG"; - CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); - - ThirdPartyFilamentMapModel model; - ThirdPartySlicerFilament a; - a.index = 0; - a.nozzle_id = -1; - model.filaments = { a }; - CHECK(filament_badge_text(a, model) == "1"); - a.index = 3; - model.filaments = { a }; - CHECK(filament_badge_text(a, model) == "4"); - - ThirdPartySlicerFilament left; - left.nozzle_id = 1; - ThirdPartySlicerFilament right; - right.nozzle_id = 0; - model.filaments = { right, left }; - CHECK(has_multiple_nozzles(model)); - CHECK(filament_badge_text(right, model) == "R"); - CHECK(filament_badge_text(left, model) == "L"); - - ThirdPartySlicerFilament third; - third.nozzle_id = 2; - model.filaments = { right, left, third }; - CHECK(filament_badge_text(right, model) == "1"); - CHECK(filament_badge_text(third, model) == "3"); - - model.filaments = { right }; - CHECK_FALSE(has_multiple_nozzles(model)); -} diff --git a/tests/slic3rutils/third_party_filament_map_tests.cpp b/tests/slic3rutils/third_party_filament_map_tests.cpp new file mode 100644 index 000000000000..2eecf94b479f --- /dev/null +++ b/tests/slic3rutils/third_party_filament_map_tests.cpp @@ -0,0 +1,409 @@ +// The shared Bambu-style filament map of the third-party send dialogs (ThirdPartyFilamentMap.hpp): +// how each host's slots are labelled on the swatch tiles, which slot each filament row starts on +// (remembered mapping, closest colour, QIDI Box material-first), the mapping string it sends, the +// mismatch rules and the nozzle badges. +// +// wx-free and printer-free: the CANVAS and Moonraker answers are mocks, the remembered mappings an +// in-memory map. + +#include + +#include +#include +#include + +#include "slic3r/GUI/ThirdPartyFilamentMap.hpp" +#include "slic3r/Utils/ElegooCanvas.hpp" +#include "slic3r/Utils/ElegooCanvasMapping.hpp" +#include "slic3r/Utils/PrintHostDeviceStatus.hpp" + +using namespace Slic3r; +using namespace Slic3r::GUI; +using PrintHostDevices::Slot; +using PrintHostDevices::Status; + +namespace { + +// CANVAS units as the printer may send them: `canvas_id` and the first `tray_id` are whatever base +// the firmware counts from. Each unit: PLA red, PETG black, empty, PLA white. +std::string canvas_unit(int canvas_id, int tray_base) +{ + auto t = [&](int k) { return std::to_string(tray_base + k); }; + return R"({"canvas_id":)" + std::to_string(canvas_id) + R"(,"connected":1,"tray_list":[ + {"tray_id":)" + t(0) + R"(,"brand":"ELEGOO","filament_type":"PLA","filament_name":"PLA","filament_color":"#FF0000","min_nozzle_temp":190,"max_nozzle_temp":230,"status":2}, + {"tray_id":)" + t(1) + R"(,"brand":"ELEGOO","filament_type":"PETG","filament_name":"PETG","filament_color":"#000000","min_nozzle_temp":220,"max_nozzle_temp":250,"status":2}, + {"tray_id":)" + t(2) + R"(,"brand":"","filament_type":"","filament_name":"","filament_color":"","status":0}, + {"tray_id":)" + t(3) + R"(,"brand":"","filament_type":"PLA","filament_name":"","filament_color":"#FFFFFF","min_nozzle_temp":190,"max_nozzle_temp":230,"status":1}]})"; +} + +Status canvas_status(const std::vector& units) +{ + std::string list; + for (const std::string& u : units) + list += (list.empty() ? "" : ",") + u; + const std::string payload = R"({"active_canvas_id":0,"active_tray_id":-1,"canvas_list":[)" + list + "]}"; + CanvasQueryResult r; + std::string error; + r.reached = true; + r.ams_connect_status = 1; + r.ok = canvas_parse_payload(payload, r.info, error); + REQUIRE(r.ok); + return PrintHostDevices::status_from_canvas(r); +} + +// A Snapmaker U1: four toolheads named by print_task_config. +Status u1_status() +{ + return PrintHostDevices::parse_moonraker_status( + "{\"print_stats\":{\"state\":\"standby\"}," + " \"print_task_config\":{" + " \"filament_type\":[\"PLA\",\"PETG\",\"PLA\",\"\"]," + " \"filament_color_rgba\":[\"FF0000FF\",\"0000FFFF\",\"FFFFFFFF\",\"\"]," + " \"filament_exist\":[true,true,true,false]}}", + "octoprint"); +} + +// A QIDI X-Max 4 with one Box: white PLA, dark ASA, an unloaded slot, ABS with no colour. +Status qidi_status() +{ + return PrintHostDevices::parse_moonraker_status( + "{\"print_stats\":{\"state\":\"standby\"}," + " \"extruder\":{\"nozzle_diameter\":0.4}," + " \"multi_color_controller\":{\"box_count\":1,\"box\":{\"boxes\":[{" + " \"box_inserted_0\":1,\"box_inserted_1\":1,\"box_inserted_2\":0,\"box_inserted_3\":1," + " \"filament_info\":[" + " {\"filament_type\":\"PLA\",\"color_rgb_str\":[\"#FFFFFF\"]}," + " {\"filament_type\":\"ASA\",\"color_rgb_str\":[\"#FF1010\"]}," + " {\"filament_type\":\"PETG\",\"color_rgb_str\":[\"#00FF00\"]}," + " {\"filament_type\":\"ABS\",\"color_rgb_str\":[]}]}]}}}", + "octoprint"); +} + +std::vector labels_of(const std::vector& slots) +{ + std::vector out; + for (const ThirdPartyDeviceSlot& s : slots) + out.push_back(slot_display_label(s)); + return out; +} + +} // namespace + +TEST_CASE("Third-party filament map: CANVAS tiles are labelled by position, whatever base the tray ids use", "[ThirdPartyFilamentMap]") +{ + const std::vector one_unit = { "1A", "1B", "1C", "1D" }; + + SECTION("0-based tray ids (the CC1 capture)") { + const Status st = canvas_status({ canvas_unit(0, 0) }); + REQUIRE(st.slots.size() == 4); + const auto tiles = third_party_slots_from(st.slots, st.qidi_box); + CHECK(labels_of(tiles) == one_unit); + CHECK(tiles[2].empty); + CHECK(tiles[1].type == "PETG"); + } + SECTION("1-based tray ids never read B, C, D, A") { + const Status st = canvas_status({ canvas_unit(0, 1) }); + CHECK(labels_of(third_party_slots_from(st.slots, st.qidi_box)) == one_unit); + } + SECTION("a 1-based canvas_id is still unit 1") { + const Status st = canvas_status({ canvas_unit(1, 1) }); + CHECK(labels_of(third_party_slots_from(st.slots, st.qidi_box)) == one_unit); + } + SECTION("two units: the second is 2A..2D, and the protocol index is kept") { + const Status st = canvas_status({ canvas_unit(0, 0), canvas_unit(1, 0) }); + const auto tiles = third_party_slots_from(st.slots, st.qidi_box); + REQUIRE(tiles.size() == 8); + CHECK(slot_display_label(tiles[4]) == "2A"); + CHECK(slot_display_label(tiles[7]) == "2D"); + for (size_t i = 0; i < tiles.size(); ++i) + CHECK(tiles[i].index == st.slots[i].index); + const auto groups = group_slots(tiles); + REQUIRE(groups.size() == 2); + CHECK(groups[1].slot_positions == std::vector{ 4, 5, 6, 7 }); + } +} + +TEST_CASE("Third-party filament map: U1 toolheads are T1..T4", "[ThirdPartyFilamentMap]") +{ + const Status st = u1_status(); + REQUIRE(st.slots.size() == 4); + REQUIRE_FALSE(st.qidi_box); + const auto tiles = third_party_slots_from(st.slots, st.qidi_box); + CHECK(labels_of(tiles) == std::vector{ "T1", "T2", "T3", "T4" }); + CHECK(tiles[0].unit_id == -1); + CHECK(tiles[3].empty); + CHECK(tiles[1].colour == "#0000FF"); + // One group, no unit heading. + REQUIRE(group_slots(tiles).size() == 1); +} + +TEST_CASE("Third-party filament map: QIDI Box slots are 1A..1D", "[ThirdPartyFilamentMap]") +{ + const Status st = qidi_status(); + REQUIRE(st.qidi_box); + REQUIRE(st.slots.size() == 4); + const auto tiles = third_party_slots_from(st.slots, st.qidi_box); + CHECK(labels_of(tiles) == std::vector{ "1A", "1B", "1C", "1D" }); + CHECK(tiles[1].index == 1); // Box slot b * 4 + n, what value_t names + CHECK(tiles[2].empty); + + // A second Box: slots 4..7 are 2A..2D. + std::vector two(st.slots); + for (int i = 0; i < 4; ++i) { + Slot s = st.slots[i]; + s.index = 4 + i; + two.push_back(s); + } + CHECK(labels_of(third_party_slots_from(two, true)) == + std::vector{ "1A", "1B", "1C", "1D", "2A", "2B", "2C", "2D" }); +} + +TEST_CASE("Third-party filament map: each host keeps its own auto-match", "[ThirdPartyFilamentMap]") +{ + SECTION("U1 and CANVAS: the closest colour among loaded slots") { + const Status u1 = u1_status(); + // Dark red PETG goes to the red PLA head: colour only, the type does not count. + CHECK(initial_slot_position(u1.slots, -1, "#E00000", "PETG", u1.qidi_box) == 0); + CHECK(initial_slot_position(u1.slots, -1, "#0010F0", "PLA", u1.qidi_box) == 1); + // The unloaded T4 is never picked, even for a colour it would match. + CHECK(initial_slot_position(u1.slots, -1, "#FFFFFF", "PLA", u1.qidi_box) == 2); + + const Status cv = canvas_status({ canvas_unit(0, 1) }); + CHECK(initial_slot_position(cv.slots, -1, "#101010", "PLA", cv.qidi_box) == 1); // black PETG tray + } + SECTION("QIDI Box: the filament's own material first, never colour alone") { + const Status q = qidi_status(); + // Red PLA: the only PLA is white, still preferred over the red ASA. + CHECK(initial_slot_position(q.slots, -1, "#FF0000", "PLA", q.qidi_box) == 0); + // ABS: the loaded ABS with no colour. + CHECK(initial_slot_position(q.slots, -1, "#FF0000", "ABS", q.qidi_box) == 3); + // PETG: the only PETG slot is unloaded, so "As sliced". + CHECK(initial_slot_position(q.slots, -1, "#00FF00", "PETG", q.qidi_box) == -1); + // No type: "As sliced". + CHECK(initial_slot_position(q.slots, -1, "#FFFFFF", "", q.qidi_box) == -1); + // The same slots treated as a non-Box host would follow the colour instead. + CHECK(initial_slot_position(q.slots, -1, "#FF0000", "PLA", false) == 1); + } +} + +TEST_CASE("Third-party filament map: a remembered CANVAS mapping wins over the colour match", "[ThirdPartyFilamentMap]") +{ + const Status st = canvas_status({ canvas_unit(0, 1) }); + + ElegooCanvasMapping::UsedFilament red; + red.index = 0; + red.type = "PLA"; + red.colour = "#FF0000"; + red.alias = "Generic PLA"; + + // Without memory the red filament goes to the red tray (1A). + std::map memory; + int remembered = ElegooCanvasMapping::recall(memory, red, st.slots); + CHECK(remembered == -1); + CHECK(initial_slot_position(st.slots, remembered, red.colour, red.type, false) == 0); + + // Last time it printed from the white PLA tray (1D): that is where it starts now. + ElegooCanvasMapping::remember(memory, { red }, { 3 }, st.slots); + remembered = ElegooCanvasMapping::recall(memory, red, st.slots); + CHECK(remembered == 3); + CHECK(initial_slot_position(st.slots, remembered, red.colour, red.type, false) == 3); + + // The row encodes to the protocol index and the tile reads 1D. + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + model.filaments = { f }; + model.slots = third_party_slots_from(st.slots, st.qidi_box); + model.mapping = { initial_slot_position(st.slots, remembered, f.colour, f.type, false) }; + CHECK(slot_display_label(model.slots[model.mapping[0]]) == "1D"); + CHECK(encode_filament_mapping(model) == "0:" + std::to_string(st.slots[3].index)); + + // A remembered position outside the slot list is ignored. + CHECK(initial_slot_position(st.slots, 99, red.colour, red.type, false) == 0); +} + +TEST_CASE("Third-party filament map: Bambu-style slot labels and grouping", "[ThirdPartyFilamentMap]") +{ + CHECK(bambu_style_slot_label(0, 0) == "1A"); + CHECK(bambu_style_slot_label(0, 1) == "1B"); + CHECK(bambu_style_slot_label(0, 3) == "1D"); + CHECK(bambu_style_slot_label(0, 4) == "1E"); // positions, so no wrap back to A + CHECK(bambu_style_slot_label(1, 0) == "2A"); + CHECK(bambu_style_slot_label(0, 26) == "1-27"); + CHECK(bambu_style_slot_label(-1, 0) == "1"); + CHECK(bambu_style_slot_label(-1, 2) == "3"); + CHECK(bambu_style_slot_label(-1, 4) == "5"); + CHECK(bambu_style_slot_label(-1, 7) == "8"); + + ThirdPartyDeviceSlot unitless; + unitless.unit_id = -1; + unitless.slot_id = -1; + unitless.index = 5; + CHECK(slot_display_label(unitless) == "6"); + + ThirdPartyDeviceSlot a; + a.unit_id = 0; + a.slot_id = 0; + a.label = "custom"; + CHECK(slot_display_label(a) == "custom"); + a.label.clear(); + CHECK(slot_display_label(a) == "1A"); + + std::vector slots(6); + slots[0].unit_id = 0; + slots[0].slot_id = 0; + slots[1].unit_id = 0; + slots[1].slot_id = 1; + slots[2].unit_id = 1; + slots[2].slot_id = 0; + slots[2].nozzle_id = 1; + slots[3].unit_id = 1; + slots[3].slot_id = 1; + slots[3].nozzle_id = 1; + slots[4].unit_id = 0; + slots[4].slot_id = 2; + slots[5].unit_id = 0; + slots[5].slot_id = 3; + const auto groups = group_slots(slots); + REQUIRE(groups.size() == 2); + CHECK(groups[0].unit_id == 0); + CHECK(groups[0].slot_positions == std::vector{ 0, 1, 4, 5 }); + CHECK(groups[1].unit_id == 1); + CHECK(groups[1].nozzle_id == 1); + CHECK(groups[1].slot_positions == std::vector{ 2, 3 }); +} + +TEST_CASE("Third-party filament map: encode matches the send dialog's filament:slot string", "[ThirdPartyFilamentMap]") +{ + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f0; + f0.index = 0; + ThirdPartySlicerFilament f1; + f1.index = 2; + model.filaments = { f0, f1 }; + ThirdPartyDeviceSlot s0; + s0.index = 5; + ThirdPartyDeviceSlot s1; + s1.index = 7; + model.slots = { s0, s1 }; + model.mapping = { 1, -1 }; + CHECK(encode_filament_mapping(model) == "0:7"); + model.mapping = { 0, 1 }; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); + model.mapping = { -1, -1 }; + CHECK(encode_filament_mapping(model).empty()); + model.mapping = { 0, 1 }; + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + model.read_only = false; + CHECK(encode_filament_mapping(model) == "0:5,2:7"); +} + +TEST_CASE("Third-party filament map: read-only mismatch uses T-number identity, not auto-match", "[ThirdPartyFilamentMap]") +{ + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + model.filaments = { f }; + + ThirdPartyDeviceSlot t0; + t0.index = 0; + t0.type = "PETG"; + t0.colour = "#0000FF"; + ThirdPartyDeviceSlot t1; + t1.index = 1; + t1.type = "PLA"; + t1.colour = "#FF0000"; + model.slots = { t0, t1 }; + model.mapping = { 1 }; // visual auto-match to the matching holder + + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::None); + CHECK(warning_slot_position(model, 0) == 1); + + model.read_only = true; + CHECK(encode_filament_mapping(model).empty()); + CHECK(warning_slot_position(model, 0) == 0); + CHECK(mismatch_at(model, 0) == ThirdPartyMapMismatch::TypeAndColour); + CHECK(model.mapping == std::vector{ 1 }); +} + +TEST_CASE("Third-party filament map: type and colour mismatch, colour parse", "[ThirdPartyFilamentMap]") +{ + CHECK(normalize_map_colour("#ff00aa") == "#FF00AA"); + CHECK(normalize_map_colour("00FF00BB") == "#00FF00"); + CHECK(normalize_map_colour("not-a-colour").empty()); + CHECK(types_match("PLA", "pla")); + CHECK(types_match("PLA", "")); + CHECK_FALSE(types_match("PLA", "PETG")); + CHECK_FALSE(colours_mismatch("#FF0000", "#EE0000")); + CHECK(colours_mismatch("#FF0000", "#0000FF")); + CHECK_FALSE(colours_mismatch("#FF0000", "")); + + ThirdPartySlicerFilament f; + f.index = 0; + f.type = "PLA"; + f.colour = "#FF0000"; + ThirdPartyDeviceSlot s; + s.type = "PETG"; + s.colour = "#0000FF"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::TypeAndColour); + s.type = "PLA"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::Colour); + s.colour = "#FF0000"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); + s.empty = true; + s.type = "PETG"; + CHECK(mismatch_for(f, s) == ThirdPartyMapMismatch::None); +} + +TEST_CASE("Third-party filament map: nozzle badges", "[ThirdPartyFilamentMap]") +{ + ThirdPartyFilamentMapModel model; + ThirdPartySlicerFilament a; + a.index = 0; + a.nozzle_id = -1; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "1"); + a.index = 3; + model.filaments = { a }; + CHECK(filament_badge_text(a, model) == "4"); + + ThirdPartySlicerFilament left; + left.index = 1; + left.nozzle_id = 1; + ThirdPartySlicerFilament right; + right.index = 0; + right.nozzle_id = 0; + + SECTION("slots that name no nozzle (U1 toolheads, CANVAS): the filament number, whatever filament_map says") { + model.filaments = { right, left }; + model.slots = third_party_slots_from(u1_status().slots, false); + CHECK(has_multiple_nozzles(model)); + CHECK(filament_badge_text(right, model) == "1"); + CHECK(filament_badge_text(left, model) == "2"); + } + SECTION("slots grouped by nozzle: L / R on two nozzles, numbers on more") { + ThirdPartyDeviceSlot r; + r.nozzle_id = 0; + ThirdPartyDeviceSlot l; + l.nozzle_id = 1; + model.slots = { r, l }; + model.filaments = { right, left }; + CHECK(filament_badge_text(right, model) == "R"); + CHECK(filament_badge_text(left, model) == "L"); + + ThirdPartySlicerFilament third; + third.nozzle_id = 2; + model.filaments = { right, left, third }; + CHECK(filament_badge_text(right, model) == "1"); + CHECK(filament_badge_text(third, model) == "3"); + } + SECTION("one nozzle is not several") { + model.filaments = { right }; + CHECK_FALSE(has_multiple_nozzles(model)); + } +}