#include "PublishSettingsDialog.hpp" #include "GUI_App.hpp" #include "MainFrame.hpp" #include "MsgDialog.hpp" #include "I18N.hpp" #include "Tab.hpp" #include "ConfigValueFormatter.hpp" #include "FilamentBitmapUtils.hpp" #include "Widgets/Label.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/DialogButtons.hpp" #include "Widgets/StaticLine.hpp" #include "Widgets/StateColor.hpp" #include "libslic3r/PrintConfig.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PublishSettings.hpp" #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Model.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Slic3r { namespace GUI { namespace { // Menu ids for show_menu(): dedicated range so the popup cannot collide with application-level // bindings (e.g. MainFrame's recent-files wxID_FILE1.. range). enum { kPublishSelectAll = wxID_HIGHEST + 1, kPublishDeselectAll, kPublishSelectVisible, kPublishDeselectVisible, kPublishFilterSelected, kPublishFilterNonSelected }; // Tree-style flags shared by the outer tabs and every group's inner tabs. constexpr long s_tab_style = wxTR_NO_BUTTONS | wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxBORDER_NONE | wxWANTS_CHARS | wxTR_FULL_ROW_HIGHLIGHT; // The author's hex colour for a filament slot (empty when the slot is out of range); shared by // the header chip, the inner-tab chip and the DPI rescale paths. std::string filament_color_hex(const DynamicPrintConfig& full, size_t slot) { if (const auto* colours = full.opt("filament_colour")) if (slot < colours->size()) return colours->get_at(slot); return std::string(); } PublishMaterialIdentity material_identity(size_t slot, const DynamicPrintConfig& full) { PublishMaterialIdentity identity; if (const auto* types = full.opt("filament_type")) if (slot < types->size()) identity.type = types->get_at(slot); if (const auto* vendors = full.opt("filament_vendor")) if (slot < vendors->size()) identity.vendor = vendors->get_at(slot); if (const auto* ids = full.opt("filament_ids")) if (slot < ids->size()) identity.id = ids->get_at(slot); return identity; } // "Generic PLA @System" -> "Generic PLA"; mirrors the alias derivation in PresetBundle.cpp. std::string material_display_name(const std::string& preset_name) { const size_t at = preset_name.find_first_of('@'); if (at == std::string::npos) return preset_name; std::string bare = preset_name.substr(0, at); boost::trim_right(bare); return bare.empty() ? preset_name : bare; } // Section title for a filament slot: the resolved preset name, then the filament type, then // the generic "Material". wxString material_title(size_t slot, const PresetBundle* bundle, const DynamicPrintConfig& full) { if (slot < bundle->filament_presets.size()) { const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[slot]); if (preset != nullptr && !preset->name.empty()) { // Display the name the sidebar does: the alias (already bare-name + variant-tail // stripped for root filament presets). const std::string& display = preset->alias.empty() ? get_preset_bare_name(preset->name) : preset->alias; return from_u8(material_display_name(display)); } } const PublishMaterialIdentity identity = material_identity(slot, full); if (!identity.type.empty()) return from_u8(identity.type); return _L("Material"); } // The component slots of a mixed filament ("1,2,3"), 1-based, or empty when out of range. std::vector mixed_slot_components(const DynamicPrintConfig& full, size_t slot) { const auto* comp_opt = full.opt("filament_mixed_components"); if (comp_opt == nullptr || slot >= comp_opt->size()) return {}; return parse_mixed_components(comp_opt->values[slot]); } // Human-readable label of a mixed filament, mirroring the sidebar's mixed filament rows: the // 1-based component slot numbers with their blend percentages (or a "->" gradient arrow), // e.g. "1 (60%) + 2 (40%)". Used as the slot's tab/header title in place of a preset name. wxString mixed_filament_label(const DynamicPrintConfig& full, size_t slot) { const std::vector comps = mixed_slot_components(full, slot); if (comps.empty()) return _L("Mixed filament"); const auto* grad_opt = full.opt("filament_mixed_gradient"); const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); wxString label; for (size_t i = 0; i < comps.size(); ++i) { if (i > 0) label += is_gradient ? wxString::FromUTF8(" \u2192 ") : wxString::FromUTF8(" + "); label += wxString::Format("%u", comps[i]); if (!is_gradient) { double ratio = 100.0 / comps.size(); if (ratios_opt != nullptr && slot < ratios_opt->size()) { const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); if (i < rs.size()) ratio = rs[i] * 100.0; } label += wxString::Format(" (%d%%)", int(ratio + 0.5)); } } return label; } // Blended representative colour of a mixed slot, computed exactly like the sidebar's swatches // (recompute_mixed_slot_colors): sublayer slots blend by their ratios, gradient slots by their // two end colours, broken references fall back to grey. wxColour mixed_filament_blend_color(const DynamicPrintConfig& full, size_t slot) { std::vector colors; if (const auto* colours = full.opt("filament_colour")) { colors.reserve(colours->values.size()); for (const std::string& hex : colours->values) { const wxColour c(hex); colors.push_back(c.IsOk() ? c : wxColour(0, 0, 0)); } } while (colors.size() <= slot) colors.push_back(wxColour("#D9D9D9")); recompute_mixed_slot_colors(colors, full); return slot < colors.size() ? colors[slot] : wxColour("#D9D9D9"); } // The slot's own chip as the main GUI renders it: a curve-sampled gradient swatch for a // gradient mixed slot, the static blended colour otherwise. Returns wxNullBitmap when nothing // could be rendered (the caller falls back to a plain label). wxBitmap mixed_filament_chip_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz) { const std::string label = std::to_string(slot + 1); const auto* grad_opt = full.opt("filament_mixed_gradient"); const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; wxBitmap* icon = nullptr; if (is_gradient) { // The same curve-sampled ramp the sidebar chips use; an empty ramp (broken definition) // degrades to a plain fade between the slot's two component colours. const std::vector ramp = mixed_gradient_ramp(full, slot, swatch_sz); if (!ramp.empty()) { icon = get_extruder_color_icon(std::vector(), true, label, swatch_sz, swatch_sz, &ramp); } else { std::vector hexes; for (const unsigned int comp : mixed_slot_components(full, slot)) { std::string hex = filament_color_hex(full, size_t(comp) - 1); if (hex.empty()) hex = "#D9D9D9"; hexes.push_back(std::move(hex)); } if (hexes.size() >= 2) icon = get_extruder_color_icon(std::move(hexes), true, label, swatch_sz, swatch_sz); } } if (icon == nullptr) { const wxColour blend = mixed_filament_blend_color(full, slot); const std::string blend_hex = blend.IsOk() ? std::string(wxString::Format("#%02X%02X%02X", blend.Red(), blend.Green(), blend.Blue()).ToUTF8()) : std::string("#808080"); icon = get_extruder_color_icon(blend_hex, label, swatch_sz, swatch_sz); } return icon != nullptr ? *icon : wxNullBitmap; } // Tab-strip bitmap for a mixed slot: the mix's own chip, then its component swatches each // followed by their percent share (or a "->" arrow for gradients), mirroring the main GUI's // sidebar rows - e.g. "[3 purple]: [1 red] 50% + [2 blue] 50%". The whole composition is one // bitmap because a TabCtrl item cannot interleave images into its text; the tab's text is // therefore empty. The bitmap is opaque and bakes tab_bg (the tab strip's background colour): // its texts are rasterized with plain GDI on that solid ground, like the sidebar rows and the // color chips. Text on a transparent bitmap has to go through the graphics-context route, and // on MSW that degrades into jagged ClearType-fallback glyphs with fringe halos - the same // reason draw_mixed_gradient_plot renders into an opaque buffer (FilamentBitmapUtils.cpp). wxBitmap mixed_filament_tab_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz, const wxColour& tab_bg) { const std::vector comps = mixed_slot_components(full, slot); if (comps.empty()) return wxNullBitmap; const auto* grad_opt = full.opt("filament_mixed_gradient"); const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); std::vector ratio_pct(comps.size(), 100.0 / comps.size()); if (!is_gradient && ratios_opt != nullptr && slot < ratios_opt->size()) { const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); for (size_t i = 0; i < rs.size() && i < comps.size(); ++i) ratio_pct[i] = rs[i] * 100.0; } const auto* colours = full.opt("filament_colour"); const wxString lead_sep = wxString::FromUTF8(":"); const wxString comp_sep = is_gradient ? wxString::FromUTF8("\u2192") : wxString::FromUTF8("+"); // Phase 1 (layout): render every swatch and measure every text piece so the composite // width is known before drawing; the dummy bitmap keeps GetTextExtent reliable. struct Piece { enum Kind { Swatch, Text } kind{Text}; wxBitmap bmp; wxString text; }; std::vector pieces; bool has_lead = false; wxBitmap dummy(1, 1); wxMemoryDC measure_dc; measure_dc.SelectObject(dummy); measure_dc.SetFont(::Label::Body_12); const int gap = wxWindow::FromDIP(4, nullptr); auto push_swatch = [&](const std::string& hex, const std::string& label) { wxBitmap* icon = get_extruder_color_icon(hex, label, swatch_sz, swatch_sz); if (icon == nullptr) return; pieces.push_back({Piece::Swatch, *icon, wxString()}); }; auto push_text = [&](const wxString& text) { pieces.push_back({Piece::Text, wxNullBitmap, text}); }; { const wxBitmap chip = mixed_filament_chip_bitmap(full, slot, swatch_sz); has_lead = chip.IsOk(); if (has_lead) pieces.push_back({Piece::Swatch, chip, wxString()}); } for (size_t ci = 0; ci < comps.size(); ++ci) { if (pieces.empty()) break; push_text(has_lead && pieces.size() == 1 ? lead_sep : comp_sep); // lead chip may have failed to render std::string hex = "#D9D9D9"; if (colours != nullptr && comps[ci] >= 1 && comps[ci] <= colours->size()) hex = colours->values[comps[ci] - 1]; const size_t before = pieces.size(); push_swatch(hex, std::to_string(comps[ci])); if (pieces.size() == before) break; // swatch failed: stop cleanly before an orphaned separator/percent pair if (!is_gradient) { push_text(wxString::Format("%d%%", int(ratio_pct[ci] + 0.5))); } } if (pieces.empty()) return wxNullBitmap; int width = 0; for (const Piece& p : pieces) width += (p.kind == Piece::Swatch ? swatch_sz : measure_dc.GetTextExtent(p.text).x + gap); // Phase 2 (draw): opaque, filled with the tab strip's background colour. Text goes through // the plain DC on every platform (see the function comment for why the alpha route is // avoided); chip bitmaps keep their own alpha and composite cleanly onto the fill. wxBitmap composite(width, swatch_sz); wxMemoryDC memdc; memdc.SelectObject(composite); memdc.SetBackground(wxBrush(tab_bg)); memdc.Clear(); memdc.SetBackgroundMode(wxTRANSPARENT); memdc.SetFont(::Label::Body_12); memdc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#262E30"))); int x = 0; for (const Piece& p : pieces) { if (p.kind == Piece::Swatch) { memdc.DrawBitmap(p.bmp, x, 0, true); x += swatch_sz; } else { const wxSize tsz = measure_dc.GetTextExtent(p.text); memdc.DrawText(p.text, x + gap / 2, (swatch_sz - tsz.y) / 2); x += tsz.x + gap; } } memdc.SelectObject(wxNullBitmap); return composite; } // The filament slots (0-based) the current project actually uses, across every plate: base and // MMU-painted volume extruders, per-object layer-range and support/wall/infill filament // overrides, and custom-gcode tool changes. Used mixed slots are preserved as themselves (so an // in-use mix is publishable) while their physical components are added too - a mix always ships // with the materials of its components even if a component is not referenced directly anywhere. std::set project_used_filament_slots(const PresetBundle& bundle, const DynamicPrintConfig& full, const Model& model) { std::set used; // 0-based used slots, mixed slots not yet expanded if (model.objects.empty()) return used; auto add_1based = [&used](int id) { if (id > 0) used.insert(size_t(id - 1)); }; const DynamicPrintConfig& print_cfg = bundle.prints.get_edited_preset().config; const int glb_support_intf = print_cfg.opt_int("support_interface_filament"); const int glb_support = print_cfg.opt_int("support_filament"); const int glb_outer_wall = print_cfg.opt_int("outer_wall_filament_id"); const int glb_inner_wall = print_cfg.opt_int("inner_wall_filament_id"); const int glb_sparse_infill = print_cfg.opt_int("sparse_infill_filament_id"); const int glb_internal_solid = print_cfg.opt_int("internal_solid_filament_id"); const int glb_top_surface = print_cfg.opt_int("top_surface_filament_id"); const int glb_bottom_surface = print_cfg.opt_int("bottom_surface_filament_id"); const bool glb_support_on = print_cfg.opt_bool("enable_support") || print_cfg.opt_int("raft_layers") > 0; for (ModelObject* mo : model.objects) { for (ModelVolume* mv : mo->volumes) for (int e : mv->get_extruders()) add_1based(e); for (const auto& range_entry : mo->layer_config_ranges) if (const ConfigOption* ext_opt = range_entry.second.option("extruder")) add_1based(ext_opt->getInt()); bool obj_support = glb_support_on; if (const ConfigOption* s_opt = mo->config.option("enable_support"); s_opt != nullptr) obj_support = s_opt->getBool(); else if (const ConfigOption* r_opt = mo->config.option("raft_layers"); r_opt != nullptr) obj_support = r_opt->getInt() > 0; if (obj_support) { if (const ConfigOption* opt = mo->config.option("support_interface_filament"); opt != nullptr && opt->getInt() != 0) add_1based(opt->getInt()); else add_1based(glb_support_intf); if (const ConfigOption* opt = mo->config.option("support_filament"); opt != nullptr && opt->getInt() != 0) add_1based(opt->getInt()); else add_1based(glb_support); } auto obj_id = [&mo](const char* key) { const ConfigOption* opt = mo->config.option(key); return (opt != nullptr) ? opt->getInt() : 0; }; int obj_outer_wall = obj_id("outer_wall_filament_id"); if (obj_outer_wall == 0) obj_outer_wall = obj_id("inner_wall_filament_id"); add_1based(obj_outer_wall != 0 ? obj_outer_wall : glb_outer_wall); int obj_inner_wall = obj_id("inner_wall_filament_id"); if (obj_inner_wall == 0) obj_inner_wall = obj_id("outer_wall_filament_id"); add_1based(obj_inner_wall != 0 ? obj_inner_wall : glb_inner_wall); const int obj_sparse = obj_id("sparse_infill_filament_id"); add_1based(obj_sparse != 0 ? obj_sparse : glb_sparse_infill); const int obj_internal_solid = obj_id("internal_solid_filament_id"); add_1based(obj_internal_solid != 0 ? obj_internal_solid : glb_internal_solid); int obj_top = obj_id("top_surface_filament_id"); if (obj_top == 0) obj_top = obj_internal_solid; add_1based(obj_top != 0 ? obj_top : glb_top_surface); int obj_bottom = obj_id("bottom_surface_filament_id"); if (obj_bottom == 0) obj_bottom = obj_internal_solid; add_1based(obj_bottom != 0 ? obj_bottom : glb_bottom_surface); } for (const auto& plate_entry : model.plates_custom_gcodes) for (const CustomGCode::Item& item : plate_entry.second.gcodes) if (item.type == CustomGCode::Type::ToolChange) add_1based(item.extruder); // Expand used mixed slots to their physical components and keep each mixed slot itself so the // dialog shows the mix. A component both used directly and pulled in via a mix is deduped. const auto* is_mixed_opt = full.opt("filament_is_mixed"); const auto* comp_opt = full.opt("filament_mixed_components"); if (is_mixed_opt != nullptr && comp_opt != nullptr && has_any_mixed_filament(is_mixed_opt->values)) { const std::vector raw(used.begin(), used.end()); const std::vector expanded = expand_mixed_filaments(raw, is_mixed_opt->values, comp_opt->values); std::set result(expanded.begin(), expanded.end()); for (unsigned int s : raw) if (s < is_mixed_opt->values.size() && is_mixed_opt->values[s]) result.insert(s); return result; } return used; } } // namespace // Warning shown on OK when an enabled mixed-filament slot relies on a filament that would ship // without its material. One row per unmet dependency: the mixed slot's colour chip, the // component filament's colour chip, and the reason. "Cancel" is the safe choice and keeps the // dialog open; "Publish anyway" continues. class MixedFilamentWarningDialog : public MsgDialog { public: MixedFilamentWarningDialog(wxWindow* parent, const DynamicPrintConfig& full, const std::vector& issues) : MsgDialog(parent, _L("Warning"), wxEmptyString, wxOK | wxCANCEL | wxICON_WARNING) { auto* content = new wxBoxSizer(wxVERTICAL); auto* intro = new wxStaticText(this, wxID_ANY, _L("Some mixed filaments rely on filaments that will not be published:")); intro->SetFont(Label::Body_13); intro->Wrap(FromDIP(400)); content->Add(intro, 0, wxEXPAND); content->AddSpacer(FromDIP(10)); const int swatch = FromDIP(20); for (const MixedDependencyIssue& issue : issues) { auto* row = new wxBoxSizer(wxHORIZONTAL); // The mixed slot as just its own chip (gradient-aware, numbered like its tab); // falls back to a plain label when the chip cannot be built. const wxString mix_label = wxString::Format(_L("Filament %d (mixed)"), int(issue.mixed_slot) + 1); const wxBitmap mix_bmp = mixed_filament_chip_bitmap(full, issue.mixed_slot, swatch); if (mix_bmp.IsOk()) { auto* bmp = new wxStaticBitmap(this, wxID_ANY, mix_bmp); bmp->SetToolTip(mix_label); row->Add(bmp, 0, wxALIGN_CENTER_VERTICAL); } else { auto* label = new wxStaticText(this, wxID_ANY, mix_label); label->SetFont(Label::Body_12); row->Add(label, 0, wxALIGN_CENTER_VERTICAL); } auto* needs = new wxStaticText(this, wxID_ANY, _L("needs")); needs->SetFont(Label::Body_12); needs->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); row->Add(needs, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(6)); // The component filament's colour chip, numbered like the tab strips; the slot // name stays on hover to keep the row itself short. std::string hex = filament_color_hex(full, issue.component_slot); if (hex.empty()) hex = "#D9D9D9"; if (wxBitmap* chip = get_extruder_color_icon(hex, std::to_string(issue.component_slot + 1), swatch, swatch)) { auto* comp_bmp = new wxStaticBitmap(this, wxID_ANY, *chip); comp_bmp->SetToolTip(wxString::Format(_L("Filament %d"), int(issue.component_slot) + 1)); row->Add(comp_bmp, 0, wxALIGN_CENTER_VERTICAL); } auto* reason = new wxStaticText(this, wxID_ANY, issue.reason == MixedDependencyIssue::Reason::Disabled ? _L("not enabled") : _L("material not published")); reason->SetFont(Label::Body_12); reason->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#989898"))); row->Add(reason, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); content->Add(row, 0, wxLEFT, FromDIP(10)); content->AddSpacer(FromDIP(6)); } auto* hint = new wxStaticText( this, wxID_ANY, _L("To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement.")); hint->SetFont(Label::Body_12); hint->Wrap(FromDIP(380)); content->Add(hint, 0, wxEXPAND | wxTOP, FromDIP(4)); content_sizer->Add(content, 0, wxEXPAND); SetButtonLabel(wxID_OK, _L("Publish anyway")); SetButtonLabel(wxID_CANCEL, _L("Cancel"), true); // safe choice gets the focus finalize(); } }; PublishSettingsDialog::MixedVisualSpec PublishSettingsDialog::make_mixed_visual_spec(const DynamicPrintConfig& full, size_t slot) { MixedVisualSpec spec; const std::vector comps = mixed_slot_components(full, slot); if (comps.empty()) return spec; const auto* grad_opt = full.opt("filament_mixed_gradient"); spec.is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; const auto* colours = full.opt("filament_colour"); for (unsigned int cid : comps) { std::string hex = "#D9D9D9"; if (colours != nullptr && cid >= 1 && cid <= colours->size()) hex = colours->values[cid - 1]; const wxColour c(hex); spec.component_colours.push_back(c.IsOk() ? c : wxColour("#D9D9D9")); } if (!spec.is_gradient) { // Sublayer shares; parse_mixed_ratios already falls back to equal shares and // normalizes to sum 1. spec.ratios.assign(comps.size(), 1.0 / comps.size()); if (const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios")) if (slot < ratios_opt->size()) { const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); if (rs.size() == comps.size()) spec.ratios = rs; } if (comps.size() == 3) spec.tri_weights = spec.ratios; // the picker's barycentric shares } else { const Slic3r::GradientCurve curve = mixed_gradient_curve(full, slot); constexpr int kSamples = 256; for (int i = 0; i <= kSamples; ++i) { const double t = double(i) / kSamples; spec.gradient_samples.emplace_back(t, sample_gradient_curve(curve, t)); } for (const Slic3r::GradientAnchor& anchor : curve.points) spec.gradient_anchors.emplace_back(anchor.x, anchor.y); } spec.valid = true; return spec; } PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, const std::vector* published_keys, const std::vector* material_keys) : DPIDialog(parent ? parent : static_cast(wxGetApp().mainframe), wxID_ANY, _L("Publish 3MF..."), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX | wxRESIZE_BORDER | wxFULL_REPAINT_ON_RESIZE) , m_search(this, "search", 16) , m_menu(this, "filter", 16) { SetBackgroundColour(*wxWHITE); // --- filter bar: search box, All/None, menu button --- wxPanel* f_bar = new wxPanel(this, wxID_ANY); f_bar->SetBackgroundColour(GetBackgroundColour()); wxBoxSizer* f_sizer = new wxBoxSizer(wxHORIZONTAL); m_filter_box = new TextInput(f_bar, "", "", "", wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER); m_filter_box->SetIcon(m_search.bmp()); m_filter_box->SetMinSize(FromDIP(wxSize(200, 24))); m_filter_box->SetSize(FromDIP(wxSize(-1, 24))); m_filter_box->SetFocus(); m_filter_ctrl = m_filter_box->GetTextCtrl(); m_filter_ctrl->SetFont(Label::Body_13); m_filter_ctrl->SetSize(wxSize(-1, FromDIP(16))); // centers text vertically m_filter_ctrl->SetHint(_L("Type to filter...")); m_filter_ctrl->Bind(wxEVT_TEXT, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); m_filter_ctrl->Bind(wxEVT_TEXT_ENTER, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); m_filter_ctrl->Bind(wxEVT_LEFT_DOWN, [this](auto& e) { if (m_filter_mode != FilterMode::Text) apply_filter(m_filter_ctrl->GetValue()); e.Skip(); }); f_sizer->Add(m_filter_box, 1, wxEXPAND); m_fb_sizer = new wxBoxSizer(wxHORIZONTAL); auto create_btn = [this, f_bar](wxString title, bool select) { auto btn = new wxStaticText(f_bar, wxID_ANY, title); btn->SetForegroundColour("#009687"); btn->SetCursor(wxCURSOR_HAND); btn->SetFont(Label::Body_13); btn->Bind(wxEVT_LEFT_DOWN, [this, select](wxMouseEvent&) { select_all(select); }); m_fb_sizer->Add(btn, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); }; f_sizer->Add(m_fb_sizer, 0, wxALIGN_CENTER_VERTICAL); create_btn(_L("All"), true); create_btn(_L("None"), false); // Indicator for the menu's pseudo filters: sits where All/None go while they are hidden. // Labels reuse the menu entries (no new strings); clicking the chip returns to text // filtering, keeping the search box contents. m_pseudo_chip = new wxStaticText(f_bar, wxID_ANY, ""); m_pseudo_chip->SetForegroundColour("#009687"); m_pseudo_chip->SetCursor(wxCURSOR_HAND); m_pseudo_chip->SetFont(Label::Body_13); m_pseudo_chip->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { apply_filter(m_filter_ctrl->GetValue()); }); m_pseudo_chip->Hide(); f_sizer->Add(m_pseudo_chip, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); m_menu_button = new wxStaticBitmap(f_bar, wxID_ANY, m_menu.bmp()); m_menu_button->SetCursor(wxCURSOR_HAND); m_menu_button->Bind(wxEVT_LEFT_DOWN, &PublishSettingsDialog::show_menu, this); f_sizer->Add(m_menu_button, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); f_bar->SetSizerAndFit(f_sizer); m_outer_tabs = new TabCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); m_outer_tabs->SetFont(Label::Body_14); m_outer_tabs->SetBackgroundColour(GetBackgroundColour()); m_outer_host = new wxPanel(this, wxID_ANY); m_outer_host->SetBackgroundColour(GetBackgroundColour()); m_outer_host_sizer = new wxBoxSizer(wxVERTICAL); m_outer_host->SetSizer(m_outer_host_sizer); wxBoxSizer* w_sizer = new wxBoxSizer(wxVERTICAL); wxStaticText* msg = new wxStaticText(this, wxID_ANY, _L("Select which settings to be published in the 3MF file")); msg->SetFont(Label::Body_13); msg->Wrap(-1); w_sizer->Add(msg, 0, wxRIGHT | wxLEFT | wxTOP, FromDIP(10)); w_sizer->Add(f_bar, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); w_sizer->Add(m_outer_tabs, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); w_sizer->Add(m_outer_host, 1, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); build_option_model(); // Seed from a remembered session selection or a freshly loaded published 3MF (authoritative). if (published_keys != nullptr || material_keys != nullptr) apply_selection(published_keys != nullptr ? *published_keys : std::vector(), material_keys != nullptr ? *material_keys : std::vector()); auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { // Publish is always allowed: no settings selected means no settings override. Warn only // when an enabled mixed filament would ship without the material of one of its // components; "Publish anyway" accepts that and publishes anyway. if (const std::vector issues = unpublished_mixed_components(); !issues.empty()) { const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); MixedFilamentWarningDialog warn(this, full, issues); if (warn.ShowModal() != wxID_OK) return; // Cancel: dismiss the warning and stay in this dialog } EndModal(wxID_OK); }); dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); // Guide links, bottom-left, sharing the footer row with the OK/Cancel buttons (pushed right). auto make_link = [this](const wxString& label, const char* url) { wxStaticText* link = new wxStaticText(this, wxID_ANY, label); link->SetFont(Label::Body_13); link->SetForegroundColour(wxColour(0x1F, 0x8E, 0xEA)); link->SetCursor(wxCURSOR_HAND); link->Bind(wxEVT_LEFT_DOWN, [url](wxMouseEvent&) { wxLaunchDefaultBrowser(url, wxBROWSER_NEW_WINDOW); }); return link; }; wxBoxSizer* links_sizer = new wxBoxSizer(wxVERTICAL); links_sizer->Add(make_link(_L("Publish 3MF Wiki"), "https://www.orcaslicer.com/wiki/publishing_3mf/publish_3mf.html"), 0, wxALIGN_LEFT); links_sizer->Add(make_link(_L("Publish 3MF Video Guide"), "https://www.youtube.com/watch?v=-xt1N29UIOg"), 0, wxTOP | wxALIGN_LEFT, FromDIP(4)); wxBoxSizer* footer = new wxBoxSizer(wxHORIZONTAL); footer->Add(links_sizer, 0, wxALIGN_CENTER_VERTICAL); footer->AddStretchSpacer(); footer->Add(dlg_btns, 0, wxALIGN_CENTER_VERTICAL); w_sizer->Add(footer, 0, wxRIGHT | wxLEFT | wxBOTTOM | wxEXPAND, FromDIP(10)); SetSizerAndFit(w_sizer); fit_to_content(); // initial size only; the dialog is resizable wxGetApp().UpdateDlgDarkUI(this); // The dark-UI walk re-colours the tab strips after the compositions were baked; rebuild so // they carry the theme-resolved background (the sidebar resolves its colours at paint time, // this dialog bakes, so the bake has to happen after the walk). refresh_mixed_tab_bitmaps(); } // Size the window to its content: width follows the widest tab strip so no filament tab is // hidden (TabCtrl::relayout hides overflowing buttons), height scales proportionally. Both // are floored at the 600x500 base and capped at hard DIP limits - deliberately not the whole // display - with one last-resort clamp so the dialog can never open larger than the screen. // Also owns the resize floor: the window cannot be resized below what the tabs need, so // shrinking never re-hides a filament tab. void PublishSettingsDialog::fit_to_content() { static const wxSize BASE{600, 500}; static const wxSize CAP{1300, 850}; int strip = m_outer_tabs->GetFullSize(); for (const SectionGroup& section : m_sections) { strip = std::max(strip, section.tabs->GetFullSize()); if (section.mixed_tabs != nullptr) strip = std::max(strip, section.mixed_tabs->GetFullSize()); } // Minimum width: whatever keeps every tab visible (never below the base). strip is device // pixels (Button min sizes); BASE/CAP are DIP and converted over. const int min_w = std::max(strip + 2 * FromDIP(10), FromDIP(BASE.x)); // Initial size: prefer proportional growth within the caps. const int w = std::clamp(min_w, FromDIP(BASE.x), FromDIP(CAP.x)); const double f = double(w) / FromDIP(BASE.x); const int h = std::clamp(int(FromDIP(BASE.y) * f), FromDIP(BASE.y), FromDIP(CAP.y)); const wxRect area = wxDisplay(this).GetClientArea(); const int max_w = area.width * 9 / 10; const int max_h = area.height * 9 / 10; SetMinSize(wxSize(std::min(min_w, max_w), std::min(FromDIP(BASE.y), max_h))); SetSize(std::min(w, max_w), std::min(h, max_h)); } PublishSettingsDialog::~PublishSettingsDialog() {} void PublishSettingsDialog::build_option_model() { // Structural / non-publishable keys, shared with the published-3MF overlay path. const std::set& denylist = publish_structural_keys(); // Base keys already added in the print section (dedup across pages/optgroups). The printer // section keeps its own printer_added set keyed by the full per-extruder "#N" opt_id, so // every extruder gets its own row (see Phase 1 below). std::set added; PresetBundle* bundle = wxGetApp().preset_bundle; DynamicPrintConfig full = bundle->full_config(); m_info_nonsel = _L("No selected items..."); m_info_allsel = _L("All items selected..."); m_info_empty = _L("No matching items..."); m_info_mix = _L("Mixed filament - published as a whole when \"Enable\" above is selected"); // Tab order differs from Section's enum order (Print, Printer, Material): the dialog // presents Printer, Filament, Process. m_sections.reserve(3); const Section tab_order[] = {Section::Printer, Section::Material, Section::Print}; for (Section kind : tab_order) section_group_for(kind); bind_tab_events(); // Shared per-option label/value computation; returns false when the option must be skipped // (denylisted / unknown / empty label). value is the stringified value; unit the translated // sidetext (may be empty). auto option_text = [&denylist, &full](const std::string& opt_id, const std::string& pure_key, wxString& label, wxString& value, wxString& unit) -> bool { if (denylist.count(pure_key) > 0) return false; const ConfigOptionDef* def = print_config_def.get(pure_key); if (def == nullptr) return false; label = _(def->full_label.empty() ? def->label : def->full_label); if (label.IsEmpty()) return false; value = get_string_value(opt_id, full); unit = _(def->sidetext); return true; }; // --- Phase 1: printer per-extruder retraction settings (first, mirroring the sidebar's // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. One inner tab per // extruder (e.g. "Left Extruder"/"Right Extruder" via Tab::translate_category), each holding // that extruder's Retraction and Z-Hop rows with per-extruder "#N" values. { size_t g = section_group_for(Section::Printer); std::set printer_added; for (Tab* tab : wxGetApp().tabs_list) { if (tab->m_type != Preset::TYPE_PRINTER) continue; for (const PageShp& page : tab->m_pages) { if (!page->title().StartsWith("Extruder")) continue; // The extruder index of this page: its options are appended with the same // "#N" opt_index (opt.second.second), so derive the tab's index from the first // allowlisted option; skip the page when none is found (defensive). int extruder_idx = -1; for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") continue; for (const auto& opt : optgroup->opt_map()) if (extruder_idx < 0) extruder_idx = opt.second.second; if (extruder_idx >= 0) break; } if (extruder_idx < 0) continue; const wxString page_title = Tab::translate_category(page->title(), tab->m_type); for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { // Allowlist on the untranslated optgroup title; the "Retraction when // switching material" group is intentionally skipped. if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") continue; const wxString subcategory = _(optgroup->title); for (const auto& opt : optgroup->opt_map()) { const std::string& opt_id = opt.first; const std::string& pure_key = opt.second.first; // Rows are keyed by the full per-extruder "#N" opt_id so each extruder // tab publishes its own value; GetPublishedKeys() emits the checked rows // as-is. if (!printer_added.insert(opt_id).second) continue; wxString label, value, unit; if (!option_text(opt_id, pure_key, label, value, unit)) continue; size_t cat_index = category_index_for(page_title, Section::Printer, g, size_t(extruder_idx)); size_t sub_index = subcategory_index_for(cat_index, subcategory, optgroup->icon); add_row_ui(opt_id, label, value, unit, cat_index, sub_index); } } } } } // --- Phase 2: per-material sections synthesized from the filament tab's "Setting // Overrides" page, under the Filament group. { size_t g = section_group_for(Section::Material); Tab* filament_tab = nullptr; for (Tab* tab : wxGetApp().tabs_list) if (tab->m_type == Preset::TYPE_FILAMENT) { filament_tab = tab; break; } if (filament_tab != nullptr) { const Page* overrides_page = nullptr; for (const PageShp& page : filament_tab->m_pages) if (page->title() == "Setting Overrides") { overrides_page = page.get(); break; } if (overrides_page != nullptr) { // Mixed-color slots are virtual: they carry no per-key Material/Retraction // settings; their "Enable" toggle always embeds the mix definition (components, // ratios, gradient). Detect them via the project-level flag. const ConfigOptionBools* is_mixed_opt = full.opt("filament_is_mixed"); // Only the slots the project actually uses: base/MMU-painted volume extruders, // support/wall/infill overrides and custom-gcode tool changes, plus the physical // components of any in-use mixed slot. Unused slots get no section at all (and are // therefore never published). One section per used slot, disambiguated by its // colour chip and slot identity while showing the bare name. const std::set used_slots = project_used_filament_slots(*bundle, full, wxGetApp().plater()->model()); for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) { if (used_slots.find(slot) == used_slots.end()) continue; const bool is_mixed = is_mixed_opt != nullptr && slot < is_mixed_opt->size() && is_mixed_opt->values[slot]; const PublishMaterialIdentity identity = material_identity(slot, full); // A mixed slot's title is its component composition (e.g. "1 (60%) + 2 // (40%)"), not the cloned preset's name shown in the main GUI. const wxString title = is_mixed ? mixed_filament_label(full, slot) : material_title(slot, bundle, full); const size_t category_index = category_index_for(title, Section::Material, g, slot, identity, is_mixed); if (is_mixed) { // A mixed slot publishes as one unit (its definition); nothing to select // per-key. Its component filaments are auto-enabled + Full Published when // "Enable" is checked (see on_enable_toggle). Its page still shows what // would be published: a ratio bar, or the gradient graph. add_mixed_visual(category_index, make_mixed_visual_spec(full, slot)); continue; } // Material requirement rows: an optional filament colour and/or a // vendor-agnostic material type for this slot, in their own optgroup so they // stay visually separated from the setting rows. { const size_t req_sub = subcategory_index_for(category_index, _L("Material"), "custom-gcode_filament"); add_row_ui("filament_colour", _L("Color"), from_u8(filament_color_hex(full, slot)), wxString(), category_index, req_sub, RowKind::Color); std::string type; if (const auto* types = full.opt("filament_type")) if (slot < types->size()) type = types->get_at(slot); add_row_ui("filament_type", _L("Type"), from_u8(normalize_filament_type(type)), wxString(), category_index, req_sub, RowKind::Type); } // A material section must not repeat a key; the same key may appear in other // material sections - that is intended. std::set material_added; for (const ConfigOptionsGroupShp& optgroup : overrides_page->m_optgroups) { // Allowlist on the untranslated optgroup title; "Ironing" is skipped. if (optgroup->title != "Retraction" && optgroup->title != "Retraction when switching material") continue; for (const auto& opt : optgroup->opt_map()) { // Row keys are base keys; the load side applies them positionally. const std::string& opt_id = opt.first; std::string base = publish_base_key(opt_id); if (!material_added.insert(base).second) continue; // Show the value of this slot; fall back to slot 0 if out of range. std::string value_opt_id = base + "#" + std::to_string(slot); if (const ConfigOption* opt_cfg = full.option(base)) if (const auto* vec = dynamic_cast(opt_cfg)) if (vec->size() > 0 && slot >= vec->size()) value_opt_id = base + "#0"; wxString label, value, unit; if (!option_text(value_opt_id, base, label, value, unit)) continue; size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); add_row_ui(base, label, value, unit, category_index, sub_index); } } } } } } // --- Phase 3: process (print) settings; grouping matches the process tab. { size_t g = section_group_for(Section::Print); for (Tab* tab : wxGetApp().tabs_list) { if (tab->m_type != Preset::TYPE_PRINT) continue; size_t page_index = 0; for (const PageShp& page : tab->m_pages) { wxString category = Tab::translate_category(page->title(), tab->m_type); const size_t category_index = category_index_for(category, Section::Print, g, page_index); for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { for (const auto& opt : optgroup->opt_map()) { // opt_map() key is the opt_id (may carry "#N"); the value is // (pure_opt_key, opt_index). const std::string& opt_id = opt.first; const std::string& pure_key = opt.second.first; // A key may appear in more than one page/group; keep the first. if (!added.insert(pure_key).second) continue; wxString label, value, unit; if (!option_text(opt_id, pure_key, label, value, unit)) continue; size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); add_row_ui(opt_id, label, value, unit, category_index, sub_index); } } ++page_index; } } } // Pre-check the dirty (modified) settings and mark them bold (per-slot match, across all // sections; collect_dirty_settings_keys unions the prints, printers and filaments). // Dirty keys carry a "#N" per-extruder/per-slot suffix (deep_diff), so the base key alone // cannot distinguish which extruder/filament slot changed: match the row's exact key, and // for material rows (base key + per-slot value) the base key plus the section's slot. std::set dirty_keys; for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) dirty_keys.insert(key); for (Row& row : m_rows) { // The Color/Type requirement rows are not "dirty overrides": never auto-checked. if (row.kind != RowKind::Setting) continue; bool dirty = dirty_keys.count(row.key) != 0; if (!dirty && row.section == Section::Material) dirty = dirty_keys.count(publish_base_key(row.key) + "#" + std::to_string(m_categories[row.inner_index].filament_slot)) != 0; row.dirty = dirty; if (row.dirty) { row.check->SetValue(true); set_row_bold(row, true); } } // Wire the "Enable" checkboxes: toggling one reveals/hides the slot's settings below the // header (and, for a mixed slot, auto-selects its components). Bind by index so the lambda // stays valid even if the vector is reallocated later. for (size_t c = 0; c < m_categories.size(); ++c) if (m_categories[c].enable_check != nullptr) m_categories[c].enable_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_enable_toggle(c); }); // Wire the "Full Publish" checkboxes (physical slots): toggling one disables/enables the // material's rows. for (size_t c = 0; c < m_categories.size(); ++c) if (m_categories[c].full_check != nullptr) m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); // No filter is active at startup: every row matches until the user types. for (Row& row : m_rows) row.matches_filter = true; apply_visibility(); for (Category& category : m_categories) { category.scroll->FitInside(); category.list_sizer->Layout(); } for (SectionGroup& section : m_sections) if (!section.categories.empty()) section.tabs->SelectItem(0); if (!m_sections.empty()) { m_outer_tabs->SelectItem(0); show_outer_page(0); } refresh_tab_indicators(); } size_t PublishSettingsDialog::section_group_for(Section kind) { for (size_t i = 0; i < m_sections.size(); ++i) if (m_sections[i].kind == kind) return i; SectionGroup section; section.kind = kind; const size_t new_index = m_sections.size(); switch (kind) { case Section::Printer: section.title = _L("Printer"); section.icon_name = "printer"; break; case Section::Material: section.title = _L("Filament"); section.icon_name = "filament"; break; case Section::Print: section.title = _L("Process"); section.icon_name = "process"; break; } section.icon_bmp = ScalableBitmap(this, section.icon_name, 16); section.page = new wxPanel(m_outer_host, wxID_ANY); section.page->SetBackgroundColour(GetBackgroundColour()); auto* page_sizer = new wxBoxSizer(wxVERTICAL); section.tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); section.tabs->SetFont(Label::Body_14); section.tabs->SetBackgroundColour(GetBackgroundColour()); page_sizer->Add(section.tabs, 0, wxEXPAND); // Mixed-color filament slots get a second tab strip below the physical filament tabs; only // the Material section has them. if (kind == Section::Material) { section.mixed_tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); section.mixed_tabs->SetFont(Label::Body_14); section.mixed_tabs->SetBackgroundColour(GetBackgroundColour()); page_sizer->Add(section.mixed_tabs, 0, wxEXPAND | wxTOP, FromDIP(2)); section.mixed_tabs->Hide(); } section.page_host = new wxPanel(section.page, wxID_ANY); section.page_host->SetBackgroundColour(GetBackgroundColour()); section.page_host_sizer = new wxBoxSizer(wxVERTICAL); section.page_host->SetSizer(section.page_host_sizer); page_sizer->Add(section.page_host, 1, wxEXPAND | wxTOP, FromDIP(4)); section.page->SetSizer(page_sizer); if (section.icon_bmp.bmp().IsOk()) m_outer_tabs->AppendItem(section.title, section.icon_bmp.bmp()); else m_outer_tabs->AppendItem(section.title); m_outer_host_sizer->Add(section.page, 1, wxEXPAND); section.page->Hide(); m_sections.push_back(std::move(section)); return new_index; } size_t PublishSettingsDialog::category_index_for( const wxString& title, Section section, size_t group, size_t source_index, const PublishMaterialIdentity& identity, bool is_mixed) { // Dedup across both tab rows (physical + mixed); mixed slots are never duplicated anyway. auto match = [&](const Category& existing) { return existing.title == title && existing.section == section && existing.source_index == source_index && existing.filament_id == identity.id && existing.filament_type == identity.type && existing.filament_vendor == identity.vendor; }; for (size_t i : m_sections[group].categories) if (match(m_categories[i])) return i; for (size_t i : m_sections[group].mixed_categories) if (match(m_categories[i])) return i; Category category; category.title = title; category.section = section; category.group = group; category.source_index = source_index; category.filament_type = identity.type; category.filament_vendor = identity.vendor; category.filament_id = identity.id; category.filament_slot = source_index; category.is_mixed = is_mixed; category.page = new wxPanel(m_sections[group].page_host, wxID_ANY); category.page->SetBackgroundColour(GetBackgroundColour()); auto* page_sizer = new wxBoxSizer(wxVERTICAL); // The physical slot's colour chip decorates the page header and the inner tab; it carries // the 1-based slot number, mirroring the main GUI's filament swatches. A mixed slot has no // header at all: its page is just the Enable toggle above the (always visible) definition // preview - the identification lives in the tab strip's composition bitmap. std::string hex; std::string chip_label; if (section == Section::Material && !is_mixed) { const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); hex = filament_color_hex(full, source_index); chip_label = std::to_string(source_index + 1); } if (section == Section::Material) { if (is_mixed) { // No chip/title: the lone "Enable" checkbox tops the page. auto* enable_sizer = new wxBoxSizer(wxHORIZONTAL); category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); category.enable_check->SetFont(Label::Body_13); category.enable_check->SetToolTip(_L("Publish this mixed filament and enable + Full Publish its component filaments")); enable_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL); page_sizer->Add(enable_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); } else { // Line 1: [chip] [title] [Enable]. The Enable checkbox gates the whole slot: while // it is unchecked nothing below the title is shown and nothing of it is published. auto* header_sizer = new wxBoxSizer(wxHORIZONTAL); if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); } category.title_label = new wxStaticText(category.page, wxID_ANY, title); category.title_label->SetFont(Label::Head_14); header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); category.enable_check->SetFont(Label::Body_13); category.enable_check->SetToolTip(_L("Publish this filament slot in the 3MF file")); header_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); // Line 2: the "Full Publish" toggle, on its own line below the title (hidden until // the slot is enabled), aligned with the colour chip above it. auto* full_sizer = new wxBoxSizer(wxHORIZONTAL); category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); category.full_check->SetFont(Label::Body_13); category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); full_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL); category.full_line_item = page_sizer->Add(full_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); } } category.scroll = new wxScrolledWindow(category.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); category.scroll->SetScrollRate(0, 10); category.scroll->SetBackgroundColour(GetBackgroundColour()); category.list_sizer = new wxBoxSizer(wxVERTICAL); category.scroll->SetSizer(category.list_sizer); category.scroll->DisableFocusFromKeyboard(); category.scroll->Bind(wxEVT_RIGHT_DOWN, &PublishSettingsDialog::show_menu, this); category.info = new wxStaticText(category.scroll, wxID_ANY, is_mixed ? m_info_mix : m_info_empty); category.info->SetFont(Label::Body_13); category.list_sizer->Add(category.info, 1, wxALIGN_CENTER_HORIZONTAL | wxALL, FromDIP(10)); category.info->Hide(); page_sizer->Add(category.scroll, 1, wxEXPAND | wxALL, FromDIP(4)); // A material slot starts disabled: its rows (and its Full Publish line) stay hidden until // "Enable" is checked. if (section == Section::Material) category.scroll->Hide(); category.page->SetSizer(page_sizer); category.page->Hide(); const size_t category_index = m_categories.size(); m_categories.push_back(std::move(category)); // Mixed slots live in a second tab row below the physical filament tabs. if (is_mixed) m_sections[group].mixed_categories.push_back(category_index); else m_sections[group].categories.push_back(category_index); if (section == Section::Material) { TabCtrl* target = is_mixed ? m_sections[group].mixed_tabs : m_sections[group].tabs; if (is_mixed) { // The tab's whole composition (mix chip + components + percents) lives in one // bitmap; the text is empty because a TabCtrl item cannot interleave images into // its text. const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full_cfg, source_index, FromDIP(20), target->GetBackgroundColour()); if (tab_bmp.IsOk()) target->AppendItem(wxString(), tab_bmp); else target->AppendItem(title); } else if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { target->AppendItem(title, *chip); } else { target->AppendItem(title); } } else { m_sections[group].tabs->AppendItem(title); } m_sections[group].page_host_sizer->Add(m_categories[category_index].page, 1, wxEXPAND); if (is_mixed) { if (m_sections[group].selected_mixed < 0) m_sections[group].selected_mixed = 0; m_sections[group].mixed_tabs->Show(); } else if (m_sections[group].selected_inner < 0) { m_sections[group].selected_inner = 0; } return category_index; } size_t PublishSettingsDialog::subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon) { Category& category = m_categories[category_index]; for (size_t i = 0; i < category.subs.size(); ++i) if (category.subs[i].title == title) return i; Subcategory sub; sub.title = title; if (!title.IsEmpty()) { sub.header = new ::StaticLine(category.scroll, false, title, icon); sub.header->SetFont(Label::Head_14); sub.header->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#363636"))); auto* wrap = new wxBoxSizer(wxVERTICAL); wrap->Add(sub.header, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(6)); sub.item = category.list_sizer->Add(wrap, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(22)); } category.subs.push_back(std::move(sub)); return category.subs.size() - 1; } void PublishSettingsDialog::add_row_ui(const std::string& key, const wxString& label, const wxString& value, const wxString& unit, size_t category_index, size_t subcategory_index, RowKind kind) { Category& category = m_categories[category_index]; Row row; row.key = key; row.label = label; row.value = value; row.unit = unit; row.kind = kind; row.category = category.title; row.subcategory = category.subs[subcategory_index].title; row.section = category.section; row.section_title = m_sections[category.group].title; row.outer_index = category.group; row.inner_index = category_index; const size_t row_index = m_rows.size(); m_rows.push_back(std::move(row)); Row& current = m_rows[row_index]; current.check = new wxCheckBox(category.scroll, wxID_ANY, label); current.check->SetFont(Label::Body_13); current.check->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { refresh_tab_indicators(); }); auto* row_sizer = new wxBoxSizer(wxHORIZONTAL); row_sizer->Add(current.check, 0, wxALIGN_CENTER_VERTICAL); // The value is read-only text (incl. the Type row: the published type is the slot's // normalized type, not author-editable). current.value_label = new wxStaticText(category.scroll, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); current.value_label->SetFont(Label::Body_13); current.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30"))); current.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit); if (kind == RowKind::Color && !value.IsEmpty()) { // The colour swatch carries this slot's 1-based number, like the main GUI swatches. const std::string chip_label = std::to_string(category.filament_slot + 1); if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) { current.color_chip = new wxStaticBitmap(category.scroll, wxID_ANY, *chip); row_sizer->Add(current.color_chip, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); } } row_sizer->Add(current.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); if (!unit.IsEmpty()) { current.unit_label = new wxStaticText(category.scroll, wxID_ANY, unit); current.unit_label->SetFont(Label::Body_13); current.unit_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); row_sizer->Add(current.unit_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); } current.item = category.list_sizer->Add(row_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(38)); category.rows.push_back(row_index); category.subs[subcategory_index].rows.push_back(row_index); } void PublishSettingsDialog::on_full_toggle(size_t category_index) { Category& cat = m_categories[category_index]; const bool full = cat.full_check->GetValue(); for (size_t r : cat.rows) m_rows[r].check->Enable(!full); refresh_tab_indicators(); } void PublishSettingsDialog::on_enable_toggle(size_t category_index) { Category& cat = m_categories[category_index]; const bool enabled = cat.enable_check->GetValue(); // A published mixed filament needs its component filaments published too: mark the slots it // uses as "Enable"d and Full Published so the mix's physical components always ship their // identities. if (cat.is_mixed && enabled) { const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); const std::vector components = mixed_slot_components(full, cat.filament_slot); for (const unsigned int component : components) { // Components are 1-based physical filament indices. const size_t component_slot = size_t(component) - 1; for (size_t c = 0; c < m_categories.size(); ++c) { Category& comp_cat = m_categories[c]; if (comp_cat.section != Section::Material || comp_cat.is_mixed || comp_cat.filament_slot != component_slot) continue; if (comp_cat.enable_check != nullptr) comp_cat.enable_check->SetValue(true); if (comp_cat.full_check != nullptr) comp_cat.full_check->SetValue(true); on_full_toggle(c); } } } // Reveal/hide everything below the slot's header (rows, info, Full Publish line) and refresh // the visibility of the auto-selected component slots. apply_visibility(); if (cat.page != nullptr) cat.page->GetSizer()->Layout(); refresh_tab_indicators(); } void PublishSettingsDialog::add_mixed_visual(size_t category_index, const MixedVisualSpec& spec) { if (category_index >= m_categories.size() || !spec.valid) return; Category& category = m_categories[category_index]; if (category.page == nullptr || category.page->GetSizer() == nullptr || category.scroll == nullptr || spec.component_colours.empty()) return; auto* viz = new wxPanel(category.page, wxID_ANY); viz->SetBackgroundStyle(wxBG_STYLE_PAINT); // Theme colours and DIP metrics are resolved inside the paint handler so dark-mode toggles // and DPI changes are picked up on the next repaint without any explicit listener. The // ternary fill cache lives inside the shared triangle painter (FilamentBitmapUtils). viz->Bind(wxEVT_PAINT, [this, panel = viz, spec](wxPaintEvent&) { const wxColour bg = StateColor::darkModeColorFor(*wxWHITE); wxBufferedPaintDC pdc(panel); pdc.SetBackground(wxBrush(bg)); pdc.Clear(); const wxRect rc = panel->GetClientRect(); if (rc.width <= 0 || rc.height <= 0) return; const size_t n = spec.component_colours.size(); if (spec.tri_weights.size() == 3 && n == 3 && !spec.is_gradient) { // Ternary mix: read-only miniature of the MixedFilamentDialog's triangle picker. const MixedTriangleTheme tri_theme{StateColor::darkModeColorFor(*wxWHITE), StateColor::darkModeColorFor(wxColour("#CECECE")), StateColor::darkModeColorFor(wxColour("#262E30")), StateColor::darkModeColorFor(wxColour(107, 107, 107))}; draw_mixed_triangle_picker(pdc, rc.GetSize(), {spec.component_colours[0], spec.component_colours[1], spec.component_colours[2]}, {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); draw_mixed_triangle_labels(pdc, rc.GetSize(), {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); } else if (!spec.is_gradient) { // Ratio bar. A 2-component slot matches the MixedFilamentDialog's continuous blend + // divider (with its two end labels); wider non-gradient mixes (rare) fall back to one // solid segment per component. if (n == 2) { const int bar_h = FromDIP(27); draw_mixed_ratio_blend_bar(pdc, wxRect(rc.x, rc.y, rc.width, bar_h), spec.component_colours[0], spec.component_colours[1], spec.ratios[1]); // Read-only twin of the dialog's left/right percentage labels. pdc.SetFont(::Label::Body_12); pdc.SetTextForeground(StateColor::darkModeColorFor(wxColour(107, 107, 107))); const wxString la = wxString::Format("%d%%", int(std::lround(spec.ratios[0] * 100.0))); const wxString lb = wxString::Format("%d%%", int(std::lround(spec.ratios[1] * 100.0))); const int lab_y = rc.y + bar_h + FromDIP(2); pdc.DrawText(la, rc.x, lab_y); pdc.DrawText(lb, rc.x + rc.width - pdc.GetTextExtent(lb).GetWidth(), lab_y); } else { draw_mixed_ratio_segments(pdc, rc, spec.component_colours, spec.ratios); // Percent label centred in each segment wide enough to hold it. std::vector shares = spec.ratios; double total = 0.0; for (double r : shares) total += r; if (total <= 0.0) { shares.assign(n, 1.0 / n); total = 1.0; } pdc.SetFont(::Label::Body_12); int x0 = rc.x; for (size_t i = 0; i < n; ++i) { const int x1 = (i + 1 < n) ? rc.x + int(std::lround(std::accumulate(shares.begin(), shares.begin() + i + 1, 0.0) / total * double(rc.width))) : rc.x + rc.width; const int w = std::max(1, x1 - x0); const wxString text = wxString::Format("%d%%", int(std::lround(shares[i] / total * 100.0))); const wxSize tsz = pdc.GetTextExtent(text); if (tsz.GetWidth() + FromDIP(4) <= w) { const wxColour& c = spec.component_colours[i]; const double lum = 0.299 * c.Red() + 0.587 * c.Green() + 0.114 * c.Blue(); pdc.SetTextForeground(lum > 140 ? wxColour("#262E30") : *wxWHITE); pdc.DrawText(text, x0 + (w - tsz.GetWidth()) / 2, rc.y + (rc.height - tsz.GetHeight()) / 2); } x0 = x1; } } } else { // Gradient: read-only miniature of the GradientCurveEditor plot, drawn through the // shared painter so it anti-aliases and keeps the square proportions and labels. const MixedGradientTheme grad_theme{StateColor::darkModeColorFor(*wxWHITE), StateColor::darkModeColorFor(wxColour(238, 238, 238)), StateColor::darkModeColorFor(wxColour(107, 107, 107)), StateColor::darkModeColorFor(wxColour(107, 107, 107)), StateColor::darkModeColorFor(wxColour(38, 46, 48)), StateColor::darkModeColorFor(wxColour(172, 172, 172)), StateColor::darkModeColorFor(*wxWHITE)}; std::vector curves; std::vector anchors; if (spec.gradient_samples.size() >= 2 && n >= 2) { const wxRect plot = mixed_gradient_plot_rect(rc.GetSize()); auto lift_alpha = [](wxColour c) { if (c.Alpha() == 0) c.Set(c.Red(), c.Green(), c.Blue(), 150); return c; }; // First component's ratio solid, its mirror twin for the other. const wxColour col_a = lift_alpha(spec.component_colours[0]); const wxColour col_b = lift_alpha(spec.component_colours[1]); std::vector pts_a, pts_b; pts_a.reserve(spec.gradient_samples.size()); pts_b.reserve(spec.gradient_samples.size()); for (const auto& [t, r] : spec.gradient_samples) { pts_a.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); pts_b.push_back({plot.x + t * plot.width, plot.y + r * plot.height}); } // Control-point anchors of the stored curve on the first component's line. anchors.reserve(spec.gradient_anchors.size()); for (const auto& [t, r] : spec.gradient_anchors) anchors.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); curves.push_back({std::move(pts_a), col_a, 4}); curves.push_back({std::move(pts_b), col_b, 2}); } draw_mixed_gradient_plot(pdc, rc.GetSize(), curves, anchors, grad_theme); } }); // Fixed DIP size, left-aligned: the visualization keeps its proportions no matter how the // dialog is resized (the paint handler draws into whatever client rect the panel ends up // with, so nothing else has to change). Sizes mirror the MixedFilamentDialog controls: the // gradient plot and triangle picker match the editor's 260x200 / 160x160, the ratio bar is // the dialog's 27px bar plus its label line. int viz_h; int viz_w; if (spec.is_gradient) { viz_w = 260; viz_h = 200; } else if (spec.tri_weights.size() == 3 && spec.component_colours.size() == 3) { viz_w = 160; viz_h = 160; } else { viz_w = 240; viz_h = spec.component_colours.size() == 2 ? 50 : 30; } const wxSize viz_sz(FromDIP(viz_w), FromDIP(viz_h)); viz->SetMinSize(viz_sz); viz->SetMaxSize(viz_sz); // Parented to the page right above the scroll area, so it is always shown with the tab: // the "Enable" toggle keeps gating only the rows/info below, never this preview. wxSizer* page_sizer = category.page->GetSizer(); int scroll_idx = -1; for (size_t i = 0; i < page_sizer->GetChildren().size(); ++i) if (page_sizer->GetChildren()[i]->GetWindow() == category.scroll) { scroll_idx = int(i); break; } if (scroll_idx < 0) page_sizer->Add(viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); // defensive: no scroll item else page_sizer->Insert(scroll_idx, viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); } void PublishSettingsDialog::set_row_bold(Row& row, bool bold) { // Rebase on the dialog's body font so clearing bold restores the exact original font. row.check->SetFont(bold ? Label::Body_13.Bold() : Label::Body_13); } void PublishSettingsDialog::save_scroll_position(Category& category) { if (category.scroll != nullptr) category.scroll->GetViewStart(&category.scroll_pos.x, &category.scroll_pos.y); } void PublishSettingsDialog::show_outer_page(size_t section_index) { if (section_index >= m_sections.size()) return; if (m_selected_outer >= 0 && m_selected_outer < static_cast(m_sections.size())) { SectionGroup& old_section = m_sections[m_selected_outer]; if (old_section.selected_inner >= 0 && old_section.selected_inner < static_cast(old_section.categories.size())) save_scroll_position(m_categories[old_section.categories[old_section.selected_inner]]); if (old_section.selected_mixed >= 0 && old_section.selected_mixed < static_cast(old_section.mixed_categories.size())) save_scroll_position(m_categories[old_section.mixed_categories[old_section.selected_mixed]]); m_sections[m_selected_outer].page->Hide(); } m_selected_outer = static_cast(section_index); SectionGroup& section = m_sections[section_index]; section.page->Show(); // Restore whichever tab row (physical or mixed) was active. if (section.selected_inner >= 0) show_inner_page(section_index, section.selected_inner); else if (section.selected_mixed >= 0) show_mixed_page(section_index, section.selected_mixed); m_outer_host_sizer->Layout(); } void PublishSettingsDialog::show_inner_page(size_t section_index, int inner_index) { if (section_index >= m_sections.size()) return; SectionGroup& section = m_sections[section_index]; if (inner_index < 0 || inner_index >= static_cast(section.categories.size())) return; if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { save_scroll_position(m_categories[section.categories[section.selected_inner]]); m_categories[section.categories[section.selected_inner]].page->Hide(); } if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); section.selected_mixed = -1; } section.selected_inner = inner_index; Category& category = m_categories[section.categories[inner_index]]; category.page->Show(); category.scroll->FitInside(); category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); if (section.mixed_tabs != nullptr) section.mixed_tabs->Unselect(); section.page_host_sizer->Layout(); } void PublishSettingsDialog::show_mixed_page(size_t section_index, int mixed_index) { if (section_index >= m_sections.size()) return; SectionGroup& section = m_sections[section_index]; if (mixed_index < 0 || mixed_index >= static_cast(section.mixed_categories.size())) return; if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); } if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { save_scroll_position(m_categories[section.categories[section.selected_inner]]); m_categories[section.categories[section.selected_inner]].page->Hide(); section.selected_inner = -1; } section.selected_mixed = mixed_index; Category& category = m_categories[section.mixed_categories[mixed_index]]; category.page->Show(); category.scroll->FitInside(); category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); section.tabs->Unselect(); section.page_host_sizer->Layout(); } void PublishSettingsDialog::on_outer_tab_changed(wxCommandEvent& event) { const int selection = event.GetInt(); if (selection >= 0 && selection < static_cast(m_sections.size())) show_outer_page(static_cast(selection)); } void PublishSettingsDialog::on_inner_tab_changed(size_t section_index, wxCommandEvent& event) { const int selection = event.GetInt(); if (section_index < m_sections.size() && selection >= 0 && selection < static_cast(m_sections[section_index].categories.size())) show_inner_page(section_index, selection); } void PublishSettingsDialog::on_mixed_tab_changed(size_t section_index, wxCommandEvent& event) { const int selection = event.GetInt(); if (section_index < m_sections.size() && selection >= 0 && selection < static_cast(m_sections[section_index].mixed_categories.size())) show_mixed_page(section_index, selection); } void PublishSettingsDialog::bind_tab_events() { m_outer_tabs->Bind(wxEVT_TAB_SEL_CHANGED, &PublishSettingsDialog::on_outer_tab_changed, this); for (size_t section_index = 0; section_index < m_sections.size(); ++section_index) { m_sections[section_index].tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { on_inner_tab_changed(section_index, event); }); if (m_sections[section_index].mixed_tabs != nullptr) m_sections[section_index].mixed_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { on_mixed_tab_changed(section_index, event); }); } } void PublishSettingsDialog::apply_filter(const wxString& filter_text) { m_filter_mode = FilterMode::Text; refresh_filter(filter_text.Lower()); } void PublishSettingsDialog::apply_pseudo_filter(bool selected_only) { m_filter_mode = selected_only ? FilterMode::SelectedOnly : FilterMode::UnselectedOnly; refresh_filter(wxString()); // the pseudo modes ignore the search text } void PublishSettingsDialog::refresh_filter(const wxString& filter) { Freeze(); const bool pseudo = m_filter_mode != FilterMode::Text; const bool want_checked = m_filter_mode == FilterMode::SelectedOnly; m_fb_sizer->Show(!pseudo); if (pseudo) { // "×" marks the chip as a dismissible filter state (U+00D7, present in all UI fonts). m_pseudo_chip->SetLabel((want_checked ? _L("Filter selected") : _L("Filter non-selected")) + " " + wxString::FromUTF8("\u00d7")); } m_pseudo_chip->Show(pseudo); // Row matches are computed first; page and optgroup visibility is applied below. if (pseudo) { for (Row& row : m_rows) row.matches_filter = row.check->IsEnabled() && row.check->GetValue() == want_checked; } else { const bool clear = filter.IsEmpty(); for (Row& row : m_rows) { row.matches_filter = clear || (row.section_title + " " + row.category + " " + row.subcategory + " " + row.label + " " + row.value + " " + row.unit) .Lower() .Contains(filter); } } size_t first_outer = 0; int first_inner = -1; bool active_has_match = false; for (size_t s = 0; s < m_sections.size(); ++s) { for (size_t inner = 0; inner < m_sections[s].categories.size(); ++inner) { Category& category = m_categories[m_sections[s].categories[inner]]; bool has_match = false; for (size_t r : category.rows) has_match = has_match || m_rows[r].matches_filter; category.info->Show(!has_match); if (!has_match) // A mixed slot has no selectable rows: always explain it is published whole. category.info->SetLabel(category.is_mixed ? m_info_mix : (pseudo ? (want_checked ? m_info_nonsel : m_info_allsel) : m_info_empty)); if (has_match && first_inner < 0) { first_outer = s; first_inner = static_cast(inner); } if (static_cast(s) == m_selected_outer && static_cast(inner) == m_sections[s].selected_inner) active_has_match = has_match; } // Mixed slots have no rows: they can never match a filter, so they always fall back to // their explanatory hint (shown once the slot is enabled). for (size_t mixed : m_sections[s].mixed_categories) { Category& category = m_categories[mixed]; category.info->Show(true); category.info->SetLabel(m_info_mix); } } if (!active_has_match && first_inner >= 0 && (m_selected_outer != static_cast(first_outer) || m_sections[first_outer].selected_inner != first_inner)) { if (m_selected_outer != static_cast(first_outer)) { m_outer_tabs->SelectItem(static_cast(first_outer)); show_outer_page(first_outer); } m_sections[first_outer].tabs->SelectItem(first_inner); show_inner_page(first_outer, first_inner); } apply_visibility(); for (Category& category : m_categories) { save_scroll_position(category); category.scroll->FitInside(); category.list_sizer->Layout(); category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); } Layout(); Thaw(); } void PublishSettingsDialog::apply_visibility() { Freeze(); for (Category& category : m_categories) { // A disabled material slot hides everything below its header (rows, info and the Full // Publish line); enable it first to reveal its settings. const bool enabled = category.section != Section::Material || (category.enable_check != nullptr && category.enable_check->GetValue()); if (category.scroll != nullptr) category.scroll->Show(enabled); if (category.full_line_item != nullptr) category.full_line_item->Show(enabled); bool category_any = false; for (size_t r : category.rows) category_any = category_any || m_rows[r].matches_filter; category.info->Show(enabled && !category_any); for (Subcategory& sub : category.subs) { bool sub_any = false; for (size_t r : sub.rows) sub_any = sub_any || m_rows[r].matches_filter; if (sub.header != nullptr) sub.item->Show(enabled && sub_any); for (size_t r : sub.rows) m_rows[r].item->Show(enabled && m_rows[r].matches_filter); } category.list_sizer->Layout(); category.scroll->FitInside(); } Thaw(); } void PublishSettingsDialog::select_all(bool value) { // "All" skips disabled (gated) rows; "None" leaves a gated row's preserved value. for (Row& row : m_rows) if (row.check->IsEnabled()) row.check->SetValue(value); // "All" also enables every material slot (so its rows/Full Publish become visible and the // selection is actually exported); "None" disables them all again. for (Category& cat : m_categories) if (cat.section == Section::Material && cat.enable_check != nullptr) cat.enable_check->SetValue(value); // wxCheckBox::SetValue does not emit wxEVT_CHECKBOX, so re-run the enable handlers to // propagate mixed-slot components and refresh visibility as if the user had clicked. for (size_t c = 0; c < m_categories.size(); ++c) if (m_categories[c].section == Section::Material) on_enable_toggle(c); apply_visibility(); refresh_tab_indicators(); } bool PublishSettingsDialog::row_is_visible(const Row& row) const { if (m_selected_outer < 0 || m_selected_outer >= static_cast(m_sections.size()) || row.outer_index != static_cast(m_selected_outer) || m_sections[m_selected_outer].selected_inner < 0 || m_sections[m_selected_outer].selected_inner >= static_cast(m_sections[m_selected_outer].categories.size()) || row.inner_index != m_sections[m_selected_outer].categories[m_sections[m_selected_outer].selected_inner] || !row.matches_filter || !row.check->IsEnabled()) return false; return row.item->IsShown(); } void PublishSettingsDialog::select_visible(bool value) { // In a pseudo-filter view the rows being toggled would all disappear; drop the filter // afterwards so the result stays visible. const bool clear_pseudo = (m_filter_mode == FilterMode::UnselectedOnly && !value) || (m_filter_mode == FilterMode::SelectedOnly && value); // Toggle the rows visible under the *current* filter. for (Row& row : m_rows) if (row_is_visible(row)) row.check->SetValue(value); if (clear_pseudo) { // Note: SetValue() may fire wxEVT_TEXT on some platforms, re-entering apply_filter() - // that is fine; the rows above were already toggled and the trailing call is idempotent. m_filter_ctrl->ChangeValue(""); apply_filter(""); // resync visibility and the All/None bar } refresh_tab_indicators(); } void PublishSettingsDialog::show_menu(wxMouseEvent& evt) { bool filtering = !m_filter_ctrl->GetValue().IsEmpty() || m_filter_mode != FilterMode::Text; bool list_empty = true; if (m_selected_outer >= 0) { for (const Row& row : m_rows) if (row_is_visible(row)) { list_empty = false; break; } } wxMenu m; m.Append(kPublishSelectAll, _L("Select All"))->Enable(!filtering); m.Append(kPublishDeselectAll, _L("Deselect All"))->Enable(!filtering); m.AppendSeparator(); m.Append(kPublishSelectVisible, _L("Select visible"))->Enable(!list_empty && filtering); m.Append(kPublishDeselectVisible, _L("Deselect visible"))->Enable(!list_empty && filtering); m.AppendSeparator(); m.AppendCheckItem(kPublishFilterSelected, _L("Filter selected")); m.AppendCheckItem(kPublishFilterNonSelected, _L("Filter non-selected")); m.Check(kPublishFilterSelected, m_filter_mode == FilterMode::SelectedOnly); m.Check(kPublishFilterNonSelected, m_filter_mode == FilterMode::UnselectedOnly); m.Bind( wxEVT_MENU, [this](wxCommandEvent& e) { switch (e.GetId()) { case kPublishSelectAll: select_all(true); break; case kPublishDeselectAll: select_all(false); break; case kPublishSelectVisible: select_visible(true); break; case kPublishDeselectVisible: select_visible(false); break; case kPublishFilterSelected: // Clicking the active entry again clears the pseudo filter. if (m_filter_mode == FilterMode::SelectedOnly) apply_filter(m_filter_ctrl->GetValue()); else apply_pseudo_filter(true); break; case kPublishFilterNonSelected: if (m_filter_mode == FilterMode::UnselectedOnly) apply_filter(m_filter_ctrl->GetValue()); else apply_pseudo_filter(false); break; default: break; } }, kPublishSelectAll, kPublishFilterNonSelected); wxWindow* src = dynamic_cast(evt.GetEventObject()); if (!src) return; wxPoint screen_pos = src->ClientToScreen(evt.GetPosition()); wxPoint local_pos = ScreenToClient(screen_pos); PopupMenu(&m, local_pos); } void PublishSettingsDialog::apply_selection(const std::vector& published_keys, const std::vector& material_keys) { // The supplied selection is authoritative: clear the dirty-default pre-check first so a // dirty key the user deselected stays off, then re-select exactly what the selection names. for (Row& row : m_rows) if (row.check != nullptr) row.check->SetValue(false); for (Category& cat : m_categories) { if (cat.section != Section::Material) continue; if (cat.enable_check != nullptr) cat.enable_check->SetValue(false); if (cat.full_check != nullptr) cat.full_check->SetValue(false); } // Print / printer rows: match by exact row key (the full "#N" opt_id where present). for (const std::string& key : published_keys) for (Row& row : m_rows) if ((row.section == Section::Print || row.section == Section::Printer) && row.check != nullptr && row.key == key) { row.check->SetValue(true); break; } // Per-slot material selections, applied positionally by slot. for (const Slic3r::PublishedMaterialEntry& entry : material_keys) { if (entry.slot < 0) continue; bool entry_mixed = false; for (const std::string& k : entry.keys) if (publish_mixed_keys().count(publish_base_key(k)) != 0) { entry_mixed = true; break; } size_t cat_idx = size_t(-1); for (size_t c = 0; c < m_categories.size(); ++c) { const Category& cat = m_categories[c]; if (cat.section != Section::Material || cat.is_mixed != entry_mixed || cat.filament_slot != size_t(entry.slot)) continue; cat_idx = c; break; } if (cat_idx == size_t(-1)) continue; // slot not present in the receiver (out of range / skipped) Category& cat = m_categories[cat_idx]; if (cat.enable_check != nullptr) { cat.enable_check->SetValue(true); on_enable_toggle(cat_idx); } if (entry.full && cat.full_check != nullptr) { cat.full_check->SetValue(true); on_full_toggle(cat_idx); } else { // Setting rows are keyed by the base key. for (const std::string& key : entry.keys) { const std::string base = publish_base_key(key); for (const size_t r : cat.rows) { Row& row = m_rows[r]; if (row.kind == RowKind::Setting && row.key == base) { row.check->SetValue(true); break; } } } if (entry.publish_type && !entry.publish_type_value.empty()) for (const size_t r : cat.rows) if (m_rows[r].kind == RowKind::Type && m_rows[r].check != nullptr) { m_rows[r].check->SetValue(true); break; } if (entry.publish_color && !entry.color.empty()) for (const size_t r : cat.rows) if (m_rows[r].kind == RowKind::Color && m_rows[r].check != nullptr) { m_rows[r].check->SetValue(true); break; } } } apply_visibility(); refresh_tab_indicators(); } std::vector PublishSettingsDialog::GetPublishedKeys() const { std::vector out; // Process and printer sections both travel through published_keys (the load-side overlay // applies process keys to the prints edited preset and the allowlisted printer keys to the // printers edited preset); material keys use a separate API. Printer rows carry the full // per-extruder "#N" opt_id (built in Phase 1), so a checked row publishes exactly that // extruder's value - per-extruder selection is independent. for (const Row& row : m_rows) { if ((row.section != Section::Print && row.section != Section::Printer) || !row.check->GetValue()) continue; out.push_back(row.key); } return out; } std::vector PublishSettingsDialog::unpublished_mixed_components() const { std::vector out; for (const Category& cat : m_categories) { if (!cat.is_mixed || cat.section != Section::Material) continue; // Only enabled mixed slots depend on their components being published. if (cat.enable_check == nullptr || !cat.enable_check->GetValue()) continue; const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); // Components are 1-based physical filament indices. for (const unsigned int component : mixed_slot_components(full, cat.filament_slot)) { const size_t component_slot = size_t(component) - 1; // Find the component slot's material category (one exists per physical slot). const Category* comp_cat = nullptr; for (const Category& other : m_categories) { if (other.section == Section::Material && !other.is_mixed && other.filament_slot == component_slot) { comp_cat = &other; break; } } if (comp_cat == nullptr || comp_cat->enable_check == nullptr) continue; // Missing when the component's "Enable" is off, or it is enabled with neither // "Full Publish" nor the "Type" requirement row checked. Colour never counts: // the receiver renders the mix from its own components' colours. if (!comp_cat->enable_check->GetValue()) { out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::Disabled}); continue; } if (comp_cat->full_check != nullptr && comp_cat->full_check->GetValue()) continue; bool type_checked = false; for (const size_t r : comp_cat->rows) { const Row& row = m_rows[r]; if (row.kind == RowKind::Type && row.check->GetValue()) { type_checked = true; break; } } if (!type_checked) out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::MaterialNotPublished}); } } // Deterministic order for the warning rows: by mixed slot, then component slot. The same // (mix, component) pair cannot repeat: each mix's components come from a config list. std::sort(out.begin(), out.end(), [](const MixedDependencyIssue& a, const MixedDependencyIssue& b) { return std::tie(a.mixed_slot, a.component_slot) < std::tie(b.mixed_slot, b.component_slot); }); return out; } std::vector PublishSettingsDialog::GetPublishedMaterialKeys() const { std::vector out; for (const Category& cat : m_categories) { if (cat.section != Section::Material) continue; // A slot that is not "Enable"d publishes nothing at all. if (cat.enable_check != nullptr && !cat.enable_check->GetValue()) continue; Slic3r::PublishedMaterialEntry entry; entry.filament_type = cat.filament_type; entry.filament_vendor = cat.filament_vendor; entry.filament_id = cat.filament_id; entry.slot = static_cast(cat.filament_slot); // The author's preset id distinguishes exact variants that share filament_id // ("Generic PLA" vs "Generic PLA Matte"), so the receiver can match precisely; the // preset name is the most direct identity and is matched first on load. PresetBundle* bundle = wxGetApp().preset_bundle; if (bundle != nullptr && cat.filament_slot < bundle->filament_presets.size()) { if (const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[cat.filament_slot], false, true)) { entry.setting_id = preset->setting_id; entry.preset_name = preset->name; } } // A mixed slot publishes as one unit: its definition (components, ratios, gradient) // always travels (its Enable implies this), and the component filaments are enabled + // Full Published by on_enable_toggle into their own entries. if (cat.is_mixed) { Slic3r::PublishedMaterialEntry mixed_entry; mixed_entry.filament_type = cat.filament_type; mixed_entry.filament_vendor = cat.filament_vendor; mixed_entry.filament_id = cat.filament_id; mixed_entry.slot = static_cast(cat.filament_slot); // The mix's own colour (blended) is a property of the definition, not a // requirement row; carry it so the receiver renders the swatch. const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); const std::string mix_color = filament_color_hex(full_cfg, cat.filament_slot); if (!mix_color.empty()) { mixed_entry.publish_color = true; mixed_entry.color = mix_color; } for (const std::string& key : publish_mixed_keys()) mixed_entry.keys.emplace_back(key); out.push_back(std::move(mixed_entry)); continue; } // "Full Publish": the whole filament preset is embedded; type and colour are implicitly // published, and the per-key rows are disabled / their state ignored. if (cat.full_check != nullptr && cat.full_check->GetValue()) { entry.full = true; entry.full_keys = full_keys_for_slot(); entry.publish_type = true; entry.publish_type_value = normalize_filament_type(cat.filament_type); for (size_t r : cat.rows) { const Row& row = m_rows[r]; if (row.kind == RowKind::Color && !row.value.IsEmpty()) { entry.publish_color = true; entry.color = row.value.ToStdString(); } } out.push_back(std::move(entry)); continue; } for (size_t r : cat.rows) { const Row& row = m_rows[r]; if (!row.check->GetValue()) continue; if (row.kind == RowKind::Color) { entry.publish_color = true; entry.color = row.value.ToStdString(); } else if (row.kind == RowKind::Type) { entry.publish_type = true; entry.publish_type_value = row.value.ToStdString(); } else { entry.keys.push_back(row.key); } } // Nothing checked at all -> nothing to write. if (!entry.keys.empty() || entry.publish_type || entry.publish_color) out.push_back(std::move(entry)); } return out; } bool PublishSettingsDialog::category_has_selection(const Category& cat) const { // A material slot publishes (or not) as a whole, gated by "Enable". if (cat.section == Section::Material) return cat.enable_check != nullptr && cat.enable_check->GetValue(); // Print/Printer categories have no gate: any checked row counts. for (const size_t r : cat.rows) if (m_rows[r].check->GetValue()) return true; return false; } void PublishSettingsDialog::refresh_tab_indicators() { for (size_t s = 0; s < m_sections.size(); ++s) { SectionGroup& section = m_sections[s]; bool any = false; for (size_t i = 0; i < section.categories.size(); ++i) { const bool on = category_has_selection(m_categories[section.categories[i]]); section.tabs->SetItemIndicator(static_cast(i), on); any = any || on; } if (section.mixed_tabs != nullptr) for (size_t i = 0; i < section.mixed_categories.size(); ++i) { const bool on = category_has_selection(m_categories[section.mixed_categories[i]]); section.mixed_tabs->SetItemIndicator(static_cast(i), on); any = any || on; } m_outer_tabs->SetItemIndicator(static_cast(s), any); } } std::vector PublishSettingsDialog::full_keys_for_slot() const { // The canonical filament preset keys minus the structural keys the published overlay must // never touch (inherits, compatibility, *_settings_id, ...), plus filament_colour (not a // member of Preset::filament_options). Values travel in the exported config, masked to // this slot, and are applied on load onto the receiver's slot. const std::set& denylist = publish_structural_keys(); std::vector keys; for (const std::string& key : Preset::filament_options()) if (denylist.count(key) == 0) keys.emplace_back(key); keys.emplace_back("filament_colour"); return keys; } void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) { // Rescale toolbar bitmaps and icons; collapse chevrons are vector-drawn and repaint. m_search.msw_rescale(); m_menu.msw_rescale(); m_filter_box->SetIcon(m_search.bmp()); m_menu_button->SetBitmap(m_menu.bmp()); m_outer_tabs->Rescale(); const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); for (Category& cat : m_categories) { if (cat.full_check != nullptr) cat.full_check->Refresh(); if (cat.enable_check != nullptr) cat.enable_check->Refresh(); if (cat.title_label != nullptr) cat.title_label->Refresh(); if (cat.filament_color_chip != nullptr) { // Mixed pages have no header chip; this only ever fires for physical slots. if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, cat.filament_slot), std::to_string(cat.filament_slot + 1), FromDIP(20), FromDIP(20))) { cat.filament_color_chip->SetBitmap(*chip); } } cat.scroll->FitInside(); cat.list_sizer->Layout(); } for (size_t s = 0; s < m_sections.size(); ++s) { SectionGroup& section = m_sections[s]; section.icon_bmp.msw_rescale(); if (section.icon_bmp.bmp().IsOk()) m_outer_tabs->SetItemBitmap(s, section.icon_bmp.bmp()); section.tabs->Rescale(); if (section.mixed_tabs != nullptr) section.mixed_tabs->Rescale(); } // Refresh the per-row Color chips at the new DPI (they carry the slot number too). for (Row& row : m_rows) { if (row.color_chip != nullptr && !row.value.IsEmpty()) { const std::string chip_label = (row.inner_index < m_categories.size()) ? std::to_string(m_categories[row.inner_index].filament_slot + 1) : ""; if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) row.color_chip->SetBitmap(*chip); } } for (size_t category_index = 0; category_index < m_categories.size(); ++category_index) { const Category& category = m_categories[category_index]; if (category.section != Section::Material || category.is_mixed) continue; if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, category.filament_slot), std::to_string(category.filament_slot + 1), FromDIP(20), FromDIP(20))) { const SectionGroup& section = m_sections[category.group]; const auto iter = std::find(section.categories.begin(), section.categories.end(), category_index); if (iter != section.categories.end()) m_sections[category.group].tabs->SetItemBitmap(static_cast(iter - section.categories.begin()), *chip); } } refresh_mixed_tab_bitmaps(); fit_to_content(); // tab buttons' min widths grew with the DPI: re-fit (incl. resize floor) Refresh(); } void PublishSettingsDialog::refresh_mixed_tab_bitmaps() { // The mixed-tab composition bitmaps bake the tab strip's background colour and DPI-scaled // text, so both a theme flip and a DPI change have to rebuild them. const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); for (SectionGroup& section : m_sections) { if (section.mixed_tabs == nullptr) continue; const wxColour tab_bg = section.mixed_tabs->GetBackgroundColour(); for (size_t i = 0; i < section.mixed_categories.size(); ++i) { const Category& category = m_categories[section.mixed_categories[i]]; const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full, category.filament_slot, FromDIP(20), tab_bg); if (tab_bmp.IsOk()) section.mixed_tabs->SetItemBitmap(static_cast(i), tab_bmp); } } } void PublishSettingsDialog::on_sys_color_changed() { // The mixed-tab compositions bake the tab strip's background colour: rebuild them when the // theme changes (on_dpi_changed covers rescales). refresh_mixed_tab_bitmaps(); Refresh(); } }} // namespace Slic3r::GUI