feat(imex): polish UI per PR feedback + add parallel-mode safeguards

Felix14-v2 PR review feedback (https://github.com/OrcaSlicer/OrcaSlicer/pull/13086#issuecomment-4323696312)
plus the slice-time validation work that follows from his bug list.

UI polish:
- Capitalize "Primary" in display (right-click mode menu, plate tooltip).
  Sentinel stays lowercase for wire compatibility.
- Pre-slice-warnings checkbox now uses Orca's ::CheckBox so it matches the
  green toggle style of the rest of the IDEX/IQEX configuration page.
- DPI-scaled the IMEXModesCtrl (modes editor) sub-panels, button grid,
  text wraps, gcode textarea, and the ghost-tooltip swatch (imgui.scaled).
  Legend swatches sized to body-text height for visual balance.
- Primary mode tool buttons in the modes editor are now disabled (read-
  only): cycling roles on the IMEX-off mode is a no-op and confusing.
- Modes editor sub-panels now explicitly inherit the app's window-default
  dark colour so chromeless ScalableButtons don't render with a visible
  light box around their icons on GTK dark themes.
- Per-mode-line reset arrows in the modes editor: each row gets a small
  reset bitmap that snaps that row's name+tools+gcode triplet back to the
  saved preset's value. matches_config() guard on the page-level reload
  prevents the textbox-being-typed-into from being destroyed mid-keystroke.
- New View menu item "Show IDEX/IQEX Toolhead" — toggles the per-carriage
  footprint boxes during G-code preview playback. Gated to Preview tab +
  IMEX printer; backed by app_config so it persists.

Coordinated config migrations:
- imex_tool_layout and imex_viz_theme migrate from coString to coEnum
  (ImexToolLayout / ImexVizTheme). Existing wire format preserved so
  saved presets and 3MFs deserialize unchanged. Side-benefit: both now
  pick up standard Field rendering and so finally show reset arrows.

Slice-time safeguards (the validation half):
- imex_suppresses_bare_toolchange(parallel_mode, count): suppresses the
  slicer's bare T<n> at print-start in any IMEX parallel mode (the user's
  imex_mode_gcode + machine_start_gcode owns tool activation there). Mid-
  print T<n> emits normally — Print::validate blocks the configurations
  where mid-print T<n> wouldn't make sense. Applied to both code paths
  inside GCode::set_extruder (the long multi-extruder path AND the
  single-extruder path that fires when multiple_extruders=false).
- imex_multicolor_block_reason(): hard-stop validator returning a user-
  facing reason string when the active IMEX configuration can't physically
  support multi-color. Catches IDEX (1 tool/gantry), 2-tool-active IQEX
  (no within-gantry swap topology), and any MMU/AFC lane sharing among
  used filaments. Wired into Print::validate as a slice blocker, and into
  PartPlate::has_imex_multimaterial_conflict so the plater badge agrees
  with the slice block (no more false positives where the badge warns but
  the slice goes through).
- New imex_mode_types config option (parallel array to imex_mode_names)
  and imex_mode_type_for() helper. Mode-type tag drives behaviour: zones,
  ghosts, and validation interpret modes differently per type. Initial
  types: "primary", "copy", "mirror", "split". Split modes are explicitly
  designed for paired-gantry IQEX multi-color and bypass the gantry-pair
  check in imex_multicolor_block_reason (MMU sharing still blocks them).

Test coverage:
- New unit tests cover imex_suppresses_bare_toolchange (4 cases),
  imex_multicolor_block_reason (8 cases including IDEX, IQEX 2-/4-tool-
  active, MMU sharing, missing primary, Split type), and imex_mode_type_for
  (3 cases including legacy fallback). Total: 186 IMEX assertions across
  71 test cases, all passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-04-27 22:51:05 -04:00
co-authored by Claude Opus 4.7
parent b10d6e1b9f
commit 0bb1cef78e
15 changed files with 793 additions and 212 deletions
+16 -2
View File
@@ -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)); 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<n> would be a duplicate. Mid-
// print toolchanges (count > 1) emit normally — Print::validate() blocks
// multi-color in configurations where mid-print T<n> 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; 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 //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. //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); 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<n> in parallel modes (the user's imex_mode_gcode + machine_start_gcode owns
// tool activation there). Mid-print T<n> 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; gcode += toolchange_command;
else { else {
// user provided his own toolchange gcode, no need to do anything // user provided his own toolchange gcode, no need to do anything
+95
View File
@@ -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); 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<std::string>& mode_names,
const std::vector<std::string>& 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<int>& 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<int> 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) int first_filament_for_physical_head(const ConfigOptionInts& pem, int physical)
{ {
const auto& v = pem.values; const auto& v = pem.values;
+75
View File
@@ -87,6 +87,81 @@ ConfigOptionInts effective_physical_extruder_map(const PresetBundle& pb);
// firmware commands like any non-IMEX printer. // firmware commands like any non-IMEX printer.
int imex_pem_tool_for(int filament_id, const std::string& parallel_mode, const ConfigOptionInts& pem); 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<n> 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<n> 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<n> 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<n> 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<std::string>& mode_names,
const std::vector<std::string>& 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<int>& used_filaments_0b,
const ConfigOptionInts& pem);
// Returns the lowest 0-based logical filament index L such that pem[L] == physical. // Returns the lowest 0-based logical filament index L such that pem[L] == physical.
// Returns -1 if no filament routes to `physical`. // Returns -1 if no filament routes to `physical`.
// Degenerate case: an empty pem returns 0 when `physical == 0` (identity-on-head-0 // Degenerate case: an empty pem returns 0 when `physical == 0` (identity-on-head-0
+1 -1
View File
@@ -1044,7 +1044,7 @@ static std::vector<std::string> s_Preset_printer_options {
// IDEX/IQEX (independent X extruder) — printer capability and user-defined modes // IDEX/IQEX (independent X extruder) — printer capability and user-defined modes
"is_imex", "imex_gantry_count", "imex_tools_per_gantry", "imex_tool_layout", "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_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<std::string> s_Preset_sla_print_options { static std::vector<std::string> s_Preset_sla_print_options {
+36
View File
@@ -1,5 +1,6 @@
#include "Config.hpp" #include "Config.hpp"
#include "Exception.hpp" #include "Exception.hpp"
#include "IMEXHelpers.hpp"
#include "Print.hpp" #include "Print.hpp"
#include "BoundingBox.hpp" #include "BoundingBox.hpp"
#include "Brim.hpp" #include "Brim.hpp"
@@ -1198,6 +1199,41 @@ StringObjectException Print::validate(StringObjectException *warning, Polygons*
if (extruders.empty()) if (extruders.empty())
return { L("No extrusions under current settings.") }; 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<int> 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) { if (nozzles < 2 && extruders.size() > 1) {
auto ret = check_multi_filament_valid(*this); auto ret = check_multi_filament_valid(*this);
if (!ret.string.empty()) if (!ret.string.empty())
+54 -4
View File
@@ -543,6 +543,24 @@ static const t_config_enum_values s_keys_map_FilamentMapMode = {
}; };
CONFIG_OPTION_ENUM_DEFINE_STATIC_MAPS(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 //BBS
std::string get_extruder_variant_string(ExtruderType extruder_type, NozzleVolumeType nozzle_volume_type) 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->mode = comAdvanced;
def->set_default_value(new ConfigOptionInt(2)); 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->label = L("Tool 0 Corner");
def->tooltip = L("Physical corner of the bed where tool T0 (index 0) is located. " def->tooltip = L("Physical corner of the bed where tool T0 (index 0) is located. "
"Determines how tool indices map to bed zones. " "Determines how tool indices map to bed zones. "
"front = lower Y (near the operator), rear = higher Y (back of machine)."); "front = lower Y (near the operator), rear = higher Y (back of machine).");
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionString("front-left")); def->enum_keys_map = &ConfigOptionEnum<ImexToolLayout>::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>(ImexToolLayout::FrontLeft));
def = this->add("imex_nozzle_clearance_x", coFloat); def = this->add("imex_nozzle_clearance_x", coFloat);
def->label = L("Nozzle Clearance X"); def->label = L("Nozzle Clearance X");
@@ -5594,11 +5621,20 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionFloat(0.0)); 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->label = L("Visualization Theme");
def->tooltip = L("Color theme for IDEX/IQEX bed zone visualization. Choose a colorblind-friendly theme if needed."); def->tooltip = L("Color theme for IDEX/IQEX bed zone visualization. Choose a colorblind-friendly theme if needed.");
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionString("standard")); def->enum_keys_map = &ConfigOptionEnum<ImexVizTheme>::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>(ImexVizTheme::Standard));
def = this->add("imex_parallel_mode", coString); def = this->add("imex_parallel_mode", coString);
def->label = L("IDEX/IQEX Print Mode"); def->label = L("IDEX/IQEX Print Mode");
@@ -5631,6 +5667,20 @@ void PrintConfigDef::init_fff_params()
def->mode = comAdvanced; def->mode = comAdvanced;
def->set_default_value(new ConfigOptionStrings()); 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 = this->add("manual_filament_change", coBool);
def->label = L("Manual Filament Change"); 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. " def->tooltip = L("Enable this option to omit the custom Change filament G-code only at the beginning of the print. "
+21
View File
@@ -131,6 +131,26 @@ enum class WallDirection
Count, 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 //BBS
enum class PrintSequence { enum class PrintSequence {
ByLayer, ByLayer,
@@ -1348,6 +1368,7 @@ PRINT_CONFIG_CLASS_DEFINE(
((ConfigOptionStrings, imex_mode_names)) ((ConfigOptionStrings, imex_mode_names))
((ConfigOptionStrings, imex_mode_active_tools)) ((ConfigOptionStrings, imex_mode_active_tools))
((ConfigOptionStrings, imex_mode_gcodes)) ((ConfigOptionStrings, imex_mode_gcodes))
((ConfigOptionStrings, imex_mode_types))
((ConfigOptionBool, single_extruder_multi_material_priming)) ((ConfigOptionBool, single_extruder_multi_material_priming))
((ConfigOptionBool, wipe_tower_no_sparse_layers)) ((ConfigOptionBool, wipe_tower_no_sparse_layers))
((ConfigOptionString, change_filament_gcode)) ((ConfigOptionString, change_filament_gcode))
+9 -5
View File
@@ -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 // Apply the same flip logic as PartPlate::calc_imex_zones() so physical
// grid positions match the bed zone visualization. // grid positions match the bed zone visualization.
auto* layout_opt = printer_cfg.opt<ConfigOptionString>("imex_tool_layout"); auto* layout_opt = printer_cfg.opt<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout");
const std::string layout_str = layout_opt ? layout_opt->value : "front-left"; const ImexToolLayout layout = layout_opt ? layout_opt->value : ImexToolLayout::FrontLeft;
const bool flip_x = (layout_str == "front-right" || layout_str == "rear-right"); const bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight);
const bool flip_y = (layout_str == "rear-left" || layout_str == "rear-right"); const bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight);
auto phys_col_of = [&](int tid) -> int { auto phys_col_of = [&](int tid) -> int {
int raw = tid % tools_per_gantry; 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. // 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. // 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 // Rebuild box mesh every frame — dimensions can change via config edit without a
// G-code reload, so dimension-based caching isn't safe. // 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. // Mesh origin: nozzle at x=0, centered in Y, Z starts at nozzle tip level.
+5 -3
View File
@@ -7405,18 +7405,20 @@ void GLCanvas3D::_render_imex_ghost_tooltip()
ImGuiWrapper& imgui = *wxGetApp().imgui(); ImGuiWrapper& imgui = *wxGetApp().imgui();
const Vec2i32 mouse = m_mouse.position.cast<int>(); const Vec2i32 mouse = m_mouse.position.cast<int>();
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); ImGuiCond_Always, 0.0f, 0.0f);
imgui.begin(std::string("##imex_ghost_tooltip"), imgui.begin(std::string("##imex_ghost_tooltip"),
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoMouseInputs); 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 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, ImGui::ColorButton("##swatch", col,
ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip, ImGuiColorEditFlags_NoBorder | ImGuiColorEditFlags_NoTooltip,
ImVec2(14, 14)); ImVec2(swatch_size, swatch_size));
ImGui::SameLine(); ImGui::SameLine();
imgui.text(t.label); imgui.text(t.label);
imgui.end(); imgui.end();
+24
View File
@@ -3061,6 +3061,30 @@ void MainFrame::init_menubar_as_editor()
[this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; }, [this]() { return m_tabpanel->GetSelection() == TabPosition::tp3DEditor || m_tabpanel->GetSelection() == TabPosition::tpPreview; },
[this]() { return wxGetApp().show_plate_gridlines(); }, this); [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<ConfigOptionBool>("is_imex");
return o && o->value;
}
return false;
},
[]() { return wxGetApp().app_config->get("show_imex_toolhead_boxes") != "false"; },
this);
append_menu_item( append_menu_item(
viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"), viewMenu, wxID_ANY, _L("Reset Window Layout"), _L("Reset to default window layout"),
[this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this, [this](wxCommandEvent&) { m_plater->reset_window_layout(); }, "", this,
+50 -16
View File
@@ -580,16 +580,15 @@ void PartPlate::calc_imex_zones()
// Grid dimensions and tool layout from printer config // Grid dimensions and tool layout from printer config
auto* gantry_opt = printer_cfg.option<ConfigOptionInt>("imex_gantry_count"); auto* gantry_opt = printer_cfg.option<ConfigOptionInt>("imex_gantry_count");
auto* tpg_opt = printer_cfg.option<ConfigOptionInt>("imex_tools_per_gantry"); auto* tpg_opt = printer_cfg.option<ConfigOptionInt>("imex_tools_per_gantry");
auto* layout_opt = printer_cfg.option<ConfigOptionString>("imex_tool_layout"); auto* layout_opt = printer_cfg.option<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout");
int n_cols = tpg_opt ? std::max(1, tpg_opt->value) : 2; int n_cols = tpg_opt ? std::max(1, tpg_opt->value) : 2;
int n_rows = gantry_opt ? std::max(1, gantry_opt->value) : 1; 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 // Which corner is T0? flip_x: col 0 is right(max-X); flip_y: row 0 is rear(max-Y)
// 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;
std::string layout_str = layout_opt ? layout_opt->value : "front-left"; bool flip_x = (layout == ImexToolLayout::FrontRight || layout == ImexToolLayout::RearRight);
bool flip_x = (layout_str == "front-right" || layout_str == "rear-right"); bool flip_y = (layout == ImexToolLayout::RearLeft || layout == ImexToolLayout::RearRight);
bool flip_y = (layout_str == "rear-left" || layout_str == "rear-right");
// Convert tool index to physical (col=X-index, row=Y-index), col/row 0 = min-X/min-Y // 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<int,int> { auto tool_to_phys = [&](int idx) -> std::pair<int,int> {
@@ -1221,16 +1220,47 @@ bool PartPlate::has_imex_placement_violations()
bool PartPlate::has_imex_multimaterial_conflict() const 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; auto* pb = wxGetApp().preset_bundle;
if (!pb) return false; if (!pb) return false;
auto* is_imex_opt = pb->printers.get_edited_preset().config.option<ConfigOptionBool>("is_imex"); const DynamicPrintConfig& printer_cfg = pb->printers.get_edited_preset().config;
auto* is_imex_opt = printer_cfg.option<ConfigOptionBool>("is_imex");
if (!is_imex_opt || !is_imex_opt->value) return false; 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 const std::string mode = get_imex_mode();
std::vector<int> used = get_extruders(true); if (mode == kImexPrimaryMode) return false;
return used.size() > 1;
// Resolve the active mode's tools string from the printer config.
auto* names_opt = printer_cfg.option<ConfigOptionStrings>("imex_mode_names");
auto* tools_opt = printer_cfg.option<ConfigOptionStrings>("imex_mode_active_tools");
auto* types_opt = printer_cfg.option<ConfigOptionStrings>("imex_mode_types");
auto* tpg_opt = printer_cfg.option<ConfigOptionInt>("imex_tools_per_gantry");
auto* pem_opt = printer_cfg.option<ConfigOptionInts>("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<std::string> 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<int> used_1b = get_extruders(true);
std::vector<int> 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) 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; const IMEXTheme* theme = &k_standard;
if (wxGetApp().preset_bundle) { if (wxGetApp().preset_bundle) {
const DynamicPrintConfig& pcfg = wxGetApp().preset_bundle->printers.get_edited_preset().config; const DynamicPrintConfig& pcfg = wxGetApp().preset_bundle->printers.get_edited_preset().config;
if (auto* t = pcfg.option<ConfigOptionString>("imex_viz_theme")) { if (auto* t = pcfg.option<ConfigOptionEnum<ImexVizTheme>>("imex_viz_theme")) {
if (t->value == "deuteranopia") theme = &k_deuteranopia; switch (t->value) {
else if (t->value == "tritanopia") theme = &k_tritanopia; case ImexVizTheme::Deuteranopia: theme = &k_deuteranopia; break;
else if (t->value == "high_contrast") theme = &k_high_contrast; 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) { if (hover_id == (int)PLATE_IMEX_MODE_ID) {
render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_hovered_texture); render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_hovered_texture);
std::string cur = get_imex_mode(); 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)")); show_tooltip(_u8L("IDEX/IQEX mode: ") + cur + _u8L(" (left-click to cycle, right-click for menu)"));
} else { } else {
render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_texture); render_icon_texture(m_imex_mode_icon.model, m_partplate_list->m_imex_mode_texture);
+2 -1
View File
@@ -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) { for (size_t i = 0; i < modes.size(); ++i) {
int id = wxNewId(); int id = wxNewId();
mode_ids.push_back(id); 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) if (modes[i] == current)
item->Check(true); item->Check(true);
} }
+275 -178
View File
@@ -47,6 +47,7 @@
#include "Widgets/Label.hpp" #include "Widgets/Label.hpp"
#include "Widgets/TabCtrl.hpp" #include "Widgets/TabCtrl.hpp"
#include "Widgets/ComboBox.hpp" #include "Widgets/ComboBox.hpp"
#include "Widgets/CheckBox.hpp"
#include "MarkdownTip.hpp" #include "MarkdownTip.hpp"
#include "Search.hpp" #include "Search.hpp"
#include "BedShapeDialog.hpp" #include "BedShapeDialog.hpp"
@@ -4401,22 +4402,32 @@ class IMEXModesCtrl : public wxPanel {
public: public:
std::function<void()> on_change; std::function<void()> on_change;
// layout: 0=front-left, 1=front-right, 2=rear-left, 3=rear-right (T0 corner) // Lazy lookup so the widget always sees the current parent (preset switches
static int parse_layout(const std::string& s) { // change the parent under us). Returns nullptr when the active preset has no
if (s == "front-right") return 1; // parent (system preset itself), which disables per-row reset arrows.
if (s == "rear-left") return 2; using ParentConfigLookup = std::function<const DynamicPrintConfig*()>;
if (s == "rear-right") return 3; void set_parent_config_lookup(ParentConfigLookup f) { m_parent_lookup = std::move(f); }
return 0; // "front-left" default
} // 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) 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) : 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_outer = new wxBoxSizer(wxVERTICAL);
m_rows_sizer = new wxBoxSizer(wxVERTICAL); m_rows_sizer = new wxBoxSizer(wxVERTICAL);
// --- Info panel: instruction text + color legend --- // --- Info panel: instruction text + color legend ---
auto* info_panel = new wxPanel(this, wxID_ANY); auto* info_panel = new wxPanel(this, wxID_ANY);
info_panel->SetBackgroundColour(GetBackgroundColour());
auto* info_sizer = new wxBoxSizer(wxVERTICAL); auto* info_sizer = new wxBoxSizer(wxVERTICAL);
auto* inst = new wxStaticText(info_panel, wxID_ANY, auto* inst = new wxStaticText(info_panel, wxID_ANY,
@@ -4424,36 +4435,41 @@ public:
"The Primary tool (green) drives all sliced paths. " "The Primary tool (green) drives all sliced paths. "
"Copy (blue) and Mirror (amber) tools follow the Primary at the firmware level. " "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.")); "Click a tool button to cycle its role — clear the Primary before reassigning it."));
inst->Wrap(620); inst->Wrap(FromDIP(620));
info_sizer->Add(inst, 0, wxBOTTOM, 6); 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); auto* leg_sizer = new wxBoxSizer(wxHORIZONTAL);
struct { int state; const char* label; } legend[] = { struct { int state; const char* label; } legend[] = {
{1,"Primary"},{2,"Copy"},{3,"Mirror"} {1,"Primary"},{2,"Copy"},{3,"Mirror"}
}; };
const int swatch_side = info_panel->GetCharHeight();
for (auto& l : legend) { 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)); 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), 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); info_panel->SetSizer(info_sizer);
// Column header row (fixed-pixel spacers to approximately align with mode row fields) // Column header row — spacers sized to align with the mode-row fields below.
// Name field: 130px + 6px gap; Tool grid: n_cols*(36+2)-2 px + 6px gap // 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); auto* hdr_panel = new wxPanel(this, wxID_ANY);
hdr_panel->SetBackgroundColour(GetBackgroundColour());
auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL); auto* hdr_sizer = new wxBoxSizer(wxHORIZONTAL);
auto* hdr_name = new wxStaticText(hdr_panel, wxID_ANY, _L("Name")); auto* hdr_name = new wxStaticText(hdr_panel, wxID_ANY, _L("Name"));
auto* hdr_tools = new wxStaticText(hdr_panel, wxID_ANY, _L("Tools")); auto* hdr_tools = new wxStaticText(hdr_panel, wxID_ANY, _L("Tools"));
auto* hdr_gcode = new wxStaticText(hdr_panel, wxID_ANY, _L("G-code")); 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->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->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_sizer->Add(hdr_gcode, 1, wxALIGN_CENTER_VERTICAL);
hdr_panel->SetSizer(hdr_sizer); hdr_panel->SetSizer(hdr_sizer);
@@ -4461,10 +4477,10 @@ public:
wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT);
add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); }); add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_row(); notify(); });
m_outer->Add(info_panel, 0, wxEXPAND | wxBOTTOM, 4); m_outer->Add(info_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(4));
m_outer->Add(hdr_panel, 0, wxEXPAND | wxBOTTOM, 2); m_outer->Add(hdr_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(2));
m_outer->Add(m_rows_sizer, 0, wxEXPAND); m_outer->Add(m_rows_sizer, 0, wxEXPAND);
m_outer->AddSpacer(4); m_outer->AddSpacer(FromDIP(4));
m_outer->Add(add_btn, 0); m_outer->Add(add_btn, 0);
SetSizer(m_outer); SetSizer(m_outer);
} }
@@ -4514,6 +4530,7 @@ public:
(tools && i < tools->values.size()) ? tools->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] : "");
} }
refresh_reset_buttons();
Layout(); Layout();
} }
@@ -4530,6 +4547,23 @@ public:
return {names, tools, gcodes}; 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<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");
}
// Parse "idx:P,idx:C,idx:M" → map<tool_idx, state> // Parse "idx:P,idx:C,idx:M" → map<tool_idx, state>
// Backwards compat: plain "idx" (no role) → state 1 (Primary) // Backwards compat: plain "idx" (no role) → state 1 (Primary)
// Indices are PHYSICAL tool indices (T0..TN-1). all_tool_states preserves // Indices are PHYSICAL tool indices (T0..TN-1). all_tool_states preserves
@@ -4592,8 +4626,51 @@ private:
std::vector<int> btn_tool_idx; std::vector<int> btn_tool_idx;
std::map<int,int> all_tool_states; std::map<int,int> all_tool_states;
bool is_primary {false}; 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<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;
}
// 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<ConfigOptionStrings>("imex_mode_names");
return p_names && row_idx >= 0 && row_idx < (int)p_names->values.size();
}
void add_row(const std::string& name = "", void add_row(const std::string& name = "",
const std::string& active_tools = "", const std::string& active_tools = "",
const std::string& gcode = "", const std::string& gcode = "",
@@ -4602,23 +4679,25 @@ private:
Row r; Row r;
r.is_primary = is_primary; r.is_primary = is_primary;
r.panel = new wxPanel(this, wxID_ANY); r.panel = new wxPanel(this, wxID_ANY);
r.panel->SetBackgroundColour(GetBackgroundColour());
auto* sizer = new wxBoxSizer(wxHORIZONTAL); auto* sizer = new wxBoxSizer(wxHORIZONTAL);
if (is_primary) { if (is_primary) {
r.name = nullptr; r.name = nullptr;
auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"), auto* lbl = new wxStaticText(r.panel, wxID_ANY, _L("Primary"),
wxDefaultPosition, wxSize(130, -1)); wxDefaultPosition, FromDIP(wxSize(130, -1)));
wxFont f = lbl->GetFont(); wxFont f = lbl->GetFont();
f.SetWeight(wxFONTWEIGHT_BOLD); f.SetWeight(wxFONTWEIGHT_BOLD);
lbl->SetFont(f); lbl->SetFont(f);
sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); sizer->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
} else { } 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(); }); r.name->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
} }
auto* grid_panel = new wxPanel(r.panel, wxID_ANY); 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); auto tool_states = parse_tool_states(active_tools);
if (tool_states.empty()) if (tool_states.empty())
@@ -4655,7 +4734,7 @@ private:
if (it != tool_states.end()) state = it->second; if (it != tool_states.end()) state = it->second;
auto* btn = new wxButton(grid_panel, wxID_ANY, wxEmptyString, 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); apply_btn(btn, tool_idx, state);
int btn_pos = (int)r.btns.size(); int btn_pos = (int)r.btns.size();
@@ -4663,34 +4742,42 @@ private:
r.btn_states.push_back(state); r.btn_states.push_back(state);
r.btn_tool_idx.push_back(tool_idx); r.btn_tool_idx.push_back(tool_idx);
btn->Bind(wxEVT_BUTTON, [this, this_panel, btn_pos](wxCommandEvent&) { // The Primary mode is the IMEX-off mode — its tool assignment is
// Find the row by panel pointer (stable across add/remove) // fixed (no parallel printing happening), so cycling tool roles
for (auto& row_ref : m_rows) { // would be a no-op and confusing. Render the buttons read-only;
if (row_ref.panel != this_panel) continue; // the row still shows up in the editor as visual context.
int& st = row_ref.btn_states[btn_pos]; 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 // Check if another button already holds the Primary state
bool other_primary = false; bool other_primary = false;
for (int j = 0; j < (int)row_ref.btn_states.size(); ++j) 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; } 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 // Advance state, skipping Primary(1) if another tool is already Primary
st = (st + 1) % 4; st = (st + 1) % 4;
if (st == 1 && other_primary) if (st == 1 && other_primary)
st = 2; // skip Primary → go straight to Copy st = 2; // skip Primary → go straight to Copy
// Keep all_tool_states in sync so off-screen tools are preserved // Keep all_tool_states in sync so off-screen tools are preserved
int tidx = row_ref.btn_tool_idx[btn_pos]; int tidx = row_ref.btn_tool_idx[btn_pos];
if (st == 0) if (st == 0)
row_ref.all_tool_states.erase(tidx); row_ref.all_tool_states.erase(tidx);
else else
row_ref.all_tool_states[tidx] = st; row_ref.all_tool_states[tidx] = st;
apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], st); apply_btn(row_ref.btns[btn_pos], row_ref.btn_tool_idx[btn_pos], st);
notify(); notify();
break; break;
} }
}); });
}
grid_sizer->Add(btn, 0); grid_sizer->Add(btn, 0);
} }
@@ -4698,16 +4785,16 @@ private:
grid_panel->SetSizer(grid_sizer); grid_panel->SetSizer(grid_sizer);
r.gcode = new wxTextCtrl(r.panel, wxID_ANY, gcode, 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(); }); r.gcode->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { notify(); });
if (!is_primary) { 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(grid_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(6));
sizer->Add(r.gcode, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); 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); auto* btn_col = new wxBoxSizer(wxVERTICAL);
wxTextCtrl* gcode_ctrl = r.gcode; wxTextCtrl* gcode_ctrl = r.gcode;
auto* ph_btn = new ScalableButton(r.panel, wxID_ANY, "edit", wxEmptyString, auto* ph_btn = new ScalableButton(r.panel, wxID_ANY, "edit", wxEmptyString,
@@ -4719,7 +4806,7 @@ private:
if (dlg.ShowModal() == wxID_OK) if (dlg.ShowModal() == wxID_OK)
gcode_ctrl->SetValue(dlg.get_edited_gcode()); 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) { if (!is_primary) {
auto* rm = new ScalableButton(r.panel, wxID_ANY, "imex_remove", wxEmptyString, auto* rm = new ScalableButton(r.panel, wxID_ANY, "imex_remove", wxEmptyString,
@@ -4732,10 +4819,28 @@ private:
}); });
btn_col->Add(rm, 0); 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<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->SetSizer(sizer); 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)); m_rows.push_back(std::move(r));
Layout(); Layout();
} }
@@ -4773,13 +4878,67 @@ private:
return s; return s;
} }
void notify() { if (on_change) on_change(); } void notify() {
refresh_reset_buttons();
if (on_change) on_change();
}
wxBoxSizer* m_outer; // Update each row's reset bitmap to reflect current dirty state. Cached so we
wxBoxSizer* m_rows_sizer; // only swap the bitmap when state actually transitions — avoids flicker on
std::vector<Row> m_rows; // every keystroke. Disabled buttons (no saved counterpart) keep their dot.
int m_n_cols, m_n_rows; void refresh_reset_buttons() {
int m_layout {0}; // 0=front-left, 1=front-right, 2=rear-left, 3=rear-right 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");
}
}
// 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<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 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<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
ParentConfigLookup m_parent_lookup;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -5381,63 +5540,33 @@ if (is_marlin_flavor)
optgroup->append_single_option_line("is_imex"); optgroup->append_single_option_line("is_imex");
optgroup->append_single_option_line("imex_gantry_count"); optgroup->append_single_option_line("imex_gantry_count");
optgroup->append_single_option_line("imex_tools_per_gantry"); optgroup->append_single_option_line("imex_tools_per_gantry");
{ optgroup->append_single_option_line("imex_tool_layout");
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<ConfigOptionString>("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_nozzle_clearance_x"); optgroup->append_single_option_line("imex_nozzle_clearance_x");
optgroup->append_single_option_line("imex_nozzle_clearance_y"); optgroup->append_single_option_line("imex_nozzle_clearance_y");
optgroup->append_single_option_line("imex_carriage_margin"); optgroup->append_single_option_line("imex_carriage_margin");
{ {
// Toggle for pre-slice IMEX safety warnings (stored in app_config, not printer profile) // 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 // We avoid full_width=1 here because that path uses different sizer math from the
// path in activate_line/append_line to avoid option_set.front() on an empty vector, // rest of the optgroup — it left-pads with 15px instead of aligning to the label
// which crashes on Windows release builds (ACCESS_VIOLATION at 0x30). // column, which made this row look out-of-place vs. the option lines above. Instead
auto line = Line{ L("Pre-slice warnings"), L("Show a warning dialog before slicing if IDEX/IQEX parallel mode " // we register a placeholder Option so option_set.front() (the empty-vector access
"concerns are detected (bed temperature conflicts, filament type " // that crashes Windows release builds) is satisfied, and let the standard line path
"mismatches, multi-material conflicts). Can be suppressed from the " // render the label + custom widget with the same column geometry as everything else.
"dialog itself. Re-enable here if suppressed accidentally.") }; // No Field is built for the placeholder because line.widget != nullptr causes
line.full_width = 1; // 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* { line.widget = [](wxWindow* parent) -> wxSizer* {
// Label belongs on the checkbox itself: full_width=1 (required to auto* cb = new ::CheckBox(parent);
// dodge the Windows crash in activate_line) skips Line-title rendering. const bool enabled = wxGetApp().app_config->get("imex_pre_slice_warnings") != "false";
auto* cb = new wxCheckBox(parent, wxID_ANY, _L("Pre-slice warnings"));
bool enabled = wxGetApp().app_config->get("imex_pre_slice_warnings") != "false";
cb->SetValue(enabled); 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"); wxGetApp().app_config->set("imex_pre_slice_warnings", cb->GetValue() ? "true" : "false");
}); });
auto* s = new wxBoxSizer(wxHORIZONTAL); auto* s = new wxBoxSizer(wxHORIZONTAL);
@@ -5446,38 +5575,7 @@ if (is_marlin_flavor)
}; };
optgroup->append_line(line); optgroup->append_line(line);
} }
{ optgroup->append_single_option_line("imex_viz_theme");
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<ConfigOptionString>("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);
}
{ {
auto modes_og = page->new_optgroup(L("IDEX/IQEX Parallel Modes"), L"param_advanced"); auto modes_og = page->new_optgroup(L("IDEX/IQEX Parallel Modes"), L"param_advanced");
auto line = Line{ L("Modes"), L("") }; auto line = Line{ L("Modes"), L("") };
@@ -5485,10 +5583,16 @@ if (is_marlin_flavor)
line.widget = [this](wxWindow* parent) -> wxSizer* { line.widget = [this](wxWindow* parent) -> wxSizer* {
int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_cols = m_config->opt_int("imex_tools_per_gantry");
int n_rows = m_config->opt_int("imex_gantry_count"); int n_rows = m_config->opt_int("imex_gantry_count");
std::string layout_str = "front-left"; ImexToolLayout layout = ImexToolLayout::FrontLeft;
if (auto* o = m_config->option<ConfigOptionString>("imex_tool_layout")) layout_str = o->value; if (auto* o = m_config->option<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout")) layout = o->value;
int layout = IMEXModesCtrl::parse_layout(layout_str); m_imex_modes_ctrl = new IMEXModesCtrl(parent, n_cols, n_rows, IMEXModesCtrl::parse_layout(layout));
m_imex_modes_ctrl = new IMEXModesCtrl(parent, n_cols, n_rows, 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->load_from_config(*m_config);
m_imex_modes_ctrl->on_change = [this]() { m_imex_modes_ctrl->on_change = [this]() {
auto [names, tools, gcodes] = m_imex_modes_ctrl->get_mode_data(); 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)); m_active_page->set_value("extruders_count", int(m_extruders_count));
if (m_config) { if (m_config) {
// Sync layout combo // imex_tool_layout / imex_viz_theme are now real enum options handled by
if (m_imex_layout_combo) { // standard Choice fields; only the modes grid still needs explicit re-sync
static const char* layout_vals[] = { "front-left", "front-right", "rear-left", "rear-right" }; // because it spans three options at once and isn't a Field.
std::string cur = "front-left";
if (auto* o = m_config->option<ConfigOptionString>("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<ConfigOptionString>("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; }
}
if (m_imex_modes_ctrl) { if (m_imex_modes_ctrl) {
int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_cols = m_config->opt_int("imex_tools_per_gantry");
int n_rows = m_config->opt_int("imex_gantry_count"); int n_rows = m_config->opt_int("imex_gantry_count");
std::string layout_str = "front-left"; ImexToolLayout layout = ImexToolLayout::FrontLeft;
if (auto* o = m_config->option<ConfigOptionString>("imex_tool_layout")) layout_str = o->value; if (auto* o = m_config->option<ConfigOptionEnum<ImexToolLayout>>("imex_tool_layout")) layout = o->value;
m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout_str)); m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout));
m_imex_modes_ctrl->load_from_config(*m_config); m_imex_modes_ctrl->load_from_config(*m_config);
} }
} }
@@ -5790,8 +5881,6 @@ void TabPrinter::clear_pages()
Tab::clear_pages(); Tab::clear_pages();
m_reset_to_filament_color = nullptr; m_reset_to_filament_color = nullptr;
m_imex_modes_ctrl = nullptr; m_imex_modes_ctrl = nullptr;
m_imex_layout_combo = nullptr;
m_imex_theme_combo = nullptr;
} }
void TabPrinter::toggle_options() void TabPrinter::toggle_options()
@@ -5869,9 +5958,8 @@ void TabPrinter::toggle_options()
"imex_nozzle_clearance_x", "imex_nozzle_clearance_y", "imex_nozzle_clearance_x", "imex_nozzle_clearance_y",
"imex_carriage_margin"}) "imex_carriage_margin"})
toggle_option(el, is_imex); toggle_option(el, is_imex);
// These lines have custom combo widgets — toggle_line hides the full row toggle_option("imex_tool_layout", is_imex);
toggle_line("imex_tool_layout", is_imex); toggle_option("imex_viz_theme", is_imex);
toggle_line("imex_viz_theme", is_imex);
if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex); if (m_imex_modes_ctrl) m_imex_modes_ctrl->Show(is_imex);
} }
wxString extruder_number; wxString extruder_number;
@@ -6014,11 +6102,20 @@ void TabPrinter::update_fff()
// Sync IDEX/IQEX tool grid whenever carriage configuration changes. // Sync IDEX/IQEX tool grid whenever carriage configuration changes.
// set_grid_size() is a no-op when dimensions and layout are unchanged. // 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) { if (m_imex_modes_ctrl) {
int n_cols = m_config->opt_int("imex_tools_per_gantry"); int n_cols = m_config->opt_int("imex_tools_per_gantry");
int n_rows = m_config->opt_int("imex_gantry_count"); int n_rows = m_config->opt_int("imex_gantry_count");
std::string layout_str = m_config->opt_string("imex_tool_layout"); ImexToolLayout layout = ImexToolLayout::FrontLeft;
m_imex_modes_ctrl->set_grid_size(n_cols, n_rows, IMEXModesCtrl::parse_layout(layout_str)); if (auto* o = m_config->option<ConfigOptionEnum<ImexToolLayout>>("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(); toggle_options();
-2
View File
@@ -606,8 +606,6 @@ private:
wxBoxSizer* m_presets_sizer {nullptr}; wxBoxSizer* m_presets_sizer {nullptr};
IMEXModesCtrl* m_imex_modes_ctrl {nullptr}; IMEXModesCtrl* m_imex_modes_ctrl {nullptr};
ComboBox* m_imex_layout_combo {nullptr};
ComboBox* m_imex_theme_combo {nullptr};
public: public:
ScalableButton* m_reset_to_filament_color = nullptr; ScalableButton* m_reset_to_filament_color = nullptr;
+130
View File
@@ -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); 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<n> 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<n> 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<std::string> names = {"primary", "my-copy", "my-split"};
std::vector<std::string> 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<std::string> names = {"primary", "copy", "mirror", "split", "weird-name"};
std::vector<std::string> 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]") { TEST_CASE("first_filament_for_physical_head — identity pem", "[IMEX]") {
auto pem = make_pem({0, 1, 2, 3}); auto pem = make_pem({0, 1, 2, 3});
REQUIRE(first_filament_for_physical_head(pem, 0) == 0); REQUIRE(first_filament_for_physical_head(pem, 0) == 0);