diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index c070d2a63f..554a5c57b5 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -7676,7 +7676,15 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo m_pa_processor->resetPreviousPA(m_config.pressure_advance.get_at(new_filament_id)); } - gcode += m_writer.toolchange(new_filament_id); + // Same suppression as the long multi-extruder path: at print-start in IMEX + // parallel modes the user's mode_gcode + machine_start_gcode is responsible + // for tool selection, so the slicer's bare T would be a duplicate. Mid- + // print toolchanges (count > 1) emit normally — Print::validate() blocks + // multi-color in configurations where mid-print T wouldn't make sense + // (see imex_multicolor_block_reason). + const std::string toolchange_command = m_writer.toolchange(new_filament_id); + if (!imex_suppresses_bare_toolchange(m_imex_parallel_mode, m_toolchange_count)) + gcode += toolchange_command; return gcode; } @@ -7913,7 +7921,13 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo //BBS: don't add T[next extruder] if there is no T cmd on filament change //We inform the writer about what is happening, but we may not use the resulting gcode. std::string toolchange_command = m_writer.toolchange(new_filament_id); - if (!custom_gcode_changes_tool(toolchange_gcode_parsed, m_writer.toolchange_prefix(), new_filament_id)) + // See imex_suppresses_bare_toolchange() — only suppress the print-start initial + // T in parallel modes (the user's imex_mode_gcode + machine_start_gcode owns + // tool activation there). Mid-print T emits normally; Print::validate blocks + // multi-color setups where it wouldn't make sense. + const bool suppress_imex_bare = imex_suppresses_bare_toolchange(m_imex_parallel_mode, m_toolchange_count); + if (!custom_gcode_changes_tool(toolchange_gcode_parsed, m_writer.toolchange_prefix(), new_filament_id) + && !suppress_imex_bare) gcode += toolchange_command; else { // user provided his own toolchange gcode, no need to do anything diff --git a/src/libslic3r/IMEXHelpers.cpp b/src/libslic3r/IMEXHelpers.cpp index 32cdfb24ac..aac0b5a1d4 100644 --- a/src/libslic3r/IMEXHelpers.cpp +++ b/src/libslic3r/IMEXHelpers.cpp @@ -41,6 +41,101 @@ int imex_pem_tool_for(int filament_id, const std::string& parallel_mode, const C return pem.get_at(filament_id); } +bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned int toolchange_count) +{ + return toolchange_count <= 1 + && !parallel_mode.empty() + && parallel_mode != kImexPrimaryMode; +} + +std::string imex_mode_type_for(const std::string& mode_name, + const std::vector& mode_names, + const std::vector& mode_types) +{ + if (mode_name.empty() || mode_name == kImexPrimaryMode) + return kImexModeTypePrimary; + // Look up the parallel-vector entry by index match. + for (size_t i = 0; i < mode_names.size(); ++i) { + if (mode_names[i] != mode_name) continue; + if (i < mode_types.size() && !mode_types[i].empty()) + return mode_types[i]; + break; // entry found but no type set → fall through to inference + } + // Legacy fallback: a mode literally named "copy" / "mirror" / "split" was authored + // before imex_mode_types existed. Take the name as the type. + if (mode_name == kImexModeTypeCopy || + mode_name == kImexModeTypeMirror || + mode_name == kImexModeTypeSplit) + return mode_name; + return kImexModeTypePrimary; +} + +std::string imex_multicolor_block_reason(const std::string& parallel_mode, + const std::string& mode_type, + const std::string& active_tools_str, + int tools_per_gantry, + const std::vector& used_filaments_0b, + const ConfigOptionInts& pem) +{ + // Not an IMEX parallel print → nothing to validate here. + if (parallel_mode.empty() || parallel_mode == kImexPrimaryMode) return {}; + // Single-color prints have no toolchanges to constrain. + if (used_filaments_0b.size() <= 1) return {}; + + // MMU lane sharing detection: two or more used filaments routed to the same + // physical head means a mid-print MMU swap on that head, which IMEX parallel + // modes can't slave to the secondary gantry. Applies to all parallel mode + // types including Split — even Split can't replicate MMU lane swaps across + // gantries because the slaved gantry's physical heads can't independently + // toolchange. + if (!pem.values.empty()) { + std::unordered_set seen_physicals; + for (int filament : used_filaments_0b) { + if (filament < 0 || filament >= (int)pem.values.size()) continue; + const int phys = pem.get_at(filament); + if (!seen_physicals.insert(phys).second) { + return "Multi-color prints in IDEX/IQEX parallel modes require each filament " + "to have its own dedicated physical extruder. Two or more of the active " + "filaments are routed to the same physical head via the printer's " + "physical extruder map (an MMU/AFC manifold), which the slaved gantry " + "cannot follow."; + } + } + } + + // Split modes are explicitly designed for per-gantry multi-color: each gantry + // gets half the bed and runs its own multi-tool sequence (the slaved gantry + // mirrors/copies the primary's sequence). The within-gantry-toolchange check + // below doesn't apply because Split's whole purpose is to enable that. + if (mode_type == kImexModeTypeSplit) + return {}; + + // Determine which physical head is the primary in this mode, then count how many + // tool roles are assigned to tools on the same gantry. Multi-color requires at + // least 2 (so there's a within-gantry toolchange topology). + const int primary_physical = imex_primary_tool_for_mode(active_tools_str); + if (primary_physical < 0) { + return "The active IDEX/IQEX mode does not define a primary tool, so multi-color " + "printing cannot be scheduled. Open the printer settings IDEX/IQEX Modes " + "editor and assign a Primary role to one tool."; + } + const int tpg = std::max(1, tools_per_gantry); + const int primary_gantry = primary_physical / tpg; + int tools_on_primary_gantry = 0; + for (const auto& [phys, role] : parse_imex_active_tools(active_tools_str)) { + if (phys / tpg == primary_gantry) + ++tools_on_primary_gantry; + } + if (tools_on_primary_gantry < 2) { + return "Multi-color prints in IDEX/IQEX parallel modes require at least two tools " + "assigned to the primary tool's gantry — there is no within-gantry " + "toolchange path otherwise. Either reduce the print to a single filament, " + "switch to Primary mode, or use a Split-type mode designed for per-gantry " + "multi-color printing."; + } + return {}; +} + int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical) { const auto& v = pem.values; diff --git a/src/libslic3r/IMEXHelpers.hpp b/src/libslic3r/IMEXHelpers.hpp index 0d987a0af7..af60f34471 100644 --- a/src/libslic3r/IMEXHelpers.hpp +++ b/src/libslic3r/IMEXHelpers.hpp @@ -87,6 +87,81 @@ ConfigOptionInts effective_physical_extruder_map(const PresetBundle& pb); // firmware commands like any non-IMEX printer. int imex_pem_tool_for(int filament_id, const std::string& parallel_mode, const ConfigOptionInts& pem); +// True when GCode::set_extruder should suppress its bare T at the print-start +// initial-tool selection because the active IMEX parallel mode's setup macro +// (imex_mode_gcode) and machine_start_gcode already activate the primary tool +// (Klipper SET_PRINT_MODE / RRF M567 — both reference an active tool by definition), +// so any slicer-emitted T at print-start is a duplicate. +// +// Mid-print toolchanges in parallel mode are intentionally NOT suppressed: +// - Single-color prints have no mid-print toolchanges anyway +// - Multi-color prints are blocked at slice-time when the active IMEX configuration +// can't physically support multi-color (see imex_multicolor_block_reason). The only +// not-blocked case is IQEX with 2+ tools active on the primary gantry, where mid- +// print T is legitimately needed and the firmware handles the slaved gantry. +// +// Returns false for: non-IMEX printers (empty parallel_mode), Primary mode, and any +// toolchange after the first (toolchange_count > 1). Custom change_filament_gcode that +// contains its own T is handled separately by GCode.cpp's custom_gcode_changes_tool(). +bool imex_suppresses_bare_toolchange(const std::string& parallel_mode, unsigned int toolchange_count); + +// Mode-type sentinels used by imex_mode_types entries and returned by +// imex_mode_type_for(). Compared against to drive zone/ghost/validation behavior. +inline constexpr const char* kImexModeTypePrimary = "primary"; +inline constexpr const char* kImexModeTypeCopy = "copy"; +inline constexpr const char* kImexModeTypeMirror = "mirror"; +inline constexpr const char* kImexModeTypeSplit = "split"; + +// Looks up the topology type ("primary" / "copy" / "mirror" / "split") for the named +// mode. Reads from the parallel imex_mode_types vector when present; falls back to +// inferring from the mode_name itself when the entry is missing or empty (legacy +// presets pre-dating imex_mode_types — a mode literally named "copy" is treated as +// a copy-type mode, etc.). Anything unknown defaults to "primary". +// +// `mode_name` should be the active mode being looked up. `mode_names` and `mode_types` +// are the parallel vectors from the printer config. +std::string imex_mode_type_for(const std::string& mode_name, + const std::vector& mode_names, + const std::vector& mode_types); + +// Returns a user-facing block reason when multi-color printing is incompatible with +// the active IMEX parallel mode, or an empty string when the configuration is OK. +// +// Multi-color in a parallel mode requires the firmware to swap tools mid-print on the +// primary gantry while the slaved gantry follows automatically. That works only when +// every used filament has its own dedicated physical head (no MMU lane sharing) AND +// the active mode definition assigns at least 2 roles to tools on the primary's gantry +// (so there's a within-gantry toolchange topology to swap among). +// +// Catches: +// - IDEX (1 tool per gantry): only 1 tool on primary's gantry → blocked +// - IQEX 2-tool-active (e.g. T0 primary + T2 copy on different gantries): only 1 tool +// on primary's gantry → blocked +// - MMU/AFC sharing among used filaments: multiple used filaments routed to same +// physical head → blocked (the slaved gantry can't follow MMU lane swaps) +// - IQEX 4-tool-active (T0+T1 on primary gantry, T2+T3 paired): 2 tools on primary's +// gantry, no MMU sharing → ALLOWED +// +// Returns empty string for: non-IMEX (empty parallel_mode), Primary mode, single-color +// prints, or any configuration where multi-color is physically supportable. +// +// Inputs: +// parallel_mode — m_imex_parallel_mode at slice time +// mode_type — imex_mode_type_for(parallel_mode, ...). "split" types skip the +// gantry-pair check (Split modes are explicitly designed for +// per-gantry multi-color); MMU sharing still blocks them. +// active_tools_str — the imex_mode_active_tools entry for the active mode, e.g. +// "0:P,1:C,2:M,3:M" +// tools_per_gantry — imex_tools_per_gantry from printer config (>= 1) +// used_filaments_0b — 0-based logical filament indices used by the print +// pem — physical_extruder_map (logical idx → physical head) +std::string imex_multicolor_block_reason(const std::string& parallel_mode, + const std::string& mode_type, + const std::string& active_tools_str, + int tools_per_gantry, + const std::vector& used_filaments_0b, + const ConfigOptionInts& pem); + // Returns the lowest 0-based logical filament index L such that pem[L] == physical. // Returns -1 if no filament routes to `physical`. // Degenerate case: an empty pem returns 0 when `physical == 0` (identity-on-head-0 diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 501ec19d1f..b7317dbab8 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1044,7 +1044,7 @@ static std::vector s_Preset_printer_options { // IDEX/IQEX (independent X extruder) — printer capability and user-defined modes "is_imex", "imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout", "imex_nozzle_clearance_x", "imex_nozzle_clearance_y", "imex_carriage_margin", "imex_viz_theme", - "imex_mode_names", "imex_mode_active_tools", "imex_mode_gcodes" + "imex_mode_names", "imex_mode_active_tools", "imex_mode_gcodes", "imex_mode_types" }; static std::vector s_Preset_sla_print_options { diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index ad6e279cd4..173fb97a31 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -1,5 +1,6 @@ #include "Config.hpp" #include "Exception.hpp" +#include "IMEXHelpers.hpp" #include "Print.hpp" #include "BoundingBox.hpp" #include "Brim.hpp" @@ -1198,6 +1199,41 @@ StringObjectException Print::validate(StringObjectException *warning, Polygons* if (extruders.empty()) return { L("No extrusions under current settings.") }; + // IDEX/IQEX: block multi-color in non-primary parallel modes when the active + // configuration can't physically support it (no within-gantry toolchange path + // or MMU lane sharing). See imex_multicolor_block_reason() for the full rule. + if (m_config.is_imex.value && extruders.size() > 1 && !m_objects.empty()) { + const std::string& parallel_mode = m_objects.front()->config().imex_parallel_mode.value; + if (!parallel_mode.empty() && parallel_mode != kImexPrimaryMode) { + // Look up the active mode's tools string in the printer config's parallel + // table. Fall back to empty (block helper handles it gracefully). + std::string active_tools_str; + const auto& mode_names = m_config.imex_mode_names.values; + const auto& mode_tools = m_config.imex_mode_active_tools.values; + for (size_t i = 0; i < mode_names.size(); ++i) { + if (mode_names[i] == parallel_mode && i < mode_tools.size()) { + active_tools_str = mode_tools[i]; + break; + } + } + const std::string mode_type = imex_mode_type_for( + parallel_mode, mode_names, m_config.imex_mode_types.values); + std::vector used_filaments_0b; + used_filaments_0b.reserve(extruders.size()); + for (unsigned int e : extruders) + used_filaments_0b.push_back((int)e); + const std::string reason = imex_multicolor_block_reason( + parallel_mode, + mode_type, + active_tools_str, + m_config.imex_tools_per_gantry.value, + used_filaments_0b, + m_config.physical_extruder_map); + if (!reason.empty()) + return { reason }; + } + } + if (nozzles < 2 && extruders.size() > 1) { auto ret = check_multi_filament_valid(*this); if (!ret.string.empty()) diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 1aad2be000..6c39eda068 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -543,6 +543,24 @@ static const t_config_enum_values s_keys_map_FilamentMapMode = { }; CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(FilamentMapMode) +// IMEX: keep the legacy hyphenated wire strings so existing presets / 3MFs +// deserialize unchanged after the coString -> coEnum migration. +static const t_config_enum_values s_keys_map_ImexToolLayout = { + { "front-left", int(ImexToolLayout::FrontLeft) }, + { "front-right", int(ImexToolLayout::FrontRight) }, + { "rear-left", int(ImexToolLayout::RearLeft) }, + { "rear-right", int(ImexToolLayout::RearRight) } +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(ImexToolLayout) + +static const t_config_enum_values s_keys_map_ImexVizTheme = { + { "standard", int(ImexVizTheme::Standard) }, + { "deuteranopia", int(ImexVizTheme::Deuteranopia) }, + { "tritanopia", int(ImexVizTheme::Tritanopia) }, + { "high_contrast", int(ImexVizTheme::HighContrast) } +}; +CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(ImexVizTheme) + //BBS std::string get_extruder_variant_string(ExtruderType extruder_type, NozzleVolumeType nozzle_volume_type) @@ -5561,13 +5579,22 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionInt(2)); - def = this->add("imex_tool_layout", coString); + def = this->add("imex_tool_layout", coEnum); def->label = L("Tool 0 Corner"); def->tooltip = L("Physical corner of the bed where tool T0 (index 0) is located. " "Determines how tool indices map to bed zones. " "front = lower Y (near the operator), rear = higher Y (back of machine)."); def->mode = comAdvanced; - def->set_default_value(new ConfigOptionString("front-left")); + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("front-left"); + def->enum_values.push_back("front-right"); + def->enum_values.push_back("rear-left"); + def->enum_values.push_back("rear-right"); + def->enum_labels.push_back(L("Front-left")); + def->enum_labels.push_back(L("Front-right")); + def->enum_labels.push_back(L("Rear-left")); + def->enum_labels.push_back(L("Rear-right")); + def->set_default_value(new ConfigOptionEnum(ImexToolLayout::FrontLeft)); def = this->add("imex_nozzle_clearance_x", coFloat); def->label = L("Nozzle Clearance X"); @@ -5594,11 +5621,20 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionFloat(0.0)); - def = this->add("imex_viz_theme", coString); + def = this->add("imex_viz_theme", coEnum); def->label = L("Visualization Theme"); def->tooltip = L("Color theme for IDEX/IQEX bed zone visualization. Choose a colorblind-friendly theme if needed."); def->mode = comAdvanced; - def->set_default_value(new ConfigOptionString("standard")); + def->enum_keys_map = &ConfigOptionEnum::get_enum_values(); + def->enum_values.push_back("standard"); + def->enum_values.push_back("deuteranopia"); + def->enum_values.push_back("tritanopia"); + def->enum_values.push_back("high_contrast"); + def->enum_labels.push_back(L("Standard")); + def->enum_labels.push_back(L("Deuteranopia / Protanopia (red-green)")); + def->enum_labels.push_back(L("Tritanopia (blue-yellow)")); + def->enum_labels.push_back(L("High Contrast")); + def->set_default_value(new ConfigOptionEnum(ImexVizTheme::Standard)); def = this->add("imex_parallel_mode", coString); def->label = L("IDEX/IQEX Print Mode"); @@ -5631,6 +5667,20 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionStrings()); + // Mode-type tag drives how zones, ghosts, and slice-time validation interpret a + // given mode entry. Parallel to imex_mode_names. Values: "primary", "copy", + // "mirror", "split". Empty / missing entries fall back to "primary" except for + // entries named "copy" or "mirror" (legacy compat with pre-type configs). + def = this->add("imex_mode_types", coStrings); + def->label = L("IDEX/IQEX Mode Types"); + def->tooltip = L("Per-mode topology tag. \"copy\" / \"mirror\" produce one ghost per " + "non-primary tool at the corresponding mirrored/copied position. " + "\"split\" splits the bed in half along the gantry axis and aggregates " + "each gantry's tools into a single multi-color print region — used " + "for paired-gantry IQEX multi-color printing."); + def->mode = comAdvanced; + def->set_default_value(new ConfigOptionStrings()); + def = this->add("manual_filament_change", coBool); def->label = L("Manual Filament Change"); def->tooltip = L("Enable this option to omit the custom Change filament G-code only at the beginning of the print. " diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 24ca584fdd..e93d954055 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -131,6 +131,26 @@ enum class WallDirection Count, }; +// IMEX: physical bed corner where tool T0 sits. Determines how tool indices +// map to bed zones (front = lower Y near operator, rear = higher Y). +enum class ImexToolLayout { + FrontLeft, + FrontRight, + RearLeft, + RearRight, + Count, +}; + +// IMEX: color theme for the bed-zone visualization. Colorblind-friendly variants +// are provided for users with color-vision differences. +enum class ImexVizTheme { + Standard, + Deuteranopia, + Tritanopia, + HighContrast, + Count, +}; + //BBS enum class PrintSequence { ByLayer, @@ -1348,6 +1368,7 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, imex_mode_names)) ((ConfigOptionStrings, imex_mode_active_tools)) ((ConfigOptionStrings, imex_mode_gcodes)) + ((ConfigOptionStrings, imex_mode_types)) ((ConfigOptionBool, single_extruder_multi_material_priming)) ((ConfigOptionBool, wipe_tower_no_sparse_layers)) ((ConfigOptionString, change_filament_gcode)) diff --git a/src/slic3r/GUI/GCodeViewer.cpp b/src/slic3r/GUI/GCodeViewer.cpp index 5c88864a18..9fa4a2b8c5 100644 --- a/src/slic3r/GUI/GCodeViewer.cpp +++ b/src/slic3r/GUI/GCodeViewer.cpp @@ -1655,10 +1655,10 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) // Apply the same flip logic as PartPlate::calc_imex_zones() so physical // grid positions match the bed zone visualization. - auto* layout_opt = printer_cfg.opt("imex_tool_layout"); - const std::string layout_str = layout_opt ? layout_opt->value : "front-left"; - const bool flip_x = (layout_str == "front-right" || layout_str == "rear-right"); - const bool flip_y = (layout_str == "rear-left" || layout_str == "rear-right"); + auto* layout_opt = printer_cfg.opt>("imex_tool_layout"); + const ImexToolLayout layout = layout_opt ? layout_opt->value : ImexToolLayout::FrontLeft; + const bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight); + const bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight); auto phys_col_of = [&](int tid) -> int { int raw = tid % tools_per_gantry; @@ -1791,7 +1791,11 @@ void GCodeViewer::render(int canvas_width, int canvas_height, int right_margin) // IDEX/IQEX: render toolhead footprint boxes for each active carriage. // Each box is imex_nozzle_clearance_x × imex_nozzle_clearance_y, sitting above the nozzle tip. - if (!carriage_box_draws.empty() && imex_box_wx > 0.0f && imex_box_wy > 0.0f) { + // Hidden when the user toggles off "Show IDEX/IQEX Toolhead Boxes" in the View menu — gives + // an unobstructed view of the toolpaths during sequential playback. Default-on; the absence + // of an app_config entry is also treated as on. + const bool show_toolhead_boxes = wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; + if (show_toolhead_boxes && !carriage_box_draws.empty() && imex_box_wx > 0.0f && imex_box_wy > 0.0f) { // Rebuild box mesh every frame — dimensions can change via config edit without a // G-code reload, so dimension-based caching isn't safe. // Mesh origin: nozzle at x=0, centered in Y, Z starts at nozzle tip level. diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 175bf18414..e690e56939 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -7405,18 +7405,20 @@ void GLCanvas3D::_render_imex_ghost_tooltip() ImGuiWrapper& imgui = *wxGetApp().imgui(); const Vec2i32 mouse = m_mouse.position.cast(); - imgui.set_next_window_pos(float(mouse.x() + 16), float(mouse.y() + 16), + const float cursor_offset = imgui.scaled(1.6f); // ~16px @ 100%, scales with DPI + imgui.set_next_window_pos(float(mouse.x()) + cursor_offset, float(mouse.y()) + cursor_offset, ImGuiCond_Always, 0.0f, 0.0f); imgui.begin(std::string("##imex_ghost_tooltip"), ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMouseInputs); - // 14px color swatch + label on the same row. + // Color swatch + label on the same row. imgui.scaled() keeps it readable on high-DPI. const ImVec4 col(t.swatch.r(), t.swatch.g(), t.swatch.b(), t.swatch.a()); + const float swatch_size = imgui.scaled(1.4f); ImGui::ColorButton("##swatch", col, ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip, - ImVec2(14, 14)); + ImVec2(swatch_size, swatch_size)); ImGui::SameLine(); imgui.text(t.label); imgui.end(); diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index cbcbd0adaf..8dc840d3b2 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -3061,6 +3061,30 @@ void MainFrame::init_menubar_as_editor() [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, [this]() { return wxGetApp().show_plate_gridlines(); }, this); + // IDEX/IQEX: toggle the per-carriage toolhead representation that appears during + // G-code preview playback. Today this is the symbolic footprint box; if a future + // change adds true toolhead model support (cf. printer bed models) this same toggle + // governs that. Only relevant on Preview, and only meaningful when the active + // printer is IDEX/IQEX — gated to keep the menu uncluttered for everyone else. + append_menu_check_item(viewMenu, wxID_ANY, _L("Show IDEX/IQEX Toolhead"), + _L("Show the IDEX/IQEX toolhead representation around each active nozzle in the G-code preview."), + [this](wxCommandEvent&) { + const bool current = wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; + wxGetApp().app_config->set("show_imex_toolhead_boxes", current ? "false" : "true"); + m_plater->get_current_canvas3D()->post_event(SimpleEvent(wxEVT_PAINT)); + }, + this, + [this]() { + if (m_tabpanel->GetSelection() != TabPosition::tpPreview) return false; + if (auto* pb = wxGetApp().preset_bundle) { + auto* o = pb->printers.get_edited_preset().config.option("is_imex"); + return o && o->value; + } + return false; + }, + []() { return wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; }, + this); + append_menu_item( viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 1e08000f82..5219df361e 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -580,16 +580,15 @@ void PartPlate::calc_imex_zones() // Grid dimensions and tool layout from printer config auto* gantry_opt = printer_cfg.option("imex_gantry_count"); auto* tpg_opt = printer_cfg.option("imex_tools_per_gantry"); - auto* layout_opt = printer_cfg.option("imex_tool_layout"); + auto* layout_opt = printer_cfg.option>("imex_tool_layout"); int n_cols = tpg_opt ? std::max(1, tpg_opt->value) : 2; int n_rows = gantry_opt ? std::max(1, gantry_opt->value) : 1; - // Layout: which corner is T0? 0=front-left, 1=front-right, 2=rear-left, 3=rear-right - // flip_x: col 0 is right(max-X); flip_y: row 0 is rear(max-Y) - std::string layout_str = layout_opt ? layout_opt->value : "front-left"; - bool flip_x = (layout_str == "front-right" || layout_str == "rear-right"); - bool flip_y = (layout_str == "rear-left" || layout_str == "rear-right"); + // Which corner is T0? flip_x: col 0 is right(max-X); flip_y: row 0 is rear(max-Y) + const ImexToolLayout layout = layout_opt ? layout_opt->value : ImexToolLayout::FrontLeft; + bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight); + bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight); // Convert tool index to physical (col=X-index, row=Y-index), col/row 0 = min-X/min-Y auto tool_to_phys = [&](int idx) -> std::pair { @@ -1221,16 +1220,47 @@ bool PartPlate::has_imex_placement_violations() bool PartPlate::has_imex_multimaterial_conflict() const { - // Condition 1: IMEX is active and the plate mode is non-primary + // Mirror the logic Print::validate uses so the plater badge fires exactly when + // slicing would be blocked — no false positives where the badge warns but the + // slice goes through anyway. Delegates to imex_multicolor_block_reason() so + // both paths share one source of truth. auto* pb = wxGetApp().preset_bundle; if (!pb) return false; - auto* is_imex_opt = pb->printers.get_edited_preset().config.option("is_imex"); + const DynamicPrintConfig& printer_cfg = pb->printers.get_edited_preset().config; + auto* is_imex_opt = printer_cfg.option("is_imex"); if (!is_imex_opt || !is_imex_opt->value) return false; - if (get_imex_mode() == kImexPrimaryMode) return false; - // Condition 2: objects on this plate actually use more than one unique filament/extruder - std::vector used = get_extruders(true); - return used.size() > 1; + const std::string mode = get_imex_mode(); + if (mode == kImexPrimaryMode) return false; + + // Resolve the active mode's tools string from the printer config. + 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"); + auto* tpg_opt = printer_cfg.option("imex_tools_per_gantry"); + auto* pem_opt = printer_cfg.option("physical_extruder_map"); + if (!names_opt || !tools_opt || !tpg_opt || !pem_opt) return false; + + std::string active_tools_str; + for (size_t i = 0; i < names_opt->values.size(); ++i) { + if (names_opt->values[i] == mode && i < tools_opt->values.size()) { + active_tools_str = tools_opt->values[i]; + break; + } + } + + const std::vector empty_types; + const std::string mode_type = imex_mode_type_for( + mode, names_opt->values, + types_opt ? types_opt->values : empty_types); + + // Convert PartPlate's 1-based extruder list to the 0-based form the helper expects. + const std::vector used_1b = get_extruders(true); + std::vector used_0b; + used_0b.reserve(used_1b.size()); + for (int e : used_1b) if (e > 0) used_0b.push_back(e - 1); + + return !imex_multicolor_block_reason(mode, mode_type, active_tools_str, tpg_opt->value, used_0b, *pem_opt).empty(); } void PartPlate::render_imex_zones(bool force_default_color) @@ -1283,10 +1313,13 @@ void PartPlate::render_imex_zones(bool force_default_color) const IMEXTheme* theme = &k_standard; if (wxGetApp().preset_bundle) { const DynamicPrintConfig& pcfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; - if (auto* t = pcfg.option("imex_viz_theme")) { - if (t->value == "deuteranopia") theme = &k_deuteranopia; - else if (t->value == "tritanopia") theme = &k_tritanopia; - else if (t->value == "high_contrast") theme = &k_high_contrast; + if (auto* t = pcfg.option>("imex_viz_theme")) { + switch (t->value) { + case ImexVizTheme::Deuteranopia: theme = &k_deuteranopia; break; + case ImexVizTheme::Tritanopia: theme = &k_tritanopia; break; + case ImexVizTheme::HighContrast: theme = &k_high_contrast; break; + default: break; // Standard + } } } @@ -2092,6 +2125,7 @@ void PartPlate::render_icons(bool bottom, bool only_name, int hover_id) if (hover_id == (int)PLATE_IMEX_MODE_ID) { render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_hovered_texture); std::string cur = get_imex_mode(); + if (cur == kImexPrimaryMode) cur = _u8L("Primary"); show_tooltip(_u8L("IDEX/IQEX mode: ") + cur + _u8L(" (left-click to cycle, right-click for menu)")); } else { render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_texture); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 253072fac9..c7f4c8c65c 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -18069,7 +18069,8 @@ int Plater::select_plate_by_hover_id(int hover_id, bool right_click, bool isModi for (size_t i = 0; i < modes.size(); ++i) { int id = wxNewId(); mode_ids.push_back(id); - wxMenuItem* item = menu.AppendRadioItem(id, from_u8(modes[i])); + const wxString label = (modes[i] == kImexPrimaryMode) ? _L("Primary") : from_u8(modes[i]); + wxMenuItem* item = menu.AppendRadioItem(id, label); if (modes[i] == current) item->Check(true); } diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 0b97063358..0231364f26 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -47,6 +47,7 @@ #include "Widgets/Label.hpp" #include "Widgets/TabCtrl.hpp" #include "Widgets/ComboBox.hpp" +#include "Widgets/CheckBox.hpp" #include "MarkdownTip.hpp" #include "Search.hpp" #include "BedShapeDialog.hpp" @@ -4401,22 +4402,32 @@ class IMEXModesCtrl : public wxPanel { public: std::function on_change; - // layout: 0=front-left, 1=front-right, 2=rear-left, 3=rear-right (T0 corner) - static int parse_layout(const std::string& s) { - if (s == "front-right") return 1; - if (s == "rear-left") return 2; - if (s == "rear-right") return 3; - return 0; // "front-left" default - } + // 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; + 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(layout); } IMEXModesCtrl(wxWindow* parent, int n_cols, int n_rows, int layout = 0) : 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: instruction text + color legend --- auto* info_panel = new wxPanel(this, wxID_ANY); + info_panel->SetBackgroundColour(GetBackgroundColour()); auto* info_sizer = new wxBoxSizer(wxVERTICAL); auto* inst = new wxStaticText(info_panel, wxID_ANY, @@ -4424,36 +4435,41 @@ public: "The Primary tool (green) drives all sliced paths. " "Copy (blue) and Mirror (amber) tools follow the Primary at the firmware level. " "Click a tool button to cycle its role — clear the Primary before reassigning it.")); - inst->Wrap(620); - info_sizer->Add(inst, 0, wxBOTTOM, 6); + inst->Wrap(FromDIP(620)); + info_sizer->Add(inst, 0, wxBOTTOM, FromDIP(6)); - // Color legend + // 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. auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL); struct { int state; const char* label; } legend[] = { {1,"Primary"},{2,"Copy"},{3,"Mirror"} }; + const int swatch_side = info_panel->GetCharHeight(); for (auto& l : legend) { - auto* swatch = new wxPanel(info_panel, wxID_ANY, wxDefaultPosition, wxSize(12, 12)); + auto* swatch = new wxPanel(info_panel, wxID_ANY, wxDefaultPosition, wxSize(swatch_side, swatch_side)); swatch->SetBackgroundColour(btn_color(l.state)); - leg_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + leg_sizer->Add(swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); leg_sizer->Add(new wxStaticText(info_panel, wxID_ANY, l.label), - 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 16); + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(16)); } - info_sizer->Add(leg_sizer, 0, wxBOTTOM, 6); + info_sizer->Add(leg_sizer, 0, wxBOTTOM, FromDIP(6)); info_panel->SetSizer(info_sizer); - // Column header row (fixed-pixel spacers to approximately align with mode row fields) - // Name field: 130px + 6px gap; Tool grid: n_cols*(36+2)-2 px + 6px gap + // 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. 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_tools = new wxStaticText(hdr_panel, wxID_ANY, _L("Tools")); auto* hdr_gcode = new wxStaticText(hdr_panel, wxID_ANY, _L("G-code")); - int grid_px = m_n_cols * 38 - 2; // approx grid panel width + 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, 136 - hdr_name->GetBestSize().x)); // "Name" col = 136px total + 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 + 6 - hdr_tools->GetBestSize().x)); // "Tools" col = grid_px+6 total + hdr_sizer->AddSpacer(std::max(0, grid_px + FromDIP(6) - hdr_tools->GetBestSize().x)); hdr_sizer->Add(hdr_gcode, 1, wxALIGN_CENTER_VERTICAL); hdr_panel->SetSizer(hdr_sizer); @@ -4461,10 +4477,10 @@ public: wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); }); - m_outer->Add(info_panel, 0, wxEXPAND | wxBOTTOM, 4); - m_outer->Add(hdr_panel, 0, wxEXPAND | wxBOTTOM, 2); + m_outer->Add(info_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + m_outer->Add(hdr_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(2)); m_outer->Add(m_rows_sizer, 0, wxEXPAND); - m_outer->AddSpacer(4); + m_outer->AddSpacer(FromDIP(4)); m_outer->Add(add_btn, 0); SetSizer(m_outer); } @@ -4514,6 +4530,7 @@ public: (tools && i < tools->values.size()) ? tools->values[i] : "", (gcodes && i < gcodes->values.size()) ? gcodes->values[i] : ""); } + refresh_reset_buttons(); Layout(); } @@ -4530,6 +4547,23 @@ public: return {names, tools, gcodes}; } + // 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 { + auto [names, tools, gcodes] = get_mode_data(); + auto cfg_strings = [&cfg](const char* key) { + std::vector v; + if (auto* o = cfg.option(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"); + } + // Parse "idx:P,idx:C,idx:M" → map // Backwards compat: plain "idx" (no role) → state 1 (Primary) // Indices are PHYSICAL tool indices (T0..TN-1). all_tool_states preserves @@ -4592,8 +4626,51 @@ private: std::vector btn_tool_idx; std::map all_tool_states; 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 { + 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(); + return s; + } + + // 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 { + if (!m_parent_lookup) return false; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return false; + 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"); + 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; + } + + // 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 { + if (!m_parent_lookup) return false; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return false; + auto* p_names = parent->option("imex_mode_names"); + return p_names && row_idx >= 0 && row_idx < (int)p_names->values.size(); + } + void add_row(const std::string& name = "", const std::string& active_tools = "", const std::string& gcode = "", @@ -4602,23 +4679,25 @@ private: 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.name = nullptr; auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"), - wxDefaultPosition, wxSize(130, -1)); + wxDefaultPosition, FromDIP(wxSize(130, -1))); wxFont f = lbl->GetFont(); f.SetWeight(wxFONTWEIGHT_BOLD); lbl->SetFont(f); - sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); } else { - r.name = new wxTextCtrl(r.panel, wxID_ANY, name, wxDefaultPosition, wxSize(130, -1)); + r.name = new wxTextCtrl(r.panel, wxID_ANY, name, wxDefaultPosition, FromDIP(wxSize(130, -1))); r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); }); } auto* grid_panel = new wxPanel(r.panel, wxID_ANY); - auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, 2, 2); + grid_panel->SetBackgroundColour(GetBackgroundColour()); + auto* grid_sizer = new wxGridSizer(m_n_rows, m_n_cols, FromDIP(2), FromDIP(2)); auto tool_states = parse_tool_states(active_tools); if (tool_states.empty()) @@ -4655,7 +4734,7 @@ private: if (it != tool_states.end()) state = it->second; auto* btn = new wxButton(grid_panel, wxID_ANY, wxEmptyString, - wxDefaultPosition, wxSize(36, 26), wxBU_EXACTFIT); + wxDefaultPosition, FromDIP(wxSize(36, 26)), wxBU_EXACTFIT); apply_btn(btn, tool_idx, state); int btn_pos = (int)r.btns.size(); @@ -4663,34 +4742,42 @@ private: r.btn_states.push_back(state); r.btn_tool_idx.push_back(tool_idx); - 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; - int& st = row_ref.btn_states[btn_pos]; + // The Primary mode is the IMEX-off mode — its tool assignment is + // fixed (no parallel printing happening), so cycling tool roles + // would be a no-op and confusing. Render the buttons read-only; + // the row still shows up in the editor as visual context. + if (is_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; + int& st = row_ref.btn_states[btn_pos]; - // Check if another button already holds the Primary state - bool other_primary = false; - for (int j = 0; j < (int)row_ref.btn_states.size(); ++j) - if (j != btn_pos && row_ref.btn_states[j] == 1) { other_primary = true; break; } + // Check if another button already holds the Primary state + bool other_primary = false; + for (int j = 0; j < (int)row_ref.btn_states.size(); ++j) + if (j != btn_pos && row_ref.btn_states[j] == 1) { other_primary = true; break; } - // Advance state, skipping Primary(1) if another tool is already Primary - st = (st + 1) % 4; - if (st == 1 && other_primary) - st = 2; // skip Primary → go straight to Copy + // Advance state, skipping Primary(1) if another tool is already Primary + st = (st + 1) % 4; + if (st == 1 && other_primary) + st = 2; // skip Primary → go straight to Copy - // Keep all_tool_states in sync so off-screen tools are preserved - int tidx = row_ref.btn_tool_idx[btn_pos]; - if (st == 0) - row_ref.all_tool_states.erase(tidx); - else - row_ref.all_tool_states[tidx] = st; + // Keep all_tool_states in sync so off-screen tools are preserved + int tidx = row_ref.btn_tool_idx[btn_pos]; + if (st == 0) + row_ref.all_tool_states.erase(tidx); + else + row_ref.all_tool_states[tidx] = st; - apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], st); - notify(); - break; - } - }); + apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], st); + notify(); + break; + } + }); + } grid_sizer->Add(btn, 0); } @@ -4698,16 +4785,16 @@ private: grid_panel->SetSizer(grid_sizer); r.gcode = new wxTextCtrl(r.panel, wxID_ANY, gcode, - wxDefaultPosition, wxSize(220, 54), wxTE_MULTILINE); + 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, 6); + sizer->Add(r.name, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); } - sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); - sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); + sizer->Add(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6)); + sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); - // Placeholder search + (for non-primary) delete — stacked vertically + // 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, @@ -4719,7 +4806,7 @@ private: if (dlg.ShowModal() == wxID_OK) gcode_ctrl->SetValue(dlg.get_edited_gcode()); }); - btn_col->Add(ph_btn, 0, wxBOTTOM, 2); + btn_col->Add(ph_btn, 0, wxBOTTOM, FromDIP(2)); if (!is_primary) { auto* rm = new ScalableButton(r.panel, wxID_ANY, "imex_remove", wxEmptyString, @@ -4732,10 +4819,28 @@ private: }); btn_col->Add(rm, 0); } - sizer->Add(btn_col, 0, wxALIGN_CENTER_VERTICAL); + 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(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->SetSizer(sizer); - m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, 4); + m_rows_sizer->Add(r.panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); m_rows.push_back(std::move(r)); Layout(); } @@ -4773,13 +4878,67 @@ private: return s; } - void notify() { if (on_change) on_change(); } + void notify() { + refresh_reset_buttons(); + if (on_change) on_change(); + } - wxBoxSizer* m_outer; - wxBoxSizer* m_rows_sizer; - std::vector 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 + // 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() { + 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(i)); + if (dirty == r.reset_dirty_cached) continue; + r.reset_dirty_cached = dirty; + r.reset_btn->SetBitmap_(dirty ? "undo" : "dot"); + } + } + + // 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) { + if (!m_parent_lookup) return; + const DynamicPrintConfig* parent = m_parent_lookup(); + if (!parent) return; + 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"); + 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 states from the parent's serialized form. all_tool_states + // is the source of truth for round-tripping; rebuild it then re-paint + // the visible buttons. + r.all_tool_states = parse_tool_states(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_states.find(tidx); + int st = (it == r.all_tool_states.end()) ? 0 : it->second; + r.btn_states[j] = st; + apply_btn(r.btns[j], tidx, st); + } + } + notify(); + return; + } + } + + wxBoxSizer* m_outer; + wxBoxSizer* m_rows_sizer; + std::vector 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 + ParentConfigLookup m_parent_lookup; }; // --------------------------------------------------------------------------- @@ -5381,63 +5540,33 @@ if (is_marlin_flavor) optgroup->append_single_option_line("is_imex"); optgroup->append_single_option_line("imex_gantry_count"); optgroup->append_single_option_line("imex_tools_per_gantry"); - { - auto opt = optgroup->get_option("imex_tool_layout"); - auto line = Line{ opt.opt.label, opt.opt.tooltip }; - line.append_option(opt); - line.widget = [this](wxWindow* parent) -> wxSizer* { - static const wxString choices[] = { - _L("Front-left"), _L("Front-right"), - _L("Rear-left"), _L("Rear-right") - }; - static const char* values[] = { "front-left", "front-right", "rear-left", "rear-right" }; - m_imex_layout_combo = new ComboBox(parent, wxID_ANY, wxEmptyString, - wxDefaultPosition, - wxSize(12 * wxGetApp().em_unit(), -1), - 4, choices, wxCB_READONLY); - m_imex_layout_combo->GetDropDown().SetUseContentWidth(true); - std::string cur = "front-left"; - if (auto* o = m_config->option("imex_tool_layout")) cur = o->value; - for (int i = 0; i < 4; ++i) - if (cur == values[i]) { m_imex_layout_combo->SetSelection(i); break; } - m_imex_layout_combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { - static const char* layout_vals[] = { "front-left", "front-right", "rear-left", "rear-right" }; - int sel = m_imex_layout_combo->GetSelection(); - std::string val = (sel >= 0 && sel < 4) ? layout_vals[sel] : "front-left"; - m_config->set_key_value("imex_tool_layout", new ConfigOptionString(val)); - on_value_change("imex_tool_layout", val); - if (m_imex_modes_ctrl) - m_imex_modes_ctrl->set_grid_size( - m_config->opt_int("imex_tools_per_gantry"), - m_config->opt_int("imex_gantry_count"), - IMEXModesCtrl::parse_layout(val)); - }); - auto* s = new wxBoxSizer(wxHORIZONTAL); - s->Add(m_imex_layout_combo, 0, wxALIGN_CENTER_VERTICAL); - return s; - }; - optgroup->append_line(line); - } + optgroup->append_single_option_line("imex_tool_layout"); optgroup->append_single_option_line("imex_nozzle_clearance_x"); optgroup->append_single_option_line("imex_nozzle_clearance_y"); optgroup->append_single_option_line("imex_carriage_margin"); { - // Toggle for pre-slice IMEX safety warnings (stored in app_config, not printer profile) - // full_width = 1 is required: widget-only lines (no options) must take the full-width - // path in activate_line/append_line to avoid option_set.front() on an empty vector, - // which crashes on Windows release builds (ACCESS_VIOLATION at 0x30). - auto line = Line{ L("Pre-slice warnings"), L("Show a warning dialog before slicing if IDEX/IQEX parallel mode " - "concerns are detected (bed temperature conflicts, filament type " - "mismatches, multi-material conflicts). Can be suppressed from the " - "dialog itself. Re-enable here if suppressed accidentally.") }; - line.full_width = 1; + // Toggle for pre-slice IMEX safety warnings (stored in app_config, not printer profile). + // We avoid full_width=1 here because that path uses different sizer math from the + // rest of the optgroup — it left-pads with 15px instead of aligning to the label + // column, which made this row look out-of-place vs. the option lines above. Instead + // we register a placeholder Option so option_set.front() (the empty-vector access + // that crashes Windows release builds) is satisfied, and let the standard line path + // render the label + custom widget with the same column geometry as everything else. + // No Field is built for the placeholder because line.widget != nullptr causes + // activate_line to return early before build_field. + ConfigOptionDef placeholder_def; + placeholder_def.label = L("Pre-slice warnings"); + placeholder_def.tooltip = L("Show a warning dialog before slicing if IDEX/IQEX parallel mode " + "concerns are detected (bed temperature conflicts, filament type " + "mismatches, multi-material conflicts). Can be suppressed from the " + "dialog itself. Re-enable here if suppressed accidentally."); + auto line = Line{ placeholder_def.label, placeholder_def.tooltip }; + line.append_option(Option{ placeholder_def, "imex_pre_slice_warnings" }); line.widget = [](wxWindow* parent) -> wxSizer* { - // Label belongs on the checkbox itself: full_width=1 (required to - // dodge the Windows crash in activate_line) skips Line-title rendering. - auto* cb = new wxCheckBox(parent, wxID_ANY, _L("Pre-slice warnings")); - bool enabled = wxGetApp().app_config->get("imex_pre_slice_warnings") != "false"; + auto* cb = new ::CheckBox(parent); + const bool enabled = wxGetApp().app_config->get("imex_pre_slice_warnings") != "false"; cb->SetValue(enabled); - cb->Bind(wxEVT_CHECKBOX, [cb](wxCommandEvent&) { + cb->Bind(wxEVT_TOGGLEBUTTON, [cb](wxCommandEvent&) { wxGetApp().app_config->set("imex_pre_slice_warnings", cb->GetValue() ? "true" : "false"); }); auto* s = new wxBoxSizer(wxHORIZONTAL); @@ -5446,38 +5575,7 @@ if (is_marlin_flavor) }; optgroup->append_line(line); } - { - static const wxString theme_choices[] = { - _L("Standard"), - _L("Deuteranopia / Protanopia (red-green)"), - _L("Tritanopia (blue-yellow)"), - _L("High Contrast") }; - static const char* theme_vals[] = { "standard", "deuteranopia", "tritanopia", "high_contrast" }; - auto opt = optgroup->get_option("imex_viz_theme"); - auto line = Line{ opt.opt.label, opt.opt.tooltip }; - line.append_option(opt); - line.widget = [this](wxWindow* parent) -> wxSizer* { - m_imex_theme_combo = new ComboBox(parent, wxID_ANY, wxEmptyString, - wxDefaultPosition, - wxSize(22 * wxGetApp().em_unit(), -1), - 4, theme_choices, wxCB_READONLY); - m_imex_theme_combo->GetDropDown().SetUseContentWidth(true); - std::string cur = "standard"; - if (auto* o = m_config->option("imex_viz_theme")) cur = o->value; - for (int i = 0; i < 4; ++i) - if (cur == theme_vals[i]) { m_imex_theme_combo->SetSelection(i); break; } - m_imex_theme_combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { - int sel = m_imex_theme_combo->GetSelection(); - std::string val = (sel >= 0 && sel < 4) ? theme_vals[sel] : "standard"; - m_config->set_key_value("imex_viz_theme", new ConfigOptionString(val)); - on_value_change("imex_viz_theme", val); - }); - auto* s = new wxBoxSizer(wxHORIZONTAL); - s->Add(m_imex_theme_combo, 0, wxALIGN_CENTER_VERTICAL); - return s; - }; - optgroup->append_line(line); - } + optgroup->append_single_option_line("imex_viz_theme"); { auto modes_og = page->new_optgroup(L("IDEX/IQEX Parallel Modes"), L"param_advanced"); auto line = Line{ L("Modes"), L("") }; @@ -5485,10 +5583,16 @@ if (is_marlin_flavor) line.widget = [this](wxWindow* parent) -> wxSizer* { int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_rows = m_config->opt_int("imex_gantry_count"); - std::string layout_str = "front-left"; - if (auto* o = m_config->option("imex_tool_layout")) layout_str = o->value; - int layout = IMEXModesCtrl::parse_layout(layout_str); - m_imex_modes_ctrl = new IMEXModesCtrl(parent, n_cols, n_rows, layout); + ImexToolLayout layout = ImexToolLayout::FrontLeft; + if (auto* o = m_config->option>("imex_tool_layout")) layout = o->value; + m_imex_modes_ctrl = new IMEXModesCtrl(parent, n_cols, n_rows, IMEXModesCtrl::parse_layout(layout)); + // Lazy lookup pointing at the *saved* state of the currently-selected + // preset (not the parent). Per-row reset means "discard in-session + // edits to this row" — matches the page-level reset semantic and works + // on any preset, including ones whose parent has no IMEX modes at all. + m_imex_modes_ctrl->set_parent_config_lookup([this]() -> const DynamicPrintConfig* { + return m_presets ? &m_presets->get_selected_preset().config : nullptr; + }); 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(); @@ -5748,28 +5852,15 @@ void TabPrinter::reload_config() m_active_page->set_value("extruders_count", int(m_extruders_count)); if (m_config) { - // Sync layout combo - if (m_imex_layout_combo) { - static const char* layout_vals[] = { "front-left", "front-right", "rear-left", "rear-right" }; - std::string cur = "front-left"; - if (auto* o = m_config->option("imex_tool_layout")) cur = o->value; - for (int i = 0; i < 4; ++i) - if (cur == layout_vals[i]) { m_imex_layout_combo->SetSelection(i); break; } - } - // Sync theme combo - if (m_imex_theme_combo) { - static const char* theme_vals[] = { "standard", "deuteranopia", "tritanopia", "high_contrast" }; - std::string cur = "standard"; - if (auto* o = m_config->option("imex_viz_theme")) cur = o->value; - for (int i = 0; i < 4; ++i) - if (cur == theme_vals[i]) { m_imex_theme_combo->SetSelection(i); break; } - } + // imex_tool_layout / imex_viz_theme are now real enum options handled by + // standard Choice fields; only the modes grid still needs explicit re-sync + // because it spans three options at once and isn't a Field. if (m_imex_modes_ctrl) { int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_rows = m_config->opt_int("imex_gantry_count"); - std::string layout_str = "front-left"; - if (auto* o = m_config->option("imex_tool_layout")) layout_str = o->value; - m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout_str)); + ImexToolLayout layout = ImexToolLayout::FrontLeft; + if (auto* o = m_config->option>("imex_tool_layout")) layout = o->value; + m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout)); m_imex_modes_ctrl->load_from_config(*m_config); } } @@ -5790,8 +5881,6 @@ void TabPrinter::clear_pages() Tab::clear_pages(); m_reset_to_filament_color = nullptr; m_imex_modes_ctrl = nullptr; - m_imex_layout_combo = nullptr; - m_imex_theme_combo = nullptr; } void TabPrinter::toggle_options() @@ -5869,9 +5958,8 @@ void TabPrinter::toggle_options() "imex_nozzle_clearance_x", "imex_nozzle_clearance_y", "imex_carriage_margin"}) toggle_option(el, is_imex); - // These lines have custom combo widgets — toggle_line hides the full row - toggle_line("imex_tool_layout", is_imex); - toggle_line("imex_viz_theme", is_imex); + toggle_option("imex_tool_layout", is_imex); + toggle_option("imex_viz_theme", is_imex); if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex); } wxString extruder_number; @@ -6014,11 +6102,20 @@ void TabPrinter::update_fff() // Sync IDEX/IQEX tool grid whenever carriage configuration changes. // set_grid_size() is a no-op when dimensions and layout are unchanged. + // load_from_config() is gated on matches_config() — without that guard, + // every keystroke in a row's textbox would destroy and rebuild the widget + // (because notify() writes config → update_fff() runs → load_from_config() + // detaches the textbox the user is typing in). The page-level reset still + // refreshes the rows because that path actually changes the config, which + // matches_config() then detects. if (m_imex_modes_ctrl) { int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_rows = m_config->opt_int("imex_gantry_count"); - std::string layout_str = m_config->opt_string("imex_tool_layout"); - m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout_str)); + ImexToolLayout layout = ImexToolLayout::FrontLeft; + if (auto* o = m_config->option>("imex_tool_layout")) layout = o->value; + m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout)); + if (!m_imex_modes_ctrl->matches_config(*m_config)) + m_imex_modes_ctrl->load_from_config(*m_config); } toggle_options(); diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index c76f6b5f0d..ad0e784050 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -606,8 +606,6 @@ private: wxBoxSizer* m_presets_sizer {nullptr}; IMEXModesCtrl* m_imex_modes_ctrl {nullptr}; - ComboBox* m_imex_layout_combo {nullptr}; - ComboBox* m_imex_theme_combo {nullptr}; public: ScalableButton* m_reset_to_filament_color = nullptr; diff --git a/tests/libslic3r/test_imex_helpers.cpp b/tests/libslic3r/test_imex_helpers.cpp index a1e160fdb4..2be260bf96 100644 --- a/tests/libslic3r/test_imex_helpers.cpp +++ b/tests/libslic3r/test_imex_helpers.cpp @@ -83,6 +83,136 @@ TEST_CASE("imex_pem_tool_for — MMU collapse routes multiple logical slots to o REQUIRE(imex_pem_tool_for(6, "copy_mode", pem) == 3); } +TEST_CASE("imex_suppresses_bare_toolchange — non-IMEX printer never suppresses", "[IMEX]") { + REQUIRE_FALSE(imex_suppresses_bare_toolchange("", 0)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("", 1)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("", 99)); +} + +TEST_CASE("imex_suppresses_bare_toolchange — Primary mode never suppresses (preserves AFC lane swap)", "[IMEX]") { + REQUIRE_FALSE(imex_suppresses_bare_toolchange("primary", 0)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("primary", 1)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("primary", 50)); +} + +TEST_CASE("imex_suppresses_bare_toolchange — parallel mode suppresses at print-start", "[IMEX]") { + // count == 0 in the single-extruder path (which never increments) and == 1 in + // the long multi-extruder path's first call after the increment both represent + // the print-start initial-tool select. Either should suppress so the user's + // mode_gcode-emitted T isn't duplicated. + REQUIRE(imex_suppresses_bare_toolchange("copy_mode", 0)); + REQUIRE(imex_suppresses_bare_toolchange("copy_mode", 1)); + REQUIRE(imex_suppresses_bare_toolchange("mirror_mode", 0)); + REQUIRE(imex_suppresses_bare_toolchange("iq-copy", 1)); +} + +TEST_CASE("imex_suppresses_bare_toolchange — parallel mode allows mid-print toolchange", "[IMEX]") { + // Mid-print toolchanges (count > 1) emit normally. Print::validate() blocks + // multi-color setups where mid-print T wouldn't make sense; the only + // remaining case is IQEX with 2+ tools active on the primary gantry, where + // the firmware handles the slaved gantry automatically. + REQUIRE_FALSE(imex_suppresses_bare_toolchange("copy_mode", 2)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("mirror_mode", 5)); + REQUIRE_FALSE(imex_suppresses_bare_toolchange("backup_mode", 100)); +} + +TEST_CASE("imex_mode_type_for — primary sentinel always returns primary", "[IMEX]") { + REQUIRE(imex_mode_type_for("", {}, {}) == "primary"); + REQUIRE(imex_mode_type_for("primary", {}, {}) == "primary"); +} + +TEST_CASE("imex_mode_type_for — explicit type from parallel array", "[IMEX]") { + std::vector names = {"primary", "my-copy", "my-split"}; + std::vector types = {"primary", "copy", "split" }; + REQUIRE(imex_mode_type_for("my-copy", names, types) == "copy"); + REQUIRE(imex_mode_type_for("my-split", names, types) == "split"); +} + +TEST_CASE("imex_mode_type_for — legacy fallback when types vector missing", "[IMEX]") { + // Pre-imex_mode_types presets just had names. A mode literally named "copy" / + // "mirror" / "split" maps to itself; anything else defaults to primary. + std::vector names = {"primary", "copy", "mirror", "split", "weird-name"}; + std::vector types; // empty → fallback to name-based inference + REQUIRE(imex_mode_type_for("copy", names, types) == "copy"); + REQUIRE(imex_mode_type_for("mirror", names, types) == "mirror"); + REQUIRE(imex_mode_type_for("split", names, types) == "split"); + REQUIRE(imex_mode_type_for("weird-name", names, types) == "primary"); +} + +TEST_CASE("imex_multicolor_block_reason — non-IMEX prints never block", "[IMEX]") { + auto pem = make_pem({0, 1, 2, 3}); + REQUIRE(imex_multicolor_block_reason("", "primary", "0:P,1:C", 2, {0, 1}, pem).empty()); + REQUIRE(imex_multicolor_block_reason("primary", "primary", "0:P,1:C", 2, {0, 1}, pem).empty()); +} + +TEST_CASE("imex_multicolor_block_reason — single-color prints never block", "[IMEX]") { + auto pem = make_pem({0, 1, 2, 3}); + REQUIRE(imex_multicolor_block_reason("copy", "copy", "0:P,1:C,2:M,3:M", 2, {0}, pem).empty()); + REQUIRE(imex_multicolor_block_reason("mirror", "mirror", "0:P,1:C,2:M,3:M", 2, {}, pem).empty()); +} + +TEST_CASE("imex_multicolor_block_reason — IDEX (1 tool per gantry) blocks multi-color", "[IMEX]") { + // Two physical heads, each on its own gantry: tools_per_gantry=1, primary=T0 + // on gantry 0, copy=T1 on gantry 1. Primary's gantry has only 1 active tool, + // so there's no within-gantry toolchange path. Block. + auto pem = make_pem({0, 1}); + const std::string reason = imex_multicolor_block_reason("copy", "copy", "0:P,1:C", 1, {0, 1}, pem); + REQUIRE_FALSE(reason.empty()); + REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry")); +} + +TEST_CASE("imex_multicolor_block_reason — IQEX 2-tool-active mode blocks multi-color", "[IMEX]") { + // 2x2 IQEX, mode has T0 primary + T2 copy (one tool per gantry, different + // gantries). Primary's gantry (gantry 0) has only T0 active → no within-gantry + // swap target. Block. + auto pem = make_pem({0, 1, 2, 3}); + const std::string reason = imex_multicolor_block_reason("copy", "copy", "0:P,2:C", 2, {0, 2}, pem); + REQUIRE_FALSE(reason.empty()); + REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool's gantry")); +} + +TEST_CASE("imex_multicolor_block_reason — IQEX 4-tool-active multi-color allowed", "[IMEX]") { + // 2x2 IQEX, all four tools active in the mode: T0 primary + T1 also on gantry 0, + // T2/T3 on gantry 1 mirroring. Primary's gantry has 2 tools active → within- + // gantry toolchange topology is present. Used filaments {0, 1} both on gantry 0. + auto pem = make_pem({0, 1, 2, 3}); + REQUIRE(imex_multicolor_block_reason("copy", "copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem).empty()); +} + +TEST_CASE("imex_multicolor_block_reason — Split mode allows multi-color regardless of gantry pair", "[IMEX]") { + // Split modes are explicitly designed for per-gantry multi-color, so the + // gantry-pair check is bypassed. T0+T2 with one tool per gantry would normally + // be blocked under copy/mirror semantics, but Split allows it. + auto pem = make_pem({0, 1, 2, 3}); + REQUIRE(imex_multicolor_block_reason("split-copy", "split", "0:P,2:C", 2, {0, 2}, pem).empty()); +} + +TEST_CASE("imex_multicolor_block_reason — Split mode still blocks MMU sharing", "[IMEX]") { + // Even Split can't replicate an MMU lane swap on the slaved gantry — those + // physical heads aren't independently swappable. + auto pem = make_pem({0, 0, 0, 0, 1, 2, 3}); + const std::string reason = imex_multicolor_block_reason("split-copy", "split", "0:P,2:C", 2, {0, 1}, pem); + REQUIRE_FALSE(reason.empty()); + REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("physical extruder map")); +} + +TEST_CASE("imex_multicolor_block_reason — MMU lane sharing blocks multi-color", "[IMEX]") { + // pem maps both filament 0 and filament 1 to the same physical head 0 — that's + // an MMU/AFC manifold. IMEX parallel modes can't slave the secondary gantry + // through an MMU lane swap, so block. + auto pem = make_pem({0, 0, 0, 0, 1, 2, 3}); + const std::string reason = imex_multicolor_block_reason("copy", "copy", "0:P,1:C,2:M,3:M", 2, {0, 1}, pem); + REQUIRE_FALSE(reason.empty()); + REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("physical extruder map")); +} + +TEST_CASE("imex_multicolor_block_reason — mode without primary blocks", "[IMEX]") { + auto pem = make_pem({0, 1, 2, 3}); + const std::string reason = imex_multicolor_block_reason("copy", "copy", "1:C,2:M", 2, {0, 1}, pem); + REQUIRE_FALSE(reason.empty()); + REQUIRE_THAT(reason, Catch::Matchers::ContainsSubstring("primary tool")); +} + TEST_CASE("first_filament_for_physical_head — identity pem", "[IMEX]") { auto pem = make_pem({0, 1, 2, 3}); REQUIRE(first_filament_for_physical_head(pem, 0) == 0);