From 5244b8ed082b6007133d233dc026575e0f7d5bf2 Mon Sep 17 00:00:00 2001 From: Raul Predescu Date: Sat, 27 Dec 2025 16:14:09 -0800 Subject: [PATCH 1/2] resonance avoidance v1 --- src/BambuStudio.cpp | 3 +- src/libslic3r/GCode.cpp | 29 +++++++++++++++ src/libslic3r/Preset.cpp | 2 + src/libslic3r/PrintConfig.cpp | 69 ++++++++++++++++++++++++++++++++++- src/libslic3r/PrintConfig.hpp | 5 +++ src/slic3r/GUI/Tab.cpp | 38 +++++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/src/BambuStudio.cpp b/src/BambuStudio.cpp index af3710fb6b..b2975f7881 100644 --- a/src/BambuStudio.cpp +++ b/src/BambuStudio.cpp @@ -7838,7 +7838,8 @@ bool CLI::setup(int argc, char **argv) for (const t_optiondef_map::value_type &optdef : *options) m_config.option(optdef.first, true); - //set_data_dir(m_config.opt_string("datadir")); + // Enable datadir support for isolated development environments + set_data_dir(m_config.opt_string("datadir")); //FIXME Validating at this stage most likely does not make sense, as the config is not fully initialized yet. if (!validity.empty()) { diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 3355feb36b..2fb10dac6c 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6064,6 +6064,35 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, speed = std::min(speed, extrude_speed); } + // Resonance avoidance for external perimeters + // Adjusts outer wall speeds to avoid printer resonance frequencies that cause ringing. + // + // Algorithm (bidirectional midpoint adjustment): + // 1. If speed is below resonance zone max AND zone is valid (min < max): + // - Calculate midpoint of resonance zone + // - Speeds below midpoint: clamp DOWN to min (safe zone below resonance) + // - Speeds above midpoint: boost UP to max (safe zone above resonance) + // 2. Speeds >= max_avoid are left unchanged (already above resonance) + // + // This is stateless - no flags or state variables needed. Each path segment is + // evaluated independently based on its target speed. + if (path.role() == erExternalPerimeter && EXTRUDER_CONFIG(resonance_avoidance)) { + const double min_avoid = EXTRUDER_CONFIG(min_resonance_avoidance_speed); + const double max_avoid = EXTRUDER_CONFIG(max_resonance_avoidance_speed); + + if (speed < max_avoid && max_avoid > min_avoid) { + const double midpoint = min_avoid + ((max_avoid - min_avoid) / 2.0); + + if (speed < midpoint) { + // Lower half of resonance range: clamp down to safe minimum + speed = std::min(speed, min_avoid); + } else { + // Upper half of resonance range: boost up to safe maximum + speed = max_avoid; + } + } + } + if (do_slowdown_by_height) speed = std::min(speed, desiredMaxSpeed); double F = speed * 60; // convert mm/sec to mm/min diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 3fb1278084..3656655a58 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1024,6 +1024,8 @@ static std::vector s_Preset_machine_limits_options { "machine_max_speed_x", "machine_max_speed_y", "machine_max_speed_z", "machine_max_speed_e", "machine_min_extruding_rate", "machine_min_travel_rate", "machine_max_jerk_x", "machine_max_jerk_y", "machine_max_jerk_z", "machine_max_jerk_e", + // Resonance avoidance options + "resonance_avoidance", "min_resonance_avoidance_speed", "max_resonance_avoidance_speed", }; static std::vector s_Preset_printer_options { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 157fa0d471..7a43e162f7 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -4197,6 +4197,39 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionInt(10)); + // Resonance avoidance feature + // Adjusts outer wall print speeds to avoid printer resonance frequencies that cause ringing artifacts. + // Uses a bidirectional midpoint algorithm: speeds below the midpoint are clamped to min, + // speeds above midpoint are boosted to max, avoiding the resonance zone entirely. + def = this->add("resonance_avoidance", coBools); + def->label = L("Resonance avoidance"); + def->category = L("Speed"); + def->tooltip = L("Adjust outer wall speed to avoid printer resonance zones, reducing ringing or VFA.\n" + "Enable this option and configure min/max speed range to activate.\n" + "Disable when calibrating for ringing."); + def->mode = comSimple; + def->set_default_value(new ConfigOptionBools{ false }); + + def = this->add("min_resonance_avoidance_speed", coFloats); + def->label = L("Min speed"); + def->category = L("Speed"); + def->tooltip = L("Minimum speed of resonance zone. Speeds below midpoint clamped to this.\n" + "Must be greater than 0 and less than max speed."); + def->sidetext = L("mm/s"); + def->min = 0; + def->mode = comSimple; + def->set_default_value(new ConfigOptionFloats{ 0 }); + + def = this->add("max_resonance_avoidance_speed", coFloats); + def->label = L("Max speed"); + def->category = L("Speed"); + def->tooltip = L("Maximum speed of resonance zone. Speeds above midpoint boosted to this.\n" + "Must be greater than min speed."); + def->sidetext = L("mm/s"); + def->min = 0; + def->mode = comSimple; + def->set_default_value(new ConfigOptionFloats{ 0 }); + def = this->add("seam_slope_inner_walls", coBool); def->label = L("Scarf joint for inner walls"); def->category = L("Quality"); @@ -6433,7 +6466,10 @@ std::set printer_extruder_options = { "extruder_printable_height", "min_layer_height", "max_layer_height", - "extruder_max_nozzle_count" + "extruder_max_nozzle_count", + "resonance_avoidance", + "min_resonance_avoidance_speed", + "max_resonance_avoidance_speed" }; std::set printer_options_with_variant_1 = { @@ -8377,6 +8413,30 @@ std::map validate(const FullPrintConfig &cfg, bool und } } + // Validate resonance avoidance settings + // This validation runs during slicing/export to catch configuration errors. + // Note: UI-level validation in Tab.cpp provides earlier feedback when saving presets. + for (size_t i = 0; i < cfg.resonance_avoidance.values.size(); ++i) { + if (cfg.resonance_avoidance.get_at(i)) { + double min_speed = cfg.min_resonance_avoidance_speed.get_at(i); + double max_speed = cfg.max_resonance_avoidance_speed.get_at(i); + + // Both speeds must be configured (non-zero) when feature is enabled + if (min_speed == 0 || max_speed == 0) { + error_message.emplace("min_resonance_avoidance_speed", + L("Resonance avoidance is enabled but min/max speeds are not configured (cannot be 0)")); + break; + } + // Min must be less than max to define a valid range + if (min_speed >= max_speed) { + error_message.emplace("min_resonance_avoidance_speed", + L("Min resonance speed must be less than max speed (min: ") + + std::to_string(min_speed) + L(", max: ") + std::to_string(max_speed) + L(")")); + break; + } + } + } + // The configuration is valid. return error_message; } @@ -8682,6 +8742,13 @@ CLIMiscConfigDef::CLIMiscConfigDef() { ConfigOptionDef* def; + // Add datadir support for isolated development environments + def = this->add("datadir", coString); + def->label = "Data directory"; + def->tooltip = "Use custom data directory for configuration, profiles, and cache"; + def->cli_params = "path"; + def->set_default_value(new ConfigOptionString("")); + /*def = this->add("ignore_nonexistent_config", coBool); def->label = L("Ignore non-existent config files"); def->tooltip = L("Do not fail if a file supplied to --load does not exist."); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index e8c7b6cd00..7b36d92fc2 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1044,6 +1044,11 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionFloatsNullable, machine_min_travel_rate)) // M205 S... [mm/sec] ((ConfigOptionFloatsNullable, machine_min_extruding_rate)) + + // Resonance avoidance: adjusts outer wall speeds to avoid resonance frequencies + ((ConfigOptionBools, resonance_avoidance)) + ((ConfigOptionFloats, min_resonance_avoidance_speed)) + ((ConfigOptionFloats, max_resonance_avoidance_speed)) ) // This object is mapped to Perl as Slic3r::Config::GCode. diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index bb9073ded6..f4d85498b1 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4651,6 +4651,20 @@ PageShp TabPrinter::build_kinematics_page() append_option_line(optgroup, "machine_max_jerk_" + axis); } + // Resonance avoidance UI section + // Creates enable checkbox and min/max speed range inputs. + // Speed range visibility is controlled by toggle_options() based on checkbox state. + optgroup = page->new_optgroup(L("Resonance Avoidance")); + Line ra_enable_line = optgroup->create_single_option_line("resonance_avoidance"); + optgroup->append_line(ra_enable_line); + + Line resonance_line = {L("Speed Range"), ""}; + Option min_option = optgroup->get_option("min_resonance_avoidance_speed"); + min_option.opt.width = Field::def_width_wider(); // Extra width for better spacing before Max field + resonance_line.append_option(min_option); + resonance_line.append_option(optgroup->get_option("max_resonance_avoidance_speed")); + optgroup->append_line(resonance_line); + //optgroup = page->new_optgroup(L("Minimum feedrates")); // append_option_line(optgroup, "machine_min_extruding_rate"); // append_option_line(optgroup, "machine_min_travel_rate"); @@ -5163,6 +5177,10 @@ void TabPrinter::toggle_options() "machine_min_extruding_rate", "machine_min_travel_rate" }) for (int i = 0; i < max_field; ++ i) toggle_option(opt, !is_BBL_printer, i); + + // Show/hide resonance avoidance speed range based on enable checkbox + bool ra_enabled = m_config->opt_bool("resonance_avoidance", 0); + toggle_line("Speed Range", ra_enabled); } toggle_line("fan_direction", m_config->opt_bool("auxiliary_fan")); @@ -6160,6 +6178,26 @@ void Tab::compare_preset() //BBS: add project embedded preset relate logic void Tab::save_preset(std::string name /*= ""*/, bool detach, bool save_to_project, bool from_input, std::string input_name ) { + // Validate resonance avoidance settings before saving (printer presets only) + // This provides immediate user feedback when clicking save, preventing invalid configurations. + // Validation runs regardless of enable checkbox state to catch partially-configured settings. + if (m_type == Preset::TYPE_PRINTER) { + double min_speed = m_config->opt_float("min_resonance_avoidance_speed", 0); + double max_speed = m_config->opt_float("max_resonance_avoidance_speed", 0); + + // If either value is set, both must be valid + if (min_speed != 0 || max_speed != 0) { + if (min_speed == 0 || max_speed == 0) { + show_error(m_parent, _L("Cannot save: Both min and max resonance speeds must be set (cannot be 0) or both must be 0")); + return; + } + if (min_speed >= max_speed) { + show_error(m_parent, wxString::Format(_L("Cannot save: Min speed (%g) must be less than max speed (%g)"), min_speed, max_speed)); + return; + } + } + } + // since buttons(and choices too) don't get focus on Mac, we set focus manually // to the treectrl so that the EVT_* events are fired for the input field having // focus currently.is there anything better than this ? From 48f2d46ee29a8e11c7056011c36f42792444ce3f Mon Sep 17 00:00:00 2001 From: Raul Predescu Date: Sun, 28 Dec 2025 20:08:33 -0800 Subject: [PATCH 2/2] resonance zones dev dir flag --- src/BambuStudio.cpp | 9 +- src/libslic3r/GCode.cpp | 59 +- src/libslic3r/Preset.cpp | 2 +- src/libslic3r/PrintConfig.cpp | 94 +-- src/libslic3r/PrintConfig.hpp | 75 ++- src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/GUI_ResonanceZones.cpp | 818 ++++++++++++++++++++++++++ src/slic3r/GUI/GUI_ResonanceZones.hpp | 166 ++++++ src/slic3r/GUI/OG_CustomCtrl.cpp | 30 +- src/slic3r/GUI/Tab.cpp | 110 +++- src/slic3r/GUI/Tab.hpp | 5 + 11 files changed, 1274 insertions(+), 96 deletions(-) create mode 100644 src/slic3r/GUI/GUI_ResonanceZones.cpp create mode 100644 src/slic3r/GUI/GUI_ResonanceZones.hpp diff --git a/src/BambuStudio.cpp b/src/BambuStudio.cpp index b2975f7881..48ba292088 100644 --- a/src/BambuStudio.cpp +++ b/src/BambuStudio.cpp @@ -7838,8 +7838,13 @@ bool CLI::setup(int argc, char **argv) for (const t_optiondef_map::value_type &optdef : *options) m_config.option(optdef.first, true); - // Enable datadir support for isolated development environments - set_data_dir(m_config.opt_string("datadir")); + //set_data_dir(m_config.opt_string("datadir")); + + // Development-only: Allow custom data directory via --dev-data-dir + std::string dev_data_dir = m_config.opt_string("dev-data-dir"); + if (!dev_data_dir.empty()) { + set_data_dir(dev_data_dir); + } //FIXME Validating at this stage most likely does not make sense, as the config is not fully initialized yet. if (!validity.empty()) { diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 2fb10dac6c..450be6e7e2 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -6055,40 +6055,63 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // m_config.max_volumetric_speed.value / path.mm3_per_mm // ); //} + + // Save volumetric speed limit for later re-clamping after resonance adjustments + double volumetric_speed_limit = std::numeric_limits::max(); + if (filament_max_volumetric_speed > 0) { double extrude_speed = filament_max_volumetric_speed / path.mm3_per_mm; - if (_mm3_per_mm > 0) + if (_mm3_per_mm > 0) { extrude_speed = filament_max_volumetric_speed / _mm3_per_mm; + } + + volumetric_speed_limit = extrude_speed; // Save for resonance re-clamp // cap speed with max_volumetric_speed anyway (even if user is not using autospeed) speed = std::min(speed, extrude_speed); } - // Resonance avoidance for external perimeters + // Multi-zone resonance avoidance for external perimeters // Adjusts outer wall speeds to avoid printer resonance frequencies that cause ringing. // - // Algorithm (bidirectional midpoint adjustment): - // 1. If speed is below resonance zone max AND zone is valid (min < max): - // - Calculate midpoint of resonance zone - // - Speeds below midpoint: clamp DOWN to min (safe zone below resonance) - // - Speeds above midpoint: boost UP to max (safe zone above resonance) - // 2. Speeds >= max_avoid are left unchanged (already above resonance) + // Algorithm (bidirectional midpoint adjustment for each zone): + // 1. Check each zone pair (min, max) from the zones config + // 2. If speed falls within a zone (speed < max AND max > min): + // - Calculate midpoint of that zone + // - Speeds below midpoint: clamp DOWN to zone min (safe zone below resonance) + // - Speeds above midpoint: boost UP to zone max (safe zone above resonance) + // 3. Once a zone matches, adjustment is applied and we break (no cascading) // + // Zones are stored as interleaved min-max pairs: [min1, max1, min2, max2, ...] // This is stateless - no flags or state variables needed. Each path segment is // evaluated independently based on its target speed. if (path.role() == erExternalPerimeter && EXTRUDER_CONFIG(resonance_avoidance)) { - const double min_avoid = EXTRUDER_CONFIG(min_resonance_avoidance_speed); - const double max_avoid = EXTRUDER_CONFIG(max_resonance_avoidance_speed); + // Resonance avoidance: adjust speed to avoid problematic frequencies + const auto& zones_config = m_config.resonance_avoidance_zones; - if (speed < max_avoid && max_avoid > min_avoid) { - const double midpoint = min_avoid + ((max_avoid - min_avoid) / 2.0); + // Convert config array to ResonanceZone objects and check each zone + for (size_t i = 0; i < zones_config.values.size(); i += 2) { + if (i + 1 >= zones_config.values.size()) { + break; // Incomplete pair + } - if (speed < midpoint) { - // Lower half of resonance range: clamp down to safe minimum - speed = std::min(speed, min_avoid); - } else { - // Upper half of resonance range: boost up to safe maximum - speed = max_avoid; + ResonanceZone zone(zones_config.values[i], zones_config.values[i + 1]); + + // Skip invalid zones (defensive check against corrupted config data) + if (!zone.is_valid()) { + continue; + } + + // Use the zone's adjust_speed method (bidirectional midpoint algorithm) + double adjusted = zone.adjust_speed(speed); + if (adjusted != speed) { + // Re-clamp to volumetric limit to prevent under-extrusion. + // Resonance avoidance can boost speed above the filament's flow capacity, + // which would cause under-extrusion on external perimeters + // The volumetric limit is a hard constraint - we can boost as high as possible + // while staying within the extruder's physical limits. + speed = std::min(adjusted, volumetric_speed_limit); + break; // Applied adjustment, exit loop } } } diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 3656655a58..c76b3f53e0 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1025,7 +1025,7 @@ static std::vector s_Preset_machine_limits_options { "machine_min_extruding_rate", "machine_min_travel_rate", "machine_max_jerk_x", "machine_max_jerk_y", "machine_max_jerk_z", "machine_max_jerk_e", // Resonance avoidance options - "resonance_avoidance", "min_resonance_avoidance_speed", "max_resonance_avoidance_speed", + "resonance_avoidance", "resonance_avoidance_zones", }; static std::vector s_Preset_printer_options { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 7a43e162f7..bd500a267b 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -4205,30 +4205,23 @@ void PrintConfigDef::init_fff_params() def->label = L("Resonance avoidance"); def->category = L("Speed"); def->tooltip = L("Adjust outer wall speed to avoid printer resonance zones, reducing ringing or VFA.\n" - "Enable this option and configure min/max speed range to activate.\n" + "Enable this option and configure zones to activate.\n" "Disable when calibrating for ringing."); def->mode = comSimple; def->set_default_value(new ConfigOptionBools{ false }); - def = this->add("min_resonance_avoidance_speed", coFloats); - def->label = L("Min speed"); + // Multi-zone resonance avoidance + // Stores interleaved min-max pairs: [min1, max1, min2, max2, ...] + def = this->add("resonance_avoidance_zones", coFloats); + def->label = L("Resonance zones"); def->category = L("Speed"); - def->tooltip = L("Minimum speed of resonance zone. Speeds below midpoint clamped to this.\n" - "Must be greater than 0 and less than max speed."); + def->tooltip = L("Speed ranges to avoid, in min-max pairs.\n" + "Example: 70-120 and 150-180 mm/s.\n" + "Use the dynamic UI in Printer Settings to manage zones."); def->sidetext = L("mm/s"); def->min = 0; def->mode = comSimple; - def->set_default_value(new ConfigOptionFloats{ 0 }); - - def = this->add("max_resonance_avoidance_speed", coFloats); - def->label = L("Max speed"); - def->category = L("Speed"); - def->tooltip = L("Maximum speed of resonance zone. Speeds above midpoint boosted to this.\n" - "Must be greater than min speed."); - def->sidetext = L("mm/s"); - def->min = 0; - def->mode = comSimple; - def->set_default_value(new ConfigOptionFloats{ 0 }); + def->set_default_value(new ConfigOptionFloats{}); // Empty = no zones def = this->add("seam_slope_inner_walls", coBool); def->label = L("Scarf joint for inner walls"); @@ -6467,9 +6460,7 @@ std::set printer_extruder_options = { "min_layer_height", "max_layer_height", "extruder_max_nozzle_count", - "resonance_avoidance", - "min_resonance_avoidance_speed", - "max_resonance_avoidance_speed" + "resonance_avoidance" }; std::set printer_options_with_variant_1 = { @@ -8413,26 +8404,48 @@ std::map validate(const FullPrintConfig &cfg, bool und } } - // Validate resonance avoidance settings - // This validation runs during slicing/export to catch configuration errors. - // Note: UI-level validation in Tab.cpp provides earlier feedback when saving presets. + // Validate multi-zone resonance avoidance + // Zones are stored as interleaved min-max pairs: [min1, max1, min2, max2, ...] for (size_t i = 0; i < cfg.resonance_avoidance.values.size(); ++i) { if (cfg.resonance_avoidance.get_at(i)) { - double min_speed = cfg.min_resonance_avoidance_speed.get_at(i); - double max_speed = cfg.max_resonance_avoidance_speed.get_at(i); + // Get zones for this extruder (per-extruder config) + const auto& zones = cfg.resonance_avoidance_zones.values; + + if (!zones.empty()) { + // Must have even number of values (pairs) + if (zones.size() % 2 != 0) { + error_message.emplace("resonance_avoidance_zones", + L("Resonance zones must be in min-max pairs (even number of values)")); + break; + } - // Both speeds must be configured (non-zero) when feature is enabled - if (min_speed == 0 || max_speed == 0) { - error_message.emplace("min_resonance_avoidance_speed", - L("Resonance avoidance is enabled but min/max speeds are not configured (cannot be 0)")); - break; - } - // Min must be less than max to define a valid range - if (min_speed >= max_speed) { - error_message.emplace("min_resonance_avoidance_speed", - L("Min resonance speed must be less than max speed (min: ") + - std::to_string(min_speed) + L(", max: ") + std::to_string(max_speed) + L(")")); - break; + // Validate each zone pair + for (size_t j = 0; j < zones.size(); j += 2) { + if (j + 1 >= zones.size()) { + break; + } + + double min_speed = zones[j]; + double max_speed = zones[j + 1]; + + // Both must be positive + if (min_speed <= 0 || max_speed <= 0) { + error_message.emplace("resonance_avoidance_zones", + L("Zone speeds must be greater than 0 (zone ") + + std::to_string(j/2 + 1) + L(")")); + break; + } + + // Min must be less than max + if (min_speed >= max_speed) { + error_message.emplace("resonance_avoidance_zones", + L("Zone min must be less than max (zone ") + + std::to_string(j/2 + 1) + L(": min=") + + std::to_string(min_speed) + L(", max=") + + std::to_string(max_speed) + L(")")); + break; + } + } } } } @@ -8742,13 +8755,20 @@ CLIMiscConfigDef::CLIMiscConfigDef() { ConfigOptionDef* def; - // Add datadir support for isolated development environments + // Original datadir option (kept for compatibility, currently unused) def = this->add("datadir", coString); def->label = "Data directory"; def->tooltip = "Use custom data directory for configuration, profiles, and cache"; def->cli_params = "path"; def->set_default_value(new ConfigOptionString("")); + // Development-only: custom data directory (not for production use) + def = this->add("dev-data-dir", coString); + def->label = "Dev data directory"; + def->tooltip = "Development only: Use custom data directory for configuration, profiles, and cache"; + def->cli_params = "path"; + def->set_default_value(new ConfigOptionString("")); + /*def = this->add("ignore_nonexistent_config", coBool); def->label = L("Ignore non-existent config files"); def->tooltip = L("Do not fail if a file supplied to --load does not exist."); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 7b36d92fc2..b36355a8d1 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -442,6 +442,78 @@ CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(PerimeterGeneratorType) CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS(TopOneWallType) #undef CONFIG_OPTION_ENUM_DECLARE_STATIC_MAPS +// Represents a single resonance avoidance zone with min/max speeds +struct ResonanceZone { + double min_speed; + double max_speed; + + ResonanceZone() : min_speed(0), max_speed(0) {} + ResonanceZone(double min, double max) : min_speed(min), max_speed(max) {} + + // Validation + bool is_valid(std::string* error_msg = nullptr) const { + // Min must be less than max + if (min_speed >= max_speed) { + if (error_msg) + *error_msg = "Minimum speed must be less than maximum speed"; + return false; + } + // Both values must be positive + if (min_speed <= 0 || max_speed <= 0) { + if (error_msg) + *error_msg = "Speeds must be greater than 0"; + return false; + } + return true; + } + + // Speed checks + bool contains(double speed) const { + return speed >= min_speed && speed < max_speed; + } + + double get_midpoint() const { + return min_speed + ((max_speed - min_speed) / 2.0); + } + + // Overlap detection + bool overlaps_with(const ResonanceZone& other) const { + // Ranges overlap if A_min < B_max AND B_min < A_max + return min_speed < other.max_speed && other.min_speed < max_speed; + } + + // Speed adjustment algorithm (bidirectional midpoint) + double adjust_speed(double speed) const { + // Only adjust if speed falls in this zone + if (!contains(speed)) { + return speed; + } + + // Bidirectional midpoint algorithm + const double midpoint = get_midpoint(); + + if (speed < midpoint) { + // Below midpoint -> clamp down to min + return std::min(speed, min_speed); + } else { + // Above midpoint -> boost up to max + return max_speed; + } + } + + // Comparison operators for tracking/searching + // Use epsilon for floating point comparison to avoid precision issues + bool operator==(const ResonanceZone& other) const { + const double EPSILON = 0.0001; + return std::abs(min_speed - other.min_speed) < EPSILON && + std::abs(max_speed - other.max_speed) < EPSILON; + } + + bool operator!=(const ResonanceZone& other) const { + return !(*this == other); + } +}; + // Defines each and every confiuration option of Slic3r, including the properties of the GUI dialogs. // Does not store the actual values, but defines default values. class PrintConfigDef : public ConfigDef @@ -1047,8 +1119,7 @@ PRINT_CONFIG_CLASS_DEFINE( // Resonance avoidance: adjusts outer wall speeds to avoid resonance frequencies ((ConfigOptionBools, resonance_avoidance)) - ((ConfigOptionFloats, min_resonance_avoidance_speed)) - ((ConfigOptionFloats, max_resonance_avoidance_speed)) + ((ConfigOptionFloats, resonance_avoidance_zones)) ) // This object is mapped to Perl as Slic3r::Config::GCode. diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 80cdea54c1..d3f27a162d 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -242,6 +242,8 @@ set(SLIC3R_GUI_SOURCES GUI/GUI_ObjectList.hpp GUI/GUI_ObjectLayers.cpp GUI/GUI_ObjectLayers.hpp + GUI/GUI_ResonanceZones.cpp + GUI/GUI_ResonanceZones.hpp GUI/GUI_AuxiliaryList.cpp GUI/GUI_AuxiliaryList.hpp GUI/GUI_ObjectSettings.cpp diff --git a/src/slic3r/GUI/GUI_ResonanceZones.cpp b/src/slic3r/GUI/GUI_ResonanceZones.cpp new file mode 100644 index 0000000000..16362073b1 --- /dev/null +++ b/src/slic3r/GUI/GUI_ResonanceZones.cpp @@ -0,0 +1,818 @@ +#include "GUI_ResonanceZones.hpp" + +#include "OptionsGroup.hpp" +#include "GUI_App.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "Plater.hpp" +#include "I18N.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +// SpeedRangeEditor + +SpeedRangeEditor::SpeedRangeEditor(ResonanceZones* parent, + const wxString& value, + bool is_min, + std::function edit_fn) + : TextInput(parent, value, wxEmptyString, wxEmptyString, wxDefaultPosition, + wxSize(12 * wxGetApp().em_unit(), -1), wxTE_PROCESS_ENTER, _L("mm/s")) + , m_valid_value(value) + , m_is_min(is_min) +{ + auto* ctrl = GetTextCtrl(); + wxTextValidator validator(wxFILTER_NUMERIC); + ctrl->SetValidator(validator); + + // Disable autocomplete/autofill on macOS + ctrl->SetHint(wxEmptyString); +#ifdef __WXMAC__ + ctrl->OSXDisableAllSmartSubstitutions(); +#endif + + ctrl->Bind(wxEVT_TEXT_ENTER, [this, ctrl, edit_fn](wxEvent&) { + m_enter_pressed = true; + double v = get_value(); + if (v >= 0 && edit_fn(v, true)) { + m_valid_value = double_to_string(v); + } else { + ctrl->ChangeValue(m_valid_value); + } + m_enter_pressed = false; + }, ctrl->GetId()); + + ctrl->Bind(wxEVT_KILL_FOCUS, [this, ctrl, edit_fn](wxFocusEvent& event) { + event.Skip(); + if (!m_enter_pressed) { + double v = get_value(); + if (v >= 0 && edit_fn(v, false)) { + m_valid_value = double_to_string(v); + } else { + ctrl->ChangeValue(m_valid_value); + } + } + }, ctrl->GetId()); +} + +double SpeedRangeEditor::get_value() const +{ + wxString str = GetTextCtrl()->GetValue(); + double value {0.0}; + str.ToDouble(&value); + return value; +} + +void SpeedRangeEditor::msw_rescale() +{ + SetMinSize(wxSize(12 * wxGetApp().em_unit(), -1)); +} + +// ResonanceZones + +ResonanceZones::ResonanceZones(wxWindow* parent) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) + , m_parent(parent) +{ + SetBackgroundColour(parent->GetBackgroundColour()); + + m_main_sizer = new wxBoxSizer(wxVERTICAL); + m_grid_sizer = new wxFlexGridSizer(0, 2, em_unit(this) / 2, em_unit(this)); + m_grid_sizer->SetFlexibleDirection(wxBOTH); + m_main_sizer->Add(m_grid_sizer, 0, wxEXPAND | wxALL, 0); + SetSizer(m_main_sizer); + + // Use dark mode variants of bitmaps if in dark mode + const std::string delete_icon = wxGetApp().dark_mode() ? "delete_filament_dark" : "delete_filament"; + const std::string add_icon = wxGetApp().dark_mode() ? "add_filament_dark" : "add_filament"; + m_bmp_delete = ScalableBitmap(parent, delete_icon); + m_bmp_add = ScalableBitmap(parent, add_icon); +} + +void ResonanceZones::set_on_change(std::function cb) { m_on_change_callback = cb; } +void ResonanceZones::set_on_empty(std::function cb) { m_on_empty_callback = cb; } + +void ResonanceZones::clear_ui() +{ + m_zone_rows.clear(); // Clear tracking first + m_grid_sizer->Clear(true); // Then destroy widgets +} + +void ResonanceZones::reload_from_config() +{ + auto zones = get_zones_from_config(); + size_t original_count = zones.size(); + + // Sanitize: remove invalid zones, overlaps, and duplicates + zones = sanitize_zones(zones); + + // If we cleaned up any zones, save the sanitized config back + if (zones.size() < original_count) { + BOOST_LOG_TRIVIAL(warning) << "Removed " << (original_count - zones.size()) + << " invalid/overlapping/duplicate resonance zones during config load"; + save_zones(zones); + } + + // Check if zone count changed + if (zones.size() != m_zone_rows.size()) { + rebuild_all_rows(); + } else if (zones.size() > 0) { + update_zone_values(); + } else { + m_zone_rows.clear(); + m_grid_sizer->Clear(true); + } +} + +void ResonanceZones::reload() +{ + reload_from_config(); +} + +void ResonanceZones::update_zone_values() +{ + auto zones = get_zones_from_config(); + + // Safety check: only update if zone count matches + if (zones.size() != m_zone_rows.size()) { + rebuild_all_rows(); + return; + } + + // Update each row's values + for (size_t i = 0; i < zones.size(); ++i) { + const auto& zone = zones[i]; + auto& row = m_zone_rows[i]; + + // Check if this row needs updating + if (row.zone.min_speed != zone.min_speed || + row.zone.max_speed != zone.max_speed) { + + // Update stored zone data + row.zone = zone; + + // Update min editor value without triggering events + row.min_editor->GetTextCtrl()->ChangeValue(double_to_string(zone.min_speed)); + + // Update max editor value without triggering events + row.max_editor->GetTextCtrl()->ChangeValue(double_to_string(zone.max_speed)); + + // Update button stored ranges (for event handlers) + row.del_button->range = zone; + row.add_button->range = zone; + } + } +} + +std::function ResonanceZones::create_min_editor_callback(size_t zone_index) +{ + return [this, zone_index](double min_speed, bool enter_pressed) { + if (!is_valid_zone_index(zone_index)) { + return false; + } + const auto& current_zone = m_zone_rows[zone_index].zone; + if (fabs(min_speed - current_zone.min_speed) < EPSILON) { + return false; + } + double max_speed = min_speed < current_zone.max_speed + ? current_zone.max_speed + : min_speed + 10.0; + ResonanceZone new_zone(min_speed, max_speed); + return edit_zone(current_zone, new_zone); + }; +} + +std::function ResonanceZones::create_max_editor_callback(size_t zone_index) +{ + return [this, zone_index](double max_speed, bool enter_pressed) { + if (!is_valid_zone_index(zone_index)) { + return false; + } + const auto& current_zone = m_zone_rows[zone_index].zone; + if (fabs(max_speed - current_zone.max_speed) < EPSILON || + current_zone.min_speed > max_speed) { + return false; + } + ResonanceZone new_zone(current_zone.min_speed, max_speed); + return edit_zone(current_zone, new_zone); + }; +} + +void ResonanceZones::configure_button(PlusMinusButton* button, const wxString& tooltip, bool enable) +{ + button->DisableFocusFromKeyboard(); + button->SetBackgroundColour(GetBackgroundColour()); + button->SetToolTip(tooltip); + if (!enable) { + button->Enable(false); + } +} + +ResonanceZones::ZoneRow ResonanceZones::create_zone_row_tracked( + const ResonanceZone& range, + size_t zone_index, + size_t total_zones) +{ + ZoneRow tracked_row; + tracked_row.zone = range; + tracked_row.row_sizer = new wxBoxSizer(wxHORIZONTAL); + + // Label with row number (right-aligned for consistent spacing) + wxString label_text = wxString::Format("%2zu. %s", zone_index + 1, _L("Range:")); + auto label = new wxStaticText(this, wxID_ANY, label_text, wxDefaultPosition, wxDefaultSize); + label->SetBackgroundStyle(wxBG_STYLE_PAINT); + label->SetFont(wxGetApp().normal_font()); + tracked_row.row_sizer->Add(label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, em_unit(this)); + + // Min editor + tracked_row.min_editor = new SpeedRangeEditor( + this, double_to_string(range.min_speed), true, create_min_editor_callback(zone_index)); + tracked_row.row_sizer->Add(tracked_row.min_editor, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, em_unit(this)); + + // "to" label + auto middle = new wxStaticText(this, wxID_ANY, _L("to"), wxDefaultPosition, wxDefaultSize); + middle->SetBackgroundStyle(wxBG_STYLE_PAINT); + middle->SetFont(wxGetApp().normal_font()); + tracked_row.row_sizer->Add(middle, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, em_unit(this)); + + // Max editor + tracked_row.max_editor = new SpeedRangeEditor( + this, double_to_string(range.max_speed), false, create_max_editor_callback(zone_index)); + tracked_row.row_sizer->Add(tracked_row.max_editor, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, em_unit(this)); + + // Buttons + tracked_row.button_sizer = new wxBoxSizer(wxHORIZONTAL); + + // Delete button + tracked_row.del_button = new PlusMinusButton(this, m_bmp_delete, range); + configure_button(tracked_row.del_button, _L("Remove zone"), true); + tracked_row.button_sizer->Add(tracked_row.del_button, 0, wxRIGHT, em_unit(this) / 2); + + // Add button + tracked_row.add_button = new PlusMinusButton(this, m_bmp_add, range); + bool max_zones_reached = (MAX_RESONANCE_ZONES > 0 && total_zones >= MAX_RESONANCE_ZONES); + wxString add_tooltip = max_zones_reached + ? wxString::Format(_L("Maximum %d zones allowed"), MAX_RESONANCE_ZONES) + : _L("Add zone after this one"); + configure_button(tracked_row.add_button, add_tooltip, !max_zones_reached); + tracked_row.button_sizer->Add(tracked_row.add_button, 0, wxLEFT, em_unit(this) / 2); + + // Bind events + tracked_row.del_button->Bind(wxEVT_BUTTON, [this, zone_index](wxEvent&) { + if (is_valid_zone_index(zone_index)) { + del_zone(m_zone_rows[zone_index].zone); + } + }); + tracked_row.add_button->Bind(wxEVT_BUTTON, [this, zone_index](wxEvent&) { + add_zone_after(zone_index); + }); + + // Add to grid + m_grid_sizer->Add(tracked_row.row_sizer, 0, wxALIGN_CENTER_VERTICAL); + m_grid_sizer->Add(tracked_row.button_sizer, 0, wxALIGN_CENTER_VERTICAL); + + return tracked_row; +} + +void ResonanceZones::rebuild_all_rows() +{ + // Clear widget tracking + m_zone_rows.clear(); + + // Destroy all widgets + m_grid_sizer->Clear(true); + + // Get current zones from config + auto zones = get_zones_from_config(); + + // Create new rows + m_zone_rows.reserve(zones.size()); + for (size_t i = 0; i < zones.size(); ++i) { + m_zone_rows.push_back(create_zone_row_tracked(zones[i], i, zones.size())); + } + + // Layout and fit + m_grid_sizer->Layout(); + m_main_sizer->Fit(this); + + // Set minimum size so parent sizer can't shrink us to 0 + SetMinSize(GetSize()); + InvalidateBestSize(); // Force layout system to recalculate + + Layout(); +} + +void ResonanceZones::defer_rebuild() +{ + if (!m_reload_pending) { + m_reload_pending = true; + CallAfter([this]() { + m_reload_pending = false; + rebuild_all_rows(); + }); + } +} + +bool ResonanceZones::has_zones() const +{ + return m_zones_config && !m_zones_config->values.empty(); +} + +std::vector ResonanceZones::get_zones_from_config() const +{ + std::vector zones; + if (!m_zones_config) { + return zones; + } + for (size_t i = 0; i + 1 < m_zones_config->values.size(); i += 2) { + zones.emplace_back(m_zones_config->values[i], m_zones_config->values[i + 1]); + } + return zones; +} + +void ResonanceZones::save_zones(const ZonesVec& zones) +{ + if (!m_zones_config) { + return; + } + + // Don't sort during editing - only sort on load/save preset + m_zones_config->values.clear(); + for (const auto& z : zones) { + m_zones_config->values.push_back(z.min_speed); + m_zones_config->values.push_back(z.max_speed); + } +} + +ResonanceZones::ZonesVec ResonanceZones::sanitize_zones(const ZonesVec& zones) const +{ + ZonesVec clean; + + for (const auto& zone : zones) { + // Skip zones that fail basic validation (min >= max, negative values) + if (!zone.is_valid()) { + continue; + } + + // Skip if this zone overlaps or duplicates any already-accepted zone + bool has_conflict = false; + for (const auto& existing : clean) { + // Check for duplicates (using zone's operator==, which uses epsilon) + if (zone == existing) { + has_conflict = true; + break; + } + // Check for overlaps + if (zone.overlaps_with(existing)) { + has_conflict = true; + break; + } + } + + if (!has_conflict) { + clean.push_back(zone); + } + } + + return clean; +} + +void ResonanceZones::sort_and_save_zones() +{ + if (!m_zones_config) { + return; + } + + auto zones = get_zones_from_config(); + + // Sort zones by min_speed + std::sort(zones.begin(), zones.end(), [](const ResonanceZone& a, const ResonanceZone& b) { + return a.min_speed < b.min_speed; + }); + + save_zones(zones); + + // Rebuild UI to show sorted order + rebuild_all_rows(); +} + +bool ResonanceZones::validate_zone(const ResonanceZone& zone, std::string& error_msg, const ResonanceZone& exclude) const +{ + if (!zone.is_valid(&error_msg)) { + return false; + } + // Overlap checks skipped per requirements; only exclude equality checks + if (zone == exclude) { + return true; + } + return true; +} + +bool ResonanceZones::check_overlap(const ResonanceZone& new_zone, const ResonanceZone& exclude) const +{ + if (!m_zones_config) { + return false; + } + auto zones = get_zones_from_config(); + for (const auto& existing : zones) { + if (existing == exclude) { + continue; + } + if (new_zone.overlaps_with(existing)) { + return true; + } + } + return false; +} + +const ResonanceZone* ResonanceZones::find_next_zone_after(double max_speed, const ZonesVec& zones) const +{ + const ResonanceZone* next_zone = nullptr; + double min_gap = std::numeric_limits::max(); + + for (const auto& zone : zones) { + // Use >= to catch adjacent zones (e.g., 85-90 followed by 90-100) + if (zone.min_speed >= max_speed) { + double gap = zone.min_speed - max_speed; + if (gap < min_gap) { + min_gap = gap; + next_zone = &zone; + } + } + } + return next_zone; +} + +void ResonanceZones::calculate_zone_from_gap(double gap, double& buffer, double& width) const +{ + if (gap >= 20) { + buffer = 10; + width = 10; + } else if (gap >= 10) { + buffer = 5; + width = 5; + } else if (gap >= 5) { + buffer = 2; + width = 3; + } else if (gap >= 3) { + buffer = 1; + width = 2; + } else { + buffer = 0; + width = 0; + } +} + +void ResonanceZones::add_zone_after(size_t zone_index) +{ + if (!m_zones_config || !is_valid_zone_index(zone_index)) { + return; + } + + auto zones = get_zones_from_config(); + + if (MAX_RESONANCE_ZONES > 0 && zones.size() >= MAX_RESONANCE_ZONES) { + wxMessageBox(wxString::Format(_L("Maximum %d zones allowed"), MAX_RESONANCE_ZONES), + _L("Cannot add zone"), wxOK | wxICON_WARNING); + return; + } + + const ResonanceZone& current_zone = zones[zone_index]; + + // Find next zone after current + auto next_zone = find_next_zone_after(current_zone.max_speed, zones); + + double buffer, width; + + if (next_zone != nullptr) { + // Calculate gap to next zone + double gap = next_zone->min_speed - current_zone.max_speed; + + // Get buffer and width based on gap + calculate_zone_from_gap(gap, buffer, width); + + if (buffer == 0 || width == 0) { + wxMessageBox( + wxString::Format(_L("Not enough space between zones to add a new zone.\nAvailable gap: %.1f mm/s"), gap), + _L("Cannot add zone"), wxOK | wxICON_WARNING); + return; + } + } else { + // Last zone - use default spacing + buffer = 10; + width = 10; + } + + // Create new zone + double new_min = current_zone.max_speed + buffer; + double new_max = new_min + width; + ResonanceZone new_zone(new_min, new_max); + + // Insert after current zone (using index, not search) + zones.insert(zones.begin() + zone_index + 1, new_zone); + + // Don't sort here - only sort on load/save to avoid rows jumping during editing + save_zones(zones); + + // Set flag to highlight the newly added zone after rebuild + m_pending_highlight_zone = new_zone; + m_pending_autoscroll = true; + + if (m_on_change_callback) { + m_on_change_callback(); + } + + // Defer rebuild to avoid destroying widgets during button click event + defer_rebuild(); +} + +void ResonanceZones::del_zone(const ResonanceZone& zone) +{ + if (!m_zones_config) { + return; + } + + auto zones = get_zones_from_config(); + + if (zones.size() <= 1) { + // Last zone - clear config and notify + m_zones_config->values.clear(); + + // Defer UI changes to avoid deleting recently-clicked button + CallAfter([this]() { + Hide(); + if (m_on_empty_callback) { + m_on_empty_callback(); + } + }); + return; + } + + // Remove zone and rebuild + ZonesVec new_zones = zones; + auto it = std::find(new_zones.begin(), new_zones.end(), zone); + if (it != new_zones.end()) { + new_zones.erase(it); + } + save_zones(new_zones); + + if (m_on_change_callback) { + m_on_change_callback(); + } + + // Defer rebuild to avoid destroying widgets during button click event + defer_rebuild(); +} + +bool ResonanceZones::edit_zone(const ResonanceZone& old_zone, const ResonanceZone& new_zone) +{ + if (!m_zones_config) { + return false; + } + + // Allow any edits - validation happens only on save + auto zones = get_zones_from_config(); + auto it = std::find(zones.begin(), zones.end(), old_zone); + if (it != zones.end()) { + *it = new_zone; + } + save_zones(zones); + + if (m_on_change_callback) { + m_on_change_callback(); + } + + // Update values directly - no CallAfter needed since we're not destroying widgets + update_zone_values(); + + return true; +} + +std::vector ResonanceZones::validate_all_zones() const +{ + const double EPSILON = 0.0001; // Floating point comparison tolerance + std::vector errors; + auto zones = get_zones_from_config(); + + for (size_t i = 0; i < zones.size(); ++i) { + const auto& zone = zones[i]; + + // Check 1: Invalid range (min >= max with epsilon tolerance) + if (zone.max_speed - zone.min_speed < EPSILON) { + errors.push_back({i, "Min speed must be less than max speed"}); + } + + // Check 2: Negative/zero values + if (zone.min_speed <= 0) { + errors.push_back({i, "Min speed must be positive"}); + } + if (zone.max_speed <= 0) { + errors.push_back({i, "Max speed must be positive"}); + } + + // Check 3: Duplicates and overlaps + for (size_t j = i + 1; j < zones.size(); ++j) { + const auto& other = zones[j]; + + // Duplicate check + if (std::abs(zone.min_speed - other.min_speed) < EPSILON && + std::abs(zone.max_speed - other.max_speed) < EPSILON) { + errors.push_back({i, wxString::Format("Duplicate of row %zu", j + 1).ToStdString()}); + } + // Overlap check (strict - no overlaps allowed) + else if (zone.overlaps_with(other)) { + errors.push_back({i, wxString::Format("Overlaps with row %zu", j + 1).ToStdString()}); + } + } + } + + return errors; +} + +void ResonanceZones::update_parent_layout() +{ + if (!GetParent()) { + return; + } + + auto desired_size = GetBestSize(); + GetParent()->Layout(); + + // Force size back if parent Layout() shrunk us below BestSize + if (GetSize().GetHeight() < desired_size.GetHeight()) { + SetSize(desired_size); + InvalidateBestSize(); + } + + // Force parent to re-layout with our correct size + if (GetParent()->GetParent()) { + GetParent()->GetParent()->Layout(); + } + + GetParent()->Refresh(); +} + +void ResonanceZones::perform_autoscroll_to_bottom() +{ + auto* scroll_win = dynamic_cast(GetParent()); + if (!scroll_win) { + return; + } + + int unit_x, unit_y; + scroll_win->GetScrollPixelsPerUnit(&unit_x, &unit_y); + + if (unit_y > 0) { + // Get the virtual size (total scrollable content) + wxSize virtual_size = scroll_win->GetVirtualSize(); + wxSize client_size = scroll_win->GetClientSize(); + + // Calculate scroll position to show the bottom of content + // Add a small margin to ensure the last row is fully visible + int max_scroll_y = (virtual_size.GetHeight() - client_size.GetHeight() + unit_y) / unit_y; + + // Scroll to bottom to show newly added content + if (max_scroll_y > 0) { + scroll_win->Scroll(-1, max_scroll_y); + } + } +} + +void ResonanceZones::highlight_zone_row(size_t zone_index) +{ + if (!is_valid_zone_index(zone_index)) { + return; + } + + auto& row = m_zone_rows[zone_index]; + + // Focus on the min editor so user can type immediately + if (row.min_editor && row.min_editor->GetTextCtrl()) { + row.min_editor->GetTextCtrl()->SetFocus(); + row.min_editor->GetTextCtrl()->SelectAll(); + } +} + +void ResonanceZones::set_extruder(int extruder_idx, ConfigOptionFloats* zones, bool visible) +{ + m_extruder_idx = extruder_idx; + m_zones_config = zones; + + // Verify lifetime contract: zones pointer must be valid when widget is visible + assert(!visible || zones != nullptr); + + if (visible) { + // Only create a default zone if config is empty and we're becoming visible + if (m_zones_config && m_zones_config->values.empty()) { + m_zones_config->values = {DEFAULT_INITIAL_MIN_SPEED, DEFAULT_INITIAL_MAX_SPEED}; + } + + auto config_zones = get_zones_from_config(); + + // Smart logic: check if zone count changed or if we're initializing + if (config_zones.size() == m_zone_rows.size() && m_zone_rows.size() > 0) { + // Same count and widgets exist - update values directly + update_zone_values(); + + // Show and layout after updating values + Show(); + Layout(); + if (GetParent()) { + GetParent()->Layout(); + GetParent()->Refresh(); + } + } else { + // Count changed or initializing - rebuild required + if (!m_reload_pending) { + m_reload_pending = true; + + // Always defer rebuild to avoid multiple rebuilds from rapid calls + CallAfter([this]() { + m_reload_pending = false; + + rebuild_all_rows(); + Show(); + Layout(); + + update_parent_layout(); + + // Highlight newly added zone if pending + if (m_pending_highlight_zone.has_value()) { + // Find the zone in the rebuilt rows + auto zones = get_zones_from_config(); + for (size_t i = 0; i < zones.size(); ++i) { + if (std::abs(zones[i].min_speed - m_pending_highlight_zone->min_speed) < 0.01 && + std::abs(zones[i].max_speed - m_pending_highlight_zone->max_speed) < 0.01) { + highlight_zone_row(i); + break; + } + } + m_pending_highlight_zone.reset(); + } + + // Only auto-scroll if user explicitly added a zone (clicked +) + if (m_pending_autoscroll) { + m_pending_autoscroll = false; + perform_autoscroll_to_bottom(); + } + }); + } + } + } else { + // Clear zones when disabled + if (m_zones_config) { + m_zones_config->values.clear(); + } + clear_ui(); + + // Reset size so no white space is allocated + SetMinSize(wxSize(-1, -1)); + SetSize(wxSize(-1, 0)); + InvalidateBestSize(); + + Hide(); + if (GetParent()) { + GetParent()->Layout(); + GetParent()->Refresh(); + } + } +} + +wxSize ResonanceZones::DoGetBestSize() const +{ + // Calculate size based on grid sizer's minimum size + if (m_grid_sizer && m_zone_rows.size() > 0) { + wxSize grid_size = m_grid_sizer->GetMinSize(); + return grid_size; + } + + // Fallback to default + return wxPanel::DoGetBestSize(); +} + +void ResonanceZones::msw_rescale() +{ + m_bmp_delete.msw_rescale(); + m_bmp_add.msw_rescale(); +} + +void ResonanceZones::sys_color_changed() +{ + // Recreate bitmaps with dark mode variants if needed + const std::string delete_icon = wxGetApp().dark_mode() ? "delete_filament_dark" : "delete_filament"; + const std::string add_icon = wxGetApp().dark_mode() ? "add_filament_dark" : "add_filament"; + m_bmp_delete = ScalableBitmap(m_parent, delete_icon); + m_bmp_add = ScalableBitmap(m_parent, add_icon); + + reload_from_config(); // Rebuild UI with updated bitmaps for dark mode +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/GUI_ResonanceZones.hpp b/src/slic3r/GUI/GUI_ResonanceZones.hpp new file mode 100644 index 0000000000..1305fb9b01 --- /dev/null +++ b/src/slic3r/GUI/GUI_ResonanceZones.hpp @@ -0,0 +1,166 @@ +#ifndef slic3r_GUI_ResonanceZones_hpp_ +#define slic3r_GUI_ResonanceZones_hpp_ + +#include "wxExtensions.hpp" +#include "../libslic3r/PrintConfig.hpp" +#include "Widgets/TextInput.hpp" +#include +#include + +class wxBoxSizer; +class wxFlexGridSizer; + +namespace Slic3r { +namespace GUI { + +// Maximum number of resonance zones per extruder (0 = unlimited) +#define MAX_RESONANCE_ZONES 10 + +// ResonanceZone is defined in PrintConfig.hpp +using t_speed_range = ResonanceZone; + +class ResonanceZones; + +class SpeedRangeEditor : public ::TextInput +{ + bool m_enter_pressed{false}; + wxString m_valid_value; + bool m_is_min{true}; + +public: + SpeedRangeEditor(ResonanceZones* parent, + const wxString& value, + bool is_min, + std::function edit_fn); + ~SpeedRangeEditor() {} + + void msw_rescale(); + +private: + double get_value() const; +}; + +class ResonanceZones : public wxPanel +{ +public: + using ZonesVec = std::vector; + + struct ZoneValidationError { + size_t zone_index; + std::string error_message; + }; + + explicit ResonanceZones(wxWindow* parent); + ~ResonanceZones() {} + + // Parent wiring + void set_on_change(std::function cb); + void set_on_empty(std::function cb); + void set_extruder(int extruder_idx, ConfigOptionFloats* zones, bool visible); + void reload(); + + bool has_zones() const; + + // Operations + void add_zone_after(size_t zone_index); + void del_zone(const ResonanceZone& zone); + bool edit_zone(const ResonanceZone& old_zone, const ResonanceZone& new_zone); + + // Validation + std::vector validate_all_zones() const; + + // Sorting + void sort_and_save_zones(); // Sort zones and save (used on load/save preset) + +private: + // Forward declare button class for use in ZoneRow struct + class PlusMinusButton; + + // Widget tracking - each row contains widgets for one zone + struct ZoneRow { + ResonanceZone zone; // The data this row represents + SpeedRangeEditor* min_editor; // Min speed input + SpeedRangeEditor* max_editor; // Max speed input + PlusMinusButton* del_button; // Delete button + PlusMinusButton* add_button; // Add button + wxBoxSizer* row_sizer; // The horizontal sizer for the row + wxSizer* button_sizer; // The sizer for buttons + + ZoneRow() : min_editor(nullptr), max_editor(nullptr), + del_button(nullptr), add_button(nullptr), + row_sizer(nullptr), button_sizer(nullptr) {} + }; + + // Default initial zone range when resonance avoidance is first enabled + static constexpr double DEFAULT_INITIAL_MIN_SPEED = 100.0; // mm/s + static constexpr double DEFAULT_INITIAL_MAX_SPEED = 130.0; // mm/s + + wxWindow* m_parent{nullptr}; + ScalableBitmap m_bmp_delete; + ScalableBitmap m_bmp_add; + wxBoxSizer* m_main_sizer{nullptr}; + wxFlexGridSizer* m_grid_sizer{nullptr}; + // LIFETIME: Points to data owned by Tab::m_config, must outlive this widget. + // Parent Tab is responsible for ensuring config lifetime exceeds widget lifetime. + ConfigOptionFloats* m_zones_config{nullptr}; + int m_extruder_idx{0}; + std::atomic m_reload_pending{false}; + bool m_pending_autoscroll{false}; // True when user clicks + to add zone + std::optional m_pending_highlight_zone; // Zone to highlight after rebuild + std::vector m_zone_rows; // Track all current rows + + std::function m_on_change_callback; + std::function m_on_empty_callback; + + // UI build + ZoneRow create_zone_row_tracked(const ResonanceZone& range, size_t zone_index, size_t total_zones); + std::function create_min_editor_callback(size_t zone_index); + std::function create_max_editor_callback(size_t zone_index); + void configure_button(PlusMinusButton* button, const wxString& tooltip, bool enable); + void update_zone_values(); + void rebuild_all_rows(); + void defer_rebuild(); // Helper to queue rebuild if not already pending + void update_parent_layout(); // Handle parent layout with size correction + void perform_autoscroll_to_bottom(); // Scroll to show newly added zone + void highlight_zone_row(size_t zone_index); // Highlight and focus newly added zone + void clear_ui(); + void reload_from_config(); + + // Data helpers + ZonesVec get_zones() const; + void save_zones(const ZonesVec& zones); + std::vector get_zones_from_config() const; + bool is_valid_zone_index(size_t idx) const { return idx < m_zone_rows.size(); } + ZonesVec sanitize_zones(const ZonesVec& zones) const; // Remove invalid/overlapping/duplicate zones + + // Error handling pattern: + // - Internal validation helpers return bool + error string for testing/reuse + // - Public operations (add_zone_after, del_zone) show wxMessageBox directly for immediate user feedback + bool validate_zone(const ResonanceZone& zone, std::string& error_msg, const ResonanceZone& exclude = ResonanceZone()) const; + bool check_overlap(const ResonanceZone& new_zone, const ResonanceZone& exclude = ResonanceZone()) const; + + // Progressive gap-filling helpers + const ResonanceZone* find_next_zone_after(double max_speed, const ZonesVec& zones) const; + void calculate_zone_from_gap(double gap, double& buffer, double& width) const; + + // Button that remembers the speed range for which it was created + class PlusMinusButton : public ScalableButton + { + public: + PlusMinusButton(wxWindow* parent, const ScalableBitmap& bitmap, t_speed_range range) + : ScalableButton(parent, wxID_ANY, bitmap), range(range) {} + t_speed_range range; + }; + +protected: + // Override to always return correct size based on content + virtual wxSize DoGetBestSize() const wxOVERRIDE; + +public: + void msw_rescale(); + void sys_color_changed(); +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_ResonanceZones_hpp_ diff --git a/src/slic3r/GUI/OG_CustomCtrl.cpp b/src/slic3r/GUI/OG_CustomCtrl.cpp index 2a623e7235..f085159438 100644 --- a/src/slic3r/GUI/OG_CustomCtrl.cpp +++ b/src/slic3r/GUI/OG_CustomCtrl.cpp @@ -690,8 +690,13 @@ void OG_CustomCtrl::CtrlLine::update_visibility(ConfigOptionMode mode) return; const std::vector