mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 13:32:44 +00:00
2199 lines
107 KiB
C++
2199 lines
107 KiB
C++
#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 <boost/algorithm/string/trim.hpp>
|
||
#include <wx/display.h>
|
||
#include <wx/utils.h>
|
||
#include <wx/dcbuffer.h>
|
||
#include <wx/dcmemory.h>
|
||
#include <wx/dcgraph.h>
|
||
#include <wx/image.h>
|
||
#include <set>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstring>
|
||
#include <memory>
|
||
#include <numeric>
|
||
#include <tuple>
|
||
#include <utility>
|
||
|
||
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<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_type"))
|
||
if (slot < types->size())
|
||
identity.type = types->get_at(slot);
|
||
if (const auto* vendors = full.opt<ConfigOptionStrings>("filament_vendor"))
|
||
if (slot < vendors->size())
|
||
identity.vendor = vendors->get_at(slot);
|
||
if (const auto* ids = full.opt<ConfigOptionStrings>("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<unsigned int> mixed_slot_components(const DynamicPrintConfig& full, size_t slot)
|
||
{
|
||
const auto* comp_opt = full.opt<ConfigOptionStrings>("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<unsigned int> comps = mixed_slot_components(full, slot);
|
||
if (comps.empty())
|
||
return _L("Mixed filament");
|
||
const auto* grad_opt = full.opt<ConfigOptionBools>("filament_mixed_gradient");
|
||
const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot];
|
||
const auto* ratios_opt = full.opt<ConfigOptionStrings>("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<double> 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<wxColour> colors;
|
||
if (const auto* colours = full.opt<ConfigOptionStrings>("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<ConfigOptionBools>("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<wxColour> ramp = mixed_gradient_ramp(full, slot, swatch_sz);
|
||
if (!ramp.empty()) {
|
||
icon = get_extruder_color_icon(std::vector<std::string>(), true, label, swatch_sz, swatch_sz, &ramp);
|
||
} else {
|
||
std::vector<std::string> 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<unsigned int> comps = mixed_slot_components(full, slot);
|
||
if (comps.empty())
|
||
return wxNullBitmap;
|
||
const auto* grad_opt = full.opt<ConfigOptionBools>("filament_mixed_gradient");
|
||
const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot];
|
||
const auto* ratios_opt = full.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios");
|
||
std::vector<double> ratio_pct(comps.size(), 100.0 / comps.size());
|
||
if (!is_gradient && ratios_opt != nullptr && slot < ratios_opt->size()) {
|
||
const std::vector<double> 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<ConfigOptionStrings>("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<Piece> 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<size_t> project_used_filament_slots(const PresetBundle& bundle, const DynamicPrintConfig& full, const Model& model)
|
||
{
|
||
std::set<size_t> 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<ConfigOptionBools>("filament_is_mixed");
|
||
const auto* comp_opt = full.opt<ConfigOptionStrings>("filament_mixed_components");
|
||
if (is_mixed_opt != nullptr && comp_opt != nullptr && has_any_mixed_filament(is_mixed_opt->values)) {
|
||
const std::vector<unsigned int> raw(used.begin(), used.end());
|
||
const std::vector<unsigned int> expanded = expand_mixed_filaments(raw, is_mixed_opt->values, comp_opt->values);
|
||
std::set<size_t> 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<MixedDependencyIssue>& 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<unsigned int> comps = mixed_slot_components(full, slot);
|
||
if (comps.empty())
|
||
return spec;
|
||
|
||
const auto* grad_opt = full.opt<ConfigOptionBools>("filament_mixed_gradient");
|
||
spec.is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot];
|
||
|
||
const auto* colours = full.opt<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_mixed_sublayer_ratios"))
|
||
if (slot < ratios_opt->size()) {
|
||
const std::vector<double> 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<std::string>* published_keys,
|
||
const std::vector<Slic3r::PublishedMaterialEntry>* material_keys)
|
||
: DPIDialog(parent ? parent : static_cast<wxWindow*>(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<std::string>(),
|
||
material_keys != nullptr ? *material_keys : std::vector<Slic3r::PublishedMaterialEntry>());
|
||
|
||
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<MixedDependencyIssue> 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/@OfficialOrcaSlicer/videos"), 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<std::string>& 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<std::string> 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<std::string> 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<ConfigOptionBools>("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<size_t> 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<ConfigOptionStrings>("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<std::string> 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<const ConfigOptionVectorBase*>(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<std::string> 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());
|
||
// The mixed tabs carry full swatch compositions: give them a touch more room than the
|
||
// filament tabs so neighbouring compositions stay distinguishable (must precede AppendItem).
|
||
section.mixed_tabs->SetItemSpace(FromDIP(3));
|
||
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<unsigned int> 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<double> 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<MixedGradientCurve> curves;
|
||
std::vector<wxPoint2DDouble> 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<wxPoint2DDouble> 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<int>(m_sections.size())) {
|
||
SectionGroup& old_section = m_sections[m_selected_outer];
|
||
if (old_section.selected_inner >= 0 && old_section.selected_inner < static_cast<int>(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<int>(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<int>(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<int>(section.categories.size()))
|
||
return;
|
||
if (section.selected_inner >= 0 && section.selected_inner < static_cast<int>(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<int>(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<int>(section.mixed_categories.size()))
|
||
return;
|
||
if (section.selected_mixed >= 0 && section.selected_mixed < static_cast<int>(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<int>(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<int>(m_sections.size()))
|
||
show_outer_page(static_cast<size_t>(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<int>(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<int>(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<int>(inner);
|
||
}
|
||
if (static_cast<int>(s) == m_selected_outer && static_cast<int>(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<int>(first_outer) || m_sections[first_outer].selected_inner != first_inner)) {
|
||
if (m_selected_outer != static_cast<int>(first_outer)) {
|
||
m_outer_tabs->SelectItem(static_cast<int>(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<int>(m_sections.size()) ||
|
||
row.outer_index != static_cast<size_t>(m_selected_outer) || m_sections[m_selected_outer].selected_inner < 0 ||
|
||
m_sections[m_selected_outer].selected_inner >= static_cast<int>(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<wxWindow*>(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<std::string>& published_keys,
|
||
const std::vector<Slic3r::PublishedMaterialEntry>& 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<std::string> PublishSettingsDialog::GetPublishedKeys() const
|
||
{
|
||
std::vector<std::string> 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<MixedDependencyIssue> PublishSettingsDialog::unpublished_mixed_components() const
|
||
{
|
||
std::vector<MixedDependencyIssue> 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<Slic3r::PublishedMaterialEntry> PublishSettingsDialog::GetPublishedMaterialKeys() const
|
||
{
|
||
std::vector<Slic3r::PublishedMaterialEntry> 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<int>(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<int>(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<unsigned int>(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<unsigned int>(i), on);
|
||
any = any || on;
|
||
}
|
||
m_outer_tabs->SetItemIndicator(static_cast<unsigned int>(s), any);
|
||
}
|
||
}
|
||
|
||
std::vector<std::string> 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<std::string>& denylist = publish_structural_keys();
|
||
std::vector<std::string> 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<unsigned int>(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<unsigned int>(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
|