mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-10 17:21:10 +00:00
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:
co-authored by
Claude Opus 4.7
parent
b10d6e1b9f
commit
0bb1cef78e
+16
-2
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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. "
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user