From 4370cca4c98a5c6deee390316013d6a191498e75 Mon Sep 17 00:00:00 2001 From: Clifford Garwood Date: Mon, 27 Apr 2026 23:17:24 -0400 Subject: [PATCH] feat(imex): Split mode rendering + modes editor Type column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Split-type IMEX mode plumbing started in 0bb1cef. Phase 1 landed the config option, helper, validator, and plater badge updates; this commit lands the visualization side and the editor surface so users can actually create and use Split modes. Zone aggregation (PartPlate::calc_imex_zones) - When mode_type == "split" and there's row separation between primary and secondaries, collapse copy_cells and mirror_cells per-gantry (each cell gets primary's column) and force has_col_sep = false. make_boxes then takes its full-X-row branch and renders one zone covering each non-primary gantry's full Y band, instead of per-tool quadrants. Ghost aggregation (PartPlate::calc_imex_ghosts) - Pre-scan the active mode's tool list to pick a canonical head per non-primary gantry (the tool whose physical column matches primary's; fallback to first-seen). For Split modes, the per-head emission loop skips non-canonical tools so each non-primary gantry gets exactly one aggregate ghost rendered at the canonical's mirrored position. - update_imex_ghost_transforms unaffected — it iterates the already- filtered ghost set. Modes editor Type column (IMEXModesCtrl in Tab.cpp) - Each non-primary mode row gains a wxChoice dropdown selecting Copy / Mirror / Split. Primary row gets a static "Primary" label. - Header row picks up a "Type" column header with sized spacer that aligns with the dropdown. - get_mode_data() now returns a fourth tuple element (types vector). load_from_config() reads imex_mode_types via imex_mode_type_for so legacy presets without the new option still infer types from mode names. on_change writes imex_mode_types back to config. - matches_config(), snapshot_row(), row_differs_from_parent(), and reset_row_to_parent() all extended to track type alongside name/ tools/gcode — per-row reset arrows reflect type-only changes, and reset restores the parent's type via imex_mode_type_for fallback. - Type-choice dropdown fires notify() on change so the dirty/save flow catches it like any other row edit. End-to-end: switching a row to Split type in the modes editor, then selecting that mode on a plate, allows multicolor slicing (the gantry- pair check is bypassed in imex_multicolor_block_reason) and the bed visualization shows a single aggregate zone + one ghost per non-primary gantry instead of per-tool clutter. Co-Authored-By: Claude Opus 4.7 --- src/slic3r/GUI/PartPlate.cpp | 64 +++++++++++++++++++ src/slic3r/GUI/Tab.cpp | 117 ++++++++++++++++++++++++++++++----- 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 5219df361e..fce086616a 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -605,6 +605,7 @@ void PartPlate::calc_imex_zones() // Look up secondary tool indices (active in mode, but NOT the primary tool) auto* names_opt = printer_cfg.option("imex_mode_names"); auto* tools_opt = printer_cfg.option("imex_mode_active_tools"); + auto* types_opt = printer_cfg.option("imex_mode_types"); std::string active_tools_str; if (names_opt && tools_opt) { for (size_t i = 0; i < names_opt->values.size(); ++i) { @@ -614,6 +615,14 @@ void PartPlate::calc_imex_zones() } } } + // Mode topology tag — Split modes aggregate per-gantry (one zone covering the + // full gantry rail) rather than per-tool. Falls through to "primary"/"copy"/ + // "mirror" inference when imex_mode_types is empty (legacy presets). + const std::vector empty_types; + const std::string mode_type = imex_mode_type_for( + active_mode, + names_opt ? names_opt->values : empty_types, + types_opt ? types_opt->values : empty_types); // Parse "phys_idx:P/C/M" format → map (1=Primary, 2=Copy, 3=Mirror). // imex_primary_tool_for_mode handles the Primary slot (bare legacy token → Primary); @@ -709,6 +718,26 @@ void PartPlate::calc_imex_zones() if (sc != pri_col) has_col_sep = true; } + // Split-mode aggregation: each non-primary gantry renders as ONE zone covering + // its full Y band rather than per-tool quadrants. Achieved by collapsing the + // copy/mirror cell sets so all entries share primary's column (suppresses + // col-sep), then forcing has_col_sep = false so make_boxes takes its + // full-X-row branch. The role color (copy vs mirror) is preserved per gantry. + if (mode_type == kImexModeTypeSplit && has_row_sep) { + auto aggregate_per_gantry = [&](std::set>& cells) { + std::set rows; + for (const auto& [c, r] : cells) rows.insert(r); + cells.clear(); + for (int r : rows) cells.insert({pri_col, r}); + }; + aggregate_per_gantry(copy_cells); + aggregate_per_gantry(mirror_cells); + all_secondary.clear(); + for (const auto& p : copy_cells) all_secondary.insert(p); + for (const auto& p : mirror_cells) all_secondary.insert(p); + has_col_sep = false; + } + // Primary zone extent (the clear printable area): // row-sep only → full bed width × primary row's Y band // col-sep only → primary col's X band × full bed height @@ -1056,6 +1085,34 @@ void PartPlate::calc_imex_ghosts() const auto heads = parse_imex_active_tools(active_tools_str); + // Split-mode aggregation: collapse the per-tool ghost emission down to one ghost + // per non-primary gantry. The "canonical" head for a gantry is the tool whose + // physical column matches primary's (the spatial Y-mirror partner); falls back + // to the first head encountered on that gantry if no col-match exists. + const DynamicPrintConfig& gh_printer_cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; + auto* gh_names_opt = gh_printer_cfg.option("imex_mode_names"); + auto* gh_types_opt = gh_printer_cfg.option("imex_mode_types"); + auto* gh_tpg_opt = gh_printer_cfg.option("imex_tools_per_gantry"); + const std::vector gh_empty_strings; + const std::string gh_mode_type = imex_mode_type_for( + get_imex_mode(), + gh_names_opt ? gh_names_opt->values : gh_empty_strings, + gh_types_opt ? gh_types_opt->values : gh_empty_strings); + const int gh_tpg = gh_tpg_opt ? std::max(1, gh_tpg_opt->value) : 1; + const int gh_primary_col = primary_phys % gh_tpg; + std::map canonical_head_per_gantry; // gantry idx → physical head we emit + if (gh_mode_type == kImexModeTypeSplit) { + for (const auto& [phys, role] : heads) { + if (phys == primary_phys) continue; + if (phys >= IMEX_GHOST_MAX_HEADS) continue; + const int gantry = phys / gh_tpg; + const int col = phys % gh_tpg; + auto it = canonical_head_per_gantry.find(gantry); + if (it == canonical_head_per_gantry.end() || col == gh_primary_col) + canonical_head_per_gantry[gantry] = phys; + } + } + // Zone centers are the source of truth for ghost placement: they come from the // same grid math that paints the colored secondary zones, so a ghost always lands // in its own tool's zone. extruder_offset is physical-nozzle data and is left at @@ -1101,6 +1158,13 @@ void PartPlate::calc_imex_ghosts() for (const auto& [phys, role] : heads) { if (phys == primary_phys) continue; if (phys >= IMEX_GHOST_MAX_HEADS) continue; + // Split mode: skip non-canonical tools on a gantry — only the gantry's + // representative tool (col-paired with primary, fallback first-seen) + // emits a ghost so the user sees one aggregate per non-primary gantry. + if (gh_mode_type == kImexModeTypeSplit) { + auto it = canonical_head_per_gantry.find(phys / gh_tpg); + if (it == canonical_head_per_gantry.end() || it->second != phys) continue; + } const Vec2d gantry = center_for(phys) - primary_off; // Mirror reflects about the zone-boundary plane through the primary zone diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 0231364f26..2c28f47ec0 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4457,17 +4457,22 @@ public: info_panel->SetSizer(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. + // Name field: FromDIP(130) + FromDIP(6) gap; type col: FromDIP(80) + FromDIP(6) gap; + // tool grid: n_cols*(FromDIP(36)+FromDIP(2))-FromDIP(2) + FromDIP(6) gap. auto* hdr_panel = new wxPanel(this, wxID_ANY); hdr_panel->SetBackgroundColour(GetBackgroundColour()); auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL); auto* hdr_name = new wxStaticText(hdr_panel, wxID_ANY, _L("Name")); + auto* hdr_type = new wxStaticText(hdr_panel, wxID_ANY, _L("Type")); auto* hdr_tools = new wxStaticText(hdr_panel, wxID_ANY, _L("Tools")); auto* hdr_gcode = new wxStaticText(hdr_panel, wxID_ANY, _L("G-code")); const int grid_px = m_n_cols * FromDIP(38) - FromDIP(2); // approx grid panel width const int name_col_px = FromDIP(136); + const int type_col_px = FromDIP(86); 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_type, 0, wxALIGN_CENTER_VERTICAL); + hdr_sizer->AddSpacer(std::max(0, type_col_px - hdr_type->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); @@ -4490,13 +4495,15 @@ public: 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(); + auto [names, tools, gcodes, types] = get_mode_data(); clear_rows(); m_n_cols = n_cols; m_n_rows = n_rows; m_layout = layout; - for (size_t i = 0; i < names.size(); ++i) - add_row(names[i], tools[i], gcodes[i], /*is_primary=*/(names[i] == kImexPrimaryMode)); + for (size_t i = 0; i < names.size(); ++i) { + const std::string t = (i < types.size() && !types[i].empty()) ? types[i] : kImexModeTypeCopy; + add_row(names[i], tools[i], gcodes[i], t, /*is_primary=*/(names[i] == kImexPrimaryMode)); + } Layout(); } @@ -4505,6 +4512,17 @@ public: auto* names = cfg.option("imex_mode_names"); auto* tools = cfg.option("imex_mode_active_tools"); auto* gcodes = cfg.option("imex_mode_gcodes"); + auto* types = cfg.option("imex_mode_types"); + + // Resolve a row's type using imex_mode_type_for so legacy presets without + // imex_mode_types still infer copy/mirror/split from their mode name. + const std::vector empty_strings; + auto resolve_type = [&](const std::string& mode_name) -> std::string { + return imex_mode_type_for( + mode_name, + names ? names->values : empty_strings, + types ? types->values : empty_strings); + }; // Primary row is always first and non-deletable. Look for an existing // "primary" entry in the config (present in configs saved after #8 was @@ -4521,39 +4539,48 @@ public: } } } - add_row(kImexPrimaryMode, primary_tools, primary_gcode, /*is_primary=*/true); + add_row(kImexPrimaryMode, primary_tools, primary_gcode, kImexModeTypePrimary, /*is_primary=*/true); size_t n = names ? names->values.size() : 0; for (size_t i = 0; i < n; ++i) { if (i == primary_cfg_idx) continue; // already added above add_row(names->values[i], (tools && i < tools->values.size()) ? tools->values[i] : "", - (gcodes && i < gcodes->values.size()) ? gcodes->values[i] : ""); + (gcodes && i < gcodes->values.size()) ? gcodes->values[i] : "", + resolve_type(names->values[i])); } refresh_reset_buttons(); Layout(); } - std::tuple, std::vector, std::vector> + std::tuple, std::vector, std::vector, std::vector> get_mode_data() const { - std::vector names, tools, gcodes; + std::vector names, tools, gcodes, types; for (auto& r : m_rows) { std::string nm = r.is_primary ? std::string(kImexPrimaryMode) : r.name->GetValue().ToStdString(); if (nm.empty()) continue; names.push_back(nm); tools.push_back(active_tools_string(r)); gcodes.push_back(r.gcode->GetValue().ToStdString()); + // Primary row's type is sentinel-fixed; non-primary rows read from the dropdown. + if (r.is_primary || !r.type_choice) { + types.push_back(kImexModeTypePrimary); + } else { + const int idx = r.type_choice->GetSelection(); + const auto& choices = type_choices(); + types.push_back((idx >= 0 && idx < (int)choices.size()) ? choices[idx].second : kImexModeTypeCopy); + } } - return {names, tools, gcodes}; + return {names, tools, gcodes, types}; } - // True when the widget's current rows already mirror the three IMEX-mode option + // True when the widget's current rows already mirror the four 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 { - auto [names, tools, gcodes] = get_mode_data(); + auto [names, tools, gcodes, types] = get_mode_data(); auto cfg_strings = [&cfg](const char* key) { std::vector v; if (auto* o = cfg.option(key)) v = o->values; @@ -4561,7 +4588,8 @@ public: }; return names == cfg_strings("imex_mode_names") && tools == cfg_strings("imex_mode_active_tools") - && gcodes == cfg_strings("imex_mode_gcodes"); + && gcodes == cfg_strings("imex_mode_gcodes") + && types == cfg_strings("imex_mode_types"); } // Parse "idx:P,idx:C,idx:M" → map @@ -4621,6 +4649,7 @@ private: wxPanel* panel; wxTextCtrl* name; // nullptr for primary row (name is fixed) wxTextCtrl* gcode; + wxChoice* type_choice {nullptr}; // nullptr for primary row (type is fixed) std::vector btns; std::vector btn_states; std::vector btn_tool_idx; @@ -4630,13 +4659,37 @@ private: bool reset_dirty_cached {false}; }; + // Type-column dropdown indices match this static array. Wire-format strings + // (kImexModeType*) are what get written to imex_mode_types serialization. + static const std::vector>& type_choices() { + static const std::vector> v = { + { _L("Copy"), kImexModeTypeCopy }, + { _L("Mirror"), kImexModeTypeMirror }, + { _L("Split"), kImexModeTypeSplit }, + }; + return v; + } + static int type_choice_index(const std::string& type) { + const auto& choices = type_choices(); + for (size_t i = 0; i < choices.size(); ++i) + if (choices[i].second == type) return (int)i; + return 0; // default to Copy + } + // Snapshot of row content as it would be saved (matches get_mode_data() per-row). - struct RowSnapshot { std::string name, tools, gcode; }; + struct RowSnapshot { std::string name, tools, gcode, type; }; RowSnapshot snapshot_row(const Row& r) const { RowSnapshot s; s.name = r.is_primary ? std::string(kImexPrimaryMode) : r.name->GetValue().ToStdString(); s.tools = active_tools_string(r); s.gcode = r.gcode->GetValue().ToStdString(); + if (r.is_primary || !r.type_choice) { + s.type = kImexModeTypePrimary; + } else { + const int idx = r.type_choice->GetSelection(); + const auto& choices = type_choices(); + s.type = (idx >= 0 && idx < (int)choices.size()) ? choices[idx].second : kImexModeTypeCopy; + } return s; } @@ -4652,12 +4705,14 @@ private: auto* p_names = parent->option("imex_mode_names"); auto* p_tools = parent->option("imex_mode_active_tools"); auto* p_gcodes = parent->option("imex_mode_gcodes"); + auto* p_types = parent->option("imex_mode_types"); 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; + if (!r.is_primary && p_types && row_idx < (int)p_types->values.size() && s.type != p_types->values[row_idx]) return true; return false; } @@ -4674,6 +4729,7 @@ private: void add_row(const std::string& name = "", const std::string& active_tools = "", const std::string& gcode = "", + const std::string& type = kImexModeTypeCopy, bool is_primary = false) { Row r; @@ -4690,9 +4746,26 @@ private: f.SetWeight(wxFONTWEIGHT_BOLD); lbl->SetFont(f); sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + // Primary row gets a static "Primary" type label in the type column + // (the type is sentinel-fixed; no choice to offer). + auto* type_lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"), + wxDefaultPosition, FromDIP(wxSize(80, -1))); + sizer->Add(type_lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); } else { r.name = new wxTextCtrl(r.panel, wxID_ANY, name, wxDefaultPosition, FromDIP(wxSize(130, -1))); r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); }); + sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + + // Type column: per-row dropdown to select Copy / Mirror / Split. Drives + // the topology interpretation (zone/ghost aggregation, multi-color + // validation) — see imex_mode_type_for and consumers. + wxArrayString choice_labels; + for (const auto& [label, _key] : type_choices()) choice_labels.Add(label); + r.type_choice = new wxChoice(r.panel, wxID_ANY, wxDefaultPosition, + FromDIP(wxSize(80, -1)), choice_labels); + r.type_choice->SetSelection(type_choice_index(type)); + r.type_choice->Bind(wxEVT_CHOICE, [this](wxCommandEvent&) { notify(); }); + sizer->Add(r.type_choice, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); } auto* grid_panel = new wxPanel(r.panel, wxID_ANY); @@ -4788,9 +4861,9 @@ private: wxDefaultPosition, FromDIP(wxSize(220, 54)), wxTE_MULTILINE); r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); }); - if (!is_primary) { - sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); - } + // r.name and r.type_choice were added to the sizer in the non-primary branch + // above (right after creation, alongside the type dropdown). Here we add the + // tool grid and gcode columns common to every row. sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); @@ -4907,6 +4980,8 @@ private: auto* p_names = parent->option("imex_mode_names"); auto* p_tools = parent->option("imex_mode_active_tools"); auto* p_gcodes = parent->option("imex_mode_gcodes"); + auto* p_types = parent->option("imex_mode_types"); + const std::vector empty_strings; for (size_t i = 0; i < m_rows.size(); ++i) { Row& r = m_rows[i]; if (r.panel != panel) continue; @@ -4915,6 +4990,13 @@ private: 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 (!r.is_primary && r.type_choice) { + const std::string parent_type = imex_mode_type_for( + p_names->values[i], + p_names->values, + p_types ? p_types->values : empty_strings); + r.type_choice->SetSelection(type_choice_index(parent_type)); + } if (p_tools && i < p_tools->values.size()) { // Reapply tool states from the parent's serialized form. all_tool_states // is the source of truth for round-tripping; rebuild it then re-paint @@ -5595,10 +5677,11 @@ if (is_marlin_flavor) }); m_imex_modes_ctrl->load_from_config(*m_config); m_imex_modes_ctrl->on_change = [this]() { - auto [names, tools, gcodes] = m_imex_modes_ctrl->get_mode_data(); + auto [names, tools, gcodes, types] = m_imex_modes_ctrl->get_mode_data(); m_config->set_key_value("imex_mode_names", new ConfigOptionStrings(names)); m_config->set_key_value("imex_mode_active_tools", new ConfigOptionStrings(tools)); m_config->set_key_value("imex_mode_gcodes", new ConfigOptionStrings(gcodes)); + m_config->set_key_value("imex_mode_types", new ConfigOptionStrings(types)); update_dirty(); on_value_change("imex_mode_names", std::string("")); };