mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
Move the IMEX modes editor into its own file and fix four defects in it
Closes review comments 21, 4 and 12, and the widget half of 19.
- 21: IMEXModesCtrl was 572 lines inside Tab.cpp. It now lives in
IMEXModesCtrl.{hpp,cpp} next to IMEXFilamentPickerPopover, which was the
precedent named in the comment. The move itself is exact -- member order,
comments and every string literal unchanged -- and the class had no file-local
dependencies in Tab.cpp, only its include list, so the new source states those
explicitly.
- 4: a mode row with an empty Name was silently dropped on save, tools and G-code
with it, and matches_config() compared against that same filtered output so the
preset never went dirty and the row stayed on screen. Rows are now given a
generated unique name instead of being discarded, and add_row() pre-fills one so
the common path never produces a blank. Names are deliberately not translated:
objects store a mode name in imex_parallel_mode and GCode.cpp matches it by
string, so a localized name would break a project reopened in another language.
- 12: the editor had a third parser that read a bare token and an unknown role
suffix as Primary, while parse_imex_active_tools reads both as Copy -- so the
editor and the slicer could read one imex_mode_active_tools string two different
ways. Deleted; the editor now uses the same two helpers the slicer does.
- 19: tile state was an int shadowing ImexRole, with the role letters duplicated in
a second switch that wrote the on-disk format. The tile now holds
optional<ImexRole>, with Inactive spelled as the absence of a role rather than a
fifth integer, and the letters come from kImexRoleTable.
Four further changes, from testing rather than the review:
- Deleting a mode reported only the row's current name, so renaming a mode and then
deleting it left every plate using it stranded on a name that no longer exists.
Both the build-time and current names are now reported, minus any a surviving row
still carries.
- The instruction text and colour legend were built once in the constructor and
never rebuilt, so raising gantry count to 2 gave the tiles a Span role the legend
never explained until the preset was saved and the page reopened. Both are
rebuilt with the grid, and the per-role detail moved into legend tooltips so the
panel no longer opens with a paragraph.
- The tile holding Primary is now read-only. Primary is tool 0 and moves only via
Tool 0 Position; a click could previously demote the only Primary, leaving a mode
that parses to no primary at all, which degrades the plate to an ordinary
single-tool print with nothing in the editor showing what is wrong. A mode
arriving without a Primary keeps every tile live so it can still be repaired.
- Names and G-code were read with ToStdString() (the ANSI codepage on Windows) and
written with from_u8() (UTF-8). On a non-UTF-8 codepage a name like "Modus A"
with a diaeresis was stored as invalid UTF-8, came back blank, and was then
silently renamed by the auto-naming above. Every read is now into_u8() and every
write from_u8(); EditGCodeDialog was affected in both directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5b30af463d
commit
f75bdd6013
@@ -274,6 +274,8 @@ set(SLIC3R_GUI_SOURCES
|
||||
GUI/IconManager.hpp
|
||||
GUI/IMEXFilamentPickerPopover.cpp
|
||||
GUI/IMEXFilamentPickerPopover.hpp
|
||||
GUI/IMEXModesCtrl.cpp
|
||||
GUI/IMEXModesCtrl.hpp
|
||||
GUI/ImageGrid.cpp
|
||||
GUI/ImageGrid.h
|
||||
GUI/ImGuiWrapper.cpp
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
#include "slic3r/GUI/IMEXModesCtrl.hpp"
|
||||
|
||||
#include <wx/app.h>
|
||||
#include <wx/font.h>
|
||||
#include <wx/stattext.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include "slic3r/GUI/EditGCodeDialog.hpp"
|
||||
#include "slic3r/GUI/GUI.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/wxExtensions.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
namespace GUI {
|
||||
|
||||
IMEXModesCtrl::IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout)
|
||||
: wxPanel(parent, wxID_ANY), m_n_cols(std::max(1, n_cols)), m_n_rows(std::max(1, n_rows)), m_layout(layout)
|
||||
{
|
||||
// Pull the app's window-default colour explicitly. Without this, GTK gives
|
||||
// child wxPanels a slightly lighter "widget bg" instead of the app's dark
|
||||
// theme — making chromeless ScalableButtons inside the panel render with a
|
||||
// visible light box around the icon. Sub-panels inherit this colour.
|
||||
SetBackgroundColour(wxGetApp().get_window_default_clr());
|
||||
|
||||
m_outer = new wxBoxSizer(wxVERTICAL);
|
||||
m_rows_sizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
// Info panel (instructions + legend) and the column-header row. Both depend on the grid
|
||||
// shape, so their CONTENTS are (re)built by rebuild_info_and_header(), which set_grid_size()
|
||||
// calls again whenever the grid changes. The panels themselves are stable so m_outer's
|
||||
// ordering never has to be rearranged.
|
||||
m_info_panel = new wxPanel(this, wxID_ANY);
|
||||
m_info_panel->SetBackgroundColour(GetBackgroundColour());
|
||||
m_hdr_panel = new wxPanel(this, wxID_ANY);
|
||||
m_hdr_panel->SetBackgroundColour(GetBackgroundColour());
|
||||
rebuild_info_and_header();
|
||||
|
||||
auto* add_btn = new wxButton(this, wxID_ANY, _L("+ Add Mode"),
|
||||
wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
|
||||
add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); });
|
||||
|
||||
m_outer->Add(m_info_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4));
|
||||
m_outer->Add(m_hdr_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(2));
|
||||
m_outer->Add(m_rows_sizer, 0, wxEXPAND);
|
||||
m_outer->AddSpacer(FromDIP(4));
|
||||
m_outer->Add(add_btn, 0);
|
||||
// SetSizer(), not SetSizerAndFit(): no row exists yet, so there is nothing to fit to --
|
||||
// rows arrive from add_row() / load_from_config() / set_grid_size(). Deliberately NOT
|
||||
// followed by m_outer->SetSizeHints(this) once they do: this panel owns a sizer, so
|
||||
// wxWindow::GetBestSize() bypasses the best-size cache and re-runs m_outer->CalcMin()
|
||||
// on every parent layout, and that is what the enclosing sizer already reserves space
|
||||
// for via GetEffectiveMinSize(). An explicitly set min size takes PRIORITY over the best
|
||||
// size in that call, so a size hint would pin the reserved height to the row count that
|
||||
// happened to be on screen when it ran -- adding a mode after that would clip the bottom
|
||||
// row and the "+ Add Mode" button, which is the very failure the hint is meant to avoid.
|
||||
SetSizer(m_outer);
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::rebuild_info_and_header() {
|
||||
// --- Info panel: instruction text + colour legend ---
|
||||
m_info_panel->DestroyChildren();
|
||||
auto* info_sizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
// Keep this to one line. Per-role detail lives in the legend tooltips below, so the
|
||||
// panel does not open with a paragraph the user has to read before touching anything.
|
||||
const wxString instructions =
|
||||
_L("Each mode names the tool heads that take part and the role each one plays. "
|
||||
"Click a tool button to cycle its role — hover a colour below for what each role does.");
|
||||
auto* inst = new wxStaticText(m_info_panel, wxID_ANY, instructions);
|
||||
inst->Wrap(FromDIP(620));
|
||||
info_sizer->Add(inst, 0, wxBOTTOM, FromDIP(6));
|
||||
|
||||
// Color legend — swatches sized to the body text height so they read as
|
||||
// matched pairs with their labels regardless of system DPI / font scale,
|
||||
// matching the on-hover ghost tooltip swatch's visual weight.
|
||||
// One entry per role this printer offers, in kImexRoleTable order — the same order the
|
||||
// tiles cycle in. A role added to that table appears here with no edit of its own.
|
||||
auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
const int swatch_side = m_info_panel->GetCharHeight();
|
||||
for (const ImexRoleDesc& d : kImexRoleTable) {
|
||||
if (!role_offered(d.role)) continue;
|
||||
const RoleStyle style = role_style(d.role);
|
||||
auto* swatch = new wxPanel(m_info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side));
|
||||
swatch->SetBackgroundColour(style.bg);
|
||||
auto* leg_label = new wxStaticText(m_info_panel, wxID_ANY, style.label);
|
||||
const wxString hint = role_hint(d.role);
|
||||
swatch->SetToolTip(hint);
|
||||
leg_label->SetToolTip(hint);
|
||||
leg_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
|
||||
leg_sizer->Add(leg_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(16));
|
||||
}
|
||||
info_sizer->Add(leg_sizer, 0, wxBOTTOM, FromDIP(6));
|
||||
m_info_panel->SetSizerAndFit(info_sizer);
|
||||
|
||||
// --- Column header row ---
|
||||
// Spacers sized to align with the mode-row fields below. Name field: FromDIP(130) + FromDIP(6)
|
||||
// gap; tool grid: n_cols*(FromDIP(36)+FromDIP(2))-FromDIP(2) + FromDIP(6) gap. grid_px tracks
|
||||
// the live column count, so the G-code header stays aligned after a grid change.
|
||||
m_hdr_panel->DestroyChildren();
|
||||
auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto* hdr_name = new wxStaticText(m_hdr_panel, wxID_ANY, _L("Name"));
|
||||
auto* hdr_tools = new wxStaticText(m_hdr_panel, wxID_ANY, _L("Tools"));
|
||||
auto* hdr_gcode = new wxStaticText(m_hdr_panel, wxID_ANY, _L("G-code"));
|
||||
hdr_name->SetToolTip(_L("How the mode is labelled in the plate's IMEX mode selector. "
|
||||
"Required — a mode with no name cannot be selected."));
|
||||
hdr_tools->SetToolTip(_L("Which tool heads take part in the mode and what role each one plays. "
|
||||
"Click a tile to cycle its role."));
|
||||
hdr_gcode->SetToolTip(_L("G-code run at print start to put the printer into this mode "
|
||||
"(for example a Klipper SET_PRINT_MODE call). The slicer only emits "
|
||||
"the Primary tool's paths; the firmware drives the others."));
|
||||
const int grid_px = m_n_cols * FromDIP(38) - FromDIP(2); // approx grid panel width
|
||||
const int name_col_px = FromDIP(136);
|
||||
hdr_sizer->Add(hdr_name, 0, wxALIGN_CENTER_VERTICAL);
|
||||
hdr_sizer->AddSpacer(std::max(0, name_col_px - hdr_name->GetBestSize().x));
|
||||
hdr_sizer->Add(hdr_tools, 0, wxALIGN_CENTER_VERTICAL);
|
||||
hdr_sizer->AddSpacer(std::max(0, grid_px + FromDIP(6) - hdr_tools->GetBestSize().x));
|
||||
hdr_sizer->Add(hdr_gcode, 1, wxALIGN_CENTER_VERTICAL);
|
||||
m_hdr_panel->SetSizerAndFit(hdr_sizer);
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::set_grid_size(int n_cols, int n_rows, int layout) {
|
||||
n_cols = std::max(1, n_cols);
|
||||
n_rows = std::max(1, n_rows);
|
||||
if (layout < 0) layout = m_layout;
|
||||
if (n_cols == m_n_cols && n_rows == m_n_rows && layout == m_layout) return;
|
||||
auto [names, tools, gcodes] = get_mode_data();
|
||||
clear_rows();
|
||||
m_n_cols = n_cols;
|
||||
m_n_rows = n_rows;
|
||||
m_layout = layout;
|
||||
// Span becomes available the moment a second gantry exists, and the header spacing tracks the
|
||||
// column count -- so the legend and headers are rebuilt here rather than only at construction.
|
||||
// Without this, adding a gantry gives the tiles a Span role the legend never explains until
|
||||
// the preset is saved and the page reopened.
|
||||
rebuild_info_and_header();
|
||||
for (size_t i = 0; i < names.size(); ++i)
|
||||
add_row(names[i], tools[i], gcodes[i], /*is_primary=*/(names[i] == kImexPrimaryMode));
|
||||
Layout();
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::load_from_config(const DynamicPrintConfig& cfg) {
|
||||
clear_rows();
|
||||
// One ImexMode per row of imex_mode_names, with any sibling array too short for a row
|
||||
// padded to an empty string -- exactly what the three per-index bounds checks this
|
||||
// replaced were doing, now in one place shared with every other IMEX consumer.
|
||||
const std::vector<ImexMode> table = imex_mode_table(cfg);
|
||||
|
||||
// Primary row is always first and non-deletable. Look for an existing
|
||||
// "primary" entry in the config (present in configs saved after #8 was
|
||||
// implemented); fall back to empty tool/gcode for older configs.
|
||||
const ImexMode primary = find_imex_mode(cfg, kImexPrimaryMode);
|
||||
add_row(kImexPrimaryMode, primary.active_tools, primary.gcode, /*is_primary=*/true);
|
||||
|
||||
for (const ImexMode& m : table) {
|
||||
if (m.index == primary.index) continue; // already added above
|
||||
add_row(m.name, m.active_tools, m.gcode);
|
||||
}
|
||||
refresh_reset_buttons();
|
||||
Layout();
|
||||
}
|
||||
|
||||
std::tuple<std::vector<std::string>, std::vector<std::string>, std::vector<std::string>>
|
||||
IMEXModesCtrl::get_mode_data() const {
|
||||
std::vector<std::string> names, tools, gcodes;
|
||||
for (auto& r : m_rows) {
|
||||
// into_u8(), not ToStdString(): everything downstream of here -- the preset, the 3MF
|
||||
// metadata, the plate's stored mode name -- is UTF-8, while ToStdString() encodes
|
||||
// through wxConvLibc, which on Windows is the ANSI codepage. The two agree only on a
|
||||
// UTF-8 locale, so mixing them turns a non-ASCII mode name into mojibake (or an empty
|
||||
// field, once from_u8() rejects it) on the round trip through the preset.
|
||||
std::string nm = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetValue());
|
||||
if (nm.empty()) nm = unique_mode_name(names);
|
||||
names.push_back(nm);
|
||||
tools.push_back(active_tools_string(r));
|
||||
gcodes.push_back(into_u8(r.gcode->GetValue()));
|
||||
}
|
||||
return {names, tools, gcodes};
|
||||
}
|
||||
|
||||
bool IMEXModesCtrl::matches_config(const DynamicPrintConfig& cfg) const {
|
||||
auto [names, tools, gcodes] = get_mode_data();
|
||||
auto cfg_strings = [&cfg](const char* key) {
|
||||
std::vector<std::string> v;
|
||||
if (auto* o = cfg.option<ConfigOptionStrings>(key)) v = o->values;
|
||||
return v;
|
||||
};
|
||||
return names == cfg_strings("imex_mode_names")
|
||||
&& tools == cfg_strings("imex_mode_active_tools")
|
||||
&& gcodes == cfg_strings("imex_mode_gcodes");
|
||||
}
|
||||
|
||||
std::map<int, ImexRole> IMEXModesCtrl::roles_for_mode(const std::string& active_tools) {
|
||||
std::map<int, ImexRole> roles;
|
||||
for (const auto& [phys, role] : parse_imex_active_tools(active_tools))
|
||||
roles[phys] = role;
|
||||
// Promote the mode's primary, but never invent a tool that isn't in the
|
||||
// slicer's own active-tool list: parse_imex_active_tools() drops negative and
|
||||
// out-of-range indices, imex_primary_tool_for_mode() does not range-check the
|
||||
// upper bound, and a junk index round-tripped back out on save would be a
|
||||
// token the slicer ignores.
|
||||
const int primary = imex_primary_tool_for_mode(active_tools);
|
||||
if (primary >= 0) {
|
||||
auto it = roles.find(primary);
|
||||
if (it != roles.end())
|
||||
it->second = ImexRole::Primary;
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
std::string IMEXModesCtrl::unique_mode_name(const std::vector<std::string>& also_taken) const {
|
||||
auto is_taken = [&](const std::string& cand) {
|
||||
if (std::find(also_taken.begin(), also_taken.end(), cand) != also_taken.end())
|
||||
return true;
|
||||
for (const auto& r : m_rows)
|
||||
if (!r.is_primary && r.name && into_u8(r.name->GetValue()) == cand)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
for (int n = 2; ; ++n) {
|
||||
std::string cand = "Mode " + std::to_string(n);
|
||||
if (!is_taken(cand))
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
|
||||
IMEXModesCtrl::RoleStyle IMEXModesCtrl::role_style(std::optional<ImexRole> role) {
|
||||
// Grey / "Inactive" is the no-role answer; every role gets an explicit case so a new
|
||||
// one is a compile-time -Wswitch prompt rather than a tile that silently renders grey.
|
||||
if (!role)
|
||||
return { wxColour(90, 90, 90), *wxWHITE, "Inactive" };
|
||||
switch (*role) {
|
||||
case ImexRole::Primary: return { wxColour(50, 160, 50), *wxWHITE, "Primary" }; // green
|
||||
case ImexRole::Copy: return { wxColour(60, 120, 210), *wxWHITE, "Copy" }; // blue
|
||||
case ImexRole::Mirror: return { wxColour(210, 130, 20), *wxWHITE, "Mirror" }; // amber
|
||||
case ImexRole::Span: return { wxColour(180, 180, 40), *wxWHITE, "Span" }; // yellow
|
||||
}
|
||||
return { wxColour(90, 90, 90), *wxWHITE, "Inactive" }; // keeps every compiler quiet
|
||||
}
|
||||
|
||||
wxString IMEXModesCtrl::role_hint(ImexRole role) {
|
||||
switch (role) {
|
||||
case ImexRole::Primary:
|
||||
return _L("Drives every sliced path. Always tool 0 — use Tool 0 Position above to choose "
|
||||
"which corner of the bed it occupies. It cannot be moved from the grid.");
|
||||
case ImexRole::Copy:
|
||||
return _L("Follows the Primary at the firmware level, printing the same paths offset into "
|
||||
"its own zone.");
|
||||
case ImexRole::Mirror:
|
||||
return _L("Follows the Primary at the firmware level, printing the same paths reflected "
|
||||
"across the boundary between the two zones.");
|
||||
case ImexRole::Span:
|
||||
return _L("Marks a tool on the Primary's gantry as its multicolor partner. Required to "
|
||||
"allow multi-color printing in paired-gantry modes.");
|
||||
}
|
||||
return wxEmptyString;
|
||||
}
|
||||
|
||||
bool IMEXModesCtrl::role_offered(ImexRole role) const {
|
||||
// Span declares a within-gantry multicolor partner of the Primary, which only means
|
||||
// something when there is a second gantry running in parallel with it.
|
||||
if (role == ImexRole::Span)
|
||||
return m_n_rows >= 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IMEXModesCtrl::role_allowed_on_tile(ImexRole role, int tool_idx,
|
||||
bool other_primary, int primary_gantry) const {
|
||||
if (!role_offered(role))
|
||||
return false;
|
||||
// Exactly one tile drives the sliced paths.
|
||||
if (role == ImexRole::Primary)
|
||||
return !other_primary;
|
||||
// Span only on a tile sharing the Primary's gantry — that is what the marker declares.
|
||||
if (role == ImexRole::Span)
|
||||
return primary_gantry >= 0 && (tool_idx / m_n_cols) == primary_gantry;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<ImexRole> IMEXModesCtrl::next_tile_role(std::optional<ImexRole> current,
|
||||
const std::function<bool(ImexRole)>& allowed) {
|
||||
const int n = (int)(sizeof(kImexRoleTable) / sizeof(kImexRoleTable[0]));
|
||||
int start = 0; // no current role (Inactive) → start at the first role
|
||||
if (current) {
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (kImexRoleTable[i].role == *current) { start = i + 1; break; }
|
||||
}
|
||||
for (int i = start; i < n; ++i)
|
||||
if (allowed(kImexRoleTable[i].role))
|
||||
return kImexRoleTable[i].role;
|
||||
return std::nullopt; // walked off the end → back to Inactive
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::apply_btn(wxButton* btn, int tool_idx, std::optional<ImexRole> role) {
|
||||
// Always label as T{n} — the button color already encodes the role.
|
||||
const RoleStyle style = role_style(role);
|
||||
btn->SetLabel(wxString::Format("T%d", tool_idx));
|
||||
btn->SetBackgroundColour(style.bg);
|
||||
btn->SetForegroundColour(style.fg);
|
||||
btn->Refresh();
|
||||
}
|
||||
|
||||
IMEXModesCtrl::RowSnapshot IMEXModesCtrl::snapshot_row(const Row& r) const {
|
||||
RowSnapshot s;
|
||||
// UTF-8 throughout -- this snapshot is compared against the preset's own values.
|
||||
s.name = r.is_primary ? std::string(kImexPrimaryMode) : into_u8(r.name->GetValue());
|
||||
s.tools = active_tools_string(r);
|
||||
s.gcode = into_u8(r.gcode->GetValue());
|
||||
return s;
|
||||
}
|
||||
|
||||
bool IMEXModesCtrl::row_differs_from_parent(int row_idx) const {
|
||||
if (!m_parent_lookup) return false;
|
||||
const DynamicPrintConfig* parent = m_parent_lookup();
|
||||
if (!parent) return false;
|
||||
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
|
||||
auto* p_tools = parent->option<ConfigOptionStrings>("imex_mode_active_tools");
|
||||
auto* p_gcodes = parent->option<ConfigOptionStrings>("imex_mode_gcodes");
|
||||
if (!p_names || row_idx < 0 || row_idx >= (int)p_names->values.size()) return false;
|
||||
const Row& r = m_rows[row_idx];
|
||||
const RowSnapshot s = snapshot_row(r);
|
||||
if (!r.is_primary && s.name != p_names->values[row_idx]) return true;
|
||||
if (p_tools && row_idx < (int)p_tools->values.size() && s.tools != p_tools->values[row_idx]) return true;
|
||||
if (p_gcodes && row_idx < (int)p_gcodes->values.size() && s.gcode != p_gcodes->values[row_idx]) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IMEXModesCtrl::row_has_parent_counterpart(int row_idx) const {
|
||||
if (!m_parent_lookup) return false;
|
||||
const DynamicPrintConfig* parent = m_parent_lookup();
|
||||
if (!parent) return false;
|
||||
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
|
||||
return p_names && row_idx >= 0 && row_idx < (int)p_names->values.size();
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::add_row(const std::string& name,
|
||||
const std::string& active_tools,
|
||||
const std::string& gcode,
|
||||
bool is_primary)
|
||||
{
|
||||
Row r;
|
||||
r.is_primary = is_primary;
|
||||
r.panel = new wxPanel(this, wxID_ANY);
|
||||
r.panel->SetBackgroundColour(GetBackgroundColour());
|
||||
auto* sizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
|
||||
if (is_primary) {
|
||||
r.orig_name = kImexPrimaryMode;
|
||||
r.name = nullptr;
|
||||
auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"),
|
||||
wxDefaultPosition, FromDIP(wxSize(130, -1)));
|
||||
wxFont f = lbl->GetFont();
|
||||
f.SetWeight(wxFONTWEIGHT_BOLD);
|
||||
lbl->SetFont(f);
|
||||
sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
|
||||
} else {
|
||||
// A nameless mode cannot be selected on a plate and used to be dropped on
|
||||
// save together with its tool roles and G-code, so a name is pre-filled
|
||||
// here and restored below if the field is left empty. That keeps the
|
||||
// "+ Add Mode → assign tools → never typed a name" path from losing work
|
||||
// without ever showing an error.
|
||||
const std::string nm = name.empty() ? unique_mode_name({}) : name;
|
||||
// The name as first shown, i.e. the one the plate's mode selector offered while this
|
||||
// row looked like this. remove_row() reports it alongside the current name, because
|
||||
// renaming is not routed through on_mode_removed and the two then diverge.
|
||||
r.orig_name = nm;
|
||||
r.name = new wxTextCtrl(r.panel, wxID_ANY, from_u8(nm), wxDefaultPosition, FromDIP(wxSize(130, -1)));
|
||||
r.name->SetHint(_L("Mode name (required)"));
|
||||
r.name->SetToolTip(_L("Name of this parallel mode, as it appears in the plate's IMEX mode "
|
||||
"selector. Stored in the project by name, so renaming a mode that "
|
||||
"plates already use makes them fall back to Primary. Cannot be empty "
|
||||
"— a blank name is replaced with a generated one."));
|
||||
r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
|
||||
// Restore a name rather than let the row reach get_mode_data() unnamed.
|
||||
// Row is located by panel pointer (stable across add/remove) so a
|
||||
// kill-focus delivered while the rows are being torn down is a no-op.
|
||||
r.name->Bind(wxEVT_KILL_FOCUS, [this, panel = r.panel](wxFocusEvent& e) {
|
||||
e.Skip();
|
||||
if (m_clearing_rows) return; // focus-out emitted while the rows are being deleted
|
||||
for (auto& row_ref : m_rows) {
|
||||
if (row_ref.panel != panel) continue;
|
||||
if (!row_ref.name || !row_ref.name->GetValue().empty()) return;
|
||||
// ChangeValue(), not SetValue(): no nested wxEVT_TEXT.
|
||||
row_ref.name->ChangeValue(from_u8(unique_mode_name({})));
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
auto* grid_panel = new wxPanel(r.panel, wxID_ANY);
|
||||
grid_panel->SetBackgroundColour(GetBackgroundColour());
|
||||
auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, FromDIP(2), FromDIP(2));
|
||||
|
||||
auto tool_roles = roles_for_mode(active_tools);
|
||||
if (tool_roles.empty())
|
||||
tool_roles[0] = ImexRole::Primary; // default T0 → Primary when no assignment is stored
|
||||
r.all_tool_roles = tool_roles; // preserve all assignments, including off-screen tools
|
||||
wxPanel* this_panel = r.panel;
|
||||
|
||||
// Buttons rendered top=rear (high Y), bottom=front (low Y).
|
||||
// Layout remapping: m_layout encodes which corner T0 is at physically.
|
||||
// flip_x (layout 1,3): col 0 is on the right (max-X) instead of left
|
||||
// flip_y (layout 2,3): row 0 is at the rear (max-Y) instead of front
|
||||
// Display iterates: row from n_rows-1 down to 0 (rear→front = top→bottom).
|
||||
// raw_row = flip_y ? (n_rows-1-row) : row
|
||||
// raw_col = flip_x ? (n_cols-1-col) : col
|
||||
//
|
||||
// row_start: anchor displayed rows to the gantry row containing the Primary
|
||||
// assignment, so reducing gantry count keeps the meaningful row visible.
|
||||
int primary_gantry_row = 0;
|
||||
for (const auto& [idx, role] : tool_roles) {
|
||||
if (role == ImexRole::Primary) { primary_gantry_row = idx / m_n_cols; break; }
|
||||
}
|
||||
int row_start = primary_gantry_row; // display rows [row_start .. row_start+m_n_rows-1]
|
||||
|
||||
bool flip_x = (m_layout == 1 || m_layout == 3);
|
||||
bool flip_y = (m_layout == 2 || m_layout == 3);
|
||||
for (int row = m_n_rows - 1; row >= 0; --row) {
|
||||
for (int col = 0; col < m_n_cols; ++col) {
|
||||
int raw_row = flip_y ? (m_n_rows - 1 - row) : row;
|
||||
raw_row += row_start; // anchor to Primary's gantry row
|
||||
int raw_col = flip_x ? (m_n_cols - 1 - col) : col;
|
||||
int tool_idx = raw_row * m_n_cols + raw_col;
|
||||
std::optional<ImexRole> role; // nullopt == Inactive
|
||||
auto it = tool_roles.find(tool_idx);
|
||||
if (it != tool_roles.end()) role = it->second;
|
||||
|
||||
auto* btn = new wxButton(grid_panel, wxID_ANY, wxEmptyString,
|
||||
wxDefaultPosition, FromDIP(wxSize(36, 26)), wxBU_EXACTFIT);
|
||||
apply_btn(btn, tool_idx, role);
|
||||
|
||||
int btn_pos = (int)r.btns.size();
|
||||
r.btns.push_back(btn);
|
||||
r.btn_roles.push_back(role);
|
||||
r.btn_tool_idx.push_back(tool_idx);
|
||||
|
||||
// Two tiles are read-only.
|
||||
//
|
||||
// The whole reserved `primary` row: it is the IMEX-off mode, so cycling roles there
|
||||
// would be a no-op and confusing.
|
||||
//
|
||||
// And, on any row, the tile that currently HOLDS Primary. Primary is tool 0 and moves
|
||||
// only via Tool 0 Position; letting a click demote it would leave the mode with no
|
||||
// Primary at all, which parses to -1 and degrades the plate to an ordinary single-tool
|
||||
// print with no zones — authored silently, in one click, with nothing in the editor
|
||||
// showing what is wrong. The other tiles already never OFFER Primary while one is held
|
||||
// (role_allowed_on_tile), so this closes the only remaining route to that state.
|
||||
// A mode that reaches us WITHOUT a Primary (hand-edited preset, foreign 3MF) still has
|
||||
// every tile live, so it can be repaired by promoting one.
|
||||
if (is_primary || role == ImexRole::Primary) {
|
||||
btn->Disable();
|
||||
} else {
|
||||
btn->Bind(wxEVT_BUTTON, [this, this_panel, btn_pos](wxCommandEvent&) {
|
||||
// Find the row by panel pointer (stable across add/remove)
|
||||
for (auto& row_ref : m_rows) {
|
||||
if (row_ref.panel != this_panel) continue;
|
||||
std::optional<ImexRole>& tile = row_ref.btn_roles[btn_pos];
|
||||
const int tidx = row_ref.btn_tool_idx[btn_pos];
|
||||
|
||||
// Check if another button already holds the Primary role,
|
||||
// and locate the primary's gantry row for Span eligibility.
|
||||
bool other_primary = false;
|
||||
int primary_gantry = -1;
|
||||
for (int j = 0; j < (int)row_ref.btn_roles.size(); ++j) {
|
||||
if (row_ref.btn_roles[j] == ImexRole::Primary) {
|
||||
if (j != btn_pos) other_primary = true;
|
||||
primary_gantry = row_ref.btn_tool_idx[j] / m_n_cols;
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle Inactive → Primary → Copy → Mirror → Span → Inactive,
|
||||
// skipping whatever this tile may not take right now.
|
||||
tile = next_tile_role(tile, [&](ImexRole cand) {
|
||||
return role_allowed_on_tile(cand, tidx, other_primary, primary_gantry);
|
||||
});
|
||||
|
||||
// Keep all_tool_roles in sync so off-screen tools are preserved
|
||||
if (tile)
|
||||
row_ref.all_tool_roles[tidx] = *tile;
|
||||
else
|
||||
row_ref.all_tool_roles.erase(tidx);
|
||||
|
||||
apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], tile);
|
||||
notify();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
grid_sizer->Add(btn, 0);
|
||||
}
|
||||
}
|
||||
grid_panel->SetSizerAndFit(grid_sizer);
|
||||
|
||||
// from_u8(): `gcode` arrives from the preset as UTF-8. Handing the raw std::string to
|
||||
// wxString would decode it through the current locale's encoding instead.
|
||||
r.gcode = new wxTextCtrl(r.panel, wxID_ANY, from_u8(gcode),
|
||||
wxDefaultPosition, FromDIP(wxSize(220, 54)), wxTE_MULTILINE);
|
||||
// Tooltip only, no SetHint(): wxTextEntry has no native placeholder for a
|
||||
// multiline control, so wxWidgets emulates one by writing the hint into the
|
||||
// control as grey text — indistinguishable from real G-code in this box.
|
||||
r.gcode->SetToolTip(_L("G-code emitted once at the start of a print that uses this mode, before "
|
||||
"the machine start G-code. This is where the printer is put into the "
|
||||
"matching firmware mode — for example a Klipper SET_PRINT_MODE call or a "
|
||||
"RepRapFirmware M567 — since the slicer only emits the Primary tool's "
|
||||
"paths and the firmware drives the Copy / Mirror tools. Placeholders are "
|
||||
"supported; use the edit button to browse them. Leave empty if the mode "
|
||||
"needs no firmware setup."));
|
||||
r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
|
||||
|
||||
if (!is_primary) {
|
||||
sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
|
||||
}
|
||||
sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
|
||||
sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
|
||||
|
||||
// Edit + remove (for non-primary) — stacked vertically.
|
||||
auto* btn_col = new wxBoxSizer(wxVERTICAL);
|
||||
wxTextCtrl* gcode_ctrl = r.gcode;
|
||||
auto* ph_btn = new ScalableButton(r.panel, wxID_ANY, "edit", wxEmptyString,
|
||||
wxDefaultSize, wxDefaultPosition,
|
||||
wxBU_EXACTFIT | wxNO_BORDER, 16);
|
||||
ph_btn->SetToolTip(_L("Edit G-code / browse placeholders"));
|
||||
ph_btn->Bind(wxEVT_BUTTON, [this, gcode_ctrl](wxCommandEvent&) {
|
||||
// EditGCodeDialog takes and returns UTF-8 (get_edited_gcode() is a ToUTF8()).
|
||||
EditGCodeDialog dlg(this, "imex_mode_gcode", into_u8(gcode_ctrl->GetValue()));
|
||||
if (dlg.ShowModal() == wxID_OK)
|
||||
gcode_ctrl->SetValue(from_u8(dlg.get_edited_gcode()));
|
||||
});
|
||||
btn_col->Add(ph_btn, 0, wxBOTTOM, FromDIP(2));
|
||||
|
||||
if (!is_primary) {
|
||||
auto* rm = new ScalableButton(r.panel, wxID_ANY, "imex_remove", wxEmptyString,
|
||||
wxDefaultSize, wxDefaultPosition,
|
||||
wxBU_EXACTFIT | wxNO_BORDER, 16);
|
||||
rm->SetToolTip(_L("Remove mode"));
|
||||
rm->Bind(wxEVT_BUTTON, [this, this_panel](wxCommandEvent&) {
|
||||
const std::vector<std::string> removed = remove_row(this_panel);
|
||||
// Copied before notify(): on_change ends in on_value_change, which can send
|
||||
// the tab through update() and hence load_from_config(), and that rebuilds
|
||||
// every row. Reading the member afterwards would be reading through a
|
||||
// handler whose owner has just been rebuilt underneath it.
|
||||
auto removed_cb = on_mode_removed;
|
||||
notify();
|
||||
if (removed_cb && !removed.empty())
|
||||
removed_cb(removed);
|
||||
});
|
||||
btn_col->Add(rm, 0);
|
||||
}
|
||||
sizer->Add(btn_col, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4));
|
||||
|
||||
// Per-row reset gets its own column on the right so the icon reads as
|
||||
// distinct from the edit/remove column. Reset only renders when this row
|
||||
// has a counterpart in the saved preset (user-added rows beyond the saved
|
||||
// mode count get no reset — the X button covers "remove user-added row").
|
||||
auto* reset_col = new wxBoxSizer(wxVERTICAL);
|
||||
wxPanel* this_panel_for_reset = r.panel;
|
||||
if (row_has_parent_counterpart(static_cast<int>(m_rows.size()))) {
|
||||
r.reset_btn = new ScalableButton(r.panel, wxID_ANY, "dot", wxEmptyString,
|
||||
wxDefaultSize, wxDefaultPosition,
|
||||
wxBU_EXACTFIT | wxNO_BORDER, 16);
|
||||
r.reset_btn->SetToolTip(_L("Discard in-session edits to this mode (snap back to saved value)"));
|
||||
r.reset_btn->Bind(wxEVT_BUTTON, [this, this_panel_for_reset](wxCommandEvent&) {
|
||||
reset_row_to_parent(this_panel_for_reset);
|
||||
});
|
||||
reset_col->Add(r.reset_btn, 0, wxALIGN_CENTER_VERTICAL);
|
||||
}
|
||||
sizer->Add(reset_col, 0, wxALIGN_CENTER_VERTICAL);
|
||||
r.panel->SetSizerAndFit(sizer);
|
||||
|
||||
m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4));
|
||||
m_rows.push_back(std::move(r));
|
||||
Layout();
|
||||
}
|
||||
|
||||
std::vector<std::string> IMEXModesCtrl::remove_row(wxPanel* panel) {
|
||||
for (size_t i = 0; i < m_rows.size(); ++i) {
|
||||
if (m_rows[i].panel != panel) continue;
|
||||
if (m_rows[i].is_primary) return {}; // primary row is non-deletable
|
||||
|
||||
// Both names a plate can be holding for this row: the one the row was built with and
|
||||
// the one in the field now. They diverge as soon as the row is renamed, because a
|
||||
// rename does not rebuild the row and is deliberately not reported as a removal (the
|
||||
// name field notifies on every keystroke). Reporting only the current name is what
|
||||
// let "rename, then delete" leave the plate on a mode that no longer exists.
|
||||
std::vector<std::string> removed_names;
|
||||
const std::string current = m_rows[i].name ? into_u8(m_rows[i].name->GetValue())
|
||||
: std::string();
|
||||
for (const std::string& n : {m_rows[i].orig_name, current})
|
||||
if (!n.empty() && std::find(removed_names.begin(), removed_names.end(), n) == removed_names.end())
|
||||
removed_names.push_back(n);
|
||||
|
||||
m_rows_sizer->Detach(panel);
|
||||
m_rows.erase(m_rows.begin() + i);
|
||||
|
||||
// A name a surviving row still carries is not stale: the user can rename row A to
|
||||
// row B's old name and then delete B. Plates holding it now resolve to A, so drop it
|
||||
// rather than reset them.
|
||||
auto still_in_use = [this](const std::string& n) {
|
||||
return std::any_of(m_rows.begin(), m_rows.end(), [&n](const Row& r) {
|
||||
return r.is_primary ? n == kImexPrimaryMode
|
||||
: r.name && into_u8(r.name->GetValue()) == n;
|
||||
});
|
||||
};
|
||||
removed_names.erase(std::remove_if(removed_names.begin(), removed_names.end(), still_in_use),
|
||||
removed_names.end());
|
||||
|
||||
Layout();
|
||||
// Defer widget destruction so any in-flight GTK events for
|
||||
// panel's children (including the × button we're inside) finish
|
||||
// processing before the wxEvtHandlers are freed.
|
||||
wxTheApp->CallAfter([panel]() { panel->Destroy(); });
|
||||
return removed_names;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::clear_rows() {
|
||||
// Destroying a focused wxTextCtrl emits wxEVT_KILL_FOCUS, and the Name field's
|
||||
// handler would then read m_rows while it is half-destroyed. Flag the teardown
|
||||
// so that handler stays out of the way.
|
||||
m_clearing_rows = true;
|
||||
for (auto& r : m_rows) { m_rows_sizer->Detach(r.panel); r.panel->Destroy(); }
|
||||
m_rows.clear();
|
||||
m_clearing_rows = false;
|
||||
}
|
||||
|
||||
std::string IMEXModesCtrl::active_tools_string(const Row& r) const {
|
||||
// Serialize from all_tool_roles (not just visible buttons) so assignments
|
||||
// for off-screen tools are preserved across grid size changes. Inactive tools are
|
||||
// simply absent from the map, so there is nothing to filter out here.
|
||||
//
|
||||
// The letters come from imex_role_letter(), the same table parse_imex_active_tools()
|
||||
// reads back — this used to be a second, independent int → letter switch, which is
|
||||
// exactly where the editor and the slicer could come to disagree about a mode string.
|
||||
std::string s;
|
||||
for (const auto& [idx, role] : r.all_tool_roles) {
|
||||
if (!s.empty()) s += ',';
|
||||
s += std::to_string(idx) + ':' + imex_role_letter(role);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::notify() {
|
||||
refresh_reset_buttons();
|
||||
if (on_change) on_change();
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::refresh_reset_buttons() {
|
||||
for (size_t i = 0; i < m_rows.size(); ++i) {
|
||||
Row& r = m_rows[i];
|
||||
if (!r.reset_btn || !r.reset_btn->IsEnabled()) continue;
|
||||
const bool dirty = row_differs_from_parent(static_cast<int>(i));
|
||||
if (dirty == r.reset_dirty_cached) continue;
|
||||
r.reset_dirty_cached = dirty;
|
||||
r.reset_btn->SetBitmap_(dirty ? "undo" : "dot");
|
||||
}
|
||||
}
|
||||
|
||||
void IMEXModesCtrl::reset_row_to_parent(wxPanel* panel) {
|
||||
if (!m_parent_lookup) return;
|
||||
const DynamicPrintConfig* parent = m_parent_lookup();
|
||||
if (!parent) return;
|
||||
auto* p_names = parent->option<ConfigOptionStrings>("imex_mode_names");
|
||||
auto* p_tools = parent->option<ConfigOptionStrings>("imex_mode_active_tools");
|
||||
auto* p_gcodes = parent->option<ConfigOptionStrings>("imex_mode_gcodes");
|
||||
for (size_t i = 0; i < m_rows.size(); ++i) {
|
||||
Row& r = m_rows[i];
|
||||
if (r.panel != panel) continue;
|
||||
if (!p_names || i >= p_names->values.size()) return;
|
||||
if (!r.is_primary && r.name)
|
||||
r.name->ChangeValue(from_u8(p_names->values[i]));
|
||||
if (p_gcodes && i < p_gcodes->values.size())
|
||||
r.gcode->ChangeValue(from_u8(p_gcodes->values[i]));
|
||||
if (p_tools && i < p_tools->values.size()) {
|
||||
// Reapply tool roles from the parent's serialized form. all_tool_roles
|
||||
// is the source of truth for round-tripping; rebuild it then re-paint
|
||||
// the visible buttons.
|
||||
r.all_tool_roles = roles_for_mode(p_tools->values[i]);
|
||||
for (size_t j = 0; j < r.btns.size(); ++j) {
|
||||
int tidx = r.btn_tool_idx[j];
|
||||
auto it = r.all_tool_roles.find(tidx);
|
||||
std::optional<ImexRole> role;
|
||||
if (it != r.all_tool_roles.end()) role = it->second;
|
||||
r.btn_roles[j] = role;
|
||||
apply_btn(r.btns[j], tidx, role);
|
||||
}
|
||||
}
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
@@ -0,0 +1,242 @@
|
||||
#ifndef slic3r_IMEXModesCtrl_hpp_
|
||||
#define slic3r_IMEXModesCtrl_hpp_
|
||||
|
||||
#include <wx/panel.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/button.h>
|
||||
#include <wx/textctrl.h>
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include "libslic3r/IMEXHelpers.hpp"
|
||||
|
||||
class ScalableButton;
|
||||
|
||||
namespace Slic3r {
|
||||
namespace GUI {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IMEXModesCtrl — visual parallel-mode editor for the IDEX/IQEX printer tab
|
||||
//
|
||||
// A tool tile holds an `std::optional<ImexRole>`: the role the mode gives that tool, or
|
||||
// nullopt for Inactive (grey — the tool does not take part in this mode). Inactive is the
|
||||
// editor's own state and the only reason the tile is not a bare ImexRole; it is the absence
|
||||
// of a role, so it is spelled as one rather than as an extra enumerator that libslic3r would
|
||||
// then have to keep rejecting. Roles themselves are never re-encoded here — no int state
|
||||
// table, no second copy of the on-disk letters.
|
||||
//
|
||||
// nullopt (grey) — not participating in this mode
|
||||
// ImexRole::Primary (green) — the tool the slicer generates paths for
|
||||
// ImexRole::Copy (blue) — firmware duplicates Primary at an offset
|
||||
// ImexRole::Mirror (amber) — firmware mirrors Primary about an axis
|
||||
// ImexRole::Span (yellow)— multicolor partner of Primary on the same gantry;
|
||||
// declares paired-gantry multicolor topology and
|
||||
// unlocks the multicolor block at slice time. Only
|
||||
// offered on tools sharing primary's gantry, and
|
||||
// only on multi-gantry printers (n_rows >= 2).
|
||||
//
|
||||
// Colour and legend text are the editor's business and live in role_style(); the tile cycle
|
||||
// order and the serialized letters come from kImexRoleTable in IMEXHelpers.hpp, so the
|
||||
// editor cannot drift from the slicer's reading of a mode string.
|
||||
//
|
||||
// Active-tools string format: "idx:P,idx:C,idx:M,idx:S" (backwards-compat: plain "idx" = Primary)
|
||||
// ---------------------------------------------------------------------------
|
||||
class IMEXModesCtrl : public wxPanel {
|
||||
public:
|
||||
std::function<void()> on_change;
|
||||
|
||||
// Fired after on_change has written the shortened table back to the preset, with EVERY
|
||||
// name the deleted row could be known by: the name the row was built with (what a plate
|
||||
// set from the saved preset holds) and the name in the field at delete time (what a plate
|
||||
// set after an in-session rename holds). Renaming is deliberately not routed through here
|
||||
// -- the name field notifies on every keystroke -- so by the time the row is deleted the
|
||||
// two can differ, and reporting only the current name leaves the plate pointing at a mode
|
||||
// that no longer exists. Names a surviving row still carries are filtered out, so renaming
|
||||
// one row to another's old name and then deleting cannot reset a plate that legitimately
|
||||
// resolves elsewhere. Plates still set to any reported name are stale from this point on
|
||||
// -- see the handler in TabPrinter::build_fff.
|
||||
std::function<void(const std::vector<std::string>&)> on_mode_removed;
|
||||
|
||||
// Lazy lookup so the widget always sees the current parent (preset switches
|
||||
// change the parent under us). Returns nullptr when the active preset has no
|
||||
// parent (system preset itself), which disables per-row reset arrows.
|
||||
using ParentConfigLookup = std::function<const DynamicPrintConfig*()>;
|
||||
void set_parent_config_lookup(ParentConfigLookup f) { m_parent_lookup = std::move(f); }
|
||||
|
||||
// layout: 0=front-left, 1=front-right, 2=rear-left, 3=rear-right (T0 corner).
|
||||
// The enum values intentionally line up 1:1 so this is a no-op cast — the helper
|
||||
// is kept to make the conversion explicit at call sites.
|
||||
static int parse_layout(ImexToolLayout layout) { return static_cast<int>(layout); }
|
||||
|
||||
IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout = 0);
|
||||
|
||||
void set_grid_size(int n_cols, int n_rows, int layout = -1);
|
||||
|
||||
void load_from_config(const DynamicPrintConfig& cfg);
|
||||
|
||||
// Every row on screen produces exactly one entry — a row is never skipped.
|
||||
// Skipping empty-named rows used to drop the row's tool roles and G-code
|
||||
// silently, and because matches_config() compares against this same filtered
|
||||
// output the preset did not even go dirty to hint that something had gone
|
||||
// missing; the row stayed on screen until the next load_from_config() quietly
|
||||
// removed it. add_row() pre-fills a name and the Name field restores one when
|
||||
// it is left empty, so the substitution below is the last-resort guard for a
|
||||
// name that arrives empty from a hand-edited preset or 3MF.
|
||||
std::tuple<std::vector<std::string>, std::vector<std::string>, std::vector<std::string>>
|
||||
get_mode_data() const;
|
||||
|
||||
// True when the widget's current rows already mirror the three IMEX-mode option
|
||||
// vectors in cfg. Used by TabPrinter::update_fff() to skip a destroy-and-rebuild
|
||||
// pass when the only thing that just changed was the widget itself writing back
|
||||
// to config — without this guard, every keystroke in the gcode textbox triggers
|
||||
// a load_from_config() that detaches the textbox the user is typing in.
|
||||
bool matches_config(const DynamicPrintConfig& cfg) const;
|
||||
|
||||
private:
|
||||
// map<physical tool idx, role> for one `imex_mode_active_tools` entry. A tool absent
|
||||
// from the map is Inactive — the same convention the serialized string uses, so the
|
||||
// round trip needs no separate "off" value.
|
||||
//
|
||||
// Deliberately NOT a parser of its own: the slicer decides what actually prints,
|
||||
// so the editor has to read the string exactly the way the slicer does or the
|
||||
// same mode means two different things in the two places. Roles come from
|
||||
// parse_imex_active_tools(); the primary index comes from
|
||||
// imex_primary_tool_for_mode(), which is where the "a bare index is Primary"
|
||||
// backwards-compat rule lives. Both are needed: parse_imex_active_tools()
|
||||
// defaults a bare token to Copy (deliberately — see the note above
|
||||
// parse_imex_active_tools() in IMEXHelpers.hpp), so only the combination
|
||||
// reproduces the slicer's reading of a legacy "0,1,2" string, which is
|
||||
// "T0 primary, the rest copies".
|
||||
//
|
||||
// Indices are PHYSICAL tool indices (T0..TN-1). all_tool_roles preserves
|
||||
// off-grid entries so the tile widget can round-trip indices that fall
|
||||
// outside the currently visible rows × cols without losing data on save.
|
||||
static std::map<int, ImexRole> roles_for_mode(const std::string& active_tools);
|
||||
|
||||
// "Mode N" for the lowest N >= 2 not already used by a row or by `also_taken`
|
||||
// (N == 1 is conceptually the fixed Primary row). Deterministic for a given set
|
||||
// of rows, so get_mode_data() is stable across calls and matches_config() stays
|
||||
// honest. Intentionally NOT translated: mode names are identifiers — objects
|
||||
// store one in `imex_parallel_mode` and GCode.cpp matches it against
|
||||
// `imex_mode_names` by string — so a locale-dependent name would break a project
|
||||
// opened under a different language.
|
||||
std::string unique_mode_name(const std::vector<std::string>& also_taken) const;
|
||||
|
||||
// Everything the editor draws for one role, in ONE place: a new role needs a colour and
|
||||
// a legend name here and nowhere else in this file. nullopt is Inactive (grey).
|
||||
// `label` is deliberately untranslated — it names a role, and the legend sits next to
|
||||
// the same letters the preset stores.
|
||||
struct RoleStyle { wxColour bg; wxColour fg; const char* label; };
|
||||
static RoleStyle role_style(std::optional<ImexRole> role);
|
||||
|
||||
// True when this printer offers `role` at all, regardless of which tile is clicked.
|
||||
// Span needs a second gantry to be a partner across, so a single-gantry printer neither
|
||||
// cycles to it nor lists it in the legend.
|
||||
bool role_offered(ImexRole role) const;
|
||||
|
||||
// True when the tile for `tool_idx` may take `role` right now. Adds the positional rules
|
||||
// on top of role_offered(): only one tile may hold Primary, and Span only on a tile
|
||||
// sharing the Primary's gantry.
|
||||
bool role_allowed_on_tile(ImexRole role, int tool_idx,
|
||||
bool other_primary, int primary_gantry) const;
|
||||
|
||||
// Next state in the tile cycle: Inactive → kImexRoleTable order → Inactive, stepping
|
||||
// over any role `allowed` rejects. Inactive is always reachable, so it terminates.
|
||||
static std::optional<ImexRole> next_tile_role(std::optional<ImexRole> current,
|
||||
const std::function<bool(ImexRole)>& allowed);
|
||||
|
||||
void apply_btn(wxButton* btn, int tool_idx, std::optional<ImexRole> role);
|
||||
|
||||
struct Row {
|
||||
wxPanel* panel;
|
||||
wxTextCtrl* name; // nullptr for primary row (name is fixed)
|
||||
wxTextCtrl* gcode;
|
||||
std::vector<wxButton*> btns;
|
||||
std::vector<std::optional<ImexRole>> btn_roles; // nullopt == Inactive
|
||||
std::vector<int> btn_tool_idx;
|
||||
std::map<int, ImexRole> all_tool_roles; // absent == Inactive
|
||||
// Name the row was built with (UTF-8), i.e. the name that was in the preset — and so
|
||||
// in the plate's mode selector — when the row appeared. Kept because the name field is
|
||||
// free to drift from it without the row being rebuilt; remove_row() needs both to find
|
||||
// the plates a delete strands. Rebuilding the rows (load_from_config / set_grid_size)
|
||||
// re-seeds it, which is correct: config and rows agree again at that point.
|
||||
std::string orig_name;
|
||||
bool is_primary {false};
|
||||
ScalableButton* reset_btn {nullptr}; // nullptr when row has no parent counterpart
|
||||
bool reset_dirty_cached {false};
|
||||
};
|
||||
|
||||
// Snapshot of row content as it would be saved (matches get_mode_data() per-row).
|
||||
struct RowSnapshot { std::string name, tools, gcode; };
|
||||
RowSnapshot snapshot_row(const Row& r) const;
|
||||
|
||||
// True when row at `row_idx` differs from the parent preset's value at the same
|
||||
// index. Returns false when there is no parent (system preset), when the row is
|
||||
// beyond the parent's mode count (user added it — no defined "default"), or when
|
||||
// the lookup callback isn't wired. The Primary row's name is sentinel-fixed so
|
||||
// a name diff doesn't count for it; only tools/gcode do.
|
||||
bool row_differs_from_parent(int row_idx) const;
|
||||
|
||||
// True when row index has a parent counterpart at all (i.e., the reset arrow
|
||||
// should be present on the row at all, regardless of dirty state).
|
||||
bool row_has_parent_counterpart(int row_idx) const;
|
||||
|
||||
void add_row(const std::string& name = "",
|
||||
const std::string& active_tools = "",
|
||||
const std::string& gcode = "",
|
||||
bool is_primary = false);
|
||||
|
||||
// Returns every name the removed row was known by (UTF-8, deduplicated: its build-time
|
||||
// name and its current name, which differ after an in-session rename), minus any name a
|
||||
// surviving row still carries. The caller uses them to find the plates the delete
|
||||
// stranded. Empty when nothing was removed, when the row is the non-deletable Primary,
|
||||
// or when both names are blank -- a blank name is only reachable on a row that was never
|
||||
// named, which no plate can be holding.
|
||||
std::vector<std::string> remove_row(wxPanel* panel);
|
||||
|
||||
void clear_rows();
|
||||
|
||||
std::string active_tools_string(const Row& r) const;
|
||||
|
||||
void notify();
|
||||
|
||||
// Update each row's reset bitmap to reflect current dirty state. Cached so we
|
||||
// only swap the bitmap when state actually transitions — avoids flicker on
|
||||
// every keystroke. Disabled buttons (no saved counterpart) keep their dot.
|
||||
void refresh_reset_buttons();
|
||||
|
||||
// Reset row identified by `panel` (stable across add/remove) to the parent
|
||||
// preset's value at the same index. Primary row's name stays sentinel-fixed —
|
||||
// only tools and gcode are restored.
|
||||
void reset_row_to_parent(wxPanel* panel);
|
||||
|
||||
// Rebuild the instruction text, colour legend and column headers for the CURRENT grid.
|
||||
// These depend on the grid shape -- the Span role is only offered on a multi-gantry printer,
|
||||
// and the header spacing is derived from the column count -- so they cannot be built once in
|
||||
// the constructor: adding a second gantry has to reveal Span without a save-and-reopen cycle.
|
||||
void rebuild_info_and_header();
|
||||
|
||||
// One-line explanation per role, shown as a tooltip on that role's legend swatch and label.
|
||||
// Keeps the per-role detail out of the instruction paragraph.
|
||||
static wxString role_hint(ImexRole role);
|
||||
|
||||
wxBoxSizer* m_outer;
|
||||
wxBoxSizer* m_rows_sizer;
|
||||
wxPanel* m_info_panel = nullptr;
|
||||
wxPanel* m_hdr_panel = nullptr;
|
||||
std::vector<Row> m_rows;
|
||||
int m_n_cols, m_n_rows;
|
||||
int m_layout {0}; // 0=front-left, 1=front-right, 2=rear-left, 3=rear-right
|
||||
bool m_clearing_rows {false}; // guards handlers against teardown-time events
|
||||
ParentConfigLookup m_parent_lookup;
|
||||
};
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
|
||||
#endif // slic3r_IMEXModesCtrl_hpp_
|
||||
Reference in New Issue
Block a user