, which supplies the fixed face. does both but adds a blank line above it.
+class CodeExcerptTagHandler : public wxHtmlWinTagHandler
+{
+public:
+ wxString GetSupportedTags() override { return wxT("EXCERPT"); }
+ bool HandleTag(const wxHtmlTag &tag) override
+ {
+ const wxHtmlWinParser::WhitespaceMode ws = m_WParser->GetWhitespaceMode();
+ m_WParser->SetWhitespaceMode(wxHtmlWinParser::Whitespace_Pre);
+ ParseInner(tag);
+ m_WParser->SetWhitespaceMode(ws);
+ return true;
+ }
+};
+
+// Render the message as HTML, monospacing only the code excerpts.
+static std::string format_parser_error_html(const std::string &msg)
+{
+ std::string out;
+ for (const auto &[text, is_code] : classify_code_lines(msg)) {
+ if (!out.empty()) out += "
"; // join, not trail; a trailing
forces a scrollbar
+ std::string escaped = xml_escape(text);
+ if (is_code)
+ out += "" + escaped + "";
+ else
+ out += escaped;
+ }
+ return out;
+}
+
+// Measure each line in the font it will render in, so the dialog fits the longest line without slack.
+static wxSize measure_mixed_text(wxWindow *parent, const std::string &msg, const wxFont &prose_font, const wxFont &code_font)
+{
+ wxClientDC dc(parent);
+ int width = 0, height = 0;
+ for (const auto &[text, is_code] : classify_code_lines(msg)) {
+ dc.SetFont(is_code ? code_font : prose_font);
+ width = std::max(width, dc.GetTextExtent(wxString::FromUTF8(text.c_str())).GetWidth());
+ height += dc.GetCharHeight();
+ }
+ return wxSize(width, height);
+}
+
// Text shown as HTML, so that mouse selection and Ctrl-V to copy will work.
static void add_msg_content(wxWindow *parent,
wxBoxSizer *content_sizer,
wxString msg,
- bool monospaced_font = false,
- bool is_marked_msg = false,
+ bool has_code_excerpts = false,
+ bool is_marked_msg = false,
const wxString &link_text = "",
std::function link_callback = nullptr)
{
@@ -243,7 +318,7 @@ static void add_msg_content(wxWindow *parent,
// count lines in the message
int msg_lines = 0;
- if (!monospaced_font) {
+ if (!has_code_excerpts) {
int line_len = 55;// count of symbols in one line
int start_line = 0;
for (auto i = msg.begin(); i != msg.end(); ++i) {
@@ -300,13 +375,23 @@ static void add_msg_content(wxWindow *parent,
page_size = wxSize(info_width, page_height);
}
else {
- wxClientDC dc(parent);
- dc.SetFont(font); // ORCA without this it calculates bigger size
- wxSize msg_sz = dc.GetMultiLineTextExtent(msg) + parent->FromDIP(wxSize(10,5)); // added extra spacing to prevent wrapping
+ wxSize msg_sz;
+ if (has_code_excerpts) {
+ msg_sz = measure_mixed_text(parent, msg.ToUTF8().data(), font, monospace);
+ } else {
+ wxClientDC dc(parent);
+ dc.SetFont(font); // ORCA without this it calculates bigger size
+ msg_sz = dc.GetMultiLineTextExtent(msg);
+ }
+ msg_sz += parent->FromDIP(wxSize(10,5)); // added extra spacing to prevent wrapping
- page_size = wxSize(std::min(msg_sz.GetX(), info_width), std::min(msg_sz.GetY(), info_width));
+ int page_height = msg_sz.GetY();
+ // Reserve the horizontal scrollbar's height, or it clips the last line.
+ if (msg_sz.GetX() > info_width)
+ page_height += wxSystemSettings::GetMetric(wxSYS_HSCROLL_Y, parent);
+ page_size = wxSize(std::min(msg_sz.GetX(), info_width), std::min(page_height, info_width));
// Extra line breaks in message dialog
- if (link_text.IsEmpty() && !link_callback && is_marked_msg == false) {//for common text
+ if (link_text.IsEmpty() && !link_callback && is_marked_msg == false && !has_code_excerpts) {//for common text
html->Destroy();
if (msg_sz.GetX() < info_width) {//No need for line breaks
info_width = msg_sz.GetX();
@@ -337,12 +422,15 @@ static void add_msg_content(wxWindow *parent,
}
html->SetMinSize(page_size);
- std::string msg_escaped = xml_escape(msg.ToUTF8().data(), is_marked_msg);
- boost::replace_all(msg_escaped, "\r\n", "
");
- boost::replace_all(msg_escaped, "\n", "
");
- if (monospaced_font)
- // Code formatting will be preserved. This is useful for reporting errors from the placeholder parser.
- msg_escaped = std::string("") + msg_escaped + "
";
+ std::string msg_escaped;
+ if (has_code_excerpts) {
+ html->GetParser()->AddTagHandler(new CodeExcerptTagHandler());
+ msg_escaped = format_parser_error_html(msg.ToUTF8().data());
+ } else {
+ msg_escaped = xml_escape(msg.ToUTF8().data(), is_marked_msg);
+ boost::replace_all(msg_escaped, "\r\n", "
");
+ boost::replace_all(msg_escaped, "\n", "
");
+ }
if (!link_text.IsEmpty() && link_callback) {
msg_escaped += "" + std::string(link_text.ToUTF8().data()) + "";
@@ -360,15 +448,15 @@ static void add_msg_content(wxWindow *parent,
// ErrorDialog
-ErrorDialog::ErrorDialog(wxWindow *parent, const wxString &temp_msg, bool monospaced_font)
+ErrorDialog::ErrorDialog(wxWindow *parent, const wxString &temp_msg, bool has_code_excerpts)
: MsgDialog(parent, wxString::Format(_(L("%s error")), SLIC3R_APP_FULL_NAME),
wxString::Format(_(L("%s has encountered an error")), SLIC3R_APP_FULL_NAME), wxOK)
, msg(temp_msg)
{
- add_msg_content(this, content_sizer, msg, monospaced_font);
+ add_msg_content(this, content_sizer, msg, has_code_excerpts);
- // Use a small bitmap with monospaced font, as the error text will not be wrapped.
- logo->SetBitmap(create_scaled_bitmap("OrcaSlicer_192px_grayscale.png", this, monospaced_font ? 48 : /*1*/64));
+ // Use a small bitmap for code excerpts, which cannot wrap and so need the width.
+ logo->SetBitmap(create_scaled_bitmap("OrcaSlicer_192px_grayscale.png", this, has_code_excerpts ? 48 : /*1*/64));
SetMaxSize(MSG_DLG_MAX_SIZE);
diff --git a/src/slic3r/GUI/MsgDialog.hpp b/src/slic3r/GUI/MsgDialog.hpp
index 174d734336..90fd160310 100644
--- a/src/slic3r/GUI/MsgDialog.hpp
+++ b/src/slic3r/GUI/MsgDialog.hpp
@@ -106,9 +106,9 @@ protected:
class ErrorDialog : public MsgDialog
{
public:
- // If monospaced_font is true, the error message is displayed using html tags,
- // so that the code formatting will be preserved. This is useful for reporting errors from the placeholder parser.
- ErrorDialog(wxWindow *parent, const wxString &temp_msg, bool courier_font);
+ // If has_code_excerpts is true, code excerpts (a source line and the caret line below it) render
+ // monospaced so the caret aligns. Used for placeholder-parser errors.
+ ErrorDialog(wxWindow *parent, const wxString &temp_msg, bool has_code_excerpts);
ErrorDialog(ErrorDialog &&) = delete;
ErrorDialog(const ErrorDialog &) = delete;
ErrorDialog &operator=(ErrorDialog &&) = delete;
diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp
index 4f57c2577d..910c761c06 100644
--- a/src/slic3r/GUI/PartPlate.cpp
+++ b/src/slic3r/GUI/PartPlate.cpp
@@ -2262,6 +2262,12 @@ Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, con
}
double volume = wipe_volume * (extruder_count == 2 ? plate_extruder_size : (plate_extruder_size - 1));
if (extruder_count == 2) volume += filament_change_volume * (int) (plate_extruder_size / 2);
+ // Read from the passed plate config — m_print may not have been applied yet
+ // (fresh plates, CLI), in which case its PrintConfig still holds defaults.
+ const auto *purge_opt = config.option("purge_in_prime_tower");
+ const auto *semm_opt = config.option("single_extruder_multi_material");
+ const bool semm_flush = purge_opt && purge_opt->value && semm_opt && semm_opt->value;
+ if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(config, plate_extruder_size);
if (use_rib_wall) {
depth = std::sqrt(volume / layer_height * extra_spacing);
if (need_wipe_tower || plate_extruder_size > 1) {
@@ -2274,7 +2280,9 @@ Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, con
}
}
else {
- depth = volume/ (layer_height * w) *extra_spacing;
+ depth = volume / (layer_height * w);
+ // The flush volumes already hold the spacing between wipes.
+ if (!semm_flush) depth *= extra_spacing;
if (need_wipe_tower || depth > EPSILON) {
float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height);
depth = std::max((double)min_wipe_tower_depth, depth);
@@ -3337,6 +3345,11 @@ BoundingBoxf3 PartPlate::get_build_volume(bool use_share)
return plate_box;
}
+Polygon PartPlate::get_shared_printable_polygon() const
+{
+ return m_extruder_areas.empty() ? Polygon::new_scale(m_shape) : get_shared_poly(m_extruder_areas);
+}
+
bool PartPlate::contains(const Vec3d& point) const
{
return m_bounding_box.contains(point);
@@ -4375,22 +4388,21 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini
f_volume_maps = wxGetApp().preset_bundle->get_default_nozzle_volume_types_for_filaments(filament_maps);
}
DynamicPrintConfig full_config = wxGetApp().preset_bundle->full_config(false, filament_maps, f_volume_maps);
- const DynamicPrintConfig &print_cfg = wxGetApp().preset_bundle->prints.get_edited_preset().config;
- float w = dynamic_cast(print_cfg.option("prime_tower_width"))->value;
+ float w = dynamic_cast(full_config.option("prime_tower_width"))->value;
float v = dynamic_cast(full_config.option("prime_volume"))->value;
bool enable_wrapping = false;
const ConfigOptionBool *wrapping_opt = dynamic_cast(full_config.option("enable_wrapping_detection"));
if (wrapping_opt) enable_wrapping = wrapping_opt->value;
int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count();
- Vec3d wipe_tower_size = part_plate->estimate_wipe_tower_size(print_cfg, w, v, nozzle_nums, init_pos ? 2 : 0, false, enable_wrapping);
+ Vec3d wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, init_pos ? 2 : 0, false, enable_wrapping);
if (!init_pos && (is_approx(wipe_tower_size(0), 0.0) || is_approx(wipe_tower_size(1), 0.0))) {
- wipe_tower_size = part_plate->estimate_wipe_tower_size(print_cfg, w, v, nozzle_nums, 2, false, enable_wrapping);
+ wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 2, false, enable_wrapping);
}
// Compute brim-aware margin: brim extends outward from tower position
float brim_width = 0.f;
- const ConfigOptionFloat *brim_opt = print_cfg.option("prime_tower_brim_width");
+ const ConfigOptionFloat *brim_opt = full_config.option("prime_tower_brim_width");
if (brim_opt) {
brim_width = brim_opt->value;
if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z());
@@ -4412,6 +4424,18 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini
}
}
+ // The bounding box above still allows a corner a delta or hexagonal bed does not have, and the
+ // prime tower is validated against the real outline — pull it onto the bed before storing.
+ {
+ Polygons bed{part_plate->get_shared_printable_polygon()};
+ bed.front().translate(Point(-scaled(plate_origin.x()), -scaled(plate_origin.y()))); // into the frame x/y live in
+ const BoundingBox tower(Point::new_scale(x, y),
+ Point::new_scale(x + wipe_tower_size(0), y + wipe_tower_size(1)));
+ const Vec2f move = WipeTower::move_box_inside_polygon(tower, bed, scaled(margin));
+ x += move.x();
+ y += move.y();
+ }
+
ConfigOptionFloat wt_x_opt(x);
ConfigOptionFloat wt_y_opt(y);
dynamic_cast(proj_cfg.option("wipe_tower_x"))->set_at(&wt_x_opt, plate_idx, 0);
diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp
index 8f4055706f..47481dcad4 100644
--- a/src/slic3r/GUI/PartPlate.hpp
+++ b/src/slic3r/GUI/PartPlate.hpp
@@ -425,6 +425,9 @@ public:
const BoundingBox get_bounding_box_crd();
BoundingBoxf3 get_plate_box() {return get_build_volume();}
BoundingBoxf3 get_build_volume(bool use_share = false);
+ // Polygon counterpart of get_build_volume(true), in scaled world coordinates. The bounding box
+ // that one returns hides the corners a non-rectangular bed does not have.
+ Polygon get_shared_printable_polygon() const;
const std::vector& get_exclude_areas() { return m_exclude_bounding_box; }
diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp
index d83ddded34..5b06db9d3e 100644
--- a/src/slic3r/GUI/Plater.cpp
+++ b/src/slic3r/GUI/Plater.cpp
@@ -3246,7 +3246,8 @@ void Sidebar::update_all_preset_comboboxes()
auto p_mainframe = wxGetApp().mainframe;
auto cfg = preset_bundle.printers.get_edited_preset().config;
- const bool use_native_device_tab = preset_bundle.use_bbl_device_tab() || wxGetApp().app_config->get_bool("use_printer_agents");
+ const bool use_printer_agents = wxGetApp().app_config->get_bool("use_printer_agents");
+ const bool use_native_device_tab = preset_bundle.use_bbl_device_tab() || use_printer_agents;
if (preset_bundle.use_bbl_network()) {
//only show connection button for not-BBL printer
@@ -3259,7 +3260,7 @@ void Sidebar::update_all_preset_comboboxes()
} else {
//p->btn_connect_printer->Show();
// ORCA: hide the physical-printer connection button when printer agents are enabled
- p->m_printer_connect->Show(!wxGetApp().app_config->get_bool("use_printer_agents"));
+ p->m_printer_connect->Show(!use_printer_agents);
// ORCA: show/hide sync-ams button based on filament sync mode
auto agent = wxGetApp().getAgent();
@@ -3286,7 +3287,9 @@ void Sidebar::update_all_preset_comboboxes()
: MainFrame::PrintSelectType::eSendGcode;
}
- if (!use_native_device_tab)
+ if (use_printer_agents)
+ p_mainframe->load_printer_url();
+ else if (!use_native_device_tab)
p_mainframe->load_printer_url(url, apikey);
@@ -9500,7 +9503,7 @@ void Plater::priv::replace_all_with_stl()
return;
}
- std::string status = _L("Replaced with 3D files from directory:\n").ToStdString() + out_path.string() + "\n\n";
+ wxString status = _L("Replaced with 3D files from directory:\n") + from_u8(out_path.string()) + "\n\n";
for (unsigned int idx : volume_idxs) {
const GLVolume* v = selection.get_volume(idx);
@@ -9520,13 +9523,13 @@ void Plater::priv::replace_all_with_stl()
std::string volume_name = volume->name;
if (new_path == input_path) {
- status += boost::str(boost::format(_L("✖ Skipped %1%: same file.\n").ToStdString()) % volume_name);
+ status += wxString::Format(_L("✖ Skipped %s: same file.\n"), from_u8(volume_name));
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " skipping replace volume : same filename " << new_path;
continue;
}
if (!fs::exists(new_path)) {
- status += boost::str(boost::format(_L("✖ Skipped %1%: file does not exist.\n").ToStdString()) % volume_name);
+ status += wxString::Format(_L("✖ Skipped %s: file does not exist.\n"), from_u8(volume_name));
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " cannot replace volume : filen does not exist " << new_path;
continue;
}
@@ -9534,12 +9537,12 @@ void Plater::priv::replace_all_with_stl()
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " replacing volume : " << input_path << " with " << new_path;
if (!replace_volume_with_stl(object_idx, volume_idx, new_path, _u8L("Replace with 3D file"))) {
- status += boost::str(boost::format(_L("✖ Skipped %1%: failed to replace.\n").ToStdString()) % volume_name);
+ status += wxString::Format(_L("✖ Skipped %s: failed to replace.\n"), from_u8(volume_name));
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " cannot replace volume : failed to replace with " << new_path;
continue;
}
- status += boost::str(boost::format(_L("✔ Replaced %1%.\n").ToStdString()) % volume_name);
+ status += wxString::Format(_L("✔ Replaced %s.\n"), from_u8(volume_name));
}
// update 3D scene
@@ -11235,9 +11238,14 @@ void Plater::priv::on_tab_selection_changing(wxBookCtrlEvent& e)
}
}
} else {
- if (new_sel == MainFrame::tpMonitor && wxGetApp().preset_bundle != nullptr) {
+ const bool selecting_web_device_tab = main_frame->m_printer_view &&
+ main_frame->m_tabpanel->GetPage(new_sel) == main_frame->m_printer_view;
+ if (selecting_web_device_tab) {
+ // Use the selected discovered machine when the preset has no host.
+ main_frame->load_printer_url();
+ } else if (new_sel == MainFrame::tpMonitor && wxGetApp().preset_bundle != nullptr) {
auto cfg = wxGetApp().preset_bundle->printers.get_edited_preset().config;
- wxString url = cfg.opt_string("print_host_webui").empty() ? cfg.opt_string("print_host") : cfg.opt_string("print_host_webui");
+ wxString url = from_u8(PrintHost::get_print_host_webui(&cfg));
if (main_frame->m_printer_view && url.empty()) {
// It's missing_connection page, reload so that we can replay the gif image
main_frame->m_printer_view->reload();
diff --git a/src/slic3r/GUI/ReleaseNote.cpp b/src/slic3r/GUI/ReleaseNote.cpp
index 874276aabf..7b2d091176 100644
--- a/src/slic3r/GUI/ReleaseNote.cpp
+++ b/src/slic3r/GUI/ReleaseNote.cpp
@@ -2055,6 +2055,11 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt)
{
auto str_ip = m_input_ip->GetTextCtrl()->GetValue();
auto str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
+
+ if (str_access_code.empty()) {
+ str_access_code = "88888888";
+ }
+
auto str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both);
auto str_sn = m_input_sn->GetTextCtrl()->GetValue().Strip(wxString::both);
bool invalid_access_code = true;
@@ -2062,7 +2067,7 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt)
for (char c : str_access_code) {
if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) {
invalid_access_code = false;
- return;
+ break;
}
}
diff --git a/src/slic3r/GUI/SavePresetDialog.cpp b/src/slic3r/GUI/SavePresetDialog.cpp
index 52bcbed4de..0b33e48ec4 100644
--- a/src/slic3r/GUI/SavePresetDialog.cpp
+++ b/src/slic3r/GUI/SavePresetDialog.cpp
@@ -112,12 +112,56 @@ SavePresetDialog::Item::Item(Preset::Type type, const std::string &suffix, wxBox
sizer->Add(m_radio_group, 0, wxEXPAND | wxTOP | wxLEFT, BORDER_W);
if (parent->m_mode == comDevelop) {
- m_detach_checkbox = new wxCheckBox(parent, wxID_ANY, _L("Detach from parent"));
- sizer->Add(m_detach_checkbox, 0, wxALIGN_LEFT | wxALL, BORDER_W);
- // Set initial state (unchecked by default)
- m_detach_checkbox->SetValue(m_detach);
- // Bind the checkbox event to update the detach state for this item
- m_detach_checkbox->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { m_detach = m_detach_checkbox->GetValue(); });
+ // A new user copy of a system preset inherits from the selected system preset.
+ const std::string parent_name = sel_preset.is_system ? sel_preset.name : sel_preset.inherits();
+ const bool can_detach = !parent_name.empty();
+
+ wxBoxSizer *detach_sizer = new wxBoxSizer(wxHORIZONTAL);
+
+ auto detach_tooltip = _L("Copies all inherited values from the parent into this preset and removes the parent relationship. Presets compatible only with the parent may become unsupported.");
+
+ auto detach_checkbox = new ::CheckBox(parent);
+ detach_checkbox->SetToolTip(detach_tooltip);
+
+ auto detach_label = new wxStaticText(parent, wxID_ANY, _L("Detach from parent"));
+ detach_label->SetFont(::Label::Body_14);
+ detach_label->SetToolTip(detach_tooltip);
+
+ detach_sizer->Add(detach_checkbox, 0, wxALIGN_LEFT | wxLEFT, BORDER_W);
+ detach_sizer->Add(detach_label , 0, wxALIGN_CENTRE_VERTICAL | wxLEFT, FromDIP(5));
+ sizer->Add(detach_sizer, 0, wxEXPAND | wxTOP, BORDER_W);
+ sizer->AddSpacer(FromDIP(5));
+
+ const wxString parent_text = can_detach ? from_u8(parent_name) : _L("Unique preset");
+ auto parent_label = new wxStaticText(parent, wxID_ANY, parent_text);
+ parent_label->SetFont(::Label::Body_12);
+ parent_label->SetForegroundColour(wxColour("#6B6B6B"));
+ parent_label->SetToolTip(can_detach ? _L("Parent preset") : _L("This preset does not inherit from another preset."));
+ sizer->Add(parent_label, 0, wxEXPAND | wxLEFT, BORDER_W + FromDIP(24));
+
+ sizer->AddSpacer(FromDIP(5));
+
+ if (!can_detach) {
+ detach_checkbox->Disable();
+ detach_label->SetForegroundColour(wxColour("#6B6B6B"));
+ }
+ else {
+ // Set initial state (unchecked by default)
+ detach_checkbox->SetValue(m_detach);
+ // Bind the checkbox event to update the detach state for this item
+ detach_checkbox->Bind(wxEVT_TOGGLEBUTTON, [this, detach_checkbox](wxCommandEvent&) { m_detach = detach_checkbox->GetValue(); });
+
+ detach_label->SetForegroundColour(wxColour("#363636"));
+
+ auto on_toggle = [this, detach_checkbox]() {
+ detach_checkbox->SetValue(!detach_checkbox->GetValue());
+ wxCommandEvent ev(wxEVT_TOGGLEBUTTON, detach_checkbox->GetId());
+ ev.SetEventObject(detach_checkbox);
+ detach_checkbox->GetEventHandler()->ProcessEvent(ev);
+ };
+ detach_label->Bind(wxEVT_LEFT_DOWN, [on_toggle](wxMouseEvent& e) {if(!e.LeftDClick()) on_toggle();});
+ detach_label->Bind(wxEVT_LEFT_DCLICK, [on_toggle](wxMouseEvent& e) {on_toggle();});
+ }
}
m_radio_group->Bind(wxEVT_COMMAND_RADIOBOX_SELECTED, [this](wxCommandEvent &e) {
diff --git a/src/slic3r/GUI/SavePresetDialog.hpp b/src/slic3r/GUI/SavePresetDialog.hpp
index 0b71325927..05aa1b2d39 100644
--- a/src/slic3r/GUI/SavePresetDialog.hpp
+++ b/src/slic3r/GUI/SavePresetDialog.hpp
@@ -75,7 +75,6 @@ class SavePresetDialog : public DPIDialog
bool m_save_to_project {false};
RadioGroup* m_radio_group; // ORCA
bool m_detach{false};
- wxCheckBox* m_detach_checkbox{nullptr};
void update();
};
diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp
index 94754ee591..0159e10350 100644
--- a/src/slic3r/GUI/SelectMachine.cpp
+++ b/src/slic3r/GUI/SelectMachine.cpp
@@ -3629,7 +3629,7 @@ void SelectMachineDialog::on_send_print()
BOOST_LOG_TRIVIAL(error) << "build_nozzle_info errors";
}
- m_print_job->sdcard_state = obj_->GetStorage()->get_sdcard_state();
+ m_print_job->sdcard_state = obj_->GetStorage()->get_sdcard_state();
m_print_job->has_sdcard = wxGetApp().app_config->get("allow_abnormal_storage") == "true"
? (m_print_job->sdcard_state == DevStorage::SdcardState::HAS_SDCARD_NORMAL
|| m_print_job->sdcard_state == DevStorage::SdcardState::HAS_SDCARD_ABNORMAL)
@@ -3890,12 +3890,11 @@ _compare_obj_names(MachineObject* obj1, MachineObject* obj2)
}
/*******************************************************************
-*@note _collect_machine_list
-*@param dev_manager -- the device manager
-*@param sorted_machine_objs -- return the sorted machine objects
-*@param best_one -- return the best one
-*/
-/*******************************************************************/
+* @note _collect_machine_list
+* @param dev_manager -- the device manager
+* @param sorted_machine_objs -- return the sorted machine objects
+* @param best_one -- return the best one
+*******************************************************************/
static void
_collect_sorted_machines(Slic3r::DeviceManager* dev_manager,
std::vector& sorted_machine_objs)
diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp
index 5d6a545873..b6d7abde21 100644
--- a/src/slic3r/GUI/Selection.cpp
+++ b/src/slic3r/GUI/Selection.cpp
@@ -1270,9 +1270,7 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor
} else {
if (v.is_wipe_tower) {//in world cs
int plate_idx = v.object_idx() - 1000;
- BoundingBoxf3 plate_bbox = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->get_build_volume(true);
- BoundingBox plate_bbox2d = BoundingBox(scaled(Vec2f(plate_bbox.min[0], plate_bbox.min[1])), scaled(Vec2f(plate_bbox.max[0], plate_bbox.max[1])));
- Vec3d tower_size = v.bounding_box().size();
+ const Polygons bed_polys{wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->get_shared_printable_polygon()};
Vec3d tower_origin = m_cache.volumes_data[i].get_volume_position();
Vec3d actual_displacement = displacement;
bool show_read_wipe_tower = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->fff_print()->is_step_done(psWipeTower);
@@ -1287,18 +1285,7 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor
BoundingBoxf3 tower_bbox = v.bounding_box();
tower_bbox.translate(actual_displacement + tower_origin);
BoundingBox tower_bbox2d = BoundingBox(scaled(Vec2f(tower_bbox.min[0], tower_bbox.min[1])), scaled(Vec2f(tower_bbox.max[0], tower_bbox.max[1])));
- Vec2f offset = WipeTower::move_box_inside_box(tower_bbox2d, plate_bbox2d,scaled(margin));
- //if (tower_origin(0) + actual_displacement(0) - margin < plate_bbox.min(0)) {
- // actual_displacement(0) = plate_bbox.min(0) - tower_origin(0) + margin;
- //} else if (tower_origin(0) + actual_displacement(0) + tower_size(0) + margin > plate_bbox.max(0)) {
- // actual_displacement(0) = plate_bbox.max(0) - tower_origin(0) - tower_size(0) - margin;
- //}
-
- //if (tower_origin(1) + actual_displacement(1) - margin < plate_bbox.min(1)) {
- // actual_displacement(1) = plate_bbox.min(1) - tower_origin(1) + margin;
- //} else if (tower_origin(1) + actual_displacement(1) + tower_size(1) + margin > plate_bbox.max(1)) {
- // actual_displacement(1) = plate_bbox.max(1) - tower_origin(1) - tower_size(1) - margin;
- //}
+ const Vec2f offset = WipeTower::move_box_inside_polygon(tower_bbox2d, bed_polys, scaled(margin));
actual_displacement += Vec3d(offset[0], offset[1],0);
v.set_volume_offset(m_cache.volumes_data[i].get_volume_position() + actual_displacement);
}
diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp
index bade7fee98..1a31355d0e 100644
--- a/src/slic3r/GUI/Tab.cpp
+++ b/src/slic3r/GUI/Tab.cpp
@@ -2136,43 +2136,9 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value)
m_last_sparse_infill_rotate_template_value = m_config->opt_string("sparse_infill_rotate_template");
}
- if(opt_key=="layer_height"){
- auto min_layer_height_from_nozzle=m_preset_bundle->full_config().option("min_layer_height")->values;
- auto max_layer_height_from_nozzle=m_preset_bundle->full_config().option("max_layer_height")->values;
- auto layer_height_floor = *std::min_element(min_layer_height_from_nozzle.begin(), min_layer_height_from_nozzle.end());
- auto layer_height_ceil = *std::max_element(max_layer_height_from_nozzle.begin(), max_layer_height_from_nozzle.end());
- const auto lh = m_config->opt_float("layer_height");
- bool exceed_minimum_flag = lh < layer_height_floor;
- bool exceed_maximum_flag = lh > layer_height_ceil;
-
- if (exceed_maximum_flag || exceed_minimum_flag) {
- if (lh < EPSILON) {
- auto msg_text = _(L("Layer height is too small.\nIt will set to min_layer_height\n"));
- MessageDialog dialog(wxGetApp().plater(), msg_text, "", wxICON_WARNING | wxOK);
- dialog.SetButtonLabel(wxID_OK, _L("OK"));
- dialog.ShowModal();
- auto new_conf = *m_config;
- new_conf.set_key_value("layer_height", new ConfigOptionFloat(layer_height_floor));
- m_config_manipulation.apply(m_config, &new_conf);
- } else {
- wxString msg_text = _(L("Layer height exceeds the limit in Printer Settings -> Extruder -> Layer height limits, "
- "this may cause printing quality issues."));
- msg_text += "\n\n" + _(L("Adjust to the set range automatically?\n"));
- MessageDialog dialog(wxGetApp().plater(), msg_text, "", wxICON_WARNING | wxYES | wxNO);
- dialog.SetButtonLabel(wxID_YES, _L("Adjust"));
- dialog.SetButtonLabel(wxID_NO, _L("Ignore"));
- auto answer = dialog.ShowModal();
- auto new_conf = *m_config;
- if (answer == wxID_YES) {
- if (exceed_maximum_flag)
- new_conf.set_key_value("layer_height", new ConfigOptionFloat(layer_height_ceil));
- if (exceed_minimum_flag)
- new_conf.set_key_value("layer_height", new ConfigOptionFloat(layer_height_floor));
- m_config_manipulation.apply(m_config, &new_conf);
- }
- }
+ if (opt_key == "layer_height") {
+ if (m_config_manipulation.check_layer_height(m_config))
wxGetApp().plater()->update();
- }
}
string opt_key_without_idx = opt_key.substr(0, opt_key.find('#'));
@@ -3078,7 +3044,7 @@ void TabPrint::build()
optgroup->append_single_option_line("combine_brims", "others_settings_brim#combine-brims");
optgroup->append_single_option_line("brim_ears_max_angle", "others_settings_brim#ear-max-angle");
optgroup->append_single_option_line("brim_ears_detection_length", "others_settings_brim#ear-detection-radius");
- optgroup->append_single_option_line("brim_ears_outer_only");
+ optgroup->append_single_option_line("brim_ears_outer_only", "others_settings_brim#brim-ears-outer-only");
optgroup = page->new_optgroup(L("Special mode"), L"param_special");
optgroup->append_single_option_line("slicing_mode", "others_settings_special_mode#slicing-mode");
@@ -4007,13 +3973,12 @@ void TabFilament::add_filament_overrides_page()
const int extruder_idx = 0; // #ys_FIXME
- ConfigOptionsGroupShp retraction_optgroup = page->new_optgroup(L("Retraction"), L"param_retraction");
- auto append_retraction_option = [this, retraction_optgroup](const std::string& opt_key, int opt_index)
+ auto append_retraction_option = [this](ConfigOptionsGroupShp optgroup, const std::string& opt_key, int opt_index)
{
Line line {"",""};
- line = retraction_optgroup->create_single_option_line(retraction_optgroup->get_option(opt_key, opt_index));
+ line = optgroup->create_single_option_line(optgroup->get_option(opt_key, opt_index));
- line.near_label_widget = [this, optgroup_wk = ConfigOptionsGroupWkp(retraction_optgroup), opt_key, opt_index](wxWindow* parent) {
+ line.near_label_widget = [this, optgroup_wk = ConfigOptionsGroupWkp(optgroup), opt_key, opt_index](wxWindow* parent) {
auto check_box = new ::CheckBox(parent); // ORCA modernize checkboxes
check_box->Bind(wxEVT_TOGGLEBUTTON, [this, optgroup_wk, opt_key, opt_index](wxCommandEvent& evt) {
const bool is_checked = evt.IsChecked();
@@ -4040,9 +4005,10 @@ void TabFilament::add_filament_overrides_page()
return check_box;
};
- retraction_optgroup->append_line(line);
+ optgroup->append_line(line);
};
+ ConfigOptionsGroupShp retraction_optgroup = page->new_optgroup(L("Retraction"), L"param_retraction");
for (const std::string opt_key : { "filament_retraction_length",
"filament_z_hop",
"filament_z_hop_types",
@@ -4066,7 +4032,13 @@ void TabFilament::add_filament_overrides_page()
//SoftFever
// "filament_seam_gap"
})
- append_retraction_option(opt_key, extruder_idx);
+ append_retraction_option(retraction_optgroup, opt_key, extruder_idx);
+
+ ConfigOptionsGroupShp toolchange_optgroup = page->new_optgroup(L("Retraction when switching material"), L"param_retraction_material_change");
+ for (const std::string opt_key : { "filament_retract_length_toolchange",
+ "filament_retract_restart_extra_toolchange"
+ })
+ append_retraction_option(toolchange_optgroup, opt_key, extruder_idx);
ConfigOptionsGroupShp ironing_optgroup = page->new_optgroup(L("Ironing"), L"param_ironing");
auto append_ironing_option = [this, ironing_optgroup](const std::string& opt_key, int opt_index)
@@ -4181,6 +4153,8 @@ void TabFilament::update_filament_overrides_page(const DynamicPrintConfig* print
"filament_retraction_speed",
"filament_deretraction_speed",
"filament_retract_restart_extra",
+ "filament_retract_length_toolchange",
+ "filament_retract_restart_extra_toolchange",
"filament_retraction_minimum_travel",
"filament_retract_when_changing_layer",
"filament_wipe",
@@ -4211,7 +4185,8 @@ void TabFilament::update_filament_overrides_page(const DynamicPrintConfig* print
is_checked &= !dynamic_cast(m_config->option(opt_key))->is_nil(extruder_idx);
m_overrides_options[opt_key]->SetValue(is_checked);
- Field* field = optgroup->get_fieldc(opt_key, 0);
+ // the toolchange overrides live in their own optgroup, so search the whole page
+ Field* field = page->get_field(opt_key, 0);
if (field == nullptr) continue;
if (opt_key == "filament_long_retractions_when_cut") {
@@ -5017,6 +4992,7 @@ void TabPrinter::build_fff()
optgroup->append_single_option_line("printer_structure", "printer_basic_information_advanced#printer-structure");
optgroup->append_single_option_line("gcode_flavor", "printer_basic_information_advanced#g-code-flavor");
+ optgroup->append_single_option_line("gcode_skip_config_block", "printer_basic_information_advanced#skip-g-code-config-block");
optgroup->append_single_option_line("pellet_modded_printer", "printer_basic_information_advanced#pellet-modded-printer");
optgroup->append_single_option_line("bbl_use_printhost", "printer_basic_information_advanced#use-3rd-party-print-host");
@@ -5617,6 +5593,7 @@ if (is_marlin_flavor)
optgroup->append_single_option_line("purge_in_prime_tower", "printer_multimaterial_wipe_tower#purge-in-prime-tower");
optgroup->append_single_option_line("enable_filament_ramming", "printer_multimaterial_wipe_tower#enable-filament-ramming");
optgroup->append_single_option_line("tool_change_on_wipe_tower", "printer_multimaterial_wipe_tower#tool-change-on-wipe-tower");
+ optgroup->append_single_option_line("wait_for_temp_on_wipe_tower", "printer_multimaterial_wipe_tower#wait-for-temperature-on-wipe-tower");
optgroup = page->new_optgroup(L("Single extruder multi-material parameters"), "param_settings");
@@ -6150,6 +6127,7 @@ void TabPrinter::toggle_options()
// so the option is irrelevant there.
const size_t extruders_count = m_config->option("nozzle_diameter")->size();
toggle_option("tool_change_on_wipe_tower", !bSEMM && supports_wipe_tower_2 && extruders_count > 1);
+ toggle_option("wait_for_temp_on_wipe_tower", !bSEMM && supports_wipe_tower_2 && extruders_count > 1);
}
wxString extruder_number;
long val = 1;
diff --git a/src/slic3r/Utils/RaycastManager.cpp b/src/slic3r/Utils/RaycastManager.cpp
index c51a19ebd9..62c7a922d7 100644
--- a/src/slic3r/Utils/RaycastManager.cpp
+++ b/src/slic3r/Utils/RaycastManager.cpp
@@ -107,7 +107,7 @@ std::optional RaycastManager::first_hit(const Vec3d& point,
const AABBMesh *hit_mesh = nullptr;
double hit_squared_distance = 0.;
int hit_face = -1;
- Vec3d hit_world;
+ Vec3d hit_world { Vec3d::Zero() };
const Transform3d *hit_tramsformation = nullptr;
const TrKey *hit_key = nullptr;
diff --git a/tests/data/wipe_tower_temperature_trace_main.txt b/tests/data/wipe_tower_temperature_trace_main.txt
new file mode 100644
index 0000000000..f755008c26
--- /dev/null
+++ b/tests/data/wipe_tower_temperature_trace_main.txt
@@ -0,0 +1,172 @@
+# Temperature and tool-change commands of a wait_for_temp_on_wipe_tower-off slice,
+# captured from the main branch at a10d9e77cf. Regeneration is described
+# at the test that reads this file: "Toolchange temperature commands are unchanged
+# when the wipe tower wait is off" in tests/fff_print/test_multifilament.cpp.
+#
+# The "time:" and "lead" values are toolchain-specific -- GCC, Clang and MSVC each produce
+# slightly different estimates from an identical toolpath -- so they are compared with a
+# tolerance, not exactly. Do not regenerate this file to resolve a mismatch in them: no single
+# capture satisfies all three, and recapturing just moves the failure to other platforms.
+M104 S215 T0 ; set nozzle temperature
+M104 S215 T1 ; set nozzle temperature
+; CP PRIMING START
+T1 ; change extruder
+M109 S215 T1 ; set nozzle temperature and wait for it to be reached
+M104 S175 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S215 T0 ; set nozzle temperature and wait for it to be reached
+; CP PRIMING END
+M104 S215 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S175 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S215 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; set nozzle temperature
+M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 31s lead 30.9s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.3s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 31s lead 30.6s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.2s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 31s lead 30.7s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.4s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.4s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T0 ; preheat T0 time: 30s lead 30.0s
+; CP TOOLCHANGE START
+M104 S200 T1 ; set nozzle temperature ;cooldown
+T0 ; change extruder
+M109 S240 T0 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+M104 S240 T1 ; preheat T1 time: 30s lead 30.0s
+; CP TOOLCHANGE START
+M104 S200 T0 ; set nozzle temperature ;cooldown
+T1 ; change extruder
+M109 S240 T1 ; set nozzle temperature and wait for it to be reached
+; CP TOOLCHANGE END
+; CP TOOLCHANGE START
+; CP TOOLCHANGE END
+M104 S0 ; turn off temperature
diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt
index 70ab639faa..43afd4281d 100644
--- a/tests/fff_print/CMakeLists.txt
+++ b/tests/fff_print/CMakeLists.txt
@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
test_helpers.hpp
test_cooling.cpp
test_extrusion_entity.cpp
+ test_extrusion_processor.cpp
test_fill.cpp
test_flow.cpp
test_gcode_timing.cpp
@@ -18,6 +19,7 @@ add_executable(${_TEST_NAME}_tests
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
+ test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
diff --git a/tests/fff_print/test_extrusion_processor.cpp b/tests/fff_print/test_extrusion_processor.cpp
new file mode 100644
index 0000000000..76e331d66a
--- /dev/null
+++ b/tests/fff_print/test_extrusion_processor.cpp
@@ -0,0 +1,441 @@
+#include
+
+#include "libslic3r/AABBTreeLines.hpp"
+#include "libslic3r/GCode/ExtrusionProcessor.hpp"
+#include "libslic3r/GCodeReader.hpp"
+#include "libslic3r/TriangleMesh.hpp"
+
+#include "test_helpers.hpp"
+
+#include
+#include
+#include
+#include
+
+using namespace Slic3r;
+using namespace Slic3r::Test;
+
+namespace {
+
+// Print settings the assertions below are derived from.
+constexpr double caged_layer_height = 0.2; // mm
+constexpr double caged_wall_width = 0.42; // mm, outer wall line width
+constexpr double caged_outer_wall_speed = 200.; // mm/s
+constexpr double caged_slow_speed = 100.; // mm/s, between every configured overhang speed (<= 50) and the wall speed
+
+// A wall running 0.2mm out over a previous layer whose edge dishes 0.03mm away from it in the middle,
+// standing in for the endpoint readings a caged overhang perimeter takes: enough of a difference to
+// print at another speed, but only a fraction of the distance at which slowdown begins.
+constexpr double dished_wall_gap = 0.2; // mm, how far the wall runs out past the previous layer's edge
+constexpr double dished_layer_depth = 0.03; // mm, how much further out the middle of it reads
+constexpr double dished_min_distance = 0.042; // mm, the reading at which the configured speeds begin to slow down
+// Every reading here is past that, so the whole wall is slowed and only the amount is in question.
+constexpr float dished_end_reading = float(dished_wall_gap + 0.5 * caged_wall_width);
+constexpr float dished_mid_reading = float(dished_end_reading + dished_layer_depth);
+// The two readings are dished_layer_depth apart, so half of that tells them apart while still allowing
+// for the points the passes after sampling add, which read a little further out than the ends do.
+constexpr double dished_reading_tolerance = 0.5 * dished_layer_depth;
+
+// A 40 x 20 x 20 mm box with a 45 degree overhang cut into the y = 0 side. The sloped face spans
+// x = 5.086 .. 34.914 only, so the full-height walls of the box cage both ends of every overhang
+// perimeter: the endpoints look supported even though the span between them is not.
+TriangleMesh caged_overhang_mesh()
+{
+ return TriangleMesh(
+ {
+ {5.0859987f, 10.167065f, 5.711731f}, {34.914257f, 10.167065f, 5.711731f},
+ {34.914257f, 0.f, 15.878796f}, {5.0859995f, 0.f, 15.878796f},
+ {0.f, 0.f, 0.f}, {0.f, 0.f, 20.f},
+ {0.f, 20.f, 20.f}, {0.f, 20.f, 0.f},
+ {40.f, 20.f, 20.f}, {40.f, 20.f, 0.f},
+ {40.f, 0.f, 20.f}, {40.f, 0.f, 0.f},
+ {34.914257f, 0.f, 0.f}, {5.0859995f, 0.f, 0.f},
+ {34.914257f, 10.167065f, 0.f}, {5.0859995f, 10.167065f, 0.f},
+ },
+ {
+ {0, 1, 2}, {0, 2, 3}, {4, 5, 6}, {4, 6, 7}, {7, 6, 8}, {7, 8, 9},
+ {9, 8, 10}, {9, 10, 11}, {12, 11, 10}, {5, 4, 13}, {5, 13, 3}, {2, 12, 10},
+ {5, 3, 2}, {10, 5, 2}, {9, 11, 12}, {9, 12, 14}, {13, 4, 7}, {9, 14, 15},
+ {15, 13, 7}, {7, 9, 15}, {8, 6, 5}, {8, 5, 10}, {14, 1, 0}, {14, 0, 15},
+ {2, 1, 14}, {2, 14, 12}, {15, 0, 3}, {15, 3, 13},
+ });
+}
+
+// Mesh geometry the wall filters below are derived from.
+constexpr double caged_box_depth = 20.; // mm, the box spans y = 0 .. 20
+constexpr double caged_slope_face_sum = 15.878796; // mm, y + z of the sloped face, from its corners
+// The sloped face spans this x range; outside it the box walls run full height.
+constexpr double caged_slope_x_min = 5.0859995;
+constexpr double caged_slope_x_max = 34.914257;
+constexpr double caged_slope_span = caged_slope_x_max - caged_slope_x_min; // ~29.8 mm
+// The z range the sloped face occupies, from the same fixture vertices.
+constexpr double caged_slope_z_min = 5.711731;
+constexpr double caged_slope_z_max = 15.878796;
+// The lowest slope layer still sits on the solid body below the notch, so it is fully supported and
+// runs at the outer wall speed by design. The caged span proper begins one layer above it.
+constexpr double caged_span_z_min = caged_slope_z_min + caged_layer_height;
+
+// A layer printed at z is sliced at z - layer_height / 2, and the outer wall centreline sits half a
+// line width inside the contour, so the wall on the slope satisfies y + z = 16.189.
+constexpr double caged_slope_wall_sum = caged_slope_face_sum + 0.5 * caged_layer_height + 0.5 * caged_wall_width;
+// Same inset on the fully supported y = 20 face, vertical over the whole height.
+constexpr double caged_back_wall_y = caged_box_depth - 0.5 * caged_wall_width;
+// And on the y = 0 face, which runs full height only outside the slope's x range.
+constexpr double caged_front_wall_y = 0.5 * caged_wall_width;
+// Arachne varies the wall width along a face, and the centreline inset is half that width, so a
+// wall sits within about half a line width of where the nominal inset alone would put it. The
+// faces being selected are millimetres apart, so this stays far from ambiguous.
+constexpr double caged_wall_tolerance = 0.5 * caged_wall_width;
+
+// Feed rates in mm/min of the long outer wall extrusions `keep_line` selects.
+template std::vector outer_wall_feed_rates(const std::string& gcode, KeepLine keep_line)
+{
+ std::vector feed_rates;
+ bool outer_wall = false;
+ GCodeReader parser;
+ parser.parse_buffer(gcode, [&feed_rates, &outer_wall, &keep_line](GCodeReader& self, const GCodeReader::GCodeLine& line) {
+ const std::string_view comment = line.comment();
+ if (comment.find("FEATURE:") != std::string_view::npos || comment.find("TYPE:") != std::string_view::npos)
+ outer_wall = comment.find("Outer wall") != std::string_view::npos ||
+ comment.find("External perimeter") != std::string_view::npos;
+
+ if (outer_wall && line.extruding(self) && line.dist_XY(self) > 1.0 && keep_line(self, line))
+ feed_rates.push_back(line.new_F(self));
+ });
+
+ return feed_rates;
+}
+
+// The caged 45 degree overhang: outer walls crossing the sloped face for most of its width, on the
+// layers where the face genuinely overhangs.
+// Both ends are tested against the slope plane rather than requiring a constant Y. Arachne's
+// variable-width walls drift slightly in Y along the same slope (Y6.186 -> Y6.189 on one move), so
+// a constant-Y filter matches almost nothing under Arachne and silently reduces its coverage.
+// The length test excludes the cage walls: they are only as wide as the box is either side of the
+// slope, but being vertical their y + z sweeps through the slope plane as z rises, so a couple of
+// their fully supported moves would otherwise be counted as part of the span.
+std::vector caged_slope_feed_rates(const std::string& gcode)
+{
+ return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
+ const double z = line.new_Z(self);
+ return z > caged_span_z_min && z < caged_slope_z_max &&
+ line.dist_XY(self) > 0.5 * caged_slope_span &&
+ std::abs(self.y() + z - caged_slope_wall_sum) < caged_wall_tolerance &&
+ std::abs(line.new_Y(self) + z - caged_slope_wall_sum) < caged_wall_tolerance;
+ });
+}
+
+// The opposite, fully supported face, skipping the initial layer and its own speed settings.
+std::vector back_wall_feed_rates(const std::string& gcode)
+{
+ return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
+ return line.new_Z(self) > 1.5 * caged_layer_height &&
+ std::abs(self.y() - caged_back_wall_y) < caged_wall_tolerance &&
+ std::abs(line.new_Y(self) - caged_back_wall_y) < caged_wall_tolerance;
+ });
+}
+
+// The first layer printed entirely above the slope. Its y = 0 wall runs the full width of the box.
+const double caged_layer_above_slope_z = std::ceil(caged_slope_z_max / caged_layer_height) * caged_layer_height;
+
+// The parts of that wall standing on the cage rather than the slope, so on a contour identical to their own.
+// Where the support changes is found by bisection, which stops at spans of 2mm, so the move spanning each end of
+// the slope reaches a little way into the cage. Taking only the moves lying wholly outside the slope's x range
+// leaves the wall that is unambiguously supported, without asserting how closely the bisection converged.
+std::vector cage_shoulder_feed_rates(const std::string& gcode)
+{
+ return outer_wall_feed_rates(gcode, [](const GCodeReader& self, const GCodeReader::GCodeLine& line) {
+ return std::abs(line.new_Z(self) - caged_layer_above_slope_z) < 0.5 * caged_layer_height &&
+ std::abs(self.y() - caged_front_wall_y) < caged_wall_tolerance &&
+ std::abs(line.new_Y(self) - caged_front_wall_y) < caged_wall_tolerance &&
+ (std::max(self.x(), line.new_X(self)) <= caged_slope_x_min ||
+ std::min(self.x(), line.new_X(self)) >= caged_slope_x_max);
+ });
+}
+
+// The readings a 40mm wall takes over a previous layer whose edge falls away by 0.03mm towards the
+// middle: both ends read the same, and the middle reads slightly further out over air. Whether that
+// middle reading survives is what decides the speed the wall is printed at.
+std::vector> sampled_wall_over_dished_layer(const std::function& distance_to_speed)
+{
+ const AABBTreeLines::LinesDistancer prev_layer(std::vector{
+ {{0., 0.}, {20., -dished_layer_depth}},
+ {{20., -dished_layer_depth}, {40., 0.}},
+ {{40., 0.}, {40., -10.}},
+ {{40., -10.}, {0., -10.}},
+ {{0., -10.}, {0., 0.}},
+ });
+ const Points wall{Point::new_scale(0., dished_wall_gap), Point::new_scale(40., dished_wall_gap)};
+
+ return estimate_points_properties(wall, prev_layer, caged_wall_width, -1.f,
+ dished_min_distance, distance_to_speed);
+}
+
+// A straight, otherwise supported wall over a previous-layer boundary with a 2mm-wide pocket. Moving the
+// pocket between x = 10 and x = 20 covers both discovery away from the wall's midpoint and refinement around
+// a midpoint that has already been discovered. The current wall is inset half its width from the flat boundary,
+// so its supported readings are zero after the estimator applies its boundary offset.
+constexpr double narrow_pocket_wall_length = 40.;
+constexpr double narrow_pocket_width = 2.;
+constexpr double narrow_pocket_depth = 0.3;
+
+std::vector> sampled_wall_over_narrow_pocket(
+ double pocket_center, const std::function& distance_to_speed)
+{
+ const double pocket_left = pocket_center - 0.5 * narrow_pocket_width;
+ const double pocket_right = pocket_center + 0.5 * narrow_pocket_width;
+ const AABBTreeLines::LinesDistancer prev_layer(std::vector{
+ {{0., 0.}, {pocket_left, 0.}},
+ {{pocket_left, 0.}, {pocket_left, -narrow_pocket_depth}},
+ {{pocket_left, -narrow_pocket_depth}, {pocket_right, -narrow_pocket_depth}},
+ {{pocket_right, -narrow_pocket_depth}, {pocket_right, 0.}},
+ {{pocket_right, 0.}, {narrow_pocket_wall_length, 0.}},
+ {{narrow_pocket_wall_length, 0.}, {narrow_pocket_wall_length, -10.}},
+ {{narrow_pocket_wall_length, -10.}, {0., -10.}},
+ {{0., -10.}, {0., 0.}},
+ });
+ const double wall_y = -0.5 * caged_wall_width;
+ const Points wall{Point::new_scale(0., wall_y), Point::new_scale(narrow_pocket_wall_length, wall_y)};
+
+ return estimate_points_properties(wall, prev_layer, caged_wall_width, -1.f,
+ dished_min_distance, distance_to_speed);
+}
+
+// A cross section that grows a layer's worth on the two faces meeting at either end of a wall, as any
+// 45 degree overhang does. The wall itself stands on a contour identical to its own, but its ends sit
+// where the growing faces cut the corners off, and the previous layer's edge there is nearer than the
+// half line width the centreline is inset by. Both ends therefore read an overhang while everything
+// between them reads supported: the reverse of the caged span, and the case the sampling above must
+// leave to the passes after it.
+constexpr double stepped_wall_inset = 0.5 * caged_wall_width; // mm, centreline inset from the contour
+constexpr double stepped_end_gap = stepped_wall_inset - caged_layer_height; // mm, how far inside the corner ends up
+constexpr double stepped_wall_span = 30.; // mm, the length of the wall
+
+std::vector> sampled_wall_between_growing_corners(const std::function& distance_to_speed)
+{
+ const AABBTreeLines::LinesDistancer prev_layer(std::vector{
+ {{0., 0.}, {32., 0.}},
+ {{32., 0.}, {32., -stepped_wall_span}},
+ {{32., -stepped_wall_span}, {0., -stepped_wall_span}},
+ {{0., -stepped_wall_span}, {0., 0.}},
+ });
+ const Points wall{Point::new_scale(stepped_wall_inset, -stepped_end_gap),
+ Point::new_scale(stepped_wall_inset, stepped_end_gap - stepped_wall_span)};
+
+ return estimate_points_properties(wall, prev_layer, caged_wall_width, -1.f,
+ dished_min_distance, distance_to_speed);
+}
+
+// How much of a path is printed below the speed a fully supported reading gives. A segment is printed
+// at the lower of the speeds its ends read.
+double slowed_length(const std::vector>& points, const std::function& distance_to_speed)
+{
+ double length = 0.;
+ for (size_t i = 0; i + 1 < points.size(); ++i)
+ if (std::min(distance_to_speed(points[i].distance), distance_to_speed(points[i + 1].distance)) < distance_to_speed(0.f))
+ length += (points[i + 1].position - points[i].position).norm();
+ return length;
+}
+
+float furthest_reading(const std::vector>& points)
+{
+ return std::max_element(points.begin(), points.end(), [](const ExtendedPoint<2>& l, const ExtendedPoint<2>& r) {
+ return l.distance < r.distance;
+ })->distance;
+}
+
+DynamicPrintConfig caged_overhang_config(const char* wall_generator){
+ DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
+ config.set_deserialize_strict({
+ {"nozzle_diameter", "0.4"},
+ {"initial_layer_print_height", caged_layer_height},
+ {"layer_height", caged_layer_height},
+ {"line_width", caged_wall_width},
+ {"outer_wall_line_width", caged_wall_width},
+ {"inner_wall_line_width", "0.45"},
+ {"wall_loops", "2"},
+ {"wall_generator", wall_generator},
+ {"wall_sequence", "inner wall/outer wall"},
+ {"sparse_infill_density", "15%"},
+ {"detect_overhang_wall", "1"},
+ {"enable_overhang_speed", "1"},
+ {"slowdown_for_curled_perimeters", "0"},
+ {"zaa_enabled", "0"},
+ {"outer_wall_speed", caged_outer_wall_speed},
+ {"inner_wall_speed", "300"},
+ {"overhang_1_4_speed", "0"},
+ {"overhang_2_4_speed", "50"},
+ {"overhang_3_4_speed", "30"},
+ {"overhang_4_4_speed", "10"},
+ {"bridge_speed", "50"},
+ {"filament_max_volumetric_speed", "22"},
+ {"slow_down_for_layer_cooling", "0"},
+ {"slow_down_layers", "0"}, // Nothing but the overhang settings may lower a wall speed
+ });
+ return config;
+}
+
+std::string caged_overhang_gcode(const char* wall_generator)
+{
+ Print print;
+ Model model;
+ init_print(std::vector{caged_overhang_mesh()}, print, model, caged_overhang_config(wall_generator), nullptr,
+ false);
+ return gcode(print);
+}
+
+// Reports the matched move count alongside the extremes, so a filter that selected nothing is
+// distinguishable from a span that simply was not slowed.
+void info_feed_rates(const char* span, const std::vector& feed_rates)
+{
+ UNSCOPED_INFO("matched " << feed_rates.size() << " " << span << " moves");
+ if (!feed_rates.empty()) {
+ const auto extremes = std::minmax_element(feed_rates.begin(), feed_rates.end());
+ UNSCOPED_INFO("slowest " << *extremes.first / MM_PER_MIN << " mm/s, fastest " << *extremes.second / MM_PER_MIN << " mm/s");
+ }
+}
+
+} // namespace
+
+// Classic reproduces the endpoint-sampling bug: it emits the span as one long move whose endpoints
+// both read as supported, so endpoint-only sampling never slows it. Arachne's endpoints already read
+// as overhanging, but their placement near the cage makes the inferred support vary by layer. Arachne
+// parity is therefore part of this regression's scope: both generators must classify the unsupported
+// interior of the same 45-degree span consistently.
+TEST_CASE("Caged external overhangs are slowed along their span", "[ExtrusionProcessor][Regression]")
+{
+ const char* wall_generator = GENERATE("classic", "arachne");
+ INFO("wall generator: " << wall_generator);
+
+ const std::vector feed_rates = caged_slope_feed_rates(caged_overhang_gcode(wall_generator));
+ info_feed_rates("caged slope", feed_rates);
+
+ REQUIRE_FALSE(feed_rates.empty());
+
+ // The endpoint bug left Classic at the full wall speed, while Arachne's cage-adjacent endpoint
+ // samples selected much faster bands on some layers. The whole span must stay in the slowed range
+ // for both generators, without requiring their different path segmentations to match.
+ const double fastest = *std::max_element(feed_rates.begin(), feed_rates.end());
+ REQUIRE(fastest < caged_slow_speed * MM_PER_MIN);
+}
+
+// The other side of the fix: the midpoint probe fires on every long external perimeter, so a
+// regression that over-slows would leave the test above green. A fully supported wall must keep the
+// speed it was configured with.
+TEST_CASE("Supported vertical walls keep their normal speed", "[ExtrusionProcessor][Regression]")
+{
+ const char* wall_generator = GENERATE("classic", "arachne");
+ INFO("wall generator: " << wall_generator);
+
+ const std::vector feed_rates = back_wall_feed_rates(caged_overhang_gcode(wall_generator));
+ info_feed_rates("back wall", feed_rates);
+
+ REQUIRE_FALSE(feed_rates.empty());
+
+ const double slowest = *std::min_element(feed_rates.begin(), feed_rates.end());
+ REQUIRE(slowest >= caged_slow_speed * MM_PER_MIN);
+}
+
+// The slope's top edge falls mid layer, so the first layer above it still stands 0.179mm proud of the layer
+// below wherever that layer was still on the slope. That is a real overhang and is slowed, but it ends with the
+// slope: outside the slope's x range the box runs full height, so the same wall stands on a contour identical to
+// its own. Sampling the interior of that wall at a single point reported one support reading for all of it and
+// slowed these fully supported ends along with the rest.
+TEST_CASE("Wall sections beside a caged overhang keep their normal speed", "[ExtrusionProcessor][Regression]")
+{
+ const char* wall_generator = GENERATE("classic", "arachne");
+ INFO("wall generator: " << wall_generator);
+
+ const std::vector