From 7688f7a16cb90cf4c160131c3a9699969d59d892 Mon Sep 17 00:00:00 2001 From: KrishManan Date: Sat, 30 May 2026 17:44:24 +0800 Subject: [PATCH 01/45] Add dropdown item action icons --- src/slic3r/GUI/Widgets/ComboBox.cpp | 37 ++++++++++++- src/slic3r/GUI/Widgets/ComboBox.hpp | 2 + src/slic3r/GUI/Widgets/DropDown.cpp | 82 +++++++++++++++++++++++++++-- src/slic3r/GUI/Widgets/DropDown.hpp | 4 ++ 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 783e1caadf..b0d3746ad2 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -69,6 +69,11 @@ ComboBox::ComboBox(wxWindow *parent, e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + drop.Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); drop.Bind(EVT_DISMISS, [this](auto &) { drop_down = false; wxCommandEvent e(wxEVT_COMBOBOX_CLOSEUP); @@ -222,7 +227,7 @@ int ComboBox::Append(const wxString &text, int style) { auto valid_bit_map = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; - Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, clientData, group_key, group_label}; + Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, wxNullBitmap, clientData, group_key, group_label}; item.style = style; items.push_back(item); SetClientDataType(wxClientData_Void); @@ -268,6 +273,27 @@ void ComboBox::ForceDropdownOpen() } } +void ComboBox::RefreshDropdownIfOpen() +{ + if (!drop_down) + return; + + drop.hover_item = -1; + drop.offset = wxPoint(); + if (drop.subDropDown) { + drop.subDropDown->group.clear(); + drop.subDropDown->hover_item = -1; + drop.subDropDown->offset = wxPoint(); + if (drop.subDropDown->IsShown()) + drop.subDropDown->Dismiss(); + } + + drop.need_sync = true; + drop.messureSize(); + drop.autoPosition(); + drop.paintNow(); +} + unsigned int ComboBox::GetCount() const { return items.size(); } void ComboBox::set_replace_text(wxString text, wxString image_name) @@ -321,6 +347,13 @@ void ComboBox::SetItemBitmap(unsigned int n, wxBitmap const &bitmap) drop.Invalidate(); } +void ComboBox::SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap) +{ + if (n >= items.size()) return; + items[n].action_icon = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + drop.Invalidate(); +} + int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, unsigned int pos, void ** clientData, @@ -328,7 +361,7 @@ int ComboBox::DoInsertItems(const wxArrayStringsAdapter &items, { if (pos > this->items.size()) return -1; for (int i = 0; i < items.GetCount(); ++i) { - Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; + Item item { items[i], wxEmptyString, wxNullBitmap, wxNullBitmap, wxNullBitmap, clientData ? clientData[i] : NULL }; this->items.insert(this->items.begin() + pos, item); ++pos; } diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 552909b477..241458a7dd 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -80,10 +80,12 @@ public: wxBitmap GetItemBitmap(unsigned int n); void SetItemBitmap(unsigned int n, wxBitmap const &bitmap); + void SetItemActionBitmap(unsigned int n, wxBitmap const &bitmap); bool is_drop_down(){return drop_down;} void DeleteOneItem(unsigned int pos) { DoDeleteOneItem(pos); } void ForceDropdownOpen(); + void RefreshDropdownIfOpen(); protected: virtual int DoInsertItems(const wxArrayStringsAdapter &items, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 973113d0ac..7cb9aa6709 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -13,6 +13,7 @@ #include wxDEFINE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDEFINE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); BEGIN_EVENT_TABLE(DropDown, PopupWindow) @@ -225,6 +226,29 @@ static wxSize GetBmpSize(wxBitmap & bmp) #endif } +wxSize DropDown::actionIconSize() const +{ + wxSize size; + std::set groups; + for (const Item& item : items) { + if (group.IsEmpty()) { + if (!item.group_key.IsEmpty()) { + if (groups.find(item.group_key) != groups.end()) + continue; + groups.insert(item.group_key); + } + } else if (item.group_key != group) { + continue; + } + + wxBitmap icon = item.action_icon; + wxSize icon_size = GetBmpSize(icon); + size.x = std::max(size.x, icon_size.x); + size.y = std::max(size.y, icon_size.y); + } + return size; +} + static void _DrawSplitItem(const wxWindow* w, wxDC& dc, wxString split_text, wxPoint start_pt, int item_width, int item_height) { // save dc @@ -354,6 +378,9 @@ void DropDown::render(wxDC &dc) rcContent.width -= szBmp.x + 5; } + const wxSize action_icon_size = actionIconSize(); + const int action_column_width = action_icon_size.x > 0 ? action_icon_size.x + FromDIP(16) : 0; + std::set groups; // draw texts & icons int index = 0; @@ -392,6 +419,7 @@ void DropDown::render(wxDC &dc) } if (rcContent.y > size.y) break; wxPoint pt = rcContent.GetLeftTop(); + int rcContent_width = rcContent.width; if (item.style & DD_ITEM_STYLE_SPLIT_ITEM) { _DrawSplitItem(this, dc, item.text, pt, rowSize.GetWidth(), rowSize.GetHeight()); @@ -417,13 +445,27 @@ void DropDown::render(wxDC &dc) auto text = group.IsEmpty() ? (item.group_key.IsEmpty() ? item.text : item.group_label) : item.text; + if (action_column_width > 0) + rcContent.width -= action_column_width; + + if (item.action_icon.IsOk()) { + auto action_size = GetBmpSize(item.action_icon); + wxPoint action_pt(rcContent.x + rcContent_width - action_size.x - FromDIP(8), + rcContent.y + (rcContent.height - action_size.y) / 2); + dc.DrawBitmap(item.action_icon, action_pt); + } + if (!text_off && !text.IsEmpty()) { wxSize tSize = dc.GetMultiLineTextExtent(text); if (pt.x + tSize.x > rcContent.GetRight()) { - if (is_hover && item.tip.IsEmpty()) - SetToolTip(text); - text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, - rcContent.GetRight() - pt.x); + int max_text_width = rcContent.GetRight() - pt.x; + if (max_text_width <= 0) { + text.clear(); + } else { + if (is_hover && item.tip.IsEmpty()) + SetToolTip(text); + text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, max_text_width); + } } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); @@ -436,6 +478,7 @@ void DropDown::render(wxDC &dc) dc.DrawBitmap(arrow_bitmap.bmp(), pt); } } + rcContent.width = rcContent_width; rcContent.y += rowSize.y; } } @@ -545,6 +588,8 @@ void DropDown::messureSize() size1.x += size2.x + (text_off ? 0 : 5); } } + if (item.action_icon.IsOk()) + size1.x += GetBmpSize(item.action_icon).x + FromDIP(16); if (size1.x > textSize.x) textSize = size1; } if (!align_icon) iconSize.x = 0; @@ -616,6 +661,11 @@ void DropDown::messureSize() e.SetId(GetId()); GetEventHandler()->ProcessEvent(e); }); + subDropDown->Bind(EVT_DROPDOWN_ITEM_ACTION, [this](wxCommandEvent &e) { + e.SetEventObject(this); + e.SetId(GetId()); + GetEventHandler()->ProcessEvent(e); + }); } need_sync = false; } @@ -694,6 +744,19 @@ void DropDown::mouseReleased(wxMouseEvent& event) if (hover_item < 0) return; if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) { // not moved + int index = hoverIndex(); + if (index >= 0 && items[index].action_icon.IsOk()) { + wxSize action_size = actionIconSize(); + int action_right = GetSize().x; + if (rowSize.y * count > GetSize().y) + action_right -= 6; + int action_left = action_right - action_size.x - FromDIP(16); + if (event.GetX() >= action_left && event.GetX() <= action_right) { + sendDropDownActionEvent(index); + paintNow(); + return; + } + } sendDropDownEvent(); if (mainDropDown) mainDropDown->hover_item = -1; // To Dismiss mainDropDown @@ -810,6 +873,17 @@ void DropDown::sendDropDownEvent() GetEventHandler()->ProcessEvent(event); } +void DropDown::sendDropDownActionEvent(int index) +{ + if (index < 0 || index >= int(items.size()) || (items[index].style & DD_ITEM_STYLE_DISABLED)) + return; + wxCommandEvent event(EVT_DROPDOWN_ITEM_ACTION, GetId()); + event.SetEventObject(this); + event.SetInt(index); + event.SetString(items[index].text); + GetEventHandler()->ProcessEvent(event); +} + void DropDown::Dismiss() { if (subDropDown && subDropDown->IsShown()) diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 09041e3dc0..a2ad873b94 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -15,6 +15,7 @@ #define DD_ITEM_STYLE_DISABLED 0x0002 // ----text----, text with horizontal line arounds wxDECLARE_EVENT(EVT_DISMISS, wxCommandEvent); +wxDECLARE_EVENT(EVT_DROPDOWN_ITEM_ACTION, wxCommandEvent); class DropDown : public PopupWindow { @@ -25,6 +26,7 @@ public: wxString text_static_tips;// display static tips for TextInput.eg.PrinterInfoBox wxBitmap icon; wxBitmap icon_textctrl;// display icon for TextInput.eg.PrinterInfoBox + wxBitmap action_icon; void * data{nullptr}; wxString group_key{}; wxString group_label{}; @@ -123,6 +125,7 @@ private: int hoverIndex(); int selectedItem(); + wxSize actionIconSize() const; friend class ComboBox; void messureSize(); @@ -136,6 +139,7 @@ private: void mouseWheelMoved(wxMouseEvent &event); void sendDropDownEvent(); + void sendDropDownActionEvent(int index); DECLARE_EVENT_TABLE() From fb6c93963dae3f13bfec4859d9804f267f80af42 Mon Sep 17 00:00:00 2001 From: KrishManan Date: Sat, 30 May 2026 17:44:51 +0800 Subject: [PATCH 02/45] Add AMS filament favorites --- src/slic3r/GUI/AMSMaterialsSetting.cpp | 201 +++++++++++++++++++------ src/slic3r/GUI/AMSMaterialsSetting.hpp | 17 ++- 2 files changed, 170 insertions(+), 48 deletions(-) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 4a429a4a5a..f5aa15df94 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -2,6 +2,7 @@ #include "ExtrusionCalibration.hpp" #include "MsgDialog.hpp" #include "GUI_App.hpp" +#include "libslic3r/Config.hpp" #include "libslic3r/Preset.hpp" #include "I18N.hpp" #include @@ -116,7 +117,8 @@ void AMSMaterialsSetting::create() Bind(wxEVT_PAINT, &AMSMaterialsSetting::paintEvent, this); Bind(EVT_SELECTED_COLOR, &AMSMaterialsSetting::on_picker_color, this); - m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -135,11 +137,7 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0); -#ifdef __APPLE__ - m_comboBox_filament = new wxComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#else m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); -#endif m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); @@ -371,6 +369,7 @@ void AMSMaterialsSetting::paintEvent(wxPaintEvent &evt) AMSMaterialsSetting::~AMSMaterialsSetting() { m_comboBox_filament->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); + m_comboBox_filament->Unbind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); m_comboBox_cali_result->Disconnect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -472,10 +471,9 @@ void AMSMaterialsSetting::on_select_reset(wxCommandEvent& event) { std::string selected_ams_id; PresetBundle *preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { + const FilamentInfos* selected_info = selected_filament_info(); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) { selected_ams_id = it->filament_id; break; } @@ -539,11 +537,9 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event) PresetBundle* preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { + const FilamentInfos* selected_info = selected_filament_info(); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info && it->filament_id.compare(selected_info->filament_id) == 0) { //check is it in the filament blacklist @@ -840,6 +836,136 @@ static void _collect_filament_info(const wxString& shown_name, query_filament_types[shown_name] = filament.config.get_filament_type(); } +static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids"; + +const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const +{ + int selection = m_comboBox_filament->GetSelection(); + if (selection >= 0 && selection < int(m_filament_list_items.size()) && m_filament_list_items[selection].is_filament) + return &m_filament_list_items[selection].filament; + + auto iter = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); + return iter == map_filament_items.end() ? nullptr : &iter->second; +} + +std::set AMSMaterialsSetting::load_favorite_filament_ids() const +{ + std::set favorite_ids; + std::vector values; + const std::string serialized = wxGetApp().app_config->get(AMS_FAVORITE_FILAMENT_IDS_KEY); + if (!serialized.empty() && Slic3r::unescape_strings_cstyle(serialized, values)) { + favorite_ids.insert(values.begin(), values.end()); + } + return favorite_ids; +} + +void AMSMaterialsSetting::save_favorite_filament_ids(const std::set& favorite_ids) +{ + std::vector values(favorite_ids.begin(), favorite_ids.end()); + wxGetApp().app_config->set(AMS_FAVORITE_FILAMENT_IDS_KEY, Slic3r::escape_strings_cstyle(values)); + wxGetApp().app_config->save(); +} + +void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& selected_filament_id) +{ + std::vector items; + m_filament_list_items.clear(); + + const std::set favorite_ids = load_favorite_filament_ids(); + wxBitmap star_filled = ScalableBitmap(this, "score_star_light", 16).bmp(); + wxBitmap star_empty = ScalableBitmap(this, "score_star_dark", 16).bmp(); + + auto append_header = [&items, this](const wxString& label) { + DropDown::Item item; + item.text = label; + item.style = DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED; + items.push_back(item); + m_filament_list_items.push_back(FilamentListItem{}); + }; + + auto append_filament = [&items, this, &favorite_ids, &star_filled, &star_empty](const wxString& name) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it == map_filament_items.end()) + return; + + const bool is_favorite = favorite_ids.find(info_it->second.filament_id) != favorite_ids.end(); + DropDown::Item item; + item.text = name; + item.action_icon = is_favorite ? star_filled : star_empty; + item.style = 0; + items.push_back(item); + + FilamentListItem list_item; + list_item.filament = info_it->second; + list_item.is_filament = true; + m_filament_list_items.push_back(list_item); + }; + + bool has_favorites = false; + for (const wxString& name : m_sorted_filament_items) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) { + has_favorites = true; + break; + } + } + + if (has_favorites) { + append_header(_L("Favorite filaments")); + for (const wxString& name : m_sorted_filament_items) { + auto info_it = map_filament_items.find(name.ToStdString()); + if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) + append_filament(name); + } + } + + append_header(_L("All filaments")); + for (const wxString& name : m_sorted_filament_items) + append_filament(name); + + int selection_idx = -1; + if (!selected_filament_id.empty()) { + for (int i = 0; i < int(m_filament_list_items.size()); ++i) { + if (m_filament_list_items[i].is_filament && m_filament_list_items[i].filament.filament_id == selected_filament_id) { + selection_idx = i; + break; + } + } + } + + m_comboBox_filament->SetItems(items); + m_comboBox_filament->SetSelection(selection_idx); +} + +void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id) +{ + if (filament_id.empty()) + return; + + std::set favorite_ids = load_favorite_filament_ids(); + auto iter = favorite_ids.find(filament_id); + if (iter == favorite_ids.end()) + favorite_ids.insert(filament_id); + else + favorite_ids.erase(iter); + + save_favorite_filament_ids(favorite_ids); +} + +void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt) +{ + const int index = evt.GetInt(); + if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament) + return; + + std::string selected_filament_id; + if (const FilamentInfos* info = selected_filament_info()) + selected_filament_id = info->filament_id; + toggle_favorite_filament(m_filament_list_items[index].filament.filament_id); + rebuild_filament_dropdown_items(selected_filament_id); + m_comboBox_filament->RefreshDropdownIfOpen(); +} + void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n) { if (!obj) return; @@ -855,6 +981,9 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi int idx = 0; wxArrayString filament_items; + map_filament_items.clear(); + m_sorted_filament_items.clear(); + m_filament_list_items.clear(); wxString bambu_filament_name; wxString hint_filament_name; // the hint type to be selected std::unordered_map query_filament_vendors;// some information for sort @@ -1050,21 +1179,18 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi std::sort(filament_items.begin(), filament_items.end(), _filament_sorter); } - // traverse the hint selection idx - int selection_idx = -1; - { - for(int i = 0; i < filament_items.size(); i++) - { - if (hint_filament_name == filament_items[i]) - { - selection_idx = i; - break; - } + std::string selection_filament_id; + for (int i = 0; i < int(filament_items.size()); ++i) { + m_sorted_filament_items.push_back(filament_items[i]); + if (hint_filament_name == filament_items[i]) { + auto info_it = map_filament_items.find(filament_items[i].ToStdString()); + if (info_it != map_filament_items.end()) + selection_filament_id = info_it->second.filament_id; } } - m_comboBox_filament->Set(filament_items); - m_comboBox_filament->SetSelection(selection_idx); + rebuild_filament_dropdown_items(selection_filament_id); + int selection_idx = m_comboBox_filament->GetSelection(); post_select_event(selection_idx); if (selection_idx < 0) { @@ -1106,6 +1232,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) int* from_printer = static_cast(m_comboBox_filament->GetClientData()); m_filament_type = ""; + const FilamentInfos* selected_info = selected_filament_info(); PresetBundle* preset_bundle = wxGetApp().preset_bundle; if (preset_bundle) { std::ostringstream stream; @@ -1124,10 +1251,8 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) std::set printer_names = preset_bundle->get_printer_names_by_printer_type_and_nozzle(DevPrinterConfigUtil::get_printer_display_name(obj->printer_type), nozzle_diameter_str); for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - if (!m_comboBox_filament->GetValue().IsEmpty()) { - auto filament_item = map_filament_items[m_comboBox_filament->GetValue().ToStdString()]; - std::string filament_id = filament_item.filament_id; - if (it->filament_id.compare(filament_id) == 0) { + if (selected_info) { + if (it->filament_id.compare(selected_info->filament_id) == 0) { ConfigOption * printer_opt = it->config.option("compatible_printers"); ConfigOptionStrings *printer_strs = dynamic_cast(printer_opt); bool has_compatible_printer = false; @@ -1186,7 +1311,7 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) //reset cali int cali_select_idx = -1; - if ( !this->obj || m_filament_selection < 0) { + if ( !this->obj || m_filament_selection < 0 || !selected_info) { m_input_k_val->Enable(false); m_input_n_val->Enable(false); m_button_confirm->Disable(); // ORCA No need to change style @@ -1205,21 +1330,9 @@ void AMSMaterialsSetting::on_select_filament(wxCommandEvent &evt) ams_filament_id = ""; ams_setting_id = ""; - if (preset_bundle) { - for (auto it = preset_bundle->filaments.begin(); it != preset_bundle->filaments.end(); it++) { - auto itor = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); - if ( itor != map_filament_items.end()) { - ams_filament_id = itor->second.filament_id; - ams_setting_id = itor->second.setting_id; - break; - } - - if (it->alias.compare(m_comboBox_filament->GetValue().ToStdString()) == 0) { - ams_filament_id = it->filament_id; - ams_setting_id = it->setting_id; - break; - } - } + if (selected_info) { + ams_filament_id = selected_info->filament_id; + ams_setting_id = selected_info->setting_id; } wxArrayString items; diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 84688896e6..f00e55d0dc 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -139,6 +139,7 @@ protected: void create_panel_kn(wxWindow* parent); void on_dpi_changed(const wxRect &suggested_rect) override; void on_select_filament(wxCommandEvent& evt); + void on_toggle_favorite_filament(wxCommandEvent& evt); void on_select_cali_result(wxCommandEvent &evt); void on_select_ok(wxCommandEvent &event); void on_select_reset(wxCommandEvent &event); @@ -146,6 +147,10 @@ protected: void on_clr_picker(wxMouseEvent &event); bool is_virtual_tray(); void update_widgets(); + void rebuild_filament_dropdown_items(const std::string& selected_filament_id); + std::set load_favorite_filament_ids() const; + void save_favorite_filament_ids(const std::set& favorite_ids); + void toggle_favorite_filament(const std::string& filament_id); void update_filament_editing(bool is_printing); @@ -183,11 +188,7 @@ protected: int m_pa_cali_select_id = 0; -#ifdef __APPLE__ - wxComboBox *m_comboBox_filament; -#else ComboBox *m_comboBox_filament; -#endif ComboBox * m_comboBox_cali_result; TextInput* m_readonly_filament; @@ -195,7 +196,15 @@ protected: std::string filament_id; std::string setting_id; }; + struct FilamentListItem { + FilamentInfos filament; + bool is_filament{false}; + }; + const FilamentInfos* selected_filament_info() const; + std::map map_filament_items; + std::vector m_sorted_filament_items; + std::vector m_filament_list_items; }; wxDECLARE_EVENT(EVT_SELECTED_COLOR, wxCommandEvent); From 391c53a8da83c350c2913066a4e4d187581414ab Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Thu, 1 Oct 2026 09:15:15 +0200 Subject: [PATCH 03/45] Take the print config by reference while simplifying extrusions simplify_path, simplify_multi_path and simplify_loop each copied the whole PrintConfig, 10520 bytes with around 200 heap owning members, once per extrusion entity, from inside the parallel_for over all layers. LayerRegion.cpp already binds a reference a few hundred lines above. Three more sites in Layer.cpp do the same thing. The tree support changes in the same area: draw_circles() dropped layers from an owning vector without deleting them, so every re-slice leaked the layers above the topmost overhang. std::stable_partition rather than std::remove_if, because remove_if leaves moved-from duplicates of kept pointers in the tail and deleting those would be a double free. The collision and avoidance accessors returned ExPolygons by value out of a cache that hands them out by reference, several times per node per layer. The preview cache is released when the support step finishes instead of at the start of the next slice; nothing reads it in between. set_shared_object() now releases the layers the object still owns before it starts aliasing another object's. Both clear functions are no-ops once the alias is set, so this cannot free the sharee's layers. PrintObjectSlice.cpp: eps was a function local static inside a parallel_for, so slice_closing_radius was latched once per process. This one changes output for projects that override it per object, or on a re-slice after editing it. The old behaviour was undefined: whichever object won the race set it for the session. --- src/libslic3r/Layer.cpp | 6 ++-- src/libslic3r/LayerRegion.cpp | 6 ++-- src/libslic3r/Print.cpp | 24 ++++++++------- src/libslic3r/PrintObject.cpp | 3 ++ src/libslic3r/PrintObjectSlice.cpp | 2 +- src/libslic3r/Support/TreeSupport.cpp | 42 ++++++++------------------- src/libslic3r/Support/TreeSupport.hpp | 4 +-- 7 files changed, 37 insertions(+), 50 deletions(-) diff --git a/src/libslic3r/Layer.cpp b/src/libslic3r/Layer.cpp index 7bdef5a4ff..cec91c4ede 100644 --- a/src/libslic3r/Layer.cpp +++ b/src/libslic3r/Layer.cpp @@ -360,7 +360,7 @@ void Layer::simplify_support_entity_collection(ExtrusionEntityCollection* entity //BBS: method to simplify support path void Layer::simplify_support_path(ExtrusionPath * path) { - const auto print_config = this->object()->print()->config(); + const PrintConfig &print_config = this->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); @@ -375,7 +375,7 @@ void Layer::simplify_support_path(ExtrusionPath * path) //BBS: method to simplify support path void Layer::simplify_support_multi_path(ExtrusionMultiPath* multipath) { - const auto print_config = this->object()->print()->config(); + const PrintConfig &print_config = this->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); @@ -392,7 +392,7 @@ void Layer::simplify_support_multi_path(ExtrusionMultiPath* multipath) //BBS: method to simplify support path void Layer::simplify_support_loop(ExtrusionLoop* loop) { - const auto print_config = this->object()->print()->config(); + const PrintConfig &print_config = this->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); diff --git a/src/libslic3r/LayerRegion.cpp b/src/libslic3r/LayerRegion.cpp index 6f868ba095..7780c0b63b 100644 --- a/src/libslic3r/LayerRegion.cpp +++ b/src/libslic3r/LayerRegion.cpp @@ -1074,7 +1074,7 @@ void LayerRegion::simplify_entity_collection(ExtrusionEntityCollection* entity_c void LayerRegion::simplify_path(ExtrusionPath* path) { - const auto print_config = this->layer()->object()->print()->config(); + const PrintConfig &print_config = this->layer()->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); @@ -1092,7 +1092,7 @@ void LayerRegion::simplify_path(ExtrusionPath* path) void LayerRegion::simplify_multi_path(ExtrusionMultiPath* multipath) { - const auto print_config = this->layer()->object()->print()->config(); + const PrintConfig &print_config = this->layer()->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); @@ -1112,7 +1112,7 @@ void LayerRegion::simplify_multi_path(ExtrusionMultiPath* multipath) void LayerRegion::simplify_loop(ExtrusionLoop* loop) { - const auto print_config = this->layer()->object()->print()->config(); + const PrintConfig &print_config = this->layer()->object()->print()->config(); const bool spiral_mode = print_config.spiral_mode; const bool enable_arc_fitting = print_config.enable_arc_fitting; const auto scaled_resolution = scaled(print_config.resolution.value); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 5f5592cfce..2d11f975dc 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2604,6 +2604,11 @@ void Print::auto_assign_extruders(ModelObject* model_object) const void PrintObject::set_shared_object(PrintObject *object) { + // Orca: from now on m_layers / m_support_layers only alias the shared object's layers, so release the + // ones this object still owns (it may have sliced itself before it became shareable again). + // Both are no-ops once m_shared_object is set, so this cannot free layers owned by another object. + clear_support_layers(); + clear_layers(); m_shared_object = object; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": this=%1%, found shared object from %2%")%this%m_shared_object; } @@ -4329,9 +4334,9 @@ bool Print::is_dynamic_group_reorder() const return true; } -int Print::get_filament_config_indx(int filament_id, int layer_id, bool use_cache) +int Print::get_filament_config_indx(int filament_id, int layer_id) { - return get_config_index(filament_id, layer_id, m_config.filament_extruder_variant.values, m_filament_self_index, use_cache ? &m_filament_index_map : nullptr); + return get_config_index(filament_id, layer_id, m_config.filament_extruder_variant.values, m_filament_self_index, m_filament_index_map); } void Print::update_filament_self_index_cache() @@ -4374,7 +4379,7 @@ int Print::get_nozzle_config_index(int filament_id, int layer_id) return get_config_index(filament_id, layer_id, m_default_region_config.print_extruder_variant.values, m_default_region_config.print_extruder_id.values, m_nozzle_index_map); } -int Print::get_config_index(int filament_id, int layer_id, const std::vector &variant_list, const std::vector& self_index_list, FilamentIndexMap *index_map) +int Print::get_config_index(int filament_id, int layer_id, const std::vector &variant_list, const std::vector& self_index_list, FilamentIndexMap &index_map) { auto group_result = get_layered_nozzle_group_result(); // Orca: defensive — when no grouping producer has published a result yet, fall back to the @@ -4385,8 +4390,7 @@ int Print::get_config_index(int filament_id, int layer_id, const std::vectorextruder_id)); NozzleVolumeType nozzle_volume_type = nozzle_info->volume_type; - if (!index_map) - return get_config_index_base(nozzle_volume_type, extruder_type, filament_id + 1, variant_list, self_index_list); FilamentIndexKey key{filament_id, extruder_type, nozzle_volume_type}; - auto iter = index_map->find(key); - if (iter == index_map->end()) { + auto iter = index_map.find(key); + if (iter == index_map.end()) { int index = get_config_index_base(nozzle_volume_type, extruder_type, filament_id + 1, variant_list, self_index_list); - (*index_map)[key] = index; + index_map[key] = index; return index; } else { - return iter->second; + return index_map[key]; } } diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 3b7e889472..e9b3e0379b 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -987,6 +987,9 @@ void PrintObject::generate_support_material() this->_generate_support_material(); m_print->throw_if_canceled(); } + // Orca: the tree support collision/avoidance caches and support nodes are only used while this step runs + // (detect_overhangs() rebuilds them from scratch), so don't keep them resident until the next slice. + this->clear_tree_support_preview_cache(); this->set_done(posSupportMaterial); } } diff --git a/src/libslic3r/PrintObjectSlice.cpp b/src/libslic3r/PrintObjectSlice.cpp index 14bd65c9cf..36f0afa1ba 100644 --- a/src/libslic3r/PrintObjectSlice.cpp +++ b/src/libslic3r/PrintObjectSlice.cpp @@ -1346,7 +1346,7 @@ void PrintObject::slice_volumes() if (min_growth < 0.f || elfoot > 0.f) { // Apply the negative XY compensation. (the ones that is <0) ExPolygons trimming; - static const float eps = float(scale_(m_config.slice_closing_radius.value) * 1.5); + const float eps = float(scale_(m_config.slice_closing_radius.value) * 1.5); if (elfoot > 0.f) { ExPolygons expolygons_to_compensate = offset_ex(layer->merged(eps), -eps); lslices_elfoot_uncompensated[layer_id] = expolygons_to_compensate; diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 519b6e826e..4da67f68a3 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -1820,37 +1820,15 @@ coordf_t TreeSupport::get_radius(const SupportNode* node) return node->radius; } -ExPolygons TreeSupport::get_avoidance(coordf_t radius, size_t obj_layer_nr) +// Orca: these are hit up to several times per node per layer in drop_nodes(), so hand out a +// reference into the TreeSupportData cache instead of copying the ExPolygons out of it. +const ExPolygons& TreeSupport::get_avoidance(coordf_t radius, size_t obj_layer_nr) { -#if USE_SUPPORT_3D - if (m_model_volumes) { - bool on_build_plate = m_object_config->support_on_build_plate_only.value; - const Polygons& avoid_polys = m_model_volumes->getAvoidance(radius, obj_layer_nr, TreeSupport3D::TreeModelVolumes::AvoidanceType::FastSafe, on_build_plate, true); - ExPolygons expolys; - for (auto& poly : avoid_polys) - expolys.emplace_back(std::move(poly)); - return expolys; - } - return ExPolygons(); -#else return m_ts_data->get_avoidance(radius, obj_layer_nr); -#endif } -ExPolygons TreeSupport::get_collision(coordf_t radius, size_t layer_nr) +const ExPolygons& TreeSupport::get_collision(coordf_t radius, size_t layer_nr) { -#if USE_SUPPORT_3D - if (m_model_volumes) { - bool on_build_plate = m_object_config->support_on_build_plate_only.value; - const Polygons& collision_polys = m_model_volumes->getCollision(radius, layer_nr, true); - ExPolygons expolys; - for (auto& poly : collision_polys) - expolys.emplace_back(std::move(poly)); - return expolys; - } -#else return m_ts_data->get_collision(radius, layer_nr); -#endif - return ExPolygons(); } Polygons TreeSupport::get_collision_polys(coordf_t radius, size_t layer_nr) { @@ -2639,7 +2617,11 @@ void TreeSupport::draw_circles() #endif // SUPPORT_TREE_DEBUG_TO_SVG SupportLayerPtrs& ts_layers = m_object->support_layers(); - auto iter = std::remove_if(ts_layers.begin(), ts_layers.end(), [](SupportLayer* ts_layer) { return ts_layer->height < EPSILON; }); + // Orca: the vector owns its layers, so the dropped ones have to be deleted, not just unlinked. + // std::stable_partition (unlike std::remove_if) leaves exactly the dropped layers in the tail. + auto iter = std::stable_partition(ts_layers.begin(), ts_layers.end(), [](SupportLayer* ts_layer) { return ts_layer->height >= EPSILON; }); + for (auto it = iter; it != ts_layers.end(); ++it) + delete *it; ts_layers.erase(iter, ts_layers.end()); for (int layer_nr = 0; layer_nr < ts_layers.size(); layer_nr++) { ts_layers[layer_nr]->upper_layer = layer_nr != ts_layers.size() - 1 ? ts_layers[layer_nr + 1] : nullptr; @@ -2882,7 +2864,7 @@ void TreeSupport::drop_nodes() //Insert a completely new node and let both original nodes fade. Point next_position = (node.position + neighbours[0]) / 2; //Average position of the two nodes. coordf_t next_radius = calc_radius(node.dist_mm_to_top+height_next); - auto avoid_layer = get_avoidance(next_radius, obj_layer_nr_next); + const ExPolygons& avoid_layer = get_avoidance(next_radius, obj_layer_nr_next); if (group_index == 0) { //Avoid collisions. @@ -3069,7 +3051,7 @@ void TreeSupport::drop_nodes() } #endif coordf_t next_radius = calc_radius(node.dist_mm_to_top + height_next); - auto avoidance_next = get_avoidance(next_radius, obj_layer_nr_next); + const ExPolygons& avoidance_next = get_avoidance(next_radius, obj_layer_nr_next); Point to_outside = projection_onto(avoidance_next, node.position); Point direction_to_outer = to_outside - node.position; @@ -3123,7 +3105,7 @@ void TreeSupport::drop_nodes() if (is_outside) { next_layer_vertex = candidate_vertex; } } } - auto next_collision = get_collision(0, obj_layer_nr_next); + const ExPolygons& next_collision = get_collision(0, obj_layer_nr_next); const bool to_buildplate = !is_inside_ex(m_ts_data->m_layer_outlines[obj_layer_nr_next], next_layer_vertex); // don't increase radius if next node will collide partially with the object (STUDIO-7883) to_outside = projection_onto(next_collision, next_layer_vertex); diff --git a/src/libslic3r/Support/TreeSupport.hpp b/src/libslic3r/Support/TreeSupport.hpp index bd5a154e26..e2b3921b82 100644 --- a/src/libslic3r/Support/TreeSupport.hpp +++ b/src/libslic3r/Support/TreeSupport.hpp @@ -511,9 +511,9 @@ private: coordf_t calc_branch_radius(coordf_t base_radius, coordf_t mm_to_top, double diameter_angle_scale_factor, bool use_min_distance=true); coordf_t calc_radius(coordf_t mm_to_top); coordf_t get_radius(const SupportNode* node); - ExPolygons get_avoidance(coordf_t radius, size_t obj_layer_nr); + const ExPolygons& get_avoidance(coordf_t radius, size_t obj_layer_nr); // layer's expolygon expanded by radius+m_xy_distance - ExPolygons get_collision(coordf_t radius, size_t layer_nr); + const ExPolygons& get_collision(coordf_t radius, size_t layer_nr); // get Polygons instead of ExPolygons Polygons get_collision_polys(coordf_t radius, size_t layer_nr); From bea412fddfa894143cfa641fd1ab24e7c478a496 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Thu, 1 Oct 2026 09:15:15 +0200 Subject: [PATCH 04/45] Move the G-code processing result instead of copying it GCodeProcessorResult declares a copy assignment, which suppresses the implicit move assignment, so `*result = std::move(processor.extract_result())` binds to the copy and duplicates the whole moves array: 96 bytes per move, measured at 147.7 MiB in one allocation for a 597k facet model at 0.08 mm, with the source staying alive until the export returns. Add the move assignment. It assigns exactly the same 39 members in the same order as the copy, including the ones the copy deliberately leaves alone, so a target that carries its own filament maps and nozzle type keeps them. A move constructor is intentionally not added: the type holds a std::mutex, so it is neither copy nor move constructible today and a partial one would leave ten members uninitialised. reset() now releases the two print sized vectors rather than clearing them. Its callers are the paths that discard a result, so the memory went back only at the next slice. ViewerImpl::reset() does the same for the two vectors sized by the print, which is what keeps a discarded preview resident, and counts m_vertices_colors in the reported CPU memory, where it was missing. --- src/libslic3r/GCode/GCodeProcessor.cpp | 8 +++-- src/libslic3r/GCode/GCodeProcessor.hpp | 50 ++++++++++++++++++++++++++ src/libvgcode/src/ViewerImpl.cpp | 9 +++-- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index e03be228d0..505a28335d 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2582,8 +2582,12 @@ void GCodeProcessorResult::reset() { //BBS: add mutex for protection of gcode result lock(); - moves.clear(); - lines_ends.clear(); + // release rather than clear: these two are sized by the print - one entry per move and one + // per g-code line - and a reset is where the memory is expected to go back to the allocator + // (see BackgroundSlicingProcess::apply()). The capacity would not be reused anyway: the + // result is refilled by move-assigning the processor's own result. + moves = std::vector(); + lines_ends = std::vector(); printable_area = Pointfs(); //BBS: add bed exclude area bed_exclude_area = Pointfs(); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 192a80a43d..a99a5188d6 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -369,6 +369,56 @@ class Print; initial_layer_time = other.initial_layer_time; #if ENABLE_GCODE_VIEWER_STATISTICS time = other.time; +#endif + return *this; + } + // Orca: the user-declared copy assignment above suppresses the implicit move assignment, so + // `*result = std::move(processor.extract_result())` used to deep copy 'moves' (one MoveVertex + // per move, gigabytes on a large print) while the source stayed alive. This moves exactly the + // same members as the copy above, with the same omissions, so the members the copy leaves + // untouched on the target are left untouched here too. + GCodeProcessorResult& operator=(GCodeProcessorResult &&other) + { + filename = std::move(other.filename); + id = other.id; + moves = std::move(other.moves); + lines_ends = std::move(other.lines_ends); + printable_area = std::move(other.printable_area); + bed_exclude_area = std::move(other.bed_exclude_area); + wrapping_exclude_area = std::move(other.wrapping_exclude_area); + toolpath_outside = other.toolpath_outside; + label_object_enabled = other.label_object_enabled; + long_retraction_when_cut = other.long_retraction_when_cut; + timelapse_warning_code = other.timelapse_warning_code; + printable_height = other.printable_height; + settings_ids = std::move(other.settings_ids); + filaments_count = other.filaments_count; + extruder_colors = std::move(other.extruder_colors); + filament_diameters = std::move(other.filament_diameters); + filament_densities = std::move(other.filament_densities); + filament_costs = std::move(other.filament_costs); + print_statistics = std::move(other.print_statistics); + custom_gcode_per_print_z = std::move(other.custom_gcode_per_print_z); + spiral_vase_mode = other.spiral_vase_mode; + warnings = std::move(other.warnings); + bed_type = other.bed_type; + gcode_check_result = std::move(other.gcode_check_result); + limit_filament_maps = std::move(other.limit_filament_maps); + filament_printable_reuslt = std::move(other.filament_printable_reuslt); + nozzle_group_result = std::move(other.nozzle_group_result); + extruder_types = std::move(other.extruder_types); + printer_extruder_variant = std::move(other.printer_extruder_variant); + printer_extruder_id = std::move(other.printer_extruder_id); + layer_filaments = std::move(other.layer_filaments); + filament_change_sequence = std::move(other.filament_change_sequence); + used_mixed_filaments = std::move(other.used_mixed_filaments); + nozzle_change_sequence = std::move(other.nozzle_change_sequence); + optimal_assignment = std::move(other.optimal_assignment); + filament_change_count_map = std::move(other.filament_change_count_map); + skippable_part_time = std::move(other.skippable_part_time); + initial_layer_time = other.initial_layer_time; +#if ENABLE_GCODE_VIEWER_STATISTICS + time = other.time; #endif return *this; } diff --git a/src/libvgcode/src/ViewerImpl.cpp b/src/libvgcode/src/ViewerImpl.cpp index 8212717931..e3e106bb59 100644 --- a/src/libvgcode/src/ViewerImpl.cpp +++ b/src/libvgcode/src/ViewerImpl.cpp @@ -883,15 +883,17 @@ void ViewerImpl::reset() m_used_extruders.clear(); m_total_time = { 0.0f, 0.0f }; m_travels_time = { 0.0f, 0.0f }; - m_vertices.clear(); - m_vertices_colors.clear(); // swap rather than clear: these are sized by the print, and a reset means the memory // should go back, not sit reserved until the next load + std::vector().swap(m_vertices); + std::vector().swap(m_vertices_colors); for (std::vector& times : m_layer_start_times) std::vector().swap(times); std::vector().swap(m_layer_first_vertex); std::vector().swap(m_colors_scratch); - m_valid_lines_bitset.clear(); + // BitSet::clear() only zeroes the bits, it keeps the blocks allocated; load() builds a new + // bitset anyway and it is never read while m_vertices is empty + m_valid_lines_bitset = BitSet<>(); #if VGCODE_ENABLE_COG_AND_TOOL_MARKERS m_cog_marker.reset(); #endif // VGCODE_ENABLE_COG_AND_TOOL_MARKERS @@ -1812,6 +1814,7 @@ size_t ViewerImpl::get_used_cpu_memory() const ret += sizeof(m_extrusion_roles_colors); ret += sizeof(m_options_colors); ret += STDVEC_MEMSIZE(m_vertices, PathVertex); + ret += STDVEC_MEMSIZE(m_vertices_colors, float); for (const std::vector& times : m_layer_start_times) ret += STDVEC_MEMSIZE(times, float); ret += STDVEC_MEMSIZE(m_layer_first_vertex, uint32_t); From fcb1f62bd93cd89bb822bb10d258fec138731ab7 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 5 Oct 2026 08:36:22 +0200 Subject: [PATCH 05/45] Save texture displacement in the project file A painted relief did not survive closing the project: nothing about the feature was written to the .3mf, so the layers, their textures and the paint mask were all lost on save. The layer struct's cereal save()/load() serve the undo/redo stack only, which is one session and one binary stream. Write the layer stack and the per-volume options as JSON in the archive, and each layer's texture as the image file it was loaded from, both referenced by path - the same split EmbossShape already makes for its SVG. The image stays out of the XML deliberately: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file every reader parses just to list the objects. JSON rather than the cereal stream next to it because that one is positional and unversioned, which would make every future field a project-breaking change; an unknown key is ignored and a missing one keeps its default, so a project written by either side keeps loading. The paint masks go in as one attribute per layer slot, mirroring paint_color. Older readers ignore attributes they do not know, so a project written here still opens in a build without the feature - it loses the relief, which is all it could have done with it anyway. The layers are restored while the volume is being built rather than after the archive is walked: volumes are constructed only once the whole archive has been read, so a hook placed after the walk ran before any volume existed and restored nothing. --- src/libslic3r/Format/bbs_3mf.cpp | 152 ++++++++++- src/libslic3r/TextureDisplacement.cpp | 242 ++++++++++++++++++ src/libslic3r/TextureDisplacement.hpp | 17 ++ tests/libslic3r/test_texture_displacement.cpp | 136 ++++++++++ 4 files changed, 546 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 160c169d69..72e3d979da 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -298,6 +298,13 @@ static constexpr const char* CUSTOM_SUPPORTS_ATTR = "paint_supports"; static constexpr const char* CUSTOM_FUZZY_SKIN_ATTR = "paint_fuzzy_skin"; static constexpr const char* CUSTOM_SEAM_ATTR = "paint_seam"; static constexpr const char* MMU_SEGMENTATION_ATTR = "paint_color"; +// Texture displacement. One paint mask per layer slot, mirroring paint_color; the layer stack itself is +// a JSON file in the archive, named by the volume metadata key below (see add_texture_displacement()). +static constexpr const char* TEXTURE_DISPLACEMENT_ATTRS[Slic3r::TEXTURE_DISPLACEMENT_MAX_LAYERS] = { + "paint_texture_0", "paint_texture_1", "paint_texture_2", "paint_texture_3", + "paint_texture_4", "paint_texture_5", "paint_texture_6", "paint_texture_7" }; +static constexpr const char* TEXTURE_DISPLACEMENT_KEY = "texture_displacement"; +static constexpr const char* TEXTURE_DISPLACEMENT_DIR = "Metadata/texture_displacement/"; // BBS static constexpr const char* FACE_PROPERTY_ATTR = "face_property"; @@ -785,6 +792,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) std::vector custom_seam; std::vector mmu_segmentation; std::vector fuzzy_skin; + // One per texture displacement layer slot, each parallel to `triangles` like the masks above. + std::vector texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS]; // BBS std::vector face_properties; @@ -805,6 +814,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) custom_seam.clear(); mmu_segmentation.clear(); fuzzy_skin.clear(); + for (std::vector &slot : texture_displacement) + slot.clear(); } }; @@ -1138,6 +1149,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) /*IdToSlaSupportPointsMap m_sla_support_points; IdToSlaDrainHolesMap m_sla_drain_holes;*/ PathToEmbossShapeFileMap m_path_to_emboss_shape_files; + // Texture displacement: every file under Metadata/texture_displacement/, by archive path. Volumes + // are built only after the whole archive has been walked, so by the time a volume names its JSON + // every file it could refer to is already in here, whatever order the archive happened to be in. + std::map m_texture_displacement_files; + void _apply_texture_displacement(ModelVolume &volume, const std::string &json_path); std::string m_curr_metadata_name; std::string m_curr_characters; std::string m_name; @@ -2004,6 +2020,13 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) else if (_is_svg_shape_file(name)) { _extract_embossed_svg_shape_file(name, archive, stat); } + else if (boost::algorithm::istarts_with(name, TEXTURE_DISPLACEMENT_DIR)) { + std::string contents(stat.m_uncomp_size, '\0'); + if (mz_zip_reader_extract_to_mem(&archive, stat.m_file_index, contents.data(), stat.m_uncomp_size, 0)) + m_texture_displacement_files.emplace(name, std::move(contents)); + else + add_error("Error while reading texture displacement data"); + } else if (!dont_load_config && boost::algorithm::iequals(name, SLICE_INFO_CONFIG_FILE)) { m_parsing_slice_info = true; //extract slice info from archive @@ -3239,6 +3262,41 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) } }*/ + // Restores one volume's texture displacement stack from the JSON it named. Called while the volume is + // being built, which is after the whole archive has been walked - so every file it can refer to is + // already extracted, and no deferral is needed. + // + // A missing or malformed JSON leaves the volume with no layers but keeps the paint mask it already + // loaded, which is the same state as a project saved by a build without the feature: recoverable by + // picking the texture again rather than a hard failure. + void _BBS_3MF_Importer::_apply_texture_displacement(ModelVolume &volume, const std::string &json_path) + { + const auto json = m_texture_displacement_files.find(json_path); + if (json == m_texture_displacement_files.end()) { + add_error("Missing texture displacement data: " + json_path); + return; + } + std::vector layers; + TextureDisplacementOptions options; + if (!texture_displacement_layers_from_json(json->second, layers, options)) { + add_error("Malformed texture displacement data: " + json_path); + return; + } + for (TextureDisplacementLayer &layer : layers) { + if (layer.path_in_3mf.empty()) + continue; + const auto image = m_texture_displacement_files.find(layer.path_in_3mf); + if (image == m_texture_displacement_files.end()) { + add_error("Missing texture displacement image: " + layer.path_in_3mf); + continue; + } + layer.image_data = std::make_shared>(image->second.begin(), + image->second.end()); + } + volume.texture_displacement_layers = std::move(layers); + volume.texture_displacement_options = options; + } + void _BBS_3MF_Importer::_extract_embossed_svg_shape_file(const std::string &filename, mz_zip_archive &archive, const mz_zip_archive_file_stat &stat){ assert(m_path_to_emboss_shape_files.find(filename) == m_path_to_emboss_shape_files.end()); auto file = std::make_unique(stat.m_uncomp_size, '\0'); @@ -3888,6 +3946,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_curr_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR)); m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + m_curr_object->geometry.texture_displacement[slot].push_back( + bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); // BBS m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -5214,6 +5275,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume->mmu_segmentation_facets.touch(); volume->fuzzy_skin_facets.shrink_to_fit(); volume->fuzzy_skin_facets.touch(); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + const std::vector &mask = sub_object->geometry.texture_displacement[slot]; + if (mask.empty()) + continue; // written by a build without the feature + FacetsAnnotation &facets = volume->texture_displacement_facet(slot); + facets.reserve(triangles_count); + for (size_t i = 0; i < triangles_count && i < mask.size(); ++i) + if (!mask[i].empty()) + facets.set_triangle_from_string(i, mask[i]); + facets.shrink_to_fit(); + facets.touch(); + } } volume->set_type(volume_data->part_type); @@ -5229,6 +5302,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) for (const Metadata& metadata : volume_data->metadata) { if (metadata.key == NAME_KEY) volume->name = metadata.value; + else if (metadata.key == TEXTURE_DISPLACEMENT_KEY) + _apply_texture_displacement(*volume, metadata.value); //else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1")) // volume->set_type(ModelVolumeType::PARAMETER_MODIFIER); //for old format @@ -5393,12 +5468,28 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) volume->seam_facets.shrink_to_fit(); volume->mmu_segmentation_facets.shrink_to_fit(); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + const std::vector &mask = geometry.texture_displacement[slot]; + if (mask.empty()) + continue; // written by a build without the feature + FacetsAnnotation &facets = volume->texture_displacement_facet(slot); + facets.reserve(triangles_count); + for (size_t i = 0; i < triangles_count; ++i) { + const size_t index = volume_data.first_triangle_id + i; + if (index < mask.size() && !mask[index].empty()) + facets.set_triangle_from_string(i, mask[index]); + } + facets.shrink_to_fit(); + facets.touch(); + } volume->set_type(volume_data.part_type); // apply the remaining volume's metadata for (const Metadata& metadata : volume_data.metadata) { if (metadata.key == NAME_KEY) volume->name = metadata.value; + else if (metadata.key == TEXTURE_DISPLACEMENT_KEY) + _apply_texture_displacement(*volume, metadata.value); //else if ((metadata.key == MODIFIER_KEY) && (metadata.value == "1")) // volume->set_type(ModelVolumeType::PARAMETER_MODIFIER); //for old format @@ -5695,6 +5786,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) current_object->geometry.custom_seam.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_SEAM_ATTR)); current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + current_object->geometry.texture_displacement[slot].push_back( + bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); // BBS current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -6698,6 +6792,9 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) stream << " \n"; stream << " \n"; stream << " \n"; + stream << " \n"; + stream << " \n"; + stream << " \n"; stream << " \n"; stream << ""; @@ -7541,6 +7638,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) output_buffer += "\""; } + // One attribute per texture displacement layer slot. Older readers ignore attributes they do + // not know, so a project written here still opens in a build without the feature - it just + // loses the paint, which is also all it could have done with it. + for (int slot = 0; slot < TEXTURE_DISPLACEMENT_MAX_LAYERS; ++slot) { + const std::string texture_paint = volume->texture_displacement_facet(slot).get_triangle_as_string(i); + if (texture_paint.empty()) + continue; + output_buffer += " "; + output_buffer += TEXTURE_DISPLACEMENT_ATTRS[slot]; + output_buffer += "=\""; + output_buffer += texture_paint; + output_buffer += "\""; + } + // BBS if (i < its.properties.size()) { std::string prop_str = its.properties[i].to_string(); @@ -7990,7 +8101,44 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return file_path; } - bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id) + // Writes a volume's texture displacement stack into the archive and points the volume's metadata at it. +// The layer settings go in as JSON and each layer's texture as the image file it was loaded from, both +// referenced by path - the same split EmbossShape makes for its SVG. The image deliberately stays out of +// the XML: it is binary and routinely megabytes, and base64 in an attribute would bloat the one file +// every reader has to parse just to list the objects. +static void add_texture_displacement(std::stringstream &stream, const ModelVolume &volume, mz_zip_archive &archive, + const std::string &id) +{ + if (volume.texture_displacement_layers.empty()) + return; + + // A copy, because path_in_3mf is only meaningful inside the archive being written and the volume + // being exported is const (and may be saved again, elsewhere, with different paths). + std::vector layers = volume.texture_displacement_layers; + for (TextureDisplacementLayer &layer : layers) { + layer.path_in_3mf.clear(); + if (!layer.image_data || layer.image_data->empty()) + continue; + std::string ext = boost::filesystem::path(layer.path).extension().string(); + boost::to_lower(ext); + if (ext != ".png" && ext != ".jpg" && ext != ".jpeg") + ext = ".png"; // the library ships PNG; anything unrecognised is stored under a type a reader expects + const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ext; + // No deflate: PNG and JPEG are already compressed, so a second pass only costs time. + if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(), + MZ_NO_COMPRESSION)) + layer.path_in_3mf = path; + } + + const std::string json = texture_displacement_layers_to_json(layers, volume.texture_displacement_options); + const std::string json_path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + ".json"; + if (!mz_zip_writer_add_mem(&archive, json_path.c_str(), json.data(), json.size(), MZ_DEFAULT_COMPRESSION)) + return; + stream << " <" << METADATA_TAG << " " << KEY_ATTR << "=\"" << TEXTURE_DISPLACEMENT_KEY << "\" " + << VALUE_ATTR << "=\"" << xml_escape(json_path) << "\"/>\n"; +} + +bool _BBS_3MF_Exporter::_add_model_config_file_to_archive(mz_zip_archive& archive, const Model& model, PlateDataPtrs& plate_data_list, const ObjectToObjectDataMap &objects_data, const DynamicPrintConfig& config, int export_plate_idx, bool save_gcode, bool use_loaded_id) { std::stringstream stream; // Store mesh transformation in full precision, as the volumes are stored transformed and they need to be transformed back @@ -8101,6 +8249,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (const std::optional &es = volume->emboss_shape; es.has_value()) { to_xml(stream, *es, *volume, archive, m_fullpath_sources); } + + add_texture_displacement(stream, *volume, archive, std::to_string(volume->id().id)); if (const std::optional &tc = volume->text_configuration; tc.has_value()) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 862c6c9044..86ae498567 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -14,6 +14,7 @@ #include #include #include +#include "nlohmann/json.hpp" #include #include #include @@ -5041,4 +5042,245 @@ indexed_triangle_set cut_mesh_at_steps(const indexed_triangle_set &mesh, const s return out; } +// --------------------------------------------------------------------------------------------- +// Project persistence. See the declarations in TextureDisplacement.hpp for why this is JSON rather +// than the cereal stream next to them. +// --------------------------------------------------------------------------------------------- +namespace { + +nlohmann::json vec2_to_json(const Vec2f &v) { return nlohmann::json::array({ v.x(), v.y() }); } +nlohmann::json vec3_to_json(const Vec3f &v) { return nlohmann::json::array({ v.x(), v.y(), v.z() }); } + +Vec2f vec2_from_json(const nlohmann::json &j, const Vec2f &fallback) +{ + if (!j.is_array() || j.size() != 2) + return fallback; + return Vec2f(j[0].get(), j[1].get()); +} +Vec3f vec3_from_json(const nlohmann::json &j, const Vec3f &fallback) +{ + if (!j.is_array() || j.size() != 3) + return fallback; + return Vec3f(j[0].get(), j[1].get(), j[2].get()); +} + +// Reads key `k` into `out` when it is present and of the expected type, leaving `out` alone otherwise. +// That "leave it alone" is the whole point: every member starts at its struct default, so a project +// written by an older build simply keeps the defaults for whatever it did not know about. +template void read(const nlohmann::json &j, const char *k, T &out) +{ + const auto it = j.find(k); + if (it == j.end()) + return; + try { + out = it->get(); + } catch (...) { + } +} +void read_enum(const nlohmann::json &j, const char *k, int &out) +{ + const auto it = j.find(k); + if (it != j.end() && it->is_number_integer()) + out = it->get(); +} + +} // namespace + +std::string texture_displacement_layers_to_json(const std::vector &layers, + const TextureDisplacementOptions &options) +{ + nlohmann::json root; + root["version"] = 1; + + nlohmann::json &opt = root["options"]; + opt["displace_border"] = options.displace_border; + opt["smooth_enabled"] = options.smooth_enabled; + opt["smooth_strength"] = options.smooth_strength; + opt["smooth_iterations"] = options.smooth_iterations; + opt["smooth_skip_border"] = options.smooth_skip_border; + opt["pipeline_v2"] = options.pipeline_v2; + opt["v2_refine_mm"] = options.v2_refine_mm; + opt["v2_regularize"] = options.v2_regularize; + opt["v2_max_triangles_k"] = options.v2_max_triangles_k; + opt["v2_relocate"] = options.v2_relocate; + opt["v2_flip_edges"] = options.v2_flip_edges; + opt["color_mix_enabled"] = options.color_mix_enabled; + opt["color_mix_mode"] = int(options.color_mix_mode); + opt["color_despeckle"] = options.color_despeckle; + + nlohmann::json &arr = root["layers"]; + arr = nlohmann::json::array(); + for (const TextureDisplacementLayer &l : layers) { + nlohmann::json j; + j["slot"] = l.slot; + j["name"] = l.name; + j["path"] = l.path; + j["path_in_3mf"] = l.path_in_3mf; + + j["depth_mm"] = l.depth_mm; + j["tiling_scale"] = l.tiling_scale; + j["rotation_deg"] = l.rotation_deg; + j["offset"] = vec2_to_json(l.offset); + j["invert"] = l.invert; + j["midlevel"] = l.midlevel; + j["smoothing"] = l.smoothing; + + j["edge_smoothing"] = l.edge_smoothing; + j["edge_smoothing_amount"] = l.edge_smoothing_amount; + j["auto_connect_islands"] = l.auto_connect_islands; + + j["tile_enabled"] = l.tile_enabled; + j["tile_method"] = int(l.tile_method); + j["projection_method"] = int(l.projection_method); + j["blend_mode"] = int(l.blend_mode); + j["color_enabled"] = l.color_enabled; + + j["lscm_seam_angle_deg"] = l.lscm_seam_angle_deg; + j["island_padding_mm"] = l.island_padding_mm; + + j["view_project_right"] = vec3_to_json(l.view_project_right); + j["view_project_up"] = vec3_to_json(l.view_project_up); + j["view_project_projective"] = l.view_project_projective; + j["view_project_matrix"] = l.view_project_matrix; + + // Flat pairs rather than nested arrays: shorter, and the reader can simply ignore a trailing + // odd element instead of having to validate every sub-array. + nlohmann::json &seams = j["lscm_seam_edges"]; + seams = nlohmann::json::array(); + for (const auto &[a, b] : l.lscm_seam_edges) { + seams.push_back(a); + seams.push_back(b); + } + + nlohmann::json &islands = j["islands"]; + islands = nlohmann::json::array(); + for (const TextureIsland &island : l.islands) + islands.push_back(nlohmann::json::array( + { island.offset.x(), island.offset.y(), island.rotation_deg, island.scale })); + + j["island_groups"] = l.island_groups; + + nlohmann::json &overrides = j["lscm_uv_overrides"]; + overrides = nlohmann::json::array(); + for (const auto &[key, uv] : l.lscm_uv_overrides) + overrides.push_back(nlohmann::json::array({ key, uv.x(), uv.y() })); + + arr.push_back(std::move(j)); + } + return root.dump(); +} + +bool texture_displacement_layers_from_json(const std::string &text, + std::vector &layers, + TextureDisplacementOptions &options) +{ + nlohmann::json root; + try { + root = nlohmann::json::parse(text); + } catch (...) { + return false; + } + if (!root.is_object()) + return false; + + TextureDisplacementOptions out_options; + if (const auto it = root.find("options"); it != root.end() && it->is_object()) { + const nlohmann::json &opt = *it; + read(opt, "displace_border", out_options.displace_border); + read(opt, "smooth_enabled", out_options.smooth_enabled); + read(opt, "smooth_strength", out_options.smooth_strength); + read(opt, "smooth_iterations", out_options.smooth_iterations); + read(opt, "smooth_skip_border", out_options.smooth_skip_border); + read(opt, "pipeline_v2", out_options.pipeline_v2); + read(opt, "v2_refine_mm", out_options.v2_refine_mm); + read(opt, "v2_regularize", out_options.v2_regularize); + read(opt, "v2_max_triangles_k", out_options.v2_max_triangles_k); + read(opt, "v2_relocate", out_options.v2_relocate); + read(opt, "v2_flip_edges", out_options.v2_flip_edges); + read(opt, "color_mix_enabled", out_options.color_mix_enabled); + read(opt, "color_despeckle", out_options.color_despeckle); + int mix_mode = int(out_options.color_mix_mode); + read_enum(opt, "color_mix_mode", mix_mode); + out_options.color_mix_mode = ColorMixMode(std::clamp(mix_mode, 0, 2)); + } + + std::vector out_layers; + if (const auto it = root.find("layers"); it != root.end() && it->is_array()) { + for (const nlohmann::json &j : *it) { + if (!j.is_object()) + continue; + TextureDisplacementLayer l; + read(j, "slot", l.slot); + read(j, "name", l.name); + read(j, "path", l.path); + read(j, "path_in_3mf", l.path_in_3mf); + + read(j, "depth_mm", l.depth_mm); + read(j, "tiling_scale", l.tiling_scale); + read(j, "rotation_deg", l.rotation_deg); + if (const auto o = j.find("offset"); o != j.end()) + l.offset = vec2_from_json(*o, l.offset); + read(j, "invert", l.invert); + read(j, "midlevel", l.midlevel); + read(j, "smoothing", l.smoothing); + + read(j, "edge_smoothing", l.edge_smoothing); + read(j, "edge_smoothing_amount", l.edge_smoothing_amount); + read(j, "auto_connect_islands", l.auto_connect_islands); + + read(j, "tile_enabled", l.tile_enabled); + read(j, "color_enabled", l.color_enabled); + int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); + read_enum(j, "tile_method", tile_method); + read_enum(j, "projection_method", projection); + read_enum(j, "blend_mode", blend); + l.tile_method = TextureTileMethod(tile_method); + l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); + l.blend_mode = TextureBlendMode(blend); + + read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); + read(j, "island_padding_mm", l.island_padding_mm); + + if (const auto v = j.find("view_project_right"); v != j.end()) + l.view_project_right = vec3_from_json(*v, l.view_project_right); + if (const auto v = j.find("view_project_up"); v != j.end()) + l.view_project_up = vec3_from_json(*v, l.view_project_up); + read(j, "view_project_projective", l.view_project_projective); + if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) + for (size_t i = 0; i < 12; ++i) + l.view_project_matrix[i] = (*v)[i].get(); + + if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) + for (size_t i = 0; i + 1 < v->size(); i += 2) + l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); + + if (const auto v = j.find("islands"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 4) + continue; + TextureIsland island; + island.offset = Vec2f(e[0].get(), e[1].get()); + island.rotation_deg = e[2].get(); + island.scale = e[3].get(); + l.islands.push_back(island); + } + + read(j, "island_groups", l.island_groups); + + if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 3) + continue; + l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); + } + + out_layers.push_back(std::move(l)); + } + } + + layers = std::move(out_layers); + options = out_options; + return true; +} + } // namespace Slic3r diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index e97178abca..154ae540c0 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -420,6 +420,23 @@ struct TextureDisplacementOptions } }; +// Project persistence (see bbs_3mf.cpp). The layer stack and the per-volume options are written to the +// .3mf as JSON rather than through the cereal save()/load() above: those two are positional and +// unversioned, which is right for the undo/redo stack they serve (one session, one binary) but would +// make every future field a project-breaking change. A JSON object tolerates both directions - an +// unknown key is ignored, a missing one keeps the member's default - so old projects keep loading and +// new ones degrade gracefully in older builds. +// +// The texture image itself is *not* in here. It is a binary blob that belongs in the archive as a file +// of its own, exactly as EmbossShape stores its SVG; `path_in_3mf` names that file, and the caller is +// responsible for writing it and for filling `image_data` back in on load. +std::string texture_displacement_layers_to_json(const std::vector &layers, + const TextureDisplacementOptions &options); +// Returns false and leaves both outputs untouched when the text is not valid JSON. +bool texture_displacement_layers_from_json(const std::string &text, + std::vector &layers, + TextureDisplacementOptions &options); + // How much detail a height texture carries: central differences of the grey image, the mean gradient // and the share of texels steeper than 30 grey levels, mapped to how many texels one mesh edge may // span (1 for a hard-edged image, 4 for a smooth one). Cached per image, like the decode. diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 04713d3fd9..609a0571ab 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2112,3 +2112,139 @@ TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[T CHECK(second <= single * 5 / 4); } + +// The .3mf stores the layer stack as JSON (see texture_displacement_layers_to_json). Two properties +// matter: everything the UV editor and the panel can set has to survive a round trip, and a document +// written by a build that knew fewer fields has to keep loading, with the missing ones left at their +// defaults rather than zeroed. +TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisplacement]") +{ + std::vector layers(2); + TextureDisplacementLayer &a = layers[0]; + a.slot = 0; + a.name = "Bark"; + a.path = "/textures/bark.png"; + a.depth_mm = 1.25f; + a.tiling_scale = 7.5f; + a.rotation_deg = 30.f; + a.offset = Vec2f(0.25f, -0.5f); + a.invert = true; + a.midlevel = 0.125f; + a.smoothing = 0.75f; + a.edge_smoothing = true; + a.edge_smoothing_amount = 0.25f; + a.auto_connect_islands = false; + a.tile_enabled = false; + a.tile_method = TextureTileMethod::Mirror; + a.projection_method = TextureProjectionMethod::LSCM; + a.blend_mode = TextureBlendMode::Subtract; + a.color_enabled = true; + a.lscm_seam_angle_deg = 45.f; + a.island_padding_mm = 0.5f; + a.lscm_seam_edges = { { 1, 2 }, { 3, 5 } }; + a.islands = { TextureIsland{ Vec2f(1.f, 2.f), 15.f, 1.5f }, TextureIsland{} }; + a.island_groups = { 0, 0 }; + a.lscm_uv_overrides = { { -4, Vec2f(0.5f, 0.75f) } }; + + TextureDisplacementLayer &b = layers[1]; + b.slot = 1; + b.projection_method = TextureProjectionMethod::ViewProjected; + b.view_project_right = Vec3f(0.f, 1.f, 0.f); + b.view_project_up = Vec3f(0.f, 0.f, 1.f); + b.view_project_projective = true; + for (size_t i = 0; i < b.view_project_matrix.size(); ++i) + b.view_project_matrix[i] = float(i) + 0.5f; + + TextureDisplacementOptions options; + options.displace_border = false; + options.smooth_enabled = true; + options.smooth_strength = 0.6f; + options.smooth_iterations = 5; + options.pipeline_v2 = false; + options.v2_max_triangles_k = 250; + options.color_mix_mode = ColorMixMode::XYDither; + options.color_despeckle = 4; + + const std::string json = texture_displacement_layers_to_json(layers, options); + + std::vector read_layers; + TextureDisplacementOptions read_options; + REQUIRE(texture_displacement_layers_from_json(json, read_layers, read_options)); + REQUIRE(read_layers.size() == layers.size()); + + const TextureDisplacementLayer &ra = read_layers[0]; + CHECK(ra.name == a.name); + CHECK(ra.path == a.path); + CHECK(ra.depth_mm == Approx(a.depth_mm)); + CHECK(ra.tiling_scale == Approx(a.tiling_scale)); + CHECK(ra.rotation_deg == Approx(a.rotation_deg)); + CHECK(ra.offset.isApprox(a.offset)); + CHECK(ra.invert == a.invert); + CHECK(ra.midlevel == Approx(a.midlevel)); + CHECK(ra.smoothing == Approx(a.smoothing)); + CHECK(ra.edge_smoothing == a.edge_smoothing); + CHECK(ra.edge_smoothing_amount == Approx(a.edge_smoothing_amount)); + CHECK(ra.auto_connect_islands == a.auto_connect_islands); + CHECK(ra.tile_enabled == a.tile_enabled); + CHECK(ra.tile_method == a.tile_method); + CHECK(ra.projection_method == a.projection_method); + CHECK(ra.blend_mode == a.blend_mode); + CHECK(ra.color_enabled == a.color_enabled); + CHECK(ra.lscm_seam_angle_deg == Approx(a.lscm_seam_angle_deg)); + CHECK(ra.island_padding_mm == Approx(a.island_padding_mm)); + CHECK(ra.lscm_seam_edges == a.lscm_seam_edges); + REQUIRE(ra.islands.size() == a.islands.size()); + CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset)); + CHECK(ra.islands[0].rotation_deg == Approx(a.islands[0].rotation_deg)); + CHECK(ra.islands[0].scale == Approx(a.islands[0].scale)); + CHECK(ra.island_groups == a.island_groups); + REQUIRE(ra.lscm_uv_overrides.size() == 1); + CHECK(ra.lscm_uv_overrides[0].first == -4); + CHECK(ra.lscm_uv_overrides[0].second.isApprox(Vec2f(0.5f, 0.75f))); + + const TextureDisplacementLayer &rb = read_layers[1]; + CHECK(rb.projection_method == TextureProjectionMethod::ViewProjected); + CHECK(rb.view_project_right.isApprox(b.view_project_right)); + CHECK(rb.view_project_up.isApprox(b.view_project_up)); + CHECK(rb.view_project_projective); + CHECK(rb.view_project_matrix == b.view_project_matrix); + + CHECK(read_options.displace_border == options.displace_border); + CHECK(read_options.smooth_enabled == options.smooth_enabled); + CHECK(read_options.smooth_strength == Approx(options.smooth_strength)); + CHECK(read_options.smooth_iterations == options.smooth_iterations); + CHECK(read_options.pipeline_v2 == options.pipeline_v2); + CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); + CHECK(read_options.color_mix_mode == options.color_mix_mode); + CHECK(read_options.color_despeckle == options.color_despeckle); +} + +TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]") +{ + // What a build that knew fewer fields would have written: one layer, almost nothing set. + const std::string sparse = R"({"version":1,"layers":[{"slot":2,"name":"Old","depth_mm":0.75}]})"; + + std::vector layers; + TextureDisplacementOptions options; + REQUIRE(texture_displacement_layers_from_json(sparse, layers, options)); + REQUIRE(layers.size() == 1); + + const TextureDisplacementLayer &l = layers[0]; + const TextureDisplacementLayer fresh; + const TextureDisplacementOptions defaults; + CHECK(l.slot == 2); + CHECK(l.name == "Old"); + CHECK(l.depth_mm == Approx(0.75f)); + // Everything absent keeps the struct's own default rather than becoming zero. + CHECK(l.tiling_scale == Approx(fresh.tiling_scale)); + CHECK(l.auto_connect_islands == fresh.auto_connect_islands); + CHECK(l.tile_enabled == fresh.tile_enabled); + CHECK(l.projection_method == fresh.projection_method); + CHECK(l.lscm_seam_angle_deg == Approx(fresh.lscm_seam_angle_deg)); + CHECK(options.color_mix_mode == defaults.color_mix_mode); + CHECK(options.pipeline_v2 == defaults.pipeline_v2); + + std::vector unused_layers; + TextureDisplacementOptions unused_options; + CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options)); +} From fbf4652e12d864411ea6fbc92554cdecb069bbd6 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 9 Oct 2026 09:05:42 +0200 Subject: [PATCH 06/45] Fix the build and clang-tidy failures in the persistence change The round-trip test named a TextureTileMethod enumerator that does not exist (MirroredRepeat, not Mirror) and used a bare Approx, which Catch2 v3 keeps in its own namespace. The rest of the file compares floats with WithinAbs, so these follow it rather than reaching for Catch::Approx. bbs_3mf.cpp reads and writes the layer JSON but picked up the declarations through another header, and the same for boost::to_lower. --- src/libslic3r/Format/bbs_3mf.cpp | 2 ++ tests/libslic3r/test_texture_displacement.cpp | 30 +++++++++---------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 14e8468cfe..0241692e7b 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -21,6 +21,7 @@ #include "libslic3r/BrimEarsPoint.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/CustomGCode.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r_version.h" #include "bbs_3mf.hpp" @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 0c6aa0e94b..29e6540c0a 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2158,7 +2158,7 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp a.edge_smoothing_amount = 0.25f; a.auto_connect_islands = false; a.tile_enabled = false; - a.tile_method = TextureTileMethod::Mirror; + a.tile_method = TextureTileMethod::MirroredRepeat; a.projection_method = TextureProjectionMethod::LSCM; a.blend_mode = TextureBlendMode::Subtract; a.color_enabled = true; @@ -2198,28 +2198,28 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp const TextureDisplacementLayer &ra = read_layers[0]; CHECK(ra.name == a.name); CHECK(ra.path == a.path); - CHECK(ra.depth_mm == Approx(a.depth_mm)); - CHECK(ra.tiling_scale == Approx(a.tiling_scale)); - CHECK(ra.rotation_deg == Approx(a.rotation_deg)); + CHECK_THAT(ra.depth_mm, WithinAbs(a.depth_mm, 1e-6f)); + CHECK_THAT(ra.tiling_scale, WithinAbs(a.tiling_scale, 1e-6f)); + CHECK_THAT(ra.rotation_deg, WithinAbs(a.rotation_deg, 1e-6f)); CHECK(ra.offset.isApprox(a.offset)); CHECK(ra.invert == a.invert); - CHECK(ra.midlevel == Approx(a.midlevel)); - CHECK(ra.smoothing == Approx(a.smoothing)); + CHECK_THAT(ra.midlevel, WithinAbs(a.midlevel, 1e-6f)); + CHECK_THAT(ra.smoothing, WithinAbs(a.smoothing, 1e-6f)); CHECK(ra.edge_smoothing == a.edge_smoothing); - CHECK(ra.edge_smoothing_amount == Approx(a.edge_smoothing_amount)); + CHECK_THAT(ra.edge_smoothing_amount, WithinAbs(a.edge_smoothing_amount, 1e-6f)); CHECK(ra.auto_connect_islands == a.auto_connect_islands); CHECK(ra.tile_enabled == a.tile_enabled); CHECK(ra.tile_method == a.tile_method); CHECK(ra.projection_method == a.projection_method); CHECK(ra.blend_mode == a.blend_mode); CHECK(ra.color_enabled == a.color_enabled); - CHECK(ra.lscm_seam_angle_deg == Approx(a.lscm_seam_angle_deg)); - CHECK(ra.island_padding_mm == Approx(a.island_padding_mm)); + CHECK_THAT(ra.lscm_seam_angle_deg, WithinAbs(a.lscm_seam_angle_deg, 1e-6f)); + CHECK_THAT(ra.island_padding_mm, WithinAbs(a.island_padding_mm, 1e-6f)); CHECK(ra.lscm_seam_edges == a.lscm_seam_edges); REQUIRE(ra.islands.size() == a.islands.size()); CHECK(ra.islands[0].offset.isApprox(a.islands[0].offset)); - CHECK(ra.islands[0].rotation_deg == Approx(a.islands[0].rotation_deg)); - CHECK(ra.islands[0].scale == Approx(a.islands[0].scale)); + CHECK_THAT(ra.islands[0].rotation_deg, WithinAbs(a.islands[0].rotation_deg, 1e-6f)); + CHECK_THAT(ra.islands[0].scale, WithinAbs(a.islands[0].scale, 1e-6f)); CHECK(ra.island_groups == a.island_groups); REQUIRE(ra.lscm_uv_overrides.size() == 1); CHECK(ra.lscm_uv_overrides[0].first == -4); @@ -2234,7 +2234,7 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp CHECK(read_options.displace_border == options.displace_border); CHECK(read_options.smooth_enabled == options.smooth_enabled); - CHECK(read_options.smooth_strength == Approx(options.smooth_strength)); + CHECK_THAT(read_options.smooth_strength, WithinAbs(options.smooth_strength, 1e-6f)); CHECK(read_options.smooth_iterations == options.smooth_iterations); CHECK(read_options.pipeline_v2 == options.pipeline_v2); CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); @@ -2257,13 +2257,13 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", const TextureDisplacementOptions defaults; CHECK(l.slot == 2); CHECK(l.name == "Old"); - CHECK(l.depth_mm == Approx(0.75f)); + CHECK_THAT(l.depth_mm, WithinAbs(0.75f, 1e-6f)); // Everything absent keeps the struct's own default rather than becoming zero. - CHECK(l.tiling_scale == Approx(fresh.tiling_scale)); + CHECK_THAT(l.tiling_scale, WithinAbs(fresh.tiling_scale, 1e-6f)); CHECK(l.auto_connect_islands == fresh.auto_connect_islands); CHECK(l.tile_enabled == fresh.tile_enabled); CHECK(l.projection_method == fresh.projection_method); - CHECK(l.lscm_seam_angle_deg == Approx(fresh.lscm_seam_angle_deg)); + CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f)); CHECK(options.color_mix_mode == defaults.color_mix_mode); CHECK(options.pipeline_v2 == defaults.pipeline_v2); From 8ecfee6a2160d5a30d622573da62d82a6a10f476 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 9 Oct 2026 09:35:35 +0200 Subject: [PATCH 07/45] Save where the panel was left, and recover a texture the archive lacks The project carried the layer stack but not the user's place in it: a reopened project landed on slot 0 in Standard, whatever they had been editing. The active layer and Standard/Pro now live on the volume, next to the rest of its options, and come back with it. Docked/undocked goes to the application config instead - it describes the user's window, not the model, so opening someone else's project must not move their panel. A layer whose image_data was empty at save time was written with no texture and came back with none. It now falls back to the file the layer came from, which the importer put in the user's own texture library, so a project reopened on the same machine keeps its pixels. The texture is always stored as .png: the importer converts whatever the user picked, so the extension was being taken from a name that no longer described the bytes. Also adds v2_flip_edges to the cereal field list, which the undo stack was silently dropping. --- src/libslic3r/Format/bbs_3mf.cpp | 44 +++++++++++++------ src/libslic3r/TextureDisplacement.cpp | 6 +++ src/libslic3r/TextureDisplacement.hpp | 9 +++- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 30 +++++++++++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 10 ++++- tests/libslic3r/test_texture_displacement.cpp | 7 +++ 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 0241692e7b..d5895603e6 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -33,7 +33,6 @@ #include #include #include -#include #include #include #include @@ -64,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -3346,16 +3346,34 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) add_error("Malformed texture displacement data: " + json_path); return; } + // A layer the archive carries no image for - saved while its texture had not been loaded, or + // written by a build that stored only the path - falls back to the file it came from. The + // importer copies every texture into the user's own library, so that path resolves on the + // machine the project was made on and quietly resolves nowhere else, which is the old + // behaviour: settings restored, pixels not. + const auto load_image_from_disk = [](TextureDisplacementLayer &layer) { + boost::system::error_code ec; + if (layer.path.empty() || !boost::filesystem::is_regular_file(layer.path, ec) || ec) + return; + boost::nowide::ifstream file(layer.path, std::ios::binary); + if (!file.good()) + return; + std::vector bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + if (!bytes.empty()) + layer.image_data = std::make_shared>(std::move(bytes)); + }; + for (TextureDisplacementLayer &layer : layers) { - if (layer.path_in_3mf.empty()) - continue; - const auto image = m_texture_displacement_files.find(layer.path_in_3mf); - if (image == m_texture_displacement_files.end()) { + if (!layer.path_in_3mf.empty()) { + if (const auto image = m_texture_displacement_files.find(layer.path_in_3mf); + image != m_texture_displacement_files.end()) { + layer.image_data = std::make_shared>(image->second.begin(), + image->second.end()); + continue; + } add_error("Missing texture displacement image: " + layer.path_in_3mf); - continue; } - layer.image_data = std::make_shared>(image->second.begin(), - image->second.end()); + load_image_from_disk(layer); } volume.texture_displacement_layers = std::move(layers); volume.texture_displacement_options = options; @@ -8183,12 +8201,10 @@ static void add_texture_displacement(std::stringstream &stream, const ModelVolum layer.path_in_3mf.clear(); if (!layer.image_data || layer.image_data->empty()) continue; - std::string ext = boost::filesystem::path(layer.path).extension().string(); - boost::to_lower(ext); - if (ext != ".png" && ext != ".jpg" && ext != ".jpeg") - ext = ".png"; // the library ships PNG; anything unrecognised is stored under a type a reader expects - const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ext; - // No deflate: PNG and JPEG are already compressed, so a second pass only costs time. + // Always .png: the importer converts whatever the user picked (jpg, bmp, ...) to an 8-bit + // greyscale PNG, so image_data is PNG whatever layer.path happens to be named. + const std::string path = std::string(TEXTURE_DISPLACEMENT_DIR) + id + "_" + std::to_string(layer.slot) + ".png"; + // No deflate: a PNG is already compressed, so a second pass only costs time. if (mz_zip_writer_add_mem(&archive, path.c_str(), layer.image_data->data(), layer.image_data->size(), MZ_NO_COMPRESSION)) layer.path_in_3mf = path; diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index c89ee713e3..1b910e1006 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -5207,6 +5207,8 @@ std::string texture_displacement_layers_to_json(const std::vector out_layers; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 7c724b900b..c6ebe6c416 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -412,12 +412,19 @@ struct TextureDisplacementOptions // finer than the mesh leaves behind, without eating features that are genuinely a facet wide. int color_despeckle = 2; + // Where the panel was left, rather than anything the bake reads. It lives with the volume because + // that is what it describes: reopening a project should put the user back on the layer they were + // editing, in the mode they were editing it in, not on slot 0 in Standard. + int active_slot = 0; + int panel_mode = 0; // 0 Standard, 1 Pro + template void serialize(Archive &ar) { int mix_mode = int(color_mix_mode); ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border, pipeline_v2, v2_refine_mm, v2_regularize, v2_max_triangles_k, - v2_relocate, color_mix_enabled, mix_mode, color_despeckle); + v2_relocate, v2_flip_edges, color_mix_enabled, mix_mode, color_despeckle, + active_slot, panel_mode); color_mix_mode = ColorMixMode(mix_mode); } }; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index c467a7768d..3a4a32d00b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3329,6 +3329,7 @@ const ModelVolume* GLGizmoTextureDisplacement::texture_volume() const void GLGizmoTextureDisplacement::update_model_object() { + store_panel_state(); bool updated = false; ModelObject *mo = m_c->selection_info()->model_object(); int idx = -1; @@ -3364,6 +3365,24 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) { wxBusyCursor wait; + // Opening the gizmo puts the panel back where it was left: the layer and the mode come off the + // volume, because they are the user's place in this model's stack, and the dock state off the + // application config, because it is about their window. Only on open - this also runs on an + // undo/redo and on a layer switch, where overriding what the user is doing now would be wrong. + if (first_update) { + if (const ModelVolume *mv = texture_volume(); mv != nullptr) { + const TextureDisplacementOptions &opts = mv->texture_displacement_options; + m_panel_mode = std::clamp(opts.panel_mode, 0, 1); + // Only if that slot still holds a layer: the stack can have been edited by anything that + // does not go through the panel, and a selection pointing at nothing shows an empty panel. + const auto &stack = mv->texture_displacement_layers; + if (std::any_of(stack.begin(), stack.end(), + [&opts](const TextureDisplacementLayer &l) { return l.slot == opts.active_slot; })) + m_active_layer_slot = opts.active_slot; + } + m_undocked = wxGetApp().app_config->get_bool(UNDOCKED_CONFIG_KEY); + } + const ModelObject *mo = m_c->selection_info()->model_object(); m_triangle_selectors.clear(); @@ -3409,6 +3428,14 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) rebuild_preview(); } +void GLGizmoTextureDisplacement::store_panel_state() +{ + if (ModelVolume *mv = texture_volume(); mv != nullptr) { + mv->texture_displacement_options.active_slot = m_active_layer_slot; + mv->texture_displacement_options.panel_mode = m_panel_mode; + } +} + void GLGizmoTextureDisplacement::set_active_layer(int slot) { if (slot == m_active_layer_slot) @@ -3417,6 +3444,7 @@ void GLGizmoTextureDisplacement::set_active_layer(int slot) // reflect - otherwise they would be silently lost. update_model_object(); m_active_layer_slot = slot; + store_panel_state(); update_from_model_object(false); // The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync // instead of leaving it pointing at the previous layer's (now stale) paint patch. @@ -5590,6 +5618,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "not what you wanted and you know why.")); if (clicked && !on) { m_panel_mode = m; + store_panel_state(); if (!pro_mode()) { // Leaving the subdivision preview open would strand a wireframe whose controls just // disappeared, so close it as part of the switch. @@ -5612,6 +5641,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float _L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside " "the toolbar."))) m_undocked = !m_undocked; + wxGetApp().app_config->set_bool(UNDOCKED_CONFIG_KEY, m_undocked); } ImGui::Separator(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 2c6f5bc6c7..32dd66c99c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -206,6 +206,9 @@ private: // Standard is active so what Preview shows is always what Bake will do. Returns true if it actually // changed something, so the caller can invalidate the preview. bool apply_standard_mode_presets(ModelVolume *mv); + // Copies the panel's own state (active layer, Standard/Pro) onto the volume, so the project file + // can carry it. Cheap and idempotent - called wherever either of the two changes. + void store_panel_state(); // Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace. // The order matters and is the whole reason this is one button - a height map can only move // existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop @@ -904,9 +907,12 @@ private: PatchUnwrap m_uv_editor_unwrap; // When set, the panel is a free-floating window the user can drag anywhere (with a title bar to - // grab), instead of being pinned to the right of the gizmo toolbar. Persisted across gizmo - // open/close within a session, so the choice sticks while working. + // grab), instead of being pinned to the right of the gizmo toolbar. Kept in the application + // config rather than in the project: it describes the user's window, not the model, so opening + // someone else's project must not move their panel. bool m_undocked = false; + // The config key behind m_undocked. + static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked"; // Smooth scrolling for the panel body (everything between the header and the pinned Bake footer). // ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 29e6540c0a..0e617a17f1 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2187,6 +2187,8 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp options.v2_max_triangles_k = 250; options.color_mix_mode = ColorMixMode::XYDither; options.color_despeckle = 4; + options.active_slot = 3; + options.panel_mode = 1; const std::string json = texture_displacement_layers_to_json(layers, options); @@ -2240,6 +2242,9 @@ TEST_CASE("Texture displacement layers survive a JSON round trip", "[TextureDisp CHECK(read_options.v2_max_triangles_k == options.v2_max_triangles_k); CHECK(read_options.color_mix_mode == options.color_mix_mode); CHECK(read_options.color_despeckle == options.color_despeckle); + // Where the panel was left comes back with the rest, so a reopened project is on the same layer. + CHECK(read_options.active_slot == options.active_slot); + CHECK(read_options.panel_mode == options.panel_mode); } TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", "[TextureDisplacement]") @@ -2266,6 +2271,8 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", CHECK_THAT(l.lscm_seam_angle_deg, WithinAbs(fresh.lscm_seam_angle_deg, 1e-6f)); CHECK(options.color_mix_mode == defaults.color_mix_mode); CHECK(options.pipeline_v2 == defaults.pipeline_v2); + CHECK(options.active_slot == defaults.active_slot); + CHECK(options.panel_mode == defaults.panel_mode); std::vector unused_layers; TextureDisplacementOptions unused_options; From 546b9c621d3ee4915f6c4b347c605cc755cb3052 Mon Sep 17 00:00:00 2001 From: Boris Samorodov Date: Sun, 11 Oct 2026 00:49:25 +0200 Subject: [PATCH 08/45] Fix Qidi start G-code ignoring the selected plate type The start G-code of fdm_qidi_x3_common, Qidi Q1 Pro 0.4 nozzle and Qidi X-CF Pro 0.4 nozzle heated the bed to hot_plate_temp_initial_layer, so with "Support multi bed types" enabled every plate type got the Smooth PEI / High Temp Plate temperature. Use bed_temperature_initial_layer_single, which follows the selected plate. Co-Authored-By: Claude Opus 5.5 --- resources/profiles/Qidi.json | 2 +- resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json | 2 +- resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json | 2 +- resources/profiles/Qidi/machine/fdm_qidi_x3_common.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/profiles/Qidi.json b/resources/profiles/Qidi.json index 4b6496df31..dcb39ee957 100644 --- a/resources/profiles/Qidi.json +++ b/resources/profiles/Qidi.json @@ -1,6 +1,6 @@ { "name": "Qidi", - "version": "02.04.00.16", + "version": "02.04.00.17", "force_update": "0", "description": "Qidi configurations", "machine_model_list": [ diff --git a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json index bd2398ae7a..30db0fc68b 100644 --- a/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi Q1 Pro 0.4 nozzle.json @@ -83,7 +83,7 @@ "extruder_clearance_height_to_lid": "120", "layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]\nLOG_Z\nG92 E0\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", - "machine_start_gcode": "PRINT_START BED=[hot_plate_temp_initial_layer] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[hot_plate_temp_initial_layer]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", + "machine_start_gcode": "PRINT_START BED=[bed_temperature_initial_layer_single] HOTEND=[nozzle_temperature_initial_layer] CHAMBER=[chamber_temperature]\nM83\nM140 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1] - 5, first_layer_print_min[1] + 80) - 85), 0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 3} Z0\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85), 0) + 6}\nG1 Z1 F600\n", "thumbnails_format": "PNG", "default_filament_profile": [ "Generic PLA @Qidi Q1 Pro 0.4 nozzle" diff --git a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json index a41e2b6a11..968f7f63a9 100644 --- a/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json +++ b/resources/profiles/Qidi/machine/Qidi X-CF Pro 0.4 nozzle.json @@ -100,7 +100,7 @@ "default_filament_profile": [ "Generic PLA @Qidi" ], - "machine_start_gcode": "G28\nM140 S[hot_plate_temp_initial_layer]\nM190 S[hot_plate_temp_initial_layer]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", + "machine_start_gcode": "G28\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\nM109 S[nozzle_temperature_initial_layer]\nG92 E-19\nG0 Y5 Z0.3 F3600\nG1 X5 E0 F2400\n", "machine_end_gcode": "M104 S0\nM140 S0\n;Retract the filament\nG92 E0\nG1 E-3 F300\nG28\nM84\n", "scan_first_layer": "0" } diff --git a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json index 9796856379..8fe450c3c6 100644 --- a/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json +++ b/resources/profiles/Qidi/machine/fdm_qidi_x3_common.json @@ -24,7 +24,7 @@ "210x210/COLPIC", "110x110/PNG" ], - "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[hot_plate_temp_initial_layer]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", + "machine_start_gcode": "PRINT_START\nG28\nM141 S0\nG0 Z50 F600\nM190 S[bed_temperature_initial_layer_single]\nG28 Z\nG29; mesh bed leveling ,comment this code to close it\nG0 X0 Y0 Z50 F6000\nM141 S{overall_chamber_temperature}\nM109 S[nozzle_temperature_initial_layer]\nM106 P3 S255\nM83\nG4 P3000\nG0 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0)} Z5 F6000\nG0 Z[initial_layer_print_height] F600\nG1 E3 F1800\nG1 X{(min(print_bed_max[0], first_layer_print_min[0] + 80))} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0)} E{85 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 85} E{83 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 2} E{2 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 Y{max((min(print_bed_max[1], first_layer_print_min[1] + 80) - 85),0) + 3} E{82 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 X{max((min(print_bed_max[0], first_layer_print_min[0] + 80) - 85),0) + 12} E{-10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\nG1 E{10 * 0.5 * initial_layer_print_height * nozzle_diameter[0]} F3000\n", "machine_end_gcode": "M141 S0\nM104 S0\nM140 S0\nG1 E-3 F1800\nG0 Z{min(max_print_height, max_layer_z + 3)} F600\nG0 X0 Y0 F12000\n{if max_layer_z < max_print_height / 2}G1 Z{max_print_height / 2 + 10} F600{else}G1 Z{min(max_print_height, max_layer_z + 3)}{endif}", "scan_first_layer": "0" } From f8cf34c55b588799e8df8a451611b69a4ace9513 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:42:19 +0800 Subject: [PATCH 09/45] Reset the Fan Mover Instead of Releasing It (#16386) Clearing per-export state called unique_ptr::release(), which gives up ownership without deleting. Every export uses a new GCode, so the pointer is still empty there today and nothing leaks; reset() states the intent and would free the object if a GCode were ever reused. --- src/libslic3r/GCode.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index cab75d5034..ec7ef83a3d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3295,7 +3295,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato m_is_role_based_fan_on.fill(false); m_role_based_fan_marker_layer.fill(-1); - m_fan_mover.release(); + m_fan_mover.reset(); m_ordering_cache.clear(); // Belt printer: initialize belt-specific writer via virtual hook. From ed1f3e52c91843323052d881a307020b2324383b Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 07:57:07 +0200 Subject: [PATCH 10/45] Texture displacement: typed values on every panel slider Every slider in the texture displacement panel now takes an exact number, either by Ctrl+clicking the track or through a small edit icon at its right end. Both routes end in ImGui's own temp-input mode: the icon only asks ImGui to focus the slider, which is what turns it into an input box, so the parsing, the clamping and the Enter/click-away behaviour stay ImGui's rather than becoming a second implementation. The mechanism lives in one helper, typed_row(), with a float and an int form over it. float_row() and int_row() are thin wrappers on those, and the six sliders that previously built their widget inline (brush size, smart fill angle, edge fade amount, remesh sharp angle, refine resolution and the uniform triangle budget) now go through the same path. Each slider carries a tooltip with its description and its range. The range is derived from the bounds and the format string the slider is already given, so no row states them twice and the tooltip cannot drift from the track. The tooltip allows hovering while disabled, since several rows are drawn inside disabled_begin(), where a greyed slider is exactly when the explanation is wanted. The panel's "?" popup gains an entry for each of the two ways in, appended to every tool's shortcut list and shown on their own when no painting tool is selected, where that popup was previously empty. --- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 333 ++++++++++++------ .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 9 + 2 files changed, 225 insertions(+), 117 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..4d9540bc09 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -494,7 +494,17 @@ bool GLGizmoTextureDisplacement::on_init() {ctrl + _L("Mouse wheel"), m_desc["gap_area"]}, toggle_wireframe_shortcut }; - + + // Both ways into the same input box on any slider in the panel: ImGui turns a slider into one when + // it is Ctrl+clicked or when it is given the keyboard focus, which is what the edit icon asks for. + m_desc["type_value"] = _L("Type an exact value into a slider"); + m_shortcuts_sliders = { + {ctrl + _L("Left mouse button"), m_desc["type_value"]}, + {_L("Edit icon at a slider's right end"), m_desc["type_value"]}, + }; + for (std::vector> *list : {&m_shortcuts_brush, &m_shortcuts_bucket_fill, &m_shortcuts_gap_fill}) + list->insert(list->end(), m_shortcuts_sliders.begin(), m_shortcuts_sliders.end()); + return true; } @@ -3680,6 +3690,8 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", + // The "type a value instead" icon at the right end of every slider. + "edit.svg", }; std::vector paths; paths.reserve(names.size()); @@ -5919,8 +5931,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } return s + "..."; }; - // Label column + a slider filling the rest of the row, value printed inside the track. Ctrl+click on - // any of these types a value in. `right_inset` keeps a layer card's inner padding. + // Label column + a slider filling the rest of the row, value printed inside the track. + // `right_inset` keeps a layer card's inner padding. const auto slider_label = [&](const wxString &label) { const float x0 = ImGui::GetCursorPosX(); ImGui::AlignTextToFramePadding(); @@ -5928,17 +5940,107 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); }; + + // ---- Typed values on sliders ---- + // Every slider in the panel goes through typed_slider(), so all of them take a number the same + // two ways: Ctrl+click on the track, or the edit icon at the right end for the same box without + // the modifier. Both end up in ImGui's own "temp input" mode - the icon only asks ImGui to focus + // the slider, which is what turns it into an input box (see SliderScalar) - so the parsing, + // the clamping and the Enter/click-away behaviour are ImGui's, not a second implementation. + + // Retires a click whose row is no longer being drawn - the panel changed mode in between - so that + // it cannot fire later when that row comes back. Before any row is drawn, so a live one still wins. + if (!m_slider_type_request.empty() && ImGui::GetFrameCount() > m_slider_type_request_frame + 1) + m_slider_type_request.clear(); + + // Exactly as tall as the slider it sits beside, so it lines up with the track without the row + // having to be re-centred around it; icon_button() pads the glyph itself down to about 0.7 of this. + const float edit_icon_w = frame_h; + // What the slider's own width has to give up for the icon beside it. + const float edit_icon_room = edit_icon_w + gap_s; + + // One end of a range, through the slider's own format, so the bounds in the tooltip read exactly + // like the value in the track. A parenthesised note in the format - "(auto)" - is dropped: it + // describes the current state, not the bound. + const auto fmt_bound = [](const char *format, auto v) { + char buf[64]; + std::snprintf(buf, sizeof(buf), format, v); + std::string out(buf); + const size_t paren = out.find('('); + if (paren != std::string::npos) + out.erase(paren); + while (!out.empty() && out.back() == ' ') + out.pop_back(); + return out; + }; + const auto range_text = [](const std::string &lo, const std::string &hi) { + return Slic3r::format(_u8L("Range: %1% to %2%"), lo, hi); + }; + const auto edit_icon_tip = [&](const std::string &range) { + return _u8L("Type a value instead of dragging. Ctrl+click on the slider does the same.") + "\n\n" + range; + }; + + // Draws `slider` at `item_w` (SetNextItemWidth's convention: negative measures back from the right + // edge, and the caller has already left edit_icon_room in it), then the edit icon. `tip` and the + // range go on both. Returns whatever the slider returned. + const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::string &range, + const std::function &slider) -> bool { + // Deferred from the click a frame ago: SetKeyboardFocusHere() is a request for the *next* + // frame, so it has to be issued before the slider it names, not from the icon beside it. + if (m_slider_type_request == id) { + ImGui::SetKeyboardFocusHere(); + m_slider_type_request.clear(); + m_parent.set_as_dirty(); // the focus only lands on a frame that actually gets drawn + } + ImGui::SetNextItemWidth(item_w); + const bool changed = slider(); + // AllowWhenDisabled: several of these rows are drawn inside disabled_begin(), and a greyed + // slider is exactly when the user needs to be told what it is and why it is not moving. + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + wxString full = tip; + if (!full.IsEmpty()) + full += "\n\n"; + m_imgui->tooltip(full + from_u8(range), wrap_w); + } + // SameLine() puts the cursor back at the slider's own top (ItemSize records the Y the item was + // drawn at), so an icon of the same height lands on the track wherever the row placed it. + ImGui::SameLine(0.f, gap_s); + if (icon_button(int(ImGui::GetID(id)), "edit.svg", edit_icon_w, _L("Edit"), from_u8(edit_icon_tip(range)))) { + m_slider_type_request = id; + m_slider_type_request_frame = ImGui::GetFrameCount(); + m_parent.set_as_dirty(); // the next frame is where the request above is turned into focus + } + return changed; + }; + + // The two forms every slider in the panel is one of. They derive the range from the bounds they are + // already given, so no row states its bounds or its format twice and the tooltip cannot drift from + // the track. `item_w` is as typed_row() takes it. + const auto typed_slider = [&](const char *id, float item_w, float *v, float v_min, float v_max, const char *format, + bool log, const wxString &tip) -> bool { + return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + return ImGui::SliderFloat(id, v, v_min, v_max, format, + ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + }); + }; + const auto typed_slider_int = [&](const char *id, float item_w, int *v, int v_min, int v_max, const char *format, + bool log, const wxString &tip) -> bool { + return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + return ImGui::SliderInt(id, v, v_min, v_max, format, + ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + }); + }; + + // Label column + the slider filling the rest of the row. const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, const char *format, - bool log, float right_inset) -> bool { + bool log, float right_inset, const wxString &tip) -> bool { slider_label(label); - ImGui::SetNextItemWidth(-std::max(right_inset, 1.f)); - return ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip); }; const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max, const char *format, - float right_inset) -> bool { + float right_inset, const wxString &tip) -> bool { slider_label(label); - ImGui::SetNextItemWidth(-std::max(right_inset, 1.f)); - return ImGui::SliderInt(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp); + return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip); }; const auto layer_name = [](const TextureDisplacementLayer &l) { return l.name.empty() ? Slic3r::format(_u8L("Layer %1%"), l.slot + 1) : l.name; @@ -6160,11 +6262,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const auto centre_on_row = [&](float h) { ImGui::SetCursorPosY(row_y + std::round((icon_md - h) * 0.5f)); }; vsep(icon_md); if (is_brush_mode) { - ImGui::SetNextItemWidth(std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm))); centre_on_row(frame_h); - ImGui::SliderFloat("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", - ImGuiSliderFlags_AlwaysClamp); - hover_tip(m_desc.at("cursor_size")); + typed_slider("##cursor_radius", + std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm) - edit_icon_room), + &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size")); vsep(icon_sm); const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE; centre_on_row(icon_sm); @@ -6177,11 +6278,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float _L("Sphere - paints only within a ball around the point under the cursor"))) m_cursor_type = TriangleSelector::CursorType::SPHERE; } else if (is_area_mode) { - ImGui::SetNextItemWidth(std::max(1.f, row_end - ImGui::GetCursorPosX())); centre_on_row(frame_h); - ImGui::SliderFloat("##smart_fill_angle", &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", - ImGuiSliderFlags_AlwaysClamp); - hover_tip(_u8L("Angle threshold - the fill stops at edges sharper than this")); + typed_slider("##smart_fill_angle", std::max(1.f, row_end - ImGui::GetCursorPosX() - edit_icon_room), + &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false, + _L("Angle threshold - the fill stops at edges sharper than this")); } else { centre_on_row(frame_h); ImGui::AlignTextToFramePadding(); @@ -6451,16 +6551,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // The three controls nearly every layer needs; everything else waits behind "More settings". // Depth and tile size are logarithmic, so both ends of their wide ranges stay usable. - m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); - hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in " - "millimetres. Black does not move it at all, unless Midlevel says otherwise.")); - m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad); - hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern " - "more often and makes its detail finer; with Tile off, this is the size of the " - "single copy.")); - m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad); - hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an " - "edge of the model.")); + m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad, + _L("Height of the relief: how far white in the texture lifts the surface, in " + "millimetres. Black does not move it at all, unless Midlevel says otherwise.")); + m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad, + _L("How wide one copy of the texture is on the model. Smaller repeats the pattern " + "more often and makes its detail finer; with Tile off, this is the size of the " + "single copy.")); + m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad, + _L("Turns the texture on the surface, in degrees - for lining a pattern up with an " + "edge of the model.")); if (m_layer_expanded[size_t(layer.slot)]) { { @@ -6475,22 +6575,22 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Midlevel: the height that means "don't move". At 0 the surface only ever bulges outwards; // at 0.5 mid-grey is neutral and darker texels cut inwards. - m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); - hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " - "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " - "still push out - one image both embosses and engraves.\n\n" - "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " - "cut can pass through the other side.")); + m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad, + _L("Which gray stays where the surface already is. At 0 the texture only pushes " + "outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones " + "still push out - one image both embosses and engraves.\n\n" + "What cuts in has to fit: inside a sharp corner or through a thin wall, a deep " + "cut can pass through the other side.")); if (layer.midlevel > 0.f && layer.depth_mm > 1.f) { ImGui::PushTextWrapPos(content_rx - card_pad); m_imgui->warning_text(_L("Deep inward displacement may self-intersect.")); ImGui::PopTextWrapPos(); } - m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad); - hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes " - "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " - "costs fine detail.")); + m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad, + _L("Blurs the image before it is used, which rounds off hard steps and removes " + "speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it " + "costs fine detail.")); // Edge fade: the relief flattens toward the boundary of the painted area. m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing); @@ -6498,12 +6598,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "blends into the bare surface instead of stopping at a step.")); ImGui::SameLine(); m_imgui->disabled_begin(!layer.edge_smoothing); - ImGui::SetNextItemWidth(-card_pad); - m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", - ImGuiSliderFlags_AlwaysClamp); + m_preview_params_dirty |= typed_slider("##edge_amount", -(card_pad + edit_icon_room), + &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", false, + _L("How far in the fade reaches, as a share of the painted area. Small " + "values soften a narrow band at the edge; 1 flattens almost all of it.")); m_imgui->disabled_end(); - hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " - "soften a narrow band at the edge; 1 flattens almost all of it.")); // Invert and Colours share a row. { @@ -6548,19 +6647,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Every mix can become a filament slot, and there are only so many beside the // physical filaments. const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); - if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad)) + if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad, + _L("The most mixed filaments a bake may add. They are picked from the " + "texture's colors, and only the ones the bake actually uses are created."))) m_preview_params_dirty = true; - hover_tip(_u8L("The most mixed filaments a bake may add. They are picked from the " - "texture's colors, and only the ones the bake actually uses are created.")); // After the slider, so a change shows in the same frame. ImGui::TextDisabled("%s", Slic3r::format(_u8L("%1% printable colors from %2% filaments"), int(cached_palette().size()), int(m_palette_filaments.size())).c_str()); } - if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad)) - m_preview_params_dirty = true; - hover_tip(_u8L("Cleans up single stray triangles of the wrong color, which detail finer " + if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad, + _L("Cleans up single stray triangles of the wrong color, which detail finer " "than the mesh leaves behind. Raise it if the result looks speckled, " - "lower it if small features are being swallowed.")); + "lower it if small features are being swallowed."))) + m_preview_params_dirty = true; } } @@ -6665,10 +6764,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "gets the texture, and its border becomes the edge of the projection.")); if (projector_open) { - if (int_row("##projector_opacity", _L("Opacity"), &m_projector_opacity, 20, 255, "%d", card_pad)) + if (int_row("##projector_opacity", _L("Opacity"), &m_projector_opacity, 20, 255, "%d", card_pad, + _L("How solid the frame window looks while you place it. Lower to see the " + "model through it; it does not affect the result."))) m_projector_frame->set_opacity(m_projector_opacity); - hover_tip(_u8L("How solid the frame window looks while you place it. Lower to see the " - "model through it; it does not affect the result.")); if (m_imgui->button(_u8L("Apply projection frame"))) { const int painted_count = apply_projection_frame(); @@ -6969,57 +7068,57 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // pattern can sample four points that all happen to land at similar heights, report no error, // and stall before refinement ever starts. if (float_row("##subdiv_target", m_subdivide_feature ? _L("Max edge") : _L("Target edge"), &m_subdivide_target_mm, - 0.001f, 20.f, "%.3f mm", true, 0.f)) + 0.001f, 20.f, "%.3f mm", true, 0.f, + m_subdivide_feature ? + _L("No triangle in the painted area stays larger than this, even where the texture " + "is flat. Keep it near the size of the smallest feature you want to come out.") : + _L("Triangles in the painted area are split until none is larger than this. Smaller " + "means finer detail and, fast, a lot more triangles."))) preview_live(); - hover_tip(m_subdivide_feature ? - _u8L("No triangle in the painted area stays larger than this, even where the texture " - "is flat. Keep it near the size of the smallest feature you want to come out.") : - _u8L("Triangles in the painted area are split until none is larger than this. Smaller " - "means finer detail and, fast, a lot more triangles.")); if (m_subdivide_feature) { - if (float_row("##subdiv_detail", _L("Detail"), &m_subdivide_detail_mm, 0.001f, 1.f, "%.3f mm", true, 0.f)) + if (float_row("##subdiv_detail", _L("Detail"), &m_subdivide_detail_mm, 0.001f, 1.f, "%.3f mm", true, 0.f, + _L("How far the mesh may sit from the shape the texture describes, in millimetres. " + "Smaller follows fine detail and costs triangles; larger only chases the big " + "features."))) preview_live(); - hover_tip(_u8L("How far the mesh may sit from the shape the texture describes, in millimetres. " - "Smaller follows fine detail and costs triangles; larger only chases the big " - "features.")); - if (float_row("##subdiv_min", _L("Min edge"), &m_subdivide_min_edge_mm, 0.001f, 20.f, "%.3f mm", true, 0.f)) + if (float_row("##subdiv_min", _L("Min edge"), &m_subdivide_min_edge_mm, 0.001f, 20.f, "%.3f mm", true, 0.f, + _L("A floor on triangle size, so a sharp step in the texture cannot be chased " + "forever. Lower it for finer relief, raise it if the count runs away at hard " + "edges."))) preview_live(); - hover_tip(_u8L("A floor on triangle size, so a sharp step in the texture cannot be chased " - "forever. Lower it for finer relief, raise it if the count runs away at hard " - "edges.")); } // Applies in both adaptive sub-modes: it is about the step the bake puts at the paint's edge, which // exists whether or not "Follow texture detail" is on. 0 turns the band off. - if (float_row("##subdiv_border", _L("Edge detail"), &m_subdivide_border_mm, 0.f, 5.f, "%.3f mm", false, 0.f)) + if (float_row("##subdiv_border", _L("Edge detail"), &m_subdivide_border_mm, 0.f, 5.f, "%.3f mm", false, 0.f, + _L("Triangle size along the outline of the painted area, where the relief drops back to the " + "bare surface. Lower it if that rim looks jagged; it only costs triangles along the " + "outline. 0 turns it off."))) preview_live(); - hover_tip(_u8L("Triangle size along the outline of the painted area, where the relief drops back to the " - "bare surface. Lower it if that rim looks jagged; it only costs triangles along the " - "outline. 0 turns it off.")); // Only worth showing when a layer is actually colouring: with no colour there is no boundary for it // to refine and the control would do nothing whatever it is set to. if (mv != nullptr && any_layer_colors(*mv)) { - if (float_row("##subdiv_color", _L("Color detail"), &m_subdivide_color_mm, 0.f, 5.f, "%.3f mm", false, 0.f)) + if (float_row("##subdiv_color", _L("Color detail"), &m_subdivide_color_mm, 0.f, 5.f, "%.3f mm", false, 0.f, + _L("Triangle size where two colors meet. Each triangle prints in one filament, so a " + "color edge can only be as sharp as the triangles along it - and nothing else " + "refines there, since the surface is flat across a change of color. 0 turns it off."))) preview_live(); - hover_tip(_u8L("Triangle size where two colors meet. Each triangle prints in one filament, so a " - "color edge can only be as sharp as the triangles along it - and nothing else " - "refines there, since the surface is flat across a change of color. 0 turns it off.")); } ImGui::TextDisabled("%s", _u8L("Triangle budget: set with Triangles, below.").c_str()); if (m_subdivide_editing && m_subdivide_preview_tris > 0) m_imgui->text(Slic3r::format(_u8L("Preview: %1% triangles"), m_subdivide_preview_tris)); } else { - if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f)) { + if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f, + _L("Splits every triangle of the model into four, this many times over. Each step " + "quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining " + "only the painted area unless you need the whole model finer."))) { if (m_subdivide_editing) rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one m_parent.set_as_dirty(); } - hover_tip(_u8L("Splits every triangle of the model into four, this many times over. Each step " - "quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining " - "only the painted area unless you need the whole model finer.")); } { @@ -7081,9 +7180,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_remesh_target_edge_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)), 0.1f, 20.f) : 1.f; } - float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f); - hover_tip(_u8L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an " - "even mesh; this is what makes one out of an uneven import.")); + float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f, + _L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an " + "even mesh; this is what makes one out of an uneven import.")); m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges); hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it " @@ -7091,11 +7190,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "a cube comes back with wobbly edges.")); ImGui::SameLine(); m_imgui->disabled_begin(!m_remesh_keep_sharp_edges); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("##remesh_sharp_angle", &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", ImGuiSliderFlags_AlwaysClamp); - m_imgui->disabled_end(); - hover_tip(_u8L("How sharp a fold has to be, in degrees, before the remesher treats it as an edge worth " + typed_slider("##remesh_sharp_angle", -(1.f + edit_icon_room), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", false, + _L("How sharp a fold has to be, in degrees, before the remesher treats it as an edge worth " "keeping. Lower protects more of the model's shape; higher rebuilds more of it evenly.")); + m_imgui->disabled_end(); m_imgui->disabled_begin(mv == nullptr || busy); if (ImGui::Button(_u8L("Remesh").c_str())) @@ -7123,16 +7221,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "on a layer is a different thing: it blurs the image before it is used.")); if (opts.smooth_enabled) { float percent = opts.smooth_strength * 100.f; - if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f)) { + if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f, + _L("How far each pass pulls a vertex towards its neighbours. High values round the " + "relief off quickly; low values need more passes but keep more of the detail."))) { opts.smooth_strength = std::clamp(percent / 100.f, 0.01f, 1.f); m_preview_params_dirty = true; } - hover_tip(_u8L("How far each pass pulls a vertex towards its neighbours. High values round the " - "relief off quickly; low values need more passes but keep more of the detail.")); - if (int_row("##dispsmoothit", _L("Passes"), &opts.smooth_iterations, 1, 10, "%d", 0.f)) + if (int_row("##dispsmoothit", _L("Passes"), &opts.smooth_iterations, 1, 10, "%d", 0.f, + _L("How many smoothing passes to run. More passes spread the smoothing further across " + "the surface; Strength decides how much each one moves."))) m_preview_params_dirty = true; - hover_tip(_u8L("How many smoothing passes to run. More passes spread the smoothing further across " - "the surface; Strength decides how much each one moves.")); m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Ignore outer ring"), opts.smooth_skip_border); hover_tip(_u8L("Keeps the outer ring of the painted area out of the smoothing. Its neighbours " @@ -7175,16 +7273,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float int shown_k = auto_budget ? v2_recommendation(*mv).budget_k : opts.v2_max_triangles_k; m_imgui->disabled_begin(auto_budget); // TRN Slider value: %d is the triangle budget in thousands - if (int_row("##v2budget", _L("Budget"), &shown_k, 0, 4000, auto_budget ? _u8L("%d k (auto)").c_str() : "%d k", 0.f)) { + if (int_row("##v2budget", _L("Budget"), &shown_k, 0, 4000, auto_budget ? _u8L("%d k (auto)").c_str() : "%d k", 0.f, + _L("How many thousand triangles this bake may spend on the area you painted. " + "Relief already baked into the rest of the model is kept on top of it, so a " + "second bake somewhere else gets the same budget as the first. Raise it if the " + "warning below Resolution says the detail will not fit; 0 keeps every triangle " + "the refinement produced, however many that is."))) { opts.v2_max_triangles_k = shown_k; m_preview_params_dirty = true; } m_imgui->disabled_end(); - hover_tip(_u8L("How many thousand triangles this bake may spend on the area you painted. " - "Relief already baked into the rest of the model is kept on top of it, so a " - "second bake somewhere else gets the same budget as the first. Raise it if the " - "warning below Resolution says the detail will not fit; 0 keeps every triangle " - "the refinement produced, however many that is.")); } if (SHOW_PIPELINE_DEV_CONTROLS && opts.pipeline_v2) { // Keeping the relief above the plate is not a checkbox: it is unconditional, in both pipelines @@ -7291,26 +7389,28 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "default. Untick to set both yourself - worth doing for a texture much finer or " "much coarser than the part it sits on.")); ImGui::SameLine(); - ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); float shown = auto_res ? rec.edge_mm : opts.v2_refine_mm; + // Held in a local: the format outlives the call that reads it, and the slider and the + // range in its tooltip are then formatted the same way. + const std::string v2edge_format = auto_res ? _u8L("%.2f mm (auto)") : std::string("%.2f mm"); + const wxString v2edge_tip = + auto_res && rec.edge_mm > 0.f ? + from_u8(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, " + "budget %2% k. Untick Auto to set them yourself."), + rec.edge_mm, rec.budget_k)) : + _L("How fine the mesh is made under the paint, in millimetres. It has to be " + "smaller than the detail you want out of the texture - a 0.5 mm groove needs " + "triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs " + "four times as many, and once they no longer fit the budget the bake simplifies " + "back down and the detail goes with it."); m_imgui->disabled_begin(busy || auto_res); - if (ImGui::SliderFloat("##v2edge", &shown, 0.02f, 2.f, auto_res ? _u8L("%.2f mm (auto)").c_str() : "%.2f mm", - ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + if (typed_slider("##v2edge", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, &shown, 0.02f, 2.f, + v2edge_format.c_str(), true, v2edge_tip)) { opts.v2_refine_mm = shown; m_preview_params_dirty = true; m_parent.set_as_dirty(); } m_imgui->disabled_end(); - if (auto_res && rec.edge_mm > 0.f) - hover_tip(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, " - "budget %2% k. Untick Auto to set them yourself."), - rec.edge_mm, rec.budget_k)); - else - hover_tip(_u8L("How fine the mesh is made under the paint, in millimetres. It has to be " - "smaller than the detail you want out of the texture - a 0.5 mm groove needs " - "triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs " - "four times as many, and once they no longer fit the budget the bake simplifies " - "back down and the detail goes with it.")); // What this resolution costs over what is painted, against what the budget allows. Refining // past the budget is not an error - the bake simplifies back down to it - but the result @@ -7342,18 +7442,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->text(_L("Triangles")); ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); - ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); m_imgui->disabled_begin(busy); - if (ImGui::SliderInt("##subdivbudget", &m_subdivide_budget_k, 10, 2000, "+%d k", - ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_Logarithmic)) { + if (typed_slider_int("##subdivbudget", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, + &m_subdivide_budget_k, 10, 2000, "+%d k", true, + _L("How many thousand triangles the refinement may add to the model. The triangle " + "that fits worst is always split first, so even a run that spends the lot has " + "spent it where it shows most. Raise it if the relief still looks coarse."))) { if (m_subdivide_editing) rebuild_subdivide_preview(); m_parent.set_as_dirty(); } m_imgui->disabled_end(); - hover_tip(_u8L("How many thousand triangles the refinement may add to the model. The triangle " - "that fits worst is always split first, so even a run that spends the lot has " - "spent it where it shows most. Raise it if the relief still looks coarse.")); } const float button_h = std::round(frame_h * 1.25f); @@ -7372,7 +7471,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float : m_tool_type == ToolType::SMART_FILL ? m_shortcuts_bucket_fill : m_tool_type == ToolType::BRUSH ? m_shortcuts_brush : m_tool_type == ToolType::GAP_FILL ? m_shortcuts_gap_fill - : std::vector>{}; + : m_shortcuts_sliders; // the sliders are there whether or not a painting tool is ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f); // center vertically ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y)); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..ce2a7f2b9d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -404,6 +404,12 @@ private: // Per slot: whether the layer's card shows every setting or only Depth, Tile size and Rotation. // Panel state only, not saved with the project. std::array m_layer_expanded{}; + // The slider whose edit icon was just clicked, as its ImGui id, and the frame it was clicked on. + // The row acts on it on the next frame - ImGui's keyboard-focus request only takes effect a frame + // later - and clears it; the frame number is what retires a request whose row has gone off screen + // in the meantime. Panel state only, not saved with the project. + std::string m_slider_type_request; + int m_slider_type_request_frame = 0; // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane @@ -1016,6 +1022,9 @@ private: std::vector> m_shortcuts_bucket_fill; // Contains all shortcuts in the format of {shortcut, description}, e.g. {alt + _L("Left mouse button"), _L("Part_selection")} std::vector> m_shortcuts_gap_fill; + // The two ways every slider in the panel takes a typed value. They hold whatever tool is selected, + // so they are tacked onto each of the lists above and stand on their own when no tool is active. + std::vector> m_shortcuts_sliders; // Icons for the panel's icon buttons (tools, views, mapping, tiling, layer actions). Loaded through IconManager with // the same colour/monochrome variants the main toolbar uses, so an inactive button shows the icon in From 56b559d36a6d67ab706ad60aaea343f9667c62b7 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 07:57:50 +0200 Subject: [PATCH 11/45] Fix texture projection UV orientation An image laid onto a model came out upside down under the ordinary box (tri-planar) projection, and on three of a cube's six faces it was also mirrored left to right, which reads as a 180 degree rotation. DecodedHeightTexture::sample() defines v = 0 as the height map's first pixel row, i.e. its top. Every projection built v from world +Z, so v grew upward and the image landed bottom row up. The one path that was already right is the projective "project from view" matrix, which measures window y downward. Both halves of the box basis were wrong: - v is now negated on all three world planes, so the top of an image sits at the top of the model. - u now carries a sign for the side of the plane the face is on, chosen so that u x v == -normal. Without it the two faces sharing a plane get one u and one of them is mirrored. The sign flips exactly where that plane's blend weight (the fourth power of a normal component) reaches zero, so the blended tri-planar sum stays continuous and the hard axis switch the old code removed does not come back. The same v convention was missing from the other mapping modes: cylindrical (which also had a left handed frame, right x up rather than up x right, so its wrap ran mirrored), spherical, the affine branch of view projection, and the flat chart fallback of the LSCM unwrap, which now reuses the box basis so a cube face unwraps to the orientation tri-planar would have given it. The basis table is exported as box_projection_axes() and the gizmo's adjust_tangent_basis() calls it instead of restating it, so the placement ring and offset handle cannot drift from the bake. All four preview shaders get the matching change, including projection_axes(), whose tangent and bitangent have to flip with the coordinate or the relief shading inverts. Re-baking an existing project now places its texture upright, so it will differ from how it looked before. Geometry already baked into a 3mf is untouched, so nothing changes until the user re-bakes. Verified by measuring the sampled texture row and column against world position for every mapping mode on all six faces of a cube, and by a full bake of a quadrant coded image, which lands each quadrant within 1e-3 mm of its expected depth. --- .../110/texture_displacement_shaded.fs | 52 +-- .../110/texture_displacement_uvcheck.fs | 20 +- .../140/texture_displacement_shaded.fs | 52 +-- .../140/texture_displacement_uvcheck.fs | 20 +- src/libslic3r/TextureDisplacement.cpp | 125 +++++-- src/libslic3r/TextureDisplacement.hpp | 21 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 20 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 4 +- tests/libslic3r/test_texture_displacement.cpp | 315 ++++++++++++++++++ 9 files changed, 538 insertions(+), 91 deletions(-) diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 0b9192749c..93449af5d4 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -72,17 +72,21 @@ varying float weight; varying float island_active; varying vec2 vertex_uv; -// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including -// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so -// the preview wraps the texture the same way round as the bake. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // project_cylindrical() and project_spherical(). `n` is read by the planar mode only. @@ -94,7 +98,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius vec3 rel = p - patch_center; @@ -102,10 +106,18 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } - vec3 an = abs(n); // Triplanar: drop the dominant normal axis - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // sits at the top of the model instead of upside down, and u is flipped on the far side of each + // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two + // siblings, term for term. + vec3 an = abs(n); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one @@ -124,7 +136,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); float r = length(xy); t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it - b = up; // v is the distance along the axis + b = -up; // v is the distance *against* the axis return; } if (projection_mode == 2) { @@ -133,20 +145,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); float c = length(dir.xy); // cos(latitude) t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); - b = cross(dir, t); // increasing latitude, unit length + b = -cross(dir, t); // v grows away from the pole, unit length units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); return; } - vec3 an = abs(n); - if (an.x >= an.y && an.x >= an.z) { // planar = p.yz - t = vec3(0.0, 1.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs index f9e998adc2..37fd1826f2 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -36,14 +36,20 @@ varying vec4 world_pos; varying float distortion; varying vec2 vertex_uv; +// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows +// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. vec2 projection_raw(vec3 p, vec3 n) { @@ -53,7 +59,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -61,10 +67,16 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } + // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side + // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz(). vec3 an = abs(n); - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } vec2 project_uv(vec3 p, vec3 n) diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index 911a919aaf..9398b21102 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -138,17 +138,21 @@ in vec2 vertex_uv; out vec4 out_color; -// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - including -// the handedness, which comes out left-handed for an axis of +Z. Copied rather than "corrected", so -// the preview wraps the texture the same way round as the bake. +// The cylinder's own frame, built exactly as libslic3r's project_cylindrical() builds it - right +// handed, so the angle taken from it grows anticlockwise about the axis, which seen from outside the +// cylinder is to the viewer's right rather than mirrored. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // The raw, millimetre-valued projection of `p` (a position in the texture frame), before the layer's // tiling/rotation/aspect/offset - a term-for-term transcription of libslic3r's project_planar(), // project_cylindrical() and project_spherical(). `n` is read by the planar mode only. @@ -160,7 +164,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { // Spherical: (longitude, latitude) * radius vec3 rel = p - patch_center; @@ -168,10 +172,18 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } - vec3 an = abs(n); // Triplanar: drop the dominant normal axis - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + // Triplanar: drop the dominant normal axis. v is negated so the image's first pixel row (v = 0) + // sits at the top of the model instead of upside down, and u is flipped on the far side of each + // plane so the two faces sharing it are not mirror images. libslic3r's box_plane_yz() and its two + // siblings, term for term. + vec3 an = abs(n); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } // The two surface directions projection_raw()'s u and v run along at `p`, plus how many raw units one @@ -190,7 +202,7 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec2 xy = vec2(dot(rel, right), dot(rel, fwd)); float r = length(xy); t = (r > 1e-6) ? (fwd * xy.x - right * xy.y) / r : right; // circumferential: u runs along it - b = up; // v is the distance along the axis + b = -up; // v is the distance *against* the axis return; } if (projection_mode == 2) { @@ -199,20 +211,20 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ vec3 dir = (r > 1e-8) ? rel / r : vec3(0.0, 0.0, 1.0); float c = length(dir.xy); // cos(latitude) t = (c > 1e-6) ? vec3(-dir.y, dir.x, 0.0) / c : vec3(1.0, 0.0, 0.0); - b = cross(dir, t); // increasing latitude, unit length + b = -cross(dir, t); // v grows away from the pole, unit length units_per_mm = vec2(1.0 / max(c, 1e-3), 1.0); return; } - vec3 an = abs(n); - if (an.x >= an.y && an.x >= an.z) { // planar = p.yz - t = vec3(0.0, 1.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else if (an.y >= an.x && an.y >= an.z) { // planar = p.xz - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 0.0, 1.0); - } else { // planar = p.xy - t = vec3(1.0, 0.0, 0.0); - b = vec3(0.0, 1.0, 0.0); + vec3 an = abs(n); // the same signed basis projection_raw() uses + if (an.x >= an.y && an.x >= an.z) { // planar = (+-y, -z) + t = vec3(0.0, box_face_sign(n.x), 0.0); + b = vec3(0.0, 0.0, -1.0); + } else if (an.y >= an.x && an.y >= an.z) { // planar = (+-x, -z) + t = vec3(-box_face_sign(n.y), 0.0, 0.0); + b = vec3(0.0, 0.0, -1.0); + } else { // planar = (+-x, -y) + t = vec3(box_face_sign(n.z), 0.0, 0.0); + b = vec3(0.0, -1.0, 0.0); } } diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs index e6fc8036da..34f80df2bd 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -47,14 +47,20 @@ in vec2 vertex_uv; out vec4 out_color; +// Right handed (up x right), like libslic3r's project_cylindrical(), so the angle taken from it grows +// anticlockwise about the axis - to the viewer's right when seen from outside the cylinder. void cylinder_frame(out vec3 up, out vec3 right, out vec3 fwd) { up = (length(patch_axis) > 1e-8) ? normalize(patch_axis) : vec3(0.0, 0.0, 1.0); vec3 arbitrary = (abs(up.z) < 0.9) ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); right = normalize(cross(up, arbitrary)); - fwd = normalize(cross(right, up)); + fwd = normalize(cross(up, right)); } +// Which side of a world plane a face is on, with zero counting as the positive side. Not GLSL's own +// sign(), which returns 0 there and would collapse the coordinate onto a line. +float box_face_sign(float n) { return n >= 0.0 ? 1.0 : -1.0; } + // Term for term libslic3r's project_planar() / project_cylindrical() / project_spherical(), in mm. vec2 projection_raw(vec3 p, vec3 n) { @@ -64,7 +70,7 @@ vec2 projection_raw(vec3 p, vec3 n) vec3 rel = p - patch_center; float x = dot(rel, right); float y = dot(rel, fwd); - return vec2(atan(y, x) * sqrt(x * x + y * y), dot(rel, up)); + return vec2(atan(y, x) * sqrt(x * x + y * y), -dot(rel, up)); } if (projection_mode == 2) { vec3 rel = p - patch_center; @@ -72,10 +78,16 @@ vec2 projection_raw(vec3 p, vec3 n) if (radius < 1e-8) return vec2(0.0); vec3 dir = rel / radius; - return vec2(atan(dir.y, dir.x), asin(clamp(dir.z, -1.0, 1.0))) * radius; + return vec2(atan(dir.y, dir.x), -asin(clamp(dir.z, -1.0, 1.0))) * radius; } + // v negated so the image's first pixel row sits at the top of the model, u flipped on the far side + // of each plane so the two faces sharing it are not mirror images - see libslic3r's box_plane_yz(). vec3 an = abs(n); - return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); + if (an.x >= an.y && an.x >= an.z) + return vec2(box_face_sign(n.x) * p.y, -p.z); + if (an.y >= an.x && an.y >= an.z) + return vec2(-box_face_sign(n.y) * p.x, -p.z); + return vec2(box_face_sign(n.z) * p.x, -p.y); } vec2 project_uv(vec3 p, vec3 n) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..40416e7277 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -491,6 +491,57 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set return out; } +namespace { +// Which side of a world plane a face is on, with an exactly zero component counted as the positive +// side. The tie never shows: a normal component of zero carries zero tri-planar blend weight (see +// triplanar_weights()), so the plane it orients contributes nothing at that point. +inline float box_face_sign(float n) { return n >= 0.f ? 1.f : -1.f; } + +// The box projection's 2D basis for one of the three world planes, oriented the way a viewer +// standing outside the model sees that face. +// +// v runs *down* the image, because that is what v means to the sampler: v = 0 is the texture's first +// pixel row, i.e. its top (DecodedHeightTexture::sample(); the UV editor negates Y on screen for the +// same reason, see UVEditorCanvas::render()). So v = -z on the four upright planes and v = -y on the +// horizontal one, which is what puts the top of an image at the top of the model rather than +// upside down. +// +// u then has to depend on which *side* of the plane the face is on, or one of the two faces sharing +// a plane comes out left-right mirrored: with a single u per plane, a portrait image reads upright on +// +X and 180 degrees round on -X. Fixing that is the sign below, chosen so that u x v == -normal, +// i.e. (right, down, into the surface) is right-handed for a viewer outside: +// +// face u v face u v face u v +// +X +y -z +Y -x -z +Z +x -y +// -X -y -z -Y +x -z -Z -x -y +// +// The sign flips where its own normal component crosses zero, and the blend weight of the plane it +// belongs to is exactly zero there, so the blended tri-planar sum stays continuous across the flip - +// it is not a return of the hard axis switch described on TextureProjectionMethod::Triplanar. +inline Vec2f box_plane_yz(const Vec3f &p, float nx) { return Vec2f(box_face_sign(nx) * p.y(), -p.z()); } +inline Vec2f box_plane_xz(const Vec3f &p, float ny) { return Vec2f(-box_face_sign(ny) * p.x(), -p.z()); } +inline Vec2f box_plane_xy(const Vec3f &p, float nz) { return Vec2f(box_face_sign(nz) * p.x(), -p.y()); } +} // namespace + +void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis) +{ + // The same table as box_plane_yz()/box_plane_xz()/box_plane_xy(), handed out as a pair of world + // directions for the plane the normal is closest to. Two shapes of one table, because the bake's + // hot path wants the closed form and the GUI wants the axes; the test suite checks them against + // each other so they cannot drift apart. + const Vec3f n = normal.cwiseAbs(); + if (n.x() >= n.y() && n.x() >= n.z()) { + u_axis = box_face_sign(normal.x()) * Vec3f::UnitY(); + v_axis = -Vec3f::UnitZ(); + } else if (n.y() >= n.x() && n.y() >= n.z()) { + u_axis = -box_face_sign(normal.y()) * Vec3f::UnitX(); + v_axis = -Vec3f::UnitZ(); + } else { + u_axis = box_face_sign(normal.z()) * Vec3f::UnitX(); + v_axis = -Vec3f::UnitY(); + } +} + Vec2f project_planar(const Vec3f &position, const Vec3f &normal) { // Planar-project onto the two axes orthogonal to the dominant component of `normal`. Called @@ -501,23 +552,26 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal) // other face in the same patch - exactly the bug an earlier version of this feature had). const Vec3f n = normal.cwiseAbs(); if (n.x() >= n.y() && n.x() >= n.z()) - return Vec2f(position.y(), position.z()); + return box_plane_yz(position, normal.x()); if (n.y() >= n.x() && n.y() >= n.z()) - return Vec2f(position.x(), position.z()); - return Vec2f(position.x(), position.y()); + return box_plane_xz(position, normal.y()); + return box_plane_xy(position, normal.z()); } namespace { // Wrapped around patch_axis, centered at patch_center. u is the arc length (mm) around the axis at -// this point's own radius, v is the signed distance along the axis - a reasonable approximation -// for roughly cylindrical selections, not an exact fit for arbitrary geometry. +// this point's own radius, v is the distance along the axis, measured *against* it - a reasonable +// approximation for roughly cylindrical selections, not an exact fit for arbitrary geometry. Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, const Vec3f &patch_axis) { Vec3f up = patch_axis; up = (up.norm() > 1e-8f) ? Vec3f(up.normalized()) : Vec3f::UnitZ(); const Vec3f arbitrary = (std::abs(up.dot(Vec3f::UnitZ())) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); const Vec3f right = Vec3f(up.cross(arbitrary).normalized()); - const Vec3f fwd = Vec3f(right.cross(up).normalized()); + // up x right, not right x up: that makes (right, fwd, up) right-handed, so the angle below grows + // anticlockwise about the axis - which, seen from outside the cylinder, is to the viewer's right. + // The other way round the texture wraps on mirrored, as if seen through the model from behind. + const Vec3f fwd = Vec3f(up.cross(right).normalized()); const Vec3f rel = position - patch_center; const float along_axis = rel.dot(up); @@ -526,7 +580,9 @@ Vec2f project_cylindrical(const Vec3f &position, const Vec3f &patch_center, cons const float radius = std::sqrt(x * x + y * y); const float angle = std::atan2(y, x); - return Vec2f(angle * radius, along_axis); + // Negated, like every other projection's v: v = 0 is the texture's top row, so v has to grow + // *down* the axis for the top of the image to sit at the top of the model. + return Vec2f(angle * radius, -along_axis); } // Longitude/latitude around patch_center. u/v are scaled by this point's own distance from the @@ -542,7 +598,9 @@ Vec2f project_spherical(const Vec3f &position, const Vec3f &patch_center) const Vec3f dir = rel / radius; const float longitude = std::atan2(dir.y(), dir.x()); const float latitude = std::asin(std::clamp(dir.z(), -1.f, 1.f)); - return Vec2f(longitude, latitude) * radius; + // Longitude already grows to the right as seen from outside; latitude grows toward the north + // pole, so it is negated to put the image's top row (v = 0) at the top of the model. + return Vec2f(longitude, -latitude) * radius; } // CGAL's LSCM parameterizer expects a clean mesh with no isolated (unreferenced) vertices - but @@ -837,9 +895,26 @@ std::vector segment_into_charts(const indexed_triangle_set &mesh, const std // solve at all, and why this also serves as the fallback for a chart LSCM cannot handle. std::vector project_to_tangent_plane(const indexed_triangle_set &chart, const Vec3f &normal) { - const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); - const Vec3f u = Vec3f(seed.cross(normal).normalized()); - const Vec3f v = Vec3f(normal.cross(u).normalized()); + // Oriented like the box projection (see box_projection_axes()), so a chart reads the right way up + // and unmirrored instead of being flattened to whichever basis a cross product happened to give. + // For a chart whose normal *is* a world axis - a cube face, which is the common case once the + // seam angle has cut the patch along its sharp edges - this is exactly the box basis, so such a + // face unwraps to the same orientation Triplanar would have given it. + Vec3f u, v; + box_projection_axes(normal, u, v); + // The box axes lie in the tangent plane already when the normal is a world axis; for any other + // normal, u is squared up against it and v follows. u x v == -normal keeps the chart the way + // round a viewer outside the model sees it (a plain normal x u would mirror it). + u = u - normal * normal.dot(u); + if (u.norm() <= 1e-6f) { + // Out of reach for a unit normal - the box basis puts u perpendicular to the normal's own + // dominant axis, so the two can never be within 45 degrees of each other - but a caller + // handing over an unnormalized normal should still get a usable basis rather than a NaN. + const Vec3f seed = (std::abs(normal.z()) < 0.9f) ? Vec3f::UnitZ() : Vec3f::UnitX(); + u = seed.cross(normal); + } + u = Vec3f(u.normalized()); + v = Vec3f(u.cross(normal).normalized()); std::vector uvs(chart.vertices.size()); for (size_t i = 0; i < chart.vertices.size(); ++i) @@ -1814,8 +1889,10 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp return texture.sample(uv, layer.tile_enabled, layer.tile_method); } // Flat projection onto the captured projector plane. Single-valued per point, so unlike - // blended triplanar it is one sample, and it is what "project from view" places. - return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); + // blended triplanar it is one sample, and it is what "project from view" places. The + // projector's up axis is negated because v runs *down* the image (v = 0 is its first pixel + // row), the same reason the projective branch above measures window y downward. + return sample_at(Vec2f(position.dot(layer.view_project_right), -position.dot(layer.view_project_up))); case TextureProjectionMethod::LSCM: // no usable unwrap for this patch - fall back to Triplanar case TextureProjectionMethod::Triplanar: default: break; @@ -1830,14 +1907,15 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) // Degenerate normal: no axis is meaningfully dominant, so no blend is meaningful either. - return sample_at(Vec2f(position.x(), position.y())); + return sample_at(box_plane_xy(position, normal.z())); w /= w_sum; - // Each plane drops the axis it is named for, matching project_planar()'s own convention (which - // the GUI's on-canvas placement gizmo also relies on). - return w.x() * sample_at(Vec2f(position.y(), position.z())) + - w.y() * sample_at(Vec2f(position.x(), position.z())) + - w.z() * sample_at(Vec2f(position.x(), position.y())); + // Each plane drops the axis it is named for and is oriented for the side of that plane the normal + // points to, matching project_planar()'s own convention (which the GUI's on-canvas placement + // gizmo also relies on). See box_plane_yz() for why the orientation is not simply (y, z). + return w.x() * sample_at(box_plane_yz(position, normal.x())) + + w.y() * sample_at(box_plane_xz(position, normal.y())) + + w.z() * sample_at(box_plane_xy(position, normal.z())); } bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, @@ -1899,7 +1977,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla out = texture.sample_color(uv, layer.tile_enabled, layer.tile_method); return true; } else { - const Vec2f p(position.dot(layer.view_project_right), position.dot(layer.view_project_up)); + // Negated up, exactly as sample_layer_height() does it. + const Vec2f p(position.dot(layer.view_project_right), -position.dot(layer.view_project_up)); if (!covered(p)) return false; out = sample_at(p); @@ -1915,7 +1994,7 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla Vec3f w = triplanar_weights(normal); const float w_sum = w.x() + w.y() + w.z(); if (w_sum < 1e-8f) { - const Vec2f p(position.x(), position.y()); + const Vec2f p = box_plane_xy(position, normal.z()); if (!covered(p)) return false; out = sample_at(p); @@ -1926,8 +2005,8 @@ bool sample_layer_color(const DecodedHeightTexture &texture, const TextureDispla // A blend of three planes is only "not covered" where *every* contributing plane is outside the // placement; where some are, the covered ones are renormalised so the colour does not fade toward // black at the edge of an untiled tri-planar layer. - const std::array planes = { Vec2f(position.y(), position.z()), Vec2f(position.x(), position.z()), - Vec2f(position.x(), position.y()) }; + const std::array planes = { box_plane_yz(position, normal.x()), box_plane_xz(position, normal.y()), + box_plane_xy(position, normal.z()) }; Vec3f acc = Vec3f::Zero(); float acc_w = 0.f; for (int i = 0; i < 3; ++i) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..f096c847ad 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -524,11 +524,24 @@ struct TextureColorSettings bool empty() const { return palette.empty(); } }; +// The two world directions the box projection takes its u and v along on a face whose outward +// normal is `normal`: the plane is the one that normal is closest to, and the pair is oriented as a +// viewer standing outside the model sees it - u to their right, v *down* the image, which is the +// direction v runs in the texture (v = 0 is the image's first pixel row, see +// DecodedHeightTexture::sample()). So an image comes out the way it looks in a file manager rather +// than upside down, and it is not mirrored on the three faces facing the other way. +// +// Exposed because the GUI needs the same basis for the on-canvas "adjust texture placement" gizmo +// (its ring's zero angle and its offset handle's plane), and it must not be a second, drifting copy +// of the table. +void box_projection_axes(const Vec3f &normal, Vec3f &u_axis, Vec3f &v_axis); + // Raw dominant-axis planar projection of `position` (in mm, not yet scaled/rotated/offset by any -// layer), dropping the axis position that best aligns with `normal`. Exposed on its own (rather -// than only inline inside project_texture_displacement_uv()) so GUI code - the on-canvas -// "adjust texture placement" gizmo - can map a dragged 3D point into the exact same 2D space -// tiling_scale/rotation_deg/offset operate in, without duplicating the axis-selection logic. +// layer), dropping the axis position that best aligns with `normal`. Equivalent to projecting onto +// box_projection_axes()' pair. Exposed on its own (rather than only inline inside +// project_texture_displacement_uv()) so GUI code - the on-canvas "adjust texture placement" gizmo - +// can map a dragged 3D point into the exact same 2D space tiling_scale/rotation_deg/offset operate +// in, without duplicating the axis-selection logic. Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // Applies a layer's tiling_scale/rotation_deg/offset to an already-projected planar coordinate diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..d39785ade7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1252,8 +1252,9 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in uv[vi] = Vec2f(-1e6f, -1e6f); continue; } + // Negated up, as sample_layer_height()'s affine branch has it: v runs down the image. const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), - patch.vertices[vi].dot(layer.view_project_up)); + -patch.vertices[vi].dot(layer.view_project_up)); uv[vi] = apply_uv_transform(planar, layer, aspect); } return uv; @@ -3292,19 +3293,10 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const { - // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and - // the offset handle's plane always agree with what project_texture_displacement_uv() does. - const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); - if (n.x() >= n.y() && n.x() >= n.z()) { - u_axis = Vec3f::UnitY(); - v_axis = Vec3f::UnitZ(); - } else if (n.y() >= n.x() && n.y() >= n.z()) { - u_axis = Vec3f::UnitX(); - v_axis = Vec3f::UnitZ(); - } else { - u_axis = Vec3f::UnitX(); - v_axis = Vec3f::UnitY(); - } + // libslic3r's own basis, asked for rather than restated here, so the ring's "0 degrees" and the + // offset handle's plane always agree with what project_texture_displacement_uv() does - including + // v pointing *down* the texture and u flipping on the far side of each plane. + box_projection_axes(m_adjust_anchor_normal, u_axis, v_axis); } void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..4e2fcefccb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -423,8 +423,8 @@ private: // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's // foreground draw list, so it must be called from inside the gizmo's ImGui frame. void render_paint_cursor_hint(); - // Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s - // dominant-axis convention so dragging on-canvas maps consistently onto offset. + // Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging + // on-canvas maps consistently onto offset whichever face the anchor is on. void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 485f4f2a0f..37745beaa0 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -2174,3 +2174,318 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } + +// --------------------------------------------------------------------------------------------- +// UV orientation: which way up a texture lands on the model. +// +// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and +// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the +// statement that the row a point samples gets *lower* as the point moves up the face, and "it is +// not left-right mirrored" is that the column gets higher as the point moves to the right of a +// viewer standing outside the model. Both are measured below by sampling a ramp whose value says +// which row (or column) it came from, which settles the question without having to reason about the +// uv transform at all. + +static constexpr int RAMP_N = 64; // the ramp texture's size, in texels +static constexpr float RAMP_TILING = 40.f; // mm per tile +static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over +// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an +// arc length (angle * radius), so a long step also changes the radius, and that term swamps the +// angular one. It is symmetric about the face centre, so at a short step it cancels between the two +// readings a check compares. +static constexpr float RAMP_STEP = 1.f; +// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per +// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. +static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; + +// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled +// height identifies the row. Built directly rather than through a PNG because these tests are about +// the projection, not the decoder - decode_height_texture() has its own tests. +static DecodedHeightTexture ramp_texture(bool by_row) +{ + DecodedHeightTexture t; + t.width = t.height = RAMP_N; + t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); + for (int y = 0; y < RAMP_N; ++y) + for (int x = 0; x < RAMP_N; ++x) + t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); + return t; +} + +// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well +// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. +static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) +{ + TextureDisplacementLayer layer; + layer.projection_method = method; + layer.tiling_scale = RAMP_TILING; + layer.offset = Vec2f(0.5f, 0.5f); + layer.depth_mm = 1.f; + return layer; +} + +// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two +// texels reads the ramp between them; only the direction it moves in matters here. +static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, + const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), + const Vec3f &axis = Vec3f(Vec3f::UnitZ())) +{ + return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; +} + +// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on +// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. +static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) +{ + up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); + right = Vec3f((-n).cross(up).normalized()); +} + +static const std::array, 6> CUBE_FACES = { { + { "+X", Vec3f(1.f, 0.f, 0.f) }, + { "-X", Vec3f(-1.f, 0.f, 0.f) }, + { "+Y", Vec3f(0.f, 1.f, 0.f) }, + { "-Y", Vec3f(0.f, -1.f, 0.f) }, + { "+Z", Vec3f(0.f, 0.f, 1.f) }, + { "-Z", Vec3f(0.f, 0.f, -1.f) }, +} }; + +TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", + "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + const DecodedHeightTexture cols = ramp_texture(false); + const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); + + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + + // Upright: the row read near the top of the face is nearer the image's first row. + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + + // Unmirrored: the column read to the viewer's right is further into the image. Without a + // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which + // is the mirroring a bare v flip leaves behind. + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left of the face " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its + // "up" is the projector's own (view_project_up), which the default capture puts at +Y. + const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, + TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its + // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about + // which way up the image is. + if (std::abs(n.z()) > 0.9f) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : + Vec3f(0.f, 0.f, 1.f); + const Vec3f centre = n * CUBE_HALF; + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + } + } +} + +TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") +{ + const DecodedHeightTexture cols = ramp_texture(false); + const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the + // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both + // projections, and a reading taken across it compares two opposite sides of the model. + if (std::abs(n.z()) > 0.9f) + continue; + const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); + if (on_the_cut) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") +{ + indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); + for (Vec3f &v : cube.vertices) + v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); + + const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); + REQUIRE_FALSE(unwrap.empty()); + REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges + + // Each chart carries one face. The four upright ones span world Z, and their v has to grow + // downwards like every other projection's - whatever the packing then did with the island. + int upright_charts = 0; + for (int c = 0; c < unwrap.chart_count; ++c) { + int top = -1, bottom = -1; + for (size_t i = 0; i < unwrap.uvs.size(); ++i) { + if (unwrap.vertex_chart[i] != c) + continue; + const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); + if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) + top = int(i); + if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) + bottom = int(i); + } + REQUIRE(top >= 0); + REQUIRE(bottom >= 0); + const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); + if (std::abs(v_top - v_bottom) < 1e-3f) + continue; // a cap: the whole chart is at one height, so it has no up + ++upright_charts; + DYNAMIC_SECTION("chart " << c) + { + INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); + CHECK(v_bottom - v_top > 1.f); + } + } + CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above +} + +TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") +{ + // The two express the same table in different shapes - one a pair of world axes for the GUI, the + // other the closed form the bake's hot path uses - so they are checked against each other here + // rather than left to drift apart. + const Vec3f p(2.f, -3.f, 7.f); + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f u_axis, v_axis; + box_projection_axes(n, u_axis, v_axis); + const Vec2f planar = project_planar(p, n); + CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); + CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); + // Right-handed for a viewer outside the model: u to their right, v down, so u x v points + // into the surface. Getting this wrong is exactly what mirrors a face. + CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); + } + } +} + +// An image with a different value in each quadrant, so one bake says both which way up it landed +// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the +// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. +static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") +{ + // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four + // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex + // normal is then exactly the face normal, which is what makes the expected depth below an exact + // number instead of a tri-planar blend of three of them. + constexpr int STEPS = 4; // cells per side, so 5x5 vertices + constexpr float SPAN = 20.f; // mm, the wall's width and height + indexed_triangle_set wall; + for (int j = 0; j <= STEPS; ++j) + for (int i = 0; i <= STEPS; ++i) + wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, + SPAN * float(j) / float(STEPS)); + const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; + for (int j = 0; j < STEPS; ++j) + for (int i = 0; i < STEPS; ++i) { + // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. + wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); + wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); + } + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(wall); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.projection_method = TextureProjectionMethod::Triplanar; + layer.image_data = make_quadrant_marker_png(); + layer.depth_mm = 2.f; + // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in + // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a + // whole number of texels away from both. + layer.tiling_scale = 40.f; + layer.offset = Vec2f(0.5f, 0.75f); + // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by + // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With + // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. + layer.tile_enabled = false; + + const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); + REQUIRE(result.vertices.size() == wall.vertices.size()); + + // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's + // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value + // whose quadrant it must have read. + const std::array, 4> expected = { { + { "top-left (255)", 1, 3, 255 }, + { "top-right (170)", 3, 3, 170 }, + { "bottom-left (85)", 1, 1, 85 }, + { "bottom-right (0)", 3, 1, 0 }, + } }; + + for (const auto &[name, i, j, value] : expected) { + DYNAMIC_SECTION("quadrant " << name) + { + const size_t vi = size_t(at(i, j)); + // The whole displacement is along -Y, so this is the relief's height at that vertex. + const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); + INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); + CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); + // Nothing moved sideways or vertically: a mirrored or rotated basis would still have + // displaced along the normal, so this is here to catch the relief leaning instead. + CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); + CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); + } + } +} From 4c2a63e11e10f24b8d99f32abd3050696e3b71d2 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:14:28 +0800 Subject: [PATCH 12/45] Copy Plate Pick and Pattern File Paths When Loading a G-code 3MF (#16387) The importer assigned pick_file.empty() and pattern_bbox_file.empty(), a bool, to the string fields, storing a one-character string instead of the path. --- src/libslic3r/Format/bbs_3mf.cpp | 4 ++-- tests/libslic3r/test_3mf.cpp | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 8a22201c51..545cb8c05f 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -1729,8 +1729,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) //plate->pattern_file = it->second->pattern_file; plate->no_light_thumbnail_file = it->second->no_light_thumbnail_file; plate->top_file = it->second->top_file; - plate->pick_file = it->second->pick_file.empty(); - plate->pattern_bbox_file = it->second->pattern_bbox_file.empty(); + plate->pick_file = it->second->pick_file; + plate->pattern_bbox_file = it->second->pattern_bbox_file; plate->config = it->second->config; if (!plate->thumbnail_file.empty()) diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f7ac744e39..f5ac340c58 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -2043,3 +2043,40 @@ TEST_CASE("3MF XML entries declaring more than an int can hold fail to load", "[ CHECK_FALSE(parser.check_3mf_from_prusa(path)); } } + +TEST_CASE("Loading a gcode.3mf from a stream keeps the plate's pick and pattern file paths", "[3mf][Regression]") +{ + Model model; + REQUIRE(load_stl((std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl").c_str(), &model)); + model.add_default_instances(); + ScopedTemporaryDir backup_dir("orca_pick"); + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + + auto plate = std::make_unique(); + plate->plate_index = 0; + plate->pick_file = (backup_dir.path() / "pick.png").string(); + plate->pattern_bbox_file = (backup_dir.path() / "plate.json").string(); + + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.plate_data_list.push_back(plate.get()); + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + boost::nowide::ifstream in(test_file, std::ios::binary); + Model dst_model; + DynamicPrintConfig dst_config; + PlateDataPtrs dst_plates; + Semver file_version; + dst_model.set_backup_path(backup_dir.string()); + REQUIRE(load_gcode_3mf_from_stream(in, &dst_config, &dst_model, &dst_plates, &file_version)); + REQUIRE(dst_plates.size() == 1); + CHECK(dst_plates.front()->pick_file == "Metadata/pick_1.png"); + CHECK(dst_plates.front()->pattern_bbox_file == "Metadata/plate_1.json"); + release_PlateData_list(dst_plates); +} From 01b340dee16a327760211bb79cb4dc036a0d52bf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 14:54:17 +0800 Subject: [PATCH 13/45] Fix regressions in the AMS filament favorites dropdown --- src/slic3r/GUI/AMSMaterialsSetting.cpp | 126 +++++++++++++------------ src/slic3r/GUI/AMSMaterialsSetting.hpp | 8 +- src/slic3r/GUI/Widgets/ComboBox.cpp | 37 +++----- src/slic3r/GUI/Widgets/ComboBox.hpp | 2 +- src/slic3r/GUI/Widgets/DropDown.cpp | 56 +++++------ src/slic3r/GUI/Widgets/DropDown.hpp | 4 +- 6 files changed, 111 insertions(+), 122 deletions(-) diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 671431dd2f..b2b5f46466 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -16,6 +16,7 @@ #include #include "slic3r/GUI/GUI_Utils.hpp" #include "slic3r/GUI/Widgets/Button.hpp" +#include "slic3r/GUI/Widgets/DropDown.hpp" #include "slic3r/GUI/Widgets/Label.hpp" #include #include "slic3r/GUI/wxExtensions.hpp" @@ -175,6 +176,24 @@ void AMSMaterialsSetting::create() m_comboBox_filament->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_filament), NULL, this); m_comboBox_filament->Bind(EVT_DROPDOWN_ITEM_ACTION, &AMSMaterialsSetting::on_toggle_favorite_filament, this); + // Favorites toggled in the open list are regrouped just before it opens again, so rows never move + // under the cursor. These handlers run ahead of ComboBox's own, which then measure and show the + // rebuilt rows; wxEVT_COMBOBOX_DROPDOWN would be too late, as ComboBox sends it after showing the list. + auto regroup_favorites = [this]() { + if (!m_favorites_regroup || m_comboBox_filament->is_drop_down()) + return; + const FilamentInfos* info = selected_filament_info(); + const std::string filament_id = info ? info->filament_id : std::string(); + rebuild_filament_dropdown_items(filament_id); + }; + m_comboBox_filament->Bind(wxEVT_LEFT_DOWN, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_LEFT_DCLICK, [regroup_favorites](wxMouseEvent& e) { regroup_favorites(); e.Skip(); }); + m_comboBox_filament->Bind(wxEVT_KEY_DOWN, [regroup_favorites](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_RETURN || e.GetKeyCode() == WXK_SPACE) // the keys that open the list + regroup_favorites(); + e.Skip(); + }); + m_comboBox_cali_result->Connect(wxEVT_COMMAND_COMBOBOX_SELECTED, wxCommandEventHandler(AMSMaterialsSetting::on_select_cali_result), NULL, this); } @@ -193,6 +212,8 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_sizer_filament->Add(0, 0, 0, wxEXPAND, 0); m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); + m_star_filled = ScalableBitmap(this, "score_star_light", 16); + m_star_empty = ScalableBitmap(this, "score_star_dark", 16); m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); @@ -988,12 +1009,10 @@ static const char* AMS_FAVORITE_FILAMENT_IDS_KEY = "ams_favorite_filament_ids"; const AMSMaterialsSetting::FilamentInfos* AMSMaterialsSetting::selected_filament_info() const { - int selection = m_comboBox_filament->GetSelection(); - if (selection >= 0 && selection < int(m_filament_list_items.size()) && m_filament_list_items[selection].is_filament) - return &m_filament_list_items[selection].filament; - - auto iter = map_filament_items.find(m_comboBox_filament->GetValue().ToStdString()); - return iter == map_filament_items.end() ? nullptr : &iter->second; + const int selection = m_comboBox_filament->GetSelection(); + if (selection < 0 || selection >= int(m_filament_list_items.size()) || !m_filament_list_items[selection].is_filament) + return nullptr; + return &m_filament_list_items[selection].filament; } std::set AMSMaterialsSetting::load_favorite_filament_ids() const @@ -1019,10 +1038,6 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel std::vector items; m_filament_list_items.clear(); - const std::set favorite_ids = load_favorite_filament_ids(); - wxBitmap star_filled = ScalableBitmap(this, "score_star_light", 16).bmp(); - wxBitmap star_empty = ScalableBitmap(this, "score_star_dark", 16).bmp(); - auto append_header = [&items, this](const wxString& label) { DropDown::Item item; item.text = label; @@ -1031,43 +1046,32 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel m_filament_list_items.push_back(FilamentListItem{}); }; - auto append_filament = [&items, this, &favorite_ids, &star_filled, &star_empty](const wxString& name) { - auto info_it = map_filament_items.find(name.ToStdString()); + auto append_filament = [&items, this](const wxString& name) { + auto info_it = map_filament_items.find(name); if (info_it == map_filament_items.end()) return; - const bool is_favorite = favorite_ids.find(info_it->second.filament_id) != favorite_ids.end(); DropDown::Item item; item.text = name; - item.action_icon = is_favorite ? star_filled : star_empty; - item.style = 0; items.push_back(item); - - FilamentListItem list_item; - list_item.filament = info_it->second; - list_item.is_filament = true; - m_filament_list_items.push_back(list_item); + m_filament_list_items.push_back(FilamentListItem{info_it->second, true}); }; - bool has_favorites = false; + // Favorites are listed again at the top under their own header. Without any, the list is the + // plain filament list. + const std::set favorite_ids = load_favorite_filament_ids(); + std::vector favorites; for (const wxString& name : m_sorted_filament_items) { - auto info_it = map_filament_items.find(name.ToStdString()); - if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) { - has_favorites = true; - break; - } + auto info_it = map_filament_items.find(name); + if (info_it != map_filament_items.end() && favorite_ids.count(info_it->second.filament_id) > 0) + favorites.push_back(name); } - - if (has_favorites) { + if (!favorites.empty()) { append_header(_L("Favorite filaments")); - for (const wxString& name : m_sorted_filament_items) { - auto info_it = map_filament_items.find(name.ToStdString()); - if (info_it != map_filament_items.end() && favorite_ids.find(info_it->second.filament_id) != favorite_ids.end()) - append_filament(name); - } + for (const wxString& name : favorites) + append_filament(name); + append_header(_L("All filaments")); } - - append_header(_L("All filaments")); for (const wxString& name : m_sorted_filament_items) append_filament(name); @@ -1081,8 +1085,25 @@ void AMSMaterialsSetting::rebuild_filament_dropdown_items(const std::string& sel } } + // Clear() first: SetItems() keeps the old selection, and SetSelection() ignores an index equal to it, + // which would leave the previous filament's name on the combo. + m_comboBox_filament->Clear(); m_comboBox_filament->SetItems(items); + update_favorite_stars(); m_comboBox_filament->SetSelection(selection_idx); + m_favorites_regroup = false; +} + +void AMSMaterialsSetting::update_favorite_stars() +{ + const std::set favorite_ids = load_favorite_filament_ids(); + for (size_t i = 0; i < m_filament_list_items.size(); ++i) { + const FilamentListItem& row = m_filament_list_items[i]; + if (row.is_filament) { + const bool favorite = favorite_ids.count(row.filament.filament_id) > 0; + m_comboBox_filament->SetItemActionBitmap(i, favorite ? m_star_filled.bmp() : m_star_empty.bmp()); + } + } } void AMSMaterialsSetting::toggle_favorite_filament(const std::string& filament_id) @@ -1106,12 +1127,9 @@ void AMSMaterialsSetting::on_toggle_favorite_filament(wxCommandEvent& evt) if (index < 0 || index >= int(m_filament_list_items.size()) || !m_filament_list_items[index].is_filament) return; - std::string selected_filament_id; - if (const FilamentInfos* info = selected_filament_info()) - selected_filament_id = info->filament_id; toggle_favorite_filament(m_filament_list_items[index].filament.filament_id); - rebuild_filament_dropdown_items(selected_filament_id); - m_comboBox_filament->RefreshDropdownIfOpen(); + update_favorite_stars(); + m_favorites_regroup = true; } void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_min, wxString temp_max, wxString k, wxString n) @@ -1127,16 +1145,12 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi m_input_k_val->GetTextCtrl()->SetValue(k); m_input_n_val->GetTextCtrl()->SetValue(n); - wxArrayString filament_items; - map_filament_items.clear(); - m_sorted_filament_items.clear(); - m_filament_list_items.clear(); wxString bambu_filament_name; - wxString hint_filament_name; // the hint type to be selected std::unordered_map query_filament_vendors;// some information for sort std::unordered_map query_filament_types; // std::set filament_id_set; + m_sorted_filament_items.clear(); // The alias keyed map has to start empty: it is a member, so a stale alias left by an earlier // popup (a different printer, a different nozzle) would resolve to that printer's filament_id. map_filament_items.clear(); @@ -1166,7 +1180,7 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi if (alias.empty()) continue; - filament_items.push_back(alias); + m_sorted_filament_items.push_back(alias); _collect_filament_info(alias, *filament_it, query_filament_vendors, query_filament_types); FilamentInfos filament_infos; @@ -1175,7 +1189,6 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi map_filament_items[alias] = filament_infos; if (filament_it->filament_id == ams_filament_id) { - hint_filament_name = from_u8(alias); bambu_filament_name = from_u8(alias); @@ -1346,20 +1359,10 @@ void AMSMaterialsSetting::Popup(wxString filament, wxString sn, wxString temp_mi return name_compare != 0 ? name_compare < 0 : left < right; }; - std::sort(filament_items.begin(), filament_items.end(), _filament_sorter); + std::sort(m_sorted_filament_items.begin(), m_sorted_filament_items.end(), _filament_sorter); } - std::string selection_filament_id; - for (int i = 0; i < int(filament_items.size()); ++i) { - m_sorted_filament_items.push_back(filament_items[i]); - if (hint_filament_name == filament_items[i]) { - auto info_it = map_filament_items.find(filament_items[i].ToStdString()); - if (info_it != map_filament_items.end()) - selection_filament_id = info_it->second.filament_id; - } - } - - rebuild_filament_dropdown_items(selection_filament_id); + rebuild_filament_dropdown_items(ams_filament_id); int selection_idx = m_comboBox_filament->GetSelection(); post_select_event(selection_idx); @@ -1645,6 +1648,11 @@ void AMSMaterialsSetting::on_dpi_changed(const wxRect &suggested_rect) m_button_reset->Rescale(); // ORCA m_button_confirm->Rescale(); // ORCA m_button_close->Rescale(); // ORCA + // An open list would keep the measurements of the old stars, so close it before replacing them. + m_comboBox_filament->CloseDropdown(); + m_star_filled.msw_rescale(); + m_star_empty.msw_rescale(); + update_favorite_stars(); this->Refresh(); } diff --git a/src/slic3r/GUI/AMSMaterialsSetting.hpp b/src/slic3r/GUI/AMSMaterialsSetting.hpp index 673ead26ea..6fa215063a 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.hpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.hpp @@ -166,6 +166,7 @@ protected: bool is_virtual_tray(); void update_widgets(); void rebuild_filament_dropdown_items(const std::string& selected_filament_id); + void update_favorite_stars(); std::set load_favorite_filament_ids() const; void save_favorite_filament_ids(const std::set& favorite_ids); void toggle_favorite_filament(const std::string& filament_id); @@ -222,9 +223,14 @@ protected: }; const FilamentInfos* selected_filament_info() const; - std::map map_filament_items; + // Keyed by the same wxString the dropdown shows, so a row's name never has to be converted back. + std::map map_filament_items; std::vector m_sorted_filament_items; std::vector m_filament_list_items; + ScalableBitmap m_star_filled; + ScalableBitmap m_star_empty; + // Favorites changed while the list was open; their rows are regrouped before it opens again. + bool m_favorites_regroup{false}; }; wxDECLARE_EVENT(EVT_SELECTED_COLOR, wxCommandEvent); diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 500b8c364a..55e261d04d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -302,25 +302,10 @@ void ComboBox::ForceDropdownOpen() } } -void ComboBox::RefreshDropdownIfOpen() +void ComboBox::CloseDropdown() { - if (!drop_down) - return; - - drop.hover_item = -1; - drop.offset = wxPoint(); - if (drop.subDropDown) { - drop.subDropDown->group.clear(); - drop.subDropDown->hover_item = -1; - drop.subDropDown->offset = wxPoint(); - if (drop.subDropDown->IsShown()) - drop.subDropDown->Dismiss(); - } - - drop.need_sync = true; - drop.messureSize(); - drop.autoPosition(); - drop.paintNow(); + if (drop.IsShown()) + drop.DismissAndNotify(); } unsigned int ComboBox::GetCount() const { return items.size(); } @@ -452,16 +437,18 @@ void ComboBox::keyDown(wxKeyEvent& event) case WXK_UP: case WXK_DOWN: case WXK_LEFT: - case WXK_RIGHT: - if ((event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) && GetSelection() > 0) { - SetSelection(GetSelection() - 1); - } else if ((event.GetKeyCode() == WXK_DOWN || event.GetKeyCode() == WXK_RIGHT) && GetSelection() + 1 < items.size()) { - SetSelection(GetSelection() + 1); - } else { + case WXK_RIGHT: { + // Step over disabled rows: the mouse cannot select them either. + const int step = (event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_LEFT) ? -1 : 1; + int n = GetSelection() + step; + while (n >= 0 && n < int(items.size()) && (items[n].style & DD_ITEM_STYLE_DISABLED)) + n += step; + if (n < 0 || n >= int(items.size())) break; - } + SetSelection(n); sendComboBoxEvent(); break; + } case WXK_TAB: HandleAsNavigationKey(event); break; diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 1ab04c3a79..1fbc2e257d 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -101,7 +101,7 @@ public: void DeleteOneItem(unsigned int pos) { DoDeleteOneItem(pos); } void ForceDropdownOpen(); - void RefreshDropdownIfOpen(); + void CloseDropdown(); protected: virtual int DoInsertItems(const wxArrayStringsAdapter &items, diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 3fb1607551..966a090526 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -243,27 +243,17 @@ static wxSize GetBmpSize(wxBitmap & bmp) #endif } -wxSize DropDown::actionIconSize() const +// The x-range [first, second) of the column at the right of the rows that holds the items' action +// icons, empty when no item has one. render() draws the icons in it and mouseReleased() hit-tests +// clicks against it, so the drawn icon and its clickable area always match. +std::pair DropDown::actionColumn() const { - wxSize size; - std::set groups; - for (const Item& item : items) { - if (group.IsEmpty()) { - if (!item.group_key.IsEmpty()) { - if (groups.find(item.group_key) != groups.end()) - continue; - groups.insert(item.group_key); - } - } else if (item.group_key != group) { - continue; - } - - wxBitmap icon = item.action_icon; - wxSize icon_size = GetBmpSize(icon); - size.x = std::max(size.x, icon_size.x); - size.y = std::max(size.y, icon_size.y); - } - return size; + if (actionIconWidth <= 0) + return {0, 0}; + int right = rowSize.x; + if (rowSize.y * count > GetSize().y) // position bar + right -= 6; + return {right - actionIconWidth - FromDIP(16), right}; } static void _DrawSplitItem(const wxWindow* w, wxDC& dc, wxString split_text, wxPoint start_pt, int item_width, int item_height) @@ -395,8 +385,9 @@ void DropDown::render(wxDC &dc) rcContent.width -= szBmp.x + 5; } - const wxSize action_icon_size = actionIconSize(); - const int action_column_width = action_icon_size.x > 0 ? action_icon_size.x + FromDIP(16) : 0; + const auto action_column = actionColumn(); + if (action_column.second > action_column.first) + rcContent.width = action_column.first - rcContent.x; std::set groups; // draw texts & icons @@ -436,7 +427,6 @@ void DropDown::render(wxDC &dc) } if (rcContent.y > size.y) break; wxPoint pt = rcContent.GetLeftTop(); - int rcContent_width = rcContent.width; if (item.style & DD_ITEM_STYLE_SPLIT_ITEM) { _DrawSplitItem(this, dc, item.text, pt, rowSize.GetWidth(), rowSize.GetHeight()); @@ -462,12 +452,9 @@ void DropDown::render(wxDC &dc) auto text = group.IsEmpty() ? (item.group_key.IsEmpty() ? item.text : item.group_label) : item.text; - if (action_column_width > 0) - rcContent.width -= action_column_width; - if (item.action_icon.IsOk()) { auto action_size = GetBmpSize(item.action_icon); - wxPoint action_pt(rcContent.x + rcContent_width - action_size.x - FromDIP(8), + wxPoint action_pt(action_column.second - action_size.x - FromDIP(8), rcContent.y + (rcContent.height - action_size.y) / 2); dc.DrawBitmap(item.action_icon, action_pt); } @@ -498,7 +485,6 @@ void DropDown::render(wxDC &dc) dc.DrawBitmap(arrow_bitmap.bmp(), pt); } } - rcContent.width = rcContent_width; rcContent.y += rowSize.y; } } @@ -572,6 +558,7 @@ void DropDown::messureSize() if (!need_sync) return; textSize = wxSize(); iconSize = wxSize(); + actionIconWidth = 0; count = 0; wxClientDC dc(GetParent() ? GetParent() : this); dc.SetFont(GetFont()); @@ -609,9 +596,12 @@ void DropDown::messureSize() } } if (item.action_icon.IsOk()) - size1.x += GetBmpSize(item.action_icon).x + FromDIP(16); + actionIconWidth = std::max(actionIconWidth, GetBmpSize(item.action_icon).x); if (size1.x > textSize.x) textSize = size1; } + // Every row leaves room for the action column, as render() and actionColumn() lay it out. + if (actionIconWidth > 0) + textSize.x += actionIconWidth + FromDIP(16); if (!align_icon) iconSize.x = 0; wxSize szContent = textSize; if (szContent.x < FromDIP(120) && !use_content_width) @@ -766,12 +756,8 @@ void DropDown::mouseReleased(wxMouseEvent& event) if (hover_item >= 0 && (subDropDown == nullptr || subDropDown->group.empty())) { // not moved int index = hoverIndex(); if (index >= 0 && items[index].action_icon.IsOk()) { - wxSize action_size = actionIconSize(); - int action_right = GetSize().x; - if (rowSize.y * count > GetSize().y) - action_right -= 6; - int action_left = action_right - action_size.x - FromDIP(16); - if (event.GetX() >= action_left && event.GetX() <= action_right) { + const auto action_column = actionColumn(); + if (event.GetX() >= action_column.first && event.GetX() < action_column.second) { sendDropDownActionEvent(index); paintNow(); return; diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index adbe0eb95c..a0821e6fff 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "slic3r/GUI/Widgets/StateColor.hpp" #include @@ -67,6 +68,7 @@ private: wxSize textSize; wxSize iconSize; + int actionIconWidth = 0; wxSize rowSize; StateHandler state_handler; @@ -137,7 +139,7 @@ private: int hoverIndex(); int selectedItem(); - wxSize actionIconSize() const; + std::pair actionColumn() const; friend class ComboBox; void messureSize(); From e625845b347b9e6f9518910dd7146a455027429e Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:02:43 +0800 Subject: [PATCH 14/45] Keep Snapshot Suppression for the Whole Object List Edit (#16388) Five Plater::TakeSnapshot guards in GUI_ObjectList were unnamed temporaries, so snapshots were allowed again as soon as the snapshot was taken. Naming them keeps the suppression until the edit is done, as everywhere else. --- src/slic3r/GUI/GUI_ObjectList.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 21210b06b7..4a733d3342 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -1617,7 +1617,7 @@ void ObjectList::list_manipulation(const wxPoint& mouse_pos, bool evt_context_me int obj_idx, vol_idx; get_selected_item_indexes(obj_idx, vol_idx, item); if (obj_idx != -1) { - Plater::TakeSnapshot(plater, _u8L("Shift objects to bed")); + Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); (*m_objects)[obj_idx]->ensure_on_bed(); cnv->reload_scene(true, true); update_info_items(obj_idx); @@ -2779,12 +2779,13 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type) GLCanvas3D* cnv = plater->canvas3D(); switch (type) { - case InfoItemType::CustomSupports: + case InfoItemType::CustomSupports: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, "Remove support painting"); + Plater::TakeSnapshot snapshot(plater, "Remove support painting"); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->supported_facets.reset(); break; + } // BBS: remove CustomSeam @@ -2795,19 +2796,21 @@ void ObjectList::del_info_item(const int obj_idx, InfoItemType type) } break; - case InfoItemType::MmSegmentation: + case InfoItemType::MmSegmentation: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, "Remove color painting"); + Plater::TakeSnapshot snapshot(plater, "Remove color painting"); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->mmu_segmentation_facets.reset(); break; + } - case InfoItemType::FuzzySkin: + case InfoItemType::FuzzySkin: { cnv->get_gizmos_manager().reset_all_states(); - Plater::TakeSnapshot(plater, _u8L("Remove paint-on fuzzy skin")); + Plater::TakeSnapshot snapshot(plater, _u8L("Remove paint-on fuzzy skin")); for (ModelVolume* mv : (*m_objects)[obj_idx]->volumes) mv->fuzzy_skin_facets.reset(); break; + } // BBS: remove Sinking case InfoItemType::Undef : assert(false); break; @@ -6718,7 +6721,7 @@ void ObjectList::OnEditingStarted(wxDataViewEvent &event) else if (col == colSinking) { Plater * plater = wxGetApp().plater(); GLCanvas3D *cnv = plater->canvas3D(); - Plater::TakeSnapshot(plater, _u8L("Shift objects to bed")); + Plater::TakeSnapshot snapshot(plater, _u8L("Shift objects to bed")); int obj_idx, vol_idx; get_selected_item_indexes(obj_idx, vol_idx, item); (*m_objects)[obj_idx]->ensure_on_bed(); From 6a1a5d51e99fbe7cb51424a620e397c1b60f7637 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 16:03:49 +0800 Subject: [PATCH 15/45] Show an outline star for filaments that are not favorites --- resources/images/score_star_outline.svg | 3 +++ src/slic3r/GUI/AMSMaterialsSetting.cpp | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 resources/images/score_star_outline.svg diff --git a/resources/images/score_star_outline.svg b/resources/images/score_star_outline.svg new file mode 100644 index 0000000000..eb52be9e82 --- /dev/null +++ b/resources/images/score_star_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index b2b5f46466..41047dffd9 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -213,7 +213,7 @@ void AMSMaterialsSetting::create_panel_normal(wxWindow* parent) m_comboBox_filament = new ::ComboBox(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, AMS_MATERIALS_SETTING_COMBOX_WIDTH, 0, nullptr, wxCB_READONLY); m_star_filled = ScalableBitmap(this, "score_star_light", 16); - m_star_empty = ScalableBitmap(this, "score_star_dark", 16); + m_star_empty = ScalableBitmap(this, "score_star_outline", 16); m_sizer_filament->Add(m_comboBox_filament, 1, wxALIGN_CENTER, 0); From 5371c853629e01b25a32058c9695875a55b56057 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:09:05 +0800 Subject: [PATCH 16/45] Refresh Dark Mode Colours When the System Theme Differs at Startup (#16390) On Linux and macOS the app follows the system theme: at startup it overwrites the saved dark_color_mode with the system appearance. The label colours and the cached dark mode flag were already set from the saved value by then, and the later "changed" check reads the mode only after the overwrite, so it never fires. For that session every dialog that maps its colours through UpdateDlgDarkUI keeps the old mode, e.g. white dialogs on a dark main window after switching the desktop to dark, or on a fresh install on a dark desktop. Refresh the label colours and the flag when the system theme differs from the saved mode. --- src/slic3r/GUI/GUI_App.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 8940e13e6e..636ba985f5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3232,9 +3232,16 @@ bool GUI_App::on_init_inner() #ifdef _MSW_DARK_MODE #ifndef __WINDOWS__ + // The label colours and the dark mode flag were set above from the saved mode. When the system theme + // differs from it, refresh them, or dialogs that map their colours stay in the old mode for this session. + const bool saved_dark_mode = dark_mode(); wxSystemAppearance app = wxSystemSettings::GetAppearance(); GUI::wxGetApp().app_config->set("dark_color_mode", app.IsDark() ? "1" : "0"); GUI::wxGetApp().app_config->save(); + if (dark_mode() != saved_dark_mode) { + init_label_colours(); + Update_dark_mode_flag(); + } #endif // __APPLE__ From 3f78959a0c2028922b1452e3bd4a9128046725aa Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 17:47:04 +0800 Subject: [PATCH 17/45] Harden loading and saving of texture displacement in projects A project no longer reads texture files from outside its archive, and a malformed value or layer slot drops only the texture layers instead of failing the whole load. Copies of an object that share a mesh keep their own texture paint when saved, and projects without texture paint no longer use extra memory to load. --- src/libslic3r/Format/bbs_3mf.cpp | 62 +++--- src/libslic3r/TextureDisplacement.cpp | 131 ++++++------ src/libslic3r/TextureDisplacement.hpp | 5 +- tests/libslic3r/test_3mf.cpp | 191 ++++++++++++++++-- tests/libslic3r/test_texture_displacement.cpp | 29 +++ 5 files changed, 311 insertions(+), 107 deletions(-) diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 62fe3c91e4..24c2c027e5 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -64,7 +64,6 @@ #include #include #include -#include #include #include #include @@ -856,7 +855,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) std::vector custom_seam; std::vector mmu_segmentation; std::vector fuzzy_skin; - // One per texture displacement layer slot, each parallel to `triangles` like the masks above. + // One per texture displacement layer slot, indexed like `triangles` but stored only as far as the + // slot's last painted triangle: empty when the slot has no paint, which is nearly every file. std::vector texture_displacement[TEXTURE_DISPLACEMENT_MAX_LAYERS]; // BBS std::vector face_properties; @@ -3355,34 +3355,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) add_error("Malformed texture displacement data: " + json_path); return; } - // A layer the archive carries no image for - saved while its texture had not been loaded, or - // written by a build that stored only the path - falls back to the file it came from. The - // importer copies every texture into the user's own library, so that path resolves on the - // machine the project was made on and quietly resolves nowhere else, which is the old - // behaviour: settings restored, pixels not. - const auto load_image_from_disk = [](TextureDisplacementLayer &layer) { - boost::system::error_code ec; - if (layer.path.empty() || !boost::filesystem::is_regular_file(layer.path, ec) || ec) - return; - boost::nowide::ifstream file(layer.path, std::ios::binary); - if (!file.good()) - return; - std::vector bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); - if (!bytes.empty()) - layer.image_data = std::make_shared>(std::move(bytes)); - }; - + // The archive is the only source of a layer's pixels. `path` comes from the project file, so loading + // the project must never read the file it names: that would let whoever made the project pick any + // local (or, on Windows, network) file to read, and the next save would embed it in the archive. + // `path` stays a label of where the texture came from; a layer the archive carries no image for + // loads without one, its settings intact, until the user picks a texture again. for (TextureDisplacementLayer &layer : layers) { - if (!layer.path_in_3mf.empty()) { - if (const auto image = m_texture_displacement_files.find(layer.path_in_3mf); - image != m_texture_displacement_files.end()) { - layer.image_data = std::make_shared>(image->second.begin(), - image->second.end()); - continue; - } + if (layer.path_in_3mf.empty()) + continue; + const auto image = m_texture_displacement_files.find(layer.path_in_3mf); + if (image == m_texture_displacement_files.end()) { add_error("Missing texture displacement image: " + layer.path_in_3mf); + continue; } - load_image_from_disk(layer); + layer.image_data = std::make_shared>(image->second.begin(), image->second.end()); } volume.texture_displacement_layers = std::move(layers); volume.texture_displacement_options = options; @@ -4045,8 +4031,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_curr_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); m_curr_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) - m_curr_object->geometry.texture_displacement[slot].push_back( - bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); + if (const char *paint = bbs_get_attribute_value_charptr(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])) { + std::vector &mask = m_curr_object->geometry.texture_displacement[slot]; + mask.resize(m_curr_object->geometry.triangles.size()); + mask.back() = paint; + } // BBS m_curr_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -5401,7 +5390,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { const std::vector &mask = sub_object->geometry.texture_displacement[slot]; if (mask.empty()) - continue; // written by a build without the feature + continue; // no triangle painted on this slot FacetsAnnotation &facets = volume->texture_displacement_facet(slot); facets.reserve(triangles_count); for (size_t i = 0; i < triangles_count && i < mask.size(); ++i) @@ -5917,8 +5906,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) current_object->geometry.mmu_segmentation.push_back(bbs_get_attribute_value_string(attributes, num_attributes, MMU_SEGMENTATION_ATTR)); current_object->geometry.fuzzy_skin.push_back(bbs_get_attribute_value_string(attributes, num_attributes, CUSTOM_FUZZY_SKIN_ATTR)); for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) - current_object->geometry.texture_displacement[slot].push_back( - bbs_get_attribute_value_string(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])); + if (const char *paint = bbs_get_attribute_value_charptr(attributes, num_attributes, TEXTURE_DISPLACEMENT_ATTRS[slot])) { + std::vector &mask = current_object->geometry.texture_displacement[slot]; + mask.resize(current_object->geometry.triangles.size()); + mask.back() = paint; + } // BBS current_object->geometry.face_properties.push_back(bbs_get_attribute_value_string(attributes, num_attributes, FACE_PROPERTY_ATTR)); } @@ -7349,10 +7341,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (iter != m_shared_meshes.end()) { const ModelVolume* shared_volume = iter->second.second; + bool same_texture_paint = true; + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS) && same_texture_paint; ++slot) + same_texture_paint = shared_volume->texture_displacement_facet(slot).equals(volume->texture_displacement_facet(slot)); if ((shared_volume->supported_facets.equals(volume->supported_facets)) && (shared_volume->seam_facets.equals(volume->seam_facets)) && (shared_volume->mmu_segmentation_facets.equals(volume->mmu_segmentation_facets)) - && (shared_volume->fuzzy_skin_facets.equals(volume->fuzzy_skin_facets))) + && (shared_volume->fuzzy_skin_facets.equals(volume->fuzzy_skin_facets)) + && same_texture_paint) { auto data = iter->second.first; const_cast<_BBS_3MF_Exporter *>(this)->m_volume_paths.insert({volume, {data->sub_path, data->volumes_objectID.find(iter->second.second)->second}}); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 80aa313f9b..4e99f97a5a 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -5334,77 +5334,88 @@ bool texture_displacement_layers_from_json(const std::string } std::vector out_layers; - if (const auto it = root.find("layers"); it != root.end() && it->is_array()) { - for (const nlohmann::json &j : *it) { - if (!j.is_object()) - continue; - TextureDisplacementLayer l; - read(j, "slot", l.slot); - read(j, "name", l.name); - read(j, "path", l.path); - read(j, "path_in_3mf", l.path_in_3mf); + // read() skips a value of the wrong type, but the vector, matrix, seam, island and UV-override arrays + // below read their numbers with get<>(), which throws on one; that rejects the document, like text that + // does not parse. + try { + if (const auto it = root.find("layers"); it != root.end() && it->is_array()) { + for (const nlohmann::json &j : *it) { + if (!j.is_object()) + continue; + TextureDisplacementLayer l; + read(j, "slot", l.slot); + // The panel and the paint masks are indexed by slot, so a layer without a valid one rejects the + // document rather than reaching them. + if (l.slot < 0 || l.slot >= int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) + return false; + read(j, "name", l.name); + read(j, "path", l.path); + read(j, "path_in_3mf", l.path_in_3mf); - read(j, "depth_mm", l.depth_mm); - read(j, "tiling_scale", l.tiling_scale); - read(j, "rotation_deg", l.rotation_deg); - if (const auto o = j.find("offset"); o != j.end()) - l.offset = vec2_from_json(*o, l.offset); - read(j, "invert", l.invert); - read(j, "midlevel", l.midlevel); - read(j, "smoothing", l.smoothing); + read(j, "depth_mm", l.depth_mm); + read(j, "tiling_scale", l.tiling_scale); + read(j, "rotation_deg", l.rotation_deg); + if (const auto o = j.find("offset"); o != j.end()) + l.offset = vec2_from_json(*o, l.offset); + read(j, "invert", l.invert); + read(j, "midlevel", l.midlevel); + read(j, "smoothing", l.smoothing); - read(j, "edge_smoothing", l.edge_smoothing); - read(j, "edge_smoothing_amount", l.edge_smoothing_amount); - read(j, "auto_connect_islands", l.auto_connect_islands); + read(j, "edge_smoothing", l.edge_smoothing); + read(j, "edge_smoothing_amount", l.edge_smoothing_amount); + read(j, "auto_connect_islands", l.auto_connect_islands); - read(j, "tile_enabled", l.tile_enabled); - read(j, "color_enabled", l.color_enabled); - int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); - read_enum(j, "tile_method", tile_method); - read_enum(j, "projection_method", projection); - read_enum(j, "blend_mode", blend); - l.tile_method = TextureTileMethod(tile_method); - l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); - l.blend_mode = TextureBlendMode(blend); + read(j, "tile_enabled", l.tile_enabled); + read(j, "color_enabled", l.color_enabled); + int tile_method = int(l.tile_method), projection = int(l.projection_method), blend = int(l.blend_mode); + read_enum(j, "tile_method", tile_method); + read_enum(j, "projection_method", projection); + read_enum(j, "blend_mode", blend); + l.tile_method = TextureTileMethod(tile_method); + l.projection_method = TextureProjectionMethod(std::clamp(projection, 0, 4)); + l.blend_mode = TextureBlendMode(blend); - read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); - read(j, "island_padding_mm", l.island_padding_mm); + read(j, "lscm_seam_angle_deg", l.lscm_seam_angle_deg); + read(j, "island_padding_mm", l.island_padding_mm); - if (const auto v = j.find("view_project_right"); v != j.end()) - l.view_project_right = vec3_from_json(*v, l.view_project_right); - if (const auto v = j.find("view_project_up"); v != j.end()) - l.view_project_up = vec3_from_json(*v, l.view_project_up); - read(j, "view_project_projective", l.view_project_projective); - if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) - for (size_t i = 0; i < 12; ++i) - l.view_project_matrix[i] = (*v)[i].get(); + if (const auto v = j.find("view_project_right"); v != j.end()) + l.view_project_right = vec3_from_json(*v, l.view_project_right); + if (const auto v = j.find("view_project_up"); v != j.end()) + l.view_project_up = vec3_from_json(*v, l.view_project_up); + read(j, "view_project_projective", l.view_project_projective); + if (const auto v = j.find("view_project_matrix"); v != j.end() && v->is_array() && v->size() == 12) + for (size_t i = 0; i < 12; ++i) + l.view_project_matrix[i] = (*v)[i].get(); - if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) - for (size_t i = 0; i + 1 < v->size(); i += 2) - l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); + if (const auto v = j.find("lscm_seam_edges"); v != j.end() && v->is_array()) + for (size_t i = 0; i + 1 < v->size(); i += 2) + l.lscm_seam_edges.emplace_back((*v)[i].get(), (*v)[i + 1].get()); - if (const auto v = j.find("islands"); v != j.end() && v->is_array()) - for (const nlohmann::json &e : *v) { - if (!e.is_array() || e.size() != 4) - continue; - TextureIsland island; - island.offset = Vec2f(e[0].get(), e[1].get()); - island.rotation_deg = e[2].get(); - island.scale = e[3].get(); - l.islands.push_back(island); - } + if (const auto v = j.find("islands"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 4) + continue; + TextureIsland island; + island.offset = Vec2f(e[0].get(), e[1].get()); + island.rotation_deg = e[2].get(); + island.scale = e[3].get(); + l.islands.push_back(island); + } - read(j, "island_groups", l.island_groups); + read(j, "island_groups", l.island_groups); - if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) - for (const nlohmann::json &e : *v) { - if (!e.is_array() || e.size() != 3) - continue; - l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); - } + if (const auto v = j.find("lscm_uv_overrides"); v != j.end() && v->is_array()) + for (const nlohmann::json &e : *v) { + if (!e.is_array() || e.size() != 3) + continue; + l.lscm_uv_overrides.emplace_back(e[0].get(), Vec2f(e[1].get(), e[2].get())); + } - out_layers.push_back(std::move(l)); + out_layers.push_back(std::move(l)); + } } + } catch (const nlohmann::json::exception &) { + return false; } layers = std::move(out_layers); diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index e86b0c714c..36f66cc9fb 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -424,7 +424,10 @@ struct TextureDisplacementOptions // responsible for writing it and for filling `image_data` back in on load. std::string texture_displacement_layers_to_json(const std::vector &layers, const TextureDisplacementOptions &options); -// Returns false and leaves both outputs untouched when the text is not valid JSON. +// Returns false and leaves both outputs untouched when the text is not a valid JSON object, a number in a +// layer's vector, matrix, seam, island or UV-override array has the wrong type, or a layer's slot is +// missing or outside [0, TEXTURE_DISPLACEMENT_MAX_LAYERS). Any other value of the wrong type or shape +// keeps its default. bool texture_displacement_layers_from_json(const std::string &text, std::vector &layers, TextureDisplacementOptions &options); diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f5ac340c58..a59800fafe 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -28,6 +28,7 @@ #include #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleSelector.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include @@ -349,6 +351,24 @@ static bool replace_in_3mf_entry(const std::string& path, const std::string& suf return replaced; } +// Stores `model` as a one-plate project. +static void store_project(Model& model, const std::string& path, SaveStrategy strategy) +{ + ScopedTemporaryDir backup_dir("orca_paint_src"); + model.set_backup_path(backup_dir.string()); + + DynamicPrintConfig cfg; + PlateData plate; + plate.plate_index = 0; + StoreParams sp; + sp.path = path.c_str(); + sp.model = &model; + sp.config = &cfg; + sp.strategy = strategy; + sp.plate_data_list.push_back(&plate); + REQUIRE(store_bbs_3mf(sp)); +} + // Stores a one-plate project holding a cube whose first two facets are painted Extruder2 and // Extruder3, which the exporter writes as paint_color="8" and paint_color="0C". static void store_painted_cube(const std::string& path) @@ -363,19 +383,7 @@ static void store_painted_cube(const std::string& path) selector.set_facet(1, EnforcerBlockerType::Extruder3); REQUIRE(volume->mmu_segmentation_facets.set(selector)); } - ScopedTemporaryDir backup_dir("orca_paint_src"); - model.set_backup_path(backup_dir.string()); - - DynamicPrintConfig cfg; - PlateData plate; - plate.plate_index = 0; - StoreParams sp; - sp.path = path.c_str(); - sp.model = &model; - sp.config = &cfg; - sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; - sp.plate_data_list.push_back(&plate); - REQUIRE(store_bbs_3mf(sp)); + store_project(model, path, SaveStrategy::Zip64 | SaveStrategy::Silence); } // Loads `path` through the BBS importer into `model`, releasing the plates it returns. The @@ -501,6 +509,163 @@ TEST_CASE("A project with malformed paint data loads without the damaged facet", REQUIRE(selector.num_facets(EnforcerBlockerType::Extruder3) == 1); } +// Paints `triangles` of `volume` on texture displacement layer slot `slot`. +static void paint_texture_slot(ModelVolume& volume, int slot, std::initializer_list triangles) +{ + TriangleSelector selector(volume.mesh()); + for (const int triangle : triangles) + selector.set_facet(triangle, EnforcerBlockerType::ENFORCER); + REQUIRE(volume.texture_displacement_facet(slot).set(selector)); +} + +// Every triangle's texture paint on every slot, in the form the exporter writes it, so two volumes +// compare equal only when the same triangles are painted on the same slots. +static std::vector texture_paint(const ModelVolume& volume) +{ + std::vector paint; + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + for (size_t i = 0; i < volume.mesh().its.indices.size(); ++i) + paint.push_back(volume.texture_displacement_facet(slot).get_triangle_as_string(int(i))); + return paint; +} + +// A cube with one texture displacement layer in slot 2 and texture paint on gapped triangles of two +// slots. The layer's image bytes need not decode: the archive stores them as they are. +static ModelVolume& add_texture_displaced_cube(Model& model) +{ + ModelObject* object = model.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + + TextureDisplacementLayer layer; + layer.slot = 2; + layer.name = "Bark"; + layer.depth_mm = 1.25f; + layer.image_data = std::make_shared>(std::vector{ 0x89, 'P', 'N', 'G', 1, 2, 3 }); + volume->texture_displacement_layers.push_back(layer); + volume->texture_displacement_options.active_slot = 2; + + paint_texture_slot(*volume, 2, { 3, 7 }); + paint_texture_slot(*volume, 5, { 10 }); + return *volume; +} + +TEST_CASE("Texture displacement layers, textures and paint survive a project round trip", "[3mf]") +{ + // A single model file, and one file per object (read by a separate importer). + const SaveStrategy strategy = GENERATE(SaveStrategy::Zip64 | SaveStrategy::Silence, + SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel); + INFO("save strategy " << int(strategy)); + + ScopedTemporaryFile temp(".3mf"); + Model source; + const ModelVolume& painted = add_texture_displaced_cube(source); + const std::vector paint = texture_paint(painted); + REQUIRE(std::count_if(paint.begin(), paint.end(), [](const std::string& s) { return !s.empty(); }) == 3); + store_project(source, temp.string(), strategy); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 1); + const ModelVolume& volume = *model.objects.front()->volumes.front(); + + REQUIRE(volume.texture_displacement_layers.size() == 1); + const TextureDisplacementLayer& layer = volume.texture_displacement_layers.front(); + CHECK(layer.slot == 2); + CHECK(layer.name == "Bark"); + CHECK_THAT(layer.depth_mm, Catch::Matchers::WithinAbs(1.25f, 1e-6f)); + REQUIRE(layer.image_data); + CHECK(*layer.image_data == *painted.texture_displacement_layers.front().image_data); + CHECK(volume.texture_displacement_options.active_slot == 2); + CHECK(texture_paint(volume) == paint); +} + +TEST_CASE("Volumes sharing a mesh keep their own texture displacement paint", "[3mf]") +{ + Model source; + ModelObject* first_object = source.add_object(); + ModelVolume* first = first_object->add_volume(make_cube(10., 10., 10.)); + first_object->add_instance(); + ModelVolume* second = source.add_object(*first_object)->volumes.front(); + REQUIRE(second->mesh_ptr() == first->mesh_ptr()); + paint_texture_slot(*first, 0, { 3 }); + paint_texture_slot(*second, 0, { 7 }); + + // What a project save from the app uses: one file per object, an identical mesh written once. + ScopedTemporaryFile temp(".3mf"); + store_project(source, temp.string(), + SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::ShareMesh); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 2); + CHECK(texture_paint(*model.objects[0]->volumes.front()) == texture_paint(*first)); + CHECK(texture_paint(*model.objects[1]->volumes.front()) == texture_paint(*second)); +} + +TEST_CASE("A texture displacement layer never loads its image from outside the project", "[3mf]") +{ + // A file the layer names, on the machine opening the project, that the project did not carry. + ScopedTemporaryFile texture(".png"); + { + boost::nowide::ofstream file(texture.string(), std::ios::binary); + file << "not part of the project"; + } + REQUIRE(boost::filesystem::file_size(texture.path()) > 0); + + Model source; + ModelObject* object = source.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = "Elsewhere"; + layer.path = texture.string(); + layer.depth_mm = 0.75f; + volume->texture_displacement_layers.push_back(layer); + + ScopedTemporaryFile temp(".3mf"); + store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + REQUIRE(load_project(temp.string(), model, backup_dir)); + REQUIRE(model.objects.size() == 1); + const std::vector& layers = model.objects.front()->volumes.front()->texture_displacement_layers; + REQUIRE(layers.size() == 1); + CHECK(layers.front().name == "Elsewhere"); + CHECK_THAT(layers.front().depth_mm, Catch::Matchers::WithinAbs(0.75f, 1e-6f)); + CHECK_FALSE(layers.front().image_data); +} + +TEST_CASE("A project with a malformed texture displacement value still loads", "[3mf][Regression]") +{ + const auto [from, to] = GENERATE(table({ + { "\"islands\":[]", "\"islands\":[[null,0,0,1]]" }, // a number that is null + { "\"slot\":2", "\"slot\":8" }, // a slot past the last + })); + INFO(from << " -> " << to); + + ScopedTemporaryFile temp(".3mf"); + Model source; + const ModelVolume& painted = add_texture_displaced_cube(source); + store_project(source, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence); + REQUIRE(replace_in_3mf_entry(temp.string(), ".json", from, to)); + + ScopedTemporaryDir backup_dir("orca_texdisp_dst"); + Model model; + bool loaded = false; + REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir)); + REQUIRE(loaded); + REQUIRE(model.objects.size() == 1); + const ModelVolume& volume = *model.objects.front()->volumes.front(); + // The layer stack is dropped as a whole; the paint, stored apart from it, still loads. + CHECK(volume.texture_displacement_layers.empty()); + CHECK(texture_paint(volume) == texture_paint(painted)); +} + // The recipe lives only in the BBS-native backend, because that is the only one that runs: // store_bbs_3mf is the sole exporter the app calls, and 3mf.cpp's load_3mf is reached only for // files fingerprinted as PrusaSlicer's, which never carry a recipe. This locks in both halves: diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index fefc38ac33..f71c839c42 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include "libslic3r/AABBMesh.hpp" @@ -2300,6 +2301,34 @@ TEST_CASE("Texture displacement JSON keeps defaults for keys it does not carry", CHECK(!texture_displacement_layers_from_json("not json at all", unused_layers, unused_options)); } +TEST_CASE("Texture displacement JSON with a wrong-typed element or an out-of-range slot is rejected", "[TextureDisplacement]") +{ + const std::string layer = GENERATE(as{}, + R"({"slot":0,"offset":["x",0]})", + R"({"slot":0,"view_project_right":[null,0,0]})", + R"({"slot":0,"view_project_matrix":[0,0,0,0,0,0,0,0,0,0,0,"x"]})", + R"({"slot":0,"lscm_seam_edges":[1,null]})", + R"({"slot":0,"islands":[[0,0,"x",1]]})", + R"({"slot":0,"lscm_uv_overrides":[[null,0,0]]})", + R"({"slot":8})", + R"({"slot":-1})", + R"({"slot":"x"})", + R"({"name":"No slot"})"); + INFO(layer); + + std::vector layers(1); + layers.front().name = "Kept"; + TextureDisplacementOptions options; + options.color_mix_count = 7; + + bool parsed = true; + REQUIRE_NOTHROW(parsed = texture_displacement_layers_from_json(R"({"version":1,"layers":[)" + layer + "]}", layers, options)); + CHECK_FALSE(parsed); + REQUIRE(layers.size() == 1); + CHECK(layers.front().name == "Kept"); + CHECK(options.color_mix_count == 7); +} + TEST_CASE("whether a layer's texture has colour agrees with its decode", "[TextureDisplacement]") { TextureDisplacementLayer gray; From c55f9c22518a2ea5c25387f5a7fabbf08ce3f57d Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:31:03 +0800 Subject: [PATCH 18/45] Add a /bot fix-tidy Command That Suggests clang-tidy's Missing Includes (#16398) * Add a /bot fix-tidy Command That Pushes clang-tidy's Missing Includes A maintainer comments /bot fix-tidy on a PR. A read-only job runs scripts/run_clang_tidy.sh --fix on the PR's merge commit and returns the patch; a second job, which never runs the PR's code, accepts it only when it adds nothing but #include lines to C++ files the PR already changes, then commits it on the PR head and pushes with CLANG_TIDY_BOT_TOKEN. * Post clang-tidy's Fix as Review Suggestions Instead of Pushing It A failed clang-tidy check now runs once more with --fix and uploads the patch as the clang-tidy-fix artifact; the check still fails. /bot fix-tidy downloads that patch for the PR's latest commit, checks it adds only includes to C++ files the PR changes, and posts one review: a one-click suggestion for each include on a line the PR's diff shows, and the whole patch with a git apply command. Nothing is pushed and no PR code runs in the bot, so the PR's author can run it too and no token is needed. * Make the clang-tidy Fix in Its Own PR-Scoped Workflow on Request clang_tidy.yml is back to main's version. clang_tidy_fix.yml runs on every PR push but skips its job on the first attempt; /bot fix-tidy re-runs it, so run_clang_tidy.sh --fix runs in the PR's own context with a read-only token and the PR's cache, never main's, and the PR's author can ask for it. When that re-run finishes, the bot posts the patch as review suggestions as before. When the clang-tidy step fails, the bot also comments asking the author to run the script or the command, edits that comment on later failures, and deletes it once the check passes. * Give the clang-tidy Bot Write Access to PRs It Answers * Tell /bot fix-tidy Users How to Start a Fix Run When There Is None to Re-Run --- .github/workflows/clang_tidy_fix.yml | 65 ++++ .github/workflows/pr-tidy-bot.yml | 476 +++++++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 .github/workflows/clang_tidy_fix.yml create mode 100644 .github/workflows/pr-tidy-bot.yml diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml new file mode 100644 index 0000000000..6339e17570 --- /dev/null +++ b/.github/workflows/clang_tidy_fix.yml @@ -0,0 +1,65 @@ +name: clang-tidy fix + +# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a +# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the +# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact. +# +# Every push to a PR starts this workflow, and its job skips on that first +# attempt. The bot re-runs it on request. A re-run keeps the pull_request +# context, so the PR's code runs with a read-only token, no secrets and the +# PR's own cache, never main's, whoever asked for it. +on: + pull_request: + branches: + - main + - release/* + +permissions: + contents: read + +jobs: + fix: + if: github.run_attempt > 1 + # A hosted runner, so nothing the PR's code leaves behind outlives the job. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + lfs: 'false' + # The PR merge commit plus its first parent, the base it is diffed against. + fetch-depth: 2 + persist-credentials: false + + # Parsing needs the dependency headers, not a build of this PR's deps/, so + # a PR that changes deps/ falls back to the newest cache main has. + - name: Restore cached deps + uses: actions/cache/restore@v6 + with: + path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep + key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} + restore-keys: linux-clang-cache-orcaslicer_deps-build- + fail-on-cache-miss: true + + - name: Apt-Install Dependencies + uses: ./.github/actions/apt-install-deps + + # The status says whether findings remain that --fix cannot fix. + - name: Fix the changed lines + run: | + mkdir -p "$RUNNER_TEMP/clang-tidy-fix" + status=0 + scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$? + echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status" + git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \ + > "$RUNNER_TEMP/clang-tidy-fix/fix.patch" + + - name: Upload the fix + uses: actions/upload-artifact@v7 + with: + name: clang-tidy-fix + path: ${{ runner.temp }}/clang-tidy-fix/ + retention-days: 7 + # A later re-run of the same run replaces the earlier fix. + overwrite: true diff --git a/.github/workflows/pr-tidy-bot.yml b/.github/workflows/pr-tidy-bot.yml new file mode 100644 index 0000000000..62f4a0002f --- /dev/null +++ b/.github/workflows/pr-tidy-bot.yml @@ -0,0 +1,476 @@ +name: PR clang-tidy Bot + +# Helps a pull request past the clang-tidy check. +# +# When the check fails on a PR's changes, a comment asks the author to fix it, +# either with scripts/run_clang_tidy.sh --fix or by commenting: +# /bot fix-tidy +# +# That command, from the PR's author or an account with write access, re-runs +# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the +# PR's own context. When it finishes, the fix is posted as one review: each +# include whose place falls on a line the PR's diff shows is a suggestion GitHub +# can commit in one click, and the review's body holds the whole patch with a +# git apply command. Findings --fix cannot fix, such as a using in a header, +# are left for the check to report. +# +# No code from the PR runs here. Nothing is pushed, and the patch is only +# posted when every line it adds is an #include in a C++ file the PR changes. +# +# workflow_run matches the workflows by their name: keep "clang-tidy" and +# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and +# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml. + +on: + issue_comment: + types: + - created + workflow_run: + workflows: + - clang-tidy + - clang-tidy fix + types: + - completed + +permissions: {} + +jobs: + request: + # Skips the job unless a PR comment mentions the command. + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'issue_comment' + && github.event.issue.pull_request != null + && contains(github.event.comment.body, '/bot fix-tidy') + permissions: + actions: write # re-run the fix run + pull-requests: write # react to and answer the comment, which is the PR's + issues: write # feedback comment + reactions + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Re-run the PR's clang-tidy fix + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + const FIX_WORKFLOW = 'clang_tidy_fix.yml'; + + const { owner, repo } = context.repo; + const issue = context.payload.issue; + const comment = context.payload.comment; + + // Ignores a comment whose sender is not its author. + if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') { + core.info('Ignoring a comment not written by its sender, or by a bot.'); + return; + } + + const commandLine = (comment.body || '') + .split('\n') + .map((line) => line.trim()) + .find((line) => /^\/bot\s+fix-tidy\b/i.test(line)); + if (!commandLine) { + core.info('No /bot fix-tidy command found.'); + return; + } + + const commenter = comment.user.login; + // Answers only the PR's author and maintainers, so the command cannot be used to spam. + if (commenter !== issue.user.login) { + const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter }); + if (!['admin', 'write'].includes(access.permission)) { + core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`); + return; + } + } + + const react = (content) => + github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content }); + async function refuse(reason) { + await react('-1'); + await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` }); + } + + await react('eyes'); + + if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) { + return refuse('`/bot fix-tidy` takes no arguments.'); + } + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number }); + if (pr.state !== 'open') { + return refuse('the PR is not open.'); + } + + const sha = pr.head.sha.slice(0, 10); + const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({ + owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1 + }); + const run = runs[0]; + // A PR last pushed before clang_tidy_fix.yml existed has no run to re-run. + if (!run) { + return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` + + 'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.'); + } + if (run.status !== 'completed') { + // The first attempt is the one every push starts, and it makes no fix. + return refuse(run.run_attempt > 1 + ? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.` + : `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`); + } + // GitHub refuses to re-run a run created over 30 days ago. + try { + await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id }); + } catch (error) { + core.warning(`Re-running ${run.html_url} failed: ${error.message}`); + return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` + + 'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.'); + } + core.info(`Re-ran ${run.html_url}`); + + suggest: + # The fix run's re-runs only. Its first attempt makes no fix. + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'workflow_run' + && github.event.workflow_run.name == 'clang-tidy fix' + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.run_attempt > 1 + concurrency: + group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + permissions: + actions: read # download the fix + contents: read # check out the PR head + pull-requests: write # the review + issues: write # feedback comment + reactions + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Find the PR and who asked + id: find + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/; + + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. + const { data: prs } = await github.rest.pulls.list({ + owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` + }); + const pr = prs[0]; + if (!pr) { + core.info('No open PR for this run.'); + return; + } + + // The latest /bot fix-tidy comment is the one this run answers. + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || '')); + const mention = request ? `@${request.user.login} ` : ''; + async function refuse(reason) { + if (request) { + await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' }); + } + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` }); + } + + if (pr.head.sha !== run.head_sha) { + return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.'); + } + if (run.conclusion !== 'success') { + return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`); + } + + // The right-side lines of each changed C++ file's diff: the lines a + // review comment can be placed on. A file too large for GitHub to + // show its diff has none, and gets no suggestions. + const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); + const ranges = {}; + for (const file of files) { + if (file.status === 'removed' || !CPP.test(file.filename)) { + continue; + } + ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)] + .map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1]) + .filter(([first, last]) => last >= first); + } + + core.setOutput('pr', pr.number); + core.setOutput('head-sha', pr.head.sha); + core.setOutput('head-repo', run.head_repository.full_name); + core.setOutput('ranges', JSON.stringify(ranges)); + core.setOutput('mention', mention); + core.setOutput('request-id', request ? request.id : ''); + + - name: Download the fix + if: steps.find.outputs.pr != '' + uses: actions/download-artifact@v8 + with: + name: clang-tidy-fix + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/clang-tidy-fix + + # The PR's own files, only to apply the patch to. Nothing in them is run. + - name: Checkout the PR head + if: steps.find.outputs.pr != '' + uses: actions/checkout@v7 + with: + repository: ${{ steps.find.outputs.head-repo }} + ref: ${{ steps.find.outputs.head-sha }} + path: head + fetch-depth: 1 + lfs: 'false' + persist-credentials: false + sparse-checkout: | + src + tests + + # The fix was made on the PR's merge commit, so it is applied to the head + # and diffed again, giving line numbers in the PR's own files. + - name: Check the fix and place its suggestions + id: place + if: steps.find.outputs.pr != '' + env: + RANGES: ${{ steps.find.outputs.ranges }} + run: | + python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF' + import json, os, re, subprocess, sys + + patch_path, head, out = sys.argv[1:] + ranges = json.loads(os.environ["RANGES"]) + + def output(**values): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + for key, value in values.items(): + f.write(f"{key}={value}\n") + + patch = open(patch_path, encoding="utf-8").read() + if not patch.strip(): + output(result="empty") + sys.exit() + + # Every line the patch adds must be an #include or blank, in a C++ file the + # PR changes, and it may not remove, create, rename or change the mode of + # anything. + include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*') + header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)") + problems = [] + path = None + for line in patch.splitlines(): + if line.startswith("diff --git "): + match = re.fullmatch(r"diff --git a/(.+) b/\1", line) + path = match.group(1) if match else None + if path not in ranges: + problems.append(f"{line}: not a C++ file the PR changes") + # git appends a tab to the --- and +++ lines of a path that contains a space. + elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line): + pass + elif line.startswith(" ") or line == "\\ No newline at end of file": + pass + elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()): + pass + else: + problems.append(f"{path}: {line}") + if problems: + for problem in problems: + print(f"::error::Refused patch line {problem}") + output(result="rejected") + sys.exit() + + if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0: + output(result="apply-failed") + sys.exit() + + git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff", + "--src-prefix=a/", "--dst-prefix=b/"] + with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f: + f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout) + + # Each run of added lines becomes one suggestion on the line it goes after + # (before, at the top of a file): that line, replaced by itself and the + # includes. It can only sit on a line the PR's diff shows. + hunks = [] + path = None + for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines(): + if line.startswith("diff --git "): + path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1) + elif line.startswith("@@ "): + hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), [])) + elif hunks and line.startswith("+") and not line.startswith("+++ "): + hunks[-1][2].append(line[1:]) + + suggestions = [] + total = suggested = 0 + for path, after, added in hunks: + count = sum(bool(include.fullmatch(line)) for line in added) + total += count + original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True, + encoding="utf-8").stdout.splitlines() + line = max(after, 1) + if line > len(original) or not any(first <= line <= last for first, last in ranges[path]): + continue + body = "\n".join([original[line - 1], *added] if after else [*added, original[0]]) + # A fence longer than any run of backticks in the line it repeats. + fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)]) + suggestions.append({"path": path, "line": line, "side": "RIGHT", + "body": f"{fence}suggestion\n{body}\n{fence}"}) + suggested += count + with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f: + json.dump(suggestions, f) + output(result="ok", includes=total, suggested=suggested) + EOF + + - name: Post the fix + if: always() && steps.find.outputs.pr != '' + uses: actions/github-script@v9 + env: + PR: ${{ steps.find.outputs.pr }} + MENTION: ${{ steps.find.outputs.mention }} + REQUEST_ID: ${{ steps.find.outputs.request-id }} + HEAD_SHA: ${{ steps.find.outputs.head-sha }} + RESULT: ${{ steps.place.outputs.result }} + INCLUDES: ${{ steps.place.outputs.includes }} + SUGGESTED: ${{ steps.place.outputs.suggested }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const MARKER = ''; + // GitHub's limit on a review body is 65536 characters. + const MAX_PATCH = 60000; + + const { owner, repo } = context.repo; + const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env; + const issue_number = Number(PR); + const sha = HEAD_SHA.slice(0, 10); + const fixUrl = context.payload.workflow_run.html_url; + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; + + const react = (content) => REQUEST_ID + ? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content }) + : Promise.resolve(); + async function reply(body, content) { + await react(content); + await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` }); + } + + const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8'); + const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0'; + + if (RESULT === 'empty') { + return passes + ? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1') + : reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1'); + } + if (RESULT === 'rejected') { + return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1'); + } + if (RESULT === 'apply-failed') { + return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1'); + } + if (RESULT !== 'ok') { + return reply(`something went wrong. See the [run log](${runUrl}).`, '-1'); + } + + const patch = read('head.patch'); + const suggestions = JSON.parse(read('suggestions.json')); + + const one = INCLUDES === '1'; + const includes = one ? '1 missing include' : `${INCLUDES} missing includes`; + const inline = SUGGESTED === '0' + ? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.' + : 'They go on lines this PR does not change, so I can\'t suggest them inline.') + : SUGGESTED === INCLUDES + ? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.') + : `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`; + const remaining = passes + ? `With ${one ? 'it' : 'them'}, clang-tidy passes.` + : `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`; + // A fence longer than any run of backticks in the patch's context lines. + const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1))); + const apply = patch.length > MAX_PATCH + ? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.` + : `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`; + const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` + + `${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`; + + const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' }; + try { + await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions }); + } catch (error) { + // A suggestion GitHub will not place still leaves the patch. + core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`); + await github.rest.pulls.createReview({ ...review, body: body(false) }); + } + await react('+1'); + + nudge: + if: >- + github.repository == 'OrcaSlicer/OrcaSlicer' + && github.event_name == 'workflow_run' + && github.event.workflow_run.name == 'clang-tidy' + && github.event.workflow_run.event == 'pull_request' + concurrency: + group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + permissions: + actions: read # the check's steps + pull-requests: write # comment on the PR + issues: write # the comment + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Ask for a fix when the check fails + uses: actions/github-script@v9 + with: + script: | + const MARKER = ''; + // The clang-tidy step's name in clang_tidy.yml. A failure in any other + // step is the CI's, not the PR's. + const TIDY_STEP = 'Run clang-tidy on the changed lines'; + + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. + const { data: prs } = await github.rest.pulls.list({ + owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` + }); + const pr = prs.find((p) => p.head.sha === run.head_sha); + if (!pr) { + core.info('No open PR at this run\'s commit; a newer run will answer.'); + return; + } + + const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id }); + const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure')); + + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); + const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER)); + + if (!failed) { + // Removes the request once the check passes. + if (previous && run.conclusion === 'success') { + await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id }); + } + return; + } + + const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` + + `at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` + + '- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' + + '- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' + + 'Other findings, such as a `using` in a header, need a manual edit.'; + // Edits the earlier request instead of posting another, so each push does not notify again. + if (previous) { + await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); + } From 0cec10748e4466acdff48a9bc8aa9c1ec944fbd1 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:47:26 +0800 Subject: [PATCH 19/45] Remove the /bot fix-tidy clang-tidy Fix Bot (#16400) Its inline suggestions rarely apply: clang-tidy adds a missing include to the include block at the top of the file, which a PR seldom touches, and GitHub only accepts review suggestions on lines in the PR's diff. --- .github/workflows/clang_tidy_fix.yml | 65 ---- .github/workflows/pr-tidy-bot.yml | 476 --------------------------- 2 files changed, 541 deletions(-) delete mode 100644 .github/workflows/clang_tidy_fix.yml delete mode 100644 .github/workflows/pr-tidy-bot.yml diff --git a/.github/workflows/clang_tidy_fix.yml b/.github/workflows/clang_tidy_fix.yml deleted file mode 100644 index 6339e17570..0000000000 --- a/.github/workflows/clang_tidy_fix.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: clang-tidy fix - -# Makes the missing-include fix that /bot fix-tidy (pr-tidy-bot.yml) posts on a -# pull request: scripts/run_clang_tidy.sh --fix on the merge commit the -# clang-tidy check runs on, uploaded as the clang-tidy-fix artifact. -# -# Every push to a PR starts this workflow, and its job skips on that first -# attempt. The bot re-runs it on request. A re-run keeps the pull_request -# context, so the PR's code runs with a read-only token, no secrets and the -# PR's own cache, never main's, whoever asked for it. -on: - pull_request: - branches: - - main - - release/* - -permissions: - contents: read - -jobs: - fix: - if: github.run_attempt > 1 - # A hosted runner, so nothing the PR's code leaves behind outlives the job. - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - lfs: 'false' - # The PR merge commit plus its first parent, the base it is diffed against. - fetch-depth: 2 - persist-credentials: false - - # Parsing needs the dependency headers, not a build of this PR's deps/, so - # a PR that changes deps/ falls back to the newest cache main has. - - name: Restore cached deps - uses: actions/cache/restore@v6 - with: - path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep - key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} - restore-keys: linux-clang-cache-orcaslicer_deps-build- - fail-on-cache-miss: true - - - name: Apt-Install Dependencies - uses: ./.github/actions/apt-install-deps - - # The status says whether findings remain that --fix cannot fix. - - name: Fix the changed lines - run: | - mkdir -p "$RUNNER_TEMP/clang-tidy-fix" - status=0 - scripts/run_clang_tidy.sh --base HEAD^1 --yes --fix || status=$? - echo "$status" > "$RUNNER_TEMP/clang-tidy-fix/status" - git -c core.quotePath=false diff --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ \ - > "$RUNNER_TEMP/clang-tidy-fix/fix.patch" - - - name: Upload the fix - uses: actions/upload-artifact@v7 - with: - name: clang-tidy-fix - path: ${{ runner.temp }}/clang-tidy-fix/ - retention-days: 7 - # A later re-run of the same run replaces the earlier fix. - overwrite: true diff --git a/.github/workflows/pr-tidy-bot.yml b/.github/workflows/pr-tidy-bot.yml deleted file mode 100644 index 62f4a0002f..0000000000 --- a/.github/workflows/pr-tidy-bot.yml +++ /dev/null @@ -1,476 +0,0 @@ -name: PR clang-tidy Bot - -# Helps a pull request past the clang-tidy check. -# -# When the check fails on a PR's changes, a comment asks the author to fix it, -# either with scripts/run_clang_tidy.sh --fix or by commenting: -# /bot fix-tidy -# -# That command, from the PR's author or an account with write access, re-runs -# the PR's clang-tidy fix run (clang_tidy_fix.yml), which makes the fix in the -# PR's own context. When it finishes, the fix is posted as one review: each -# include whose place falls on a line the PR's diff shows is a suggestion GitHub -# can commit in one click, and the review's body holds the whole patch with a -# git apply command. Findings --fix cannot fix, such as a using in a header, -# are left for the check to report. -# -# No code from the PR runs here. Nothing is pushed, and the patch is only -# posted when every line it adds is an #include in a C++ file the PR changes. -# -# workflow_run matches the workflows by their name: keep "clang-tidy" and -# "clang-tidy fix" in step with clang_tidy.yml and clang_tidy_fix.yml, and -# TIDY_STEP below with the clang-tidy step's name in clang_tidy.yml. - -on: - issue_comment: - types: - - created - workflow_run: - workflows: - - clang-tidy - - clang-tidy fix - types: - - completed - -permissions: {} - -jobs: - request: - # Skips the job unless a PR comment mentions the command. - if: >- - github.repository == 'OrcaSlicer/OrcaSlicer' - && github.event_name == 'issue_comment' - && github.event.issue.pull_request != null - && contains(github.event.comment.body, '/bot fix-tidy') - permissions: - actions: write # re-run the fix run - pull-requests: write # react to and answer the comment, which is the PR's - issues: write # feedback comment + reactions - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Re-run the PR's clang-tidy fix - uses: actions/github-script@v9 - with: - script: | - const MARKER = ''; - const FIX_WORKFLOW = 'clang_tidy_fix.yml'; - - const { owner, repo } = context.repo; - const issue = context.payload.issue; - const comment = context.payload.comment; - - // Ignores a comment whose sender is not its author. - if (context.payload.sender.login !== comment.user.login || comment.user.type !== 'User') { - core.info('Ignoring a comment not written by its sender, or by a bot.'); - return; - } - - const commandLine = (comment.body || '') - .split('\n') - .map((line) => line.trim()) - .find((line) => /^\/bot\s+fix-tidy\b/i.test(line)); - if (!commandLine) { - core.info('No /bot fix-tidy command found.'); - return; - } - - const commenter = comment.user.login; - // Answers only the PR's author and maintainers, so the command cannot be used to spam. - if (commenter !== issue.user.login) { - const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter }); - if (!['admin', 'write'].includes(access.permission)) { - core.info(`Ignoring /bot fix-tidy from @${commenter}: not the PR's author and no write access.`); - return; - } - } - - const react = (content) => - github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content }); - async function refuse(reason) { - await react('-1'); - await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body: `${MARKER}\n@${commenter} ${reason}` }); - } - - await react('eyes'); - - if (!/^\/bot\s+fix-tidy$/i.test(commandLine)) { - return refuse('`/bot fix-tidy` takes no arguments.'); - } - - const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue.number }); - if (pr.state !== 'open') { - return refuse('the PR is not open.'); - } - - const sha = pr.head.sha.slice(0, 10); - const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({ - owner, repo, workflow_id: FIX_WORKFLOW, event: 'pull_request', head_sha: pr.head.sha, per_page: 1 - }); - const run = runs[0]; - // A PR last pushed before clang_tidy_fix.yml existed has no run to re-run. - if (!run) { - return refuse(`there is no clang-tidy fix run for ${sha}, the PR's latest commit. If you just pushed it, ` + - 'try again in a minute. Otherwise, push a commit or merge the base branch to start one, then comment `/bot fix-tidy` again.'); - } - if (run.status !== 'completed') { - // The first attempt is the one every push starts, and it makes no fix. - return refuse(run.run_attempt > 1 - ? `a clang-tidy fix for ${sha} is already [running](${run.html_url}). I'll post it when it finishes.` - : `the PR's checks on ${sha} are still starting. Comment \`/bot fix-tidy\` again in a minute.`); - } - // GitHub refuses to re-run a run created over 30 days ago. - try { - await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id }); - } catch (error) { - core.warning(`Re-running ${run.html_url} failed: ${error.message}`); - return refuse(`GitHub won't re-run the [clang-tidy fix run](${run.html_url}) for ${sha} (${error.message}). ` + - 'Push a commit or merge the base branch to start a new one, then comment `/bot fix-tidy` again.'); - } - core.info(`Re-ran ${run.html_url}`); - - suggest: - # The fix run's re-runs only. Its first attempt makes no fix. - if: >- - github.repository == 'OrcaSlicer/OrcaSlicer' - && github.event_name == 'workflow_run' - && github.event.workflow_run.name == 'clang-tidy fix' - && github.event.workflow_run.event == 'pull_request' - && github.event.workflow_run.run_attempt > 1 - concurrency: - group: ${{ github.workflow }}-suggest-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - permissions: - actions: read # download the fix - contents: read # check out the PR head - pull-requests: write # the review - issues: write # feedback comment + reactions - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Find the PR and who asked - id: find - uses: actions/github-script@v9 - with: - script: | - const MARKER = ''; - const CPP = /^(src|tests)\/.+\.(cpp|cc|cxx|hpp|h|hxx)$/; - - const { owner, repo } = context.repo; - const run = context.payload.workflow_run; - - // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. - const { data: prs } = await github.rest.pulls.list({ - owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` - }); - const pr = prs[0]; - if (!pr) { - core.info('No open PR for this run.'); - return; - } - - // The latest /bot fix-tidy comment is the one this run answers. - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); - const request = comments.reverse().find((c) => c.user.type === 'User' && /^\s*\/bot\s+fix-tidy\s*$/im.test(c.body || '')); - const mention = request ? `@${request.user.login} ` : ''; - async function refuse(reason) { - if (request) { - await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: request.id, content: '-1' }); - } - await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: `${MARKER}\n${mention}${reason}` }); - } - - if (pr.head.sha !== run.head_sha) { - return refuse('the PR changed while I was making its clang-tidy fix. Comment `/bot fix-tidy` again.'); - } - if (run.conclusion !== 'success') { - return refuse(`I couldn't make the clang-tidy fix. See the [run log](${run.html_url}).`); - } - - // The right-side lines of each changed C++ file's diff: the lines a - // review comment can be placed on. A file too large for GitHub to - // show its diff has none, and gets no suggestions. - const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pr.number, per_page: 100 }); - const ranges = {}; - for (const file of files) { - if (file.status === 'removed' || !CPP.test(file.filename)) { - continue; - } - ranges[file.filename] = [...(file.patch || '').matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)] - .map((m) => [Number(m[1]), Number(m[1]) + Number(m[2] ?? 1) - 1]) - .filter(([first, last]) => last >= first); - } - - core.setOutput('pr', pr.number); - core.setOutput('head-sha', pr.head.sha); - core.setOutput('head-repo', run.head_repository.full_name); - core.setOutput('ranges', JSON.stringify(ranges)); - core.setOutput('mention', mention); - core.setOutput('request-id', request ? request.id : ''); - - - name: Download the fix - if: steps.find.outputs.pr != '' - uses: actions/download-artifact@v8 - with: - name: clang-tidy-fix - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - path: ${{ runner.temp }}/clang-tidy-fix - - # The PR's own files, only to apply the patch to. Nothing in them is run. - - name: Checkout the PR head - if: steps.find.outputs.pr != '' - uses: actions/checkout@v7 - with: - repository: ${{ steps.find.outputs.head-repo }} - ref: ${{ steps.find.outputs.head-sha }} - path: head - fetch-depth: 1 - lfs: 'false' - persist-credentials: false - sparse-checkout: | - src - tests - - # The fix was made on the PR's merge commit, so it is applied to the head - # and diffed again, giving line numbers in the PR's own files. - - name: Check the fix and place its suggestions - id: place - if: steps.find.outputs.pr != '' - env: - RANGES: ${{ steps.find.outputs.ranges }} - run: | - python3 -I - "$RUNNER_TEMP/clang-tidy-fix/fix.patch" head "$RUNNER_TEMP" <<'EOF' - import json, os, re, subprocess, sys - - patch_path, head, out = sys.argv[1:] - ranges = json.loads(os.environ["RANGES"]) - - def output(**values): - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: - for key, value in values.items(): - f.write(f"{key}={value}\n") - - patch = open(patch_path, encoding="utf-8").read() - if not patch.strip(): - output(result="empty") - sys.exit() - - # Every line the patch adds must be an #include or blank, in a C++ file the - # PR changes, and it may not remove, create, rename or change the mode of - # anything. - include = re.compile(r'\s*#\s*include\s*(<[\w./+-]+>|"[\w./+-]+")\s*') - header = re.compile(r"(index [0-9a-f]+\.\.[0-9a-f]+( \d+)?|@@ -\d+(,\d+)? \+\d+(,\d+)? @@.*)") - problems = [] - path = None - for line in patch.splitlines(): - if line.startswith("diff --git "): - match = re.fullmatch(r"diff --git a/(.+) b/\1", line) - path = match.group(1) if match else None - if path not in ranges: - problems.append(f"{line}: not a C++ file the PR changes") - # git appends a tab to the --- and +++ lines of a path that contains a space. - elif line.removesuffix("\t") in (f"--- a/{path}", f"+++ b/{path}") or header.fullmatch(line): - pass - elif line.startswith(" ") or line == "\\ No newline at end of file": - pass - elif line.startswith("+") and (include.fullmatch(line[1:]) or not line[1:].strip()): - pass - else: - problems.append(f"{path}: {line}") - if problems: - for problem in problems: - print(f"::error::Refused patch line {problem}") - output(result="rejected") - sys.exit() - - if subprocess.run(["git", "-C", head, "apply", "--index", os.path.abspath(patch_path)]).returncode != 0: - output(result="apply-failed") - sys.exit() - - git = ["git", "-C", head, "-c", "core.quotePath=false", "diff", "--cached", "--no-color", "--no-ext-diff", - "--src-prefix=a/", "--dst-prefix=b/"] - with open(os.path.join(out, "head.patch"), "w", encoding="utf-8") as f: - f.write(subprocess.run(git, check=True, capture_output=True, encoding="utf-8").stdout) - - # Each run of added lines becomes one suggestion on the line it goes after - # (before, at the top of a file): that line, replaced by itself and the - # includes. It can only sit on a line the PR's diff shows. - hunks = [] - path = None - for line in subprocess.run(git + ["-U0"], check=True, capture_output=True, encoding="utf-8").stdout.splitlines(): - if line.startswith("diff --git "): - path = re.fullmatch(r"diff --git a/(.+) b/\1", line).group(1) - elif line.startswith("@@ "): - hunks.append((path, int(re.match(r"@@ -(\d+)", line).group(1)), [])) - elif hunks and line.startswith("+") and not line.startswith("+++ "): - hunks[-1][2].append(line[1:]) - - suggestions = [] - total = suggested = 0 - for path, after, added in hunks: - count = sum(bool(include.fullmatch(line)) for line in added) - total += count - original = subprocess.run(["git", "-C", head, "show", f"HEAD:{path}"], check=True, capture_output=True, - encoding="utf-8").stdout.splitlines() - line = max(after, 1) - if line > len(original) or not any(first <= line <= last for first, last in ranges[path]): - continue - body = "\n".join([original[line - 1], *added] if after else [*added, original[0]]) - # A fence longer than any run of backticks in the line it repeats. - fence = "`" * max([3] + [len(run) + 1 for run in re.findall(r"`+", body)]) - suggestions.append({"path": path, "line": line, "side": "RIGHT", - "body": f"{fence}suggestion\n{body}\n{fence}"}) - suggested += count - with open(os.path.join(out, "suggestions.json"), "w", encoding="utf-8") as f: - json.dump(suggestions, f) - output(result="ok", includes=total, suggested=suggested) - EOF - - - name: Post the fix - if: always() && steps.find.outputs.pr != '' - uses: actions/github-script@v9 - env: - PR: ${{ steps.find.outputs.pr }} - MENTION: ${{ steps.find.outputs.mention }} - REQUEST_ID: ${{ steps.find.outputs.request-id }} - HEAD_SHA: ${{ steps.find.outputs.head-sha }} - RESULT: ${{ steps.place.outputs.result }} - INCLUDES: ${{ steps.place.outputs.includes }} - SUGGESTED: ${{ steps.place.outputs.suggested }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const MARKER = ''; - // GitHub's limit on a review body is 65536 characters. - const MAX_PATCH = 60000; - - const { owner, repo } = context.repo; - const { PR, MENTION, REQUEST_ID, HEAD_SHA, RESULT, INCLUDES, SUGGESTED, RUNNER_TEMP } = process.env; - const issue_number = Number(PR); - const sha = HEAD_SHA.slice(0, 10); - const fixUrl = context.payload.workflow_run.html_url; - const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; - - const react = (content) => REQUEST_ID - ? github.rest.reactions.createForIssueComment({ owner, repo, comment_id: Number(REQUEST_ID), content }) - : Promise.resolve(); - async function reply(body, content) { - await react(content); - await github.rest.issues.createComment({ owner, repo, issue_number, body: `${MARKER}\n${MENTION}${body}` }); - } - - const read = (...parts) => fs.readFileSync(path.join(RUNNER_TEMP, ...parts), 'utf8'); - const passes = RESULT && read('clang-tidy-fix', 'status').trim() === '0'; - - if (RESULT === 'empty') { - return passes - ? reply(`clang-tidy passes on ${sha}. There is nothing to fix.`, '+1') - : reply(`clang-tidy has no automatic fix for what it reports on ${sha}. The findings, in the [fix run's log](${fixUrl}), need a manual edit.`, '-1'); - } - if (RESULT === 'rejected') { - return reply(`the clang-tidy fix changes more than includes, so I didn't post it. See the [run log](${runUrl}).`, '-1'); - } - if (RESULT === 'apply-failed') { - return reply(`the clang-tidy fix doesn't apply to ${sha}. Merge the base branch into the PR, then comment \`/bot fix-tidy\` again.`, '-1'); - } - if (RESULT !== 'ok') { - return reply(`something went wrong. See the [run log](${runUrl}).`, '-1'); - } - - const patch = read('head.patch'); - const suggestions = JSON.parse(read('suggestions.json')); - - const one = INCLUDES === '1'; - const includes = one ? '1 missing include' : `${INCLUDES} missing includes`; - const inline = SUGGESTED === '0' - ? (one ? 'It goes on a line this PR does not change, so I can\'t suggest it inline.' - : 'They go on lines this PR does not change, so I can\'t suggest them inline.') - : SUGGESTED === INCLUDES - ? (one ? 'It is suggested inline below.' : 'Each is suggested inline below.') - : `${SUGGESTED} of them are suggested inline below; the rest go on lines this PR does not change.`; - const remaining = passes - ? `With ${one ? 'it' : 'them'}, clang-tidy passes.` - : `Some findings remain that need a manual edit; see the [fix run's log](${fixUrl}).`; - // A fence longer than any run of backticks in the patch's context lines. - const fence = '`'.repeat(Math.max(3, ...[...patch.matchAll(/`+/g)].map((m) => m[0].length + 1))); - const apply = patch.length > MAX_PATCH - ? `The full patch is too large to show here. Download \`fix.patch\` from the [fix run](${fixUrl}) and run \`git apply fix.patch\`.` - : `To apply ${one ? 'it' : 'all of them'}, run this in your checkout of the PR branch:\n\n${fence}sh\ngit apply <<'EOF'\n${patch}EOF\n${fence}`; - const body = (withSuggestions) => `${MARKER}\n${MENTION}clang-tidy's fix for ${sha} adds ${includes}. ` + - `${withSuggestions ? `${inline} ` : ''}${remaining}\n\n${apply}`; - - const review = { owner, repo, pull_number: issue_number, commit_id: HEAD_SHA, event: 'COMMENT' }; - try { - await github.rest.pulls.createReview({ ...review, body: body(true), comments: suggestions }); - } catch (error) { - // A suggestion GitHub will not place still leaves the patch. - core.warning(`Posting the suggestions failed (${error.message}); posting the patch alone.`); - await github.rest.pulls.createReview({ ...review, body: body(false) }); - } - await react('+1'); - - nudge: - if: >- - github.repository == 'OrcaSlicer/OrcaSlicer' - && github.event_name == 'workflow_run' - && github.event.workflow_run.name == 'clang-tidy' - && github.event.workflow_run.event == 'pull_request' - concurrency: - group: ${{ github.workflow }}-nudge-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - permissions: - actions: read # the check's steps - pull-requests: write # comment on the PR - issues: write # the comment - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Ask for a fix when the check fails - uses: actions/github-script@v9 - with: - script: | - const MARKER = ''; - // The clang-tidy step's name in clang_tidy.yml. A failure in any other - // step is the CI's, not the PR's. - const TIDY_STEP = 'Run clang-tidy on the changed lines'; - - const { owner, repo } = context.repo; - const run = context.payload.workflow_run; - - // workflow_run lists no PR for a fork's branch, so the PR is found by its branch. - const { data: prs } = await github.rest.pulls.list({ - owner, repo, state: 'open', head: `${run.head_repository.owner.login}:${run.head_branch}` - }); - const pr = prs.find((p) => p.head.sha === run.head_sha); - if (!pr) { - core.info('No open PR at this run\'s commit; a newer run will answer.'); - return; - } - - const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ owner, repo, run_id: run.id }); - const failed = jobs.some((job) => (job.steps || []).some((step) => step.name === TIDY_STEP && step.conclusion === 'failure')); - - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: pr.number, per_page: 100 }); - const previous = comments.find((c) => c.user.type === 'Bot' && (c.body || '').startsWith(MARKER)); - - if (!failed) { - // Removes the request once the check passes. - if (previous && run.conclusion === 'success') { - await github.rest.issues.deleteComment({ owner, repo, comment_id: previous.id }); - } - return; - } - - const body = `${MARKER}\n@${pr.user.login} the clang-tidy check found problems in this PR's changes ` + - `at ${run.head_sha.slice(0, 10)} ([log](${run.html_url})). Missing includes can be fixed either way:\n\n` + - '- run `scripts/run_clang_tidy.sh --fix` (`scripts\\run_clang_tidy.ps1 -Fix` on Windows) and push the result, or\n' + - '- comment `/bot fix-tidy`, and I\'ll suggest the fix here for you to apply.\n\n' + - 'Other findings, such as a `using` in a header, need a manual edit.'; - // Edits the earlier request instead of posting another, so each push does not notify again. - if (previous) { - await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body }); - } From 50957333239b5d5239c935bc31aaf55b2c07d64c Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:52:53 +0800 Subject: [PATCH 20/45] Fix Include Guards Copied From Other Headers (#16368) TriangleMeshDeal.hpp reused Timer.hpp's guard and CloneDialog.hpp reused SingleChoiceDialog.hpp's, so including both headers of a pair silently dropped the second one. Use #pragma once instead. --- src/libslic3r/TriangleMeshDeal.hpp | 5 +---- src/slic3r/GUI/CloneDialog.hpp | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/libslic3r/TriangleMeshDeal.hpp b/src/libslic3r/TriangleMeshDeal.hpp index b1da431229..89d613bb85 100644 --- a/src/libslic3r/TriangleMeshDeal.hpp +++ b/src/libslic3r/TriangleMeshDeal.hpp @@ -1,5 +1,4 @@ -#ifndef libslic3r_Timer_hpp_ -#define libslic3r_Timer_hpp_ +#pragma once #include "TriangleMesh.hpp" @@ -10,5 +9,3 @@ public: static TriangleMesh smooth_triangle_mesh(const TriangleMesh &mesh,bool& ok); }; } // namespace Slic3r - -#endif // libslic3r_Timer_hpp_ diff --git a/src/slic3r/GUI/CloneDialog.hpp b/src/slic3r/GUI/CloneDialog.hpp index 7d83b93d62..3233eb3b36 100644 --- a/src/slic3r/GUI/CloneDialog.hpp +++ b/src/slic3r/GUI/CloneDialog.hpp @@ -1,5 +1,4 @@ -#ifndef slic3r_GUI_SingleChoice_hpp_ -#define slic3r_GUI_SingleChoice_hpp_ +#pragma once #include "GUI_Utils.hpp" #include "Plater.hpp" @@ -33,5 +32,3 @@ private: }; }} // namespace Slic3r::GUI - -#endif \ No newline at end of file From cccf53850e64a1abe8db4c29d8b1755ac9b8ccac Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:53:32 +0800 Subject: [PATCH 21/45] Remove Never-Built and Stub Source Files (#16361) SysInfoDialog, JumpPointSearch and WebUpdatePlugin are in no CMake list on any platform, and nothing includes ConfigExceptions.hpp, wxinit.h or Utils/Profile.hpp. MultiTaskModel holds only empty namespaces. Also drop the empty GUI_App::system_info() and comments that still mention SysInfoDialog. --- src/libslic3r/JumpPointSearch.cpp | 349 ---------------------------- src/libslic3r/JumpPointSearch.hpp | 39 ---- src/libslic3r/Utils.hpp | 1 - src/libslic3r/utils.cpp | 4 +- src/slic3r/CMakeLists.txt | 3 - src/slic3r/GUI/ConfigExceptions.hpp | 19 -- src/slic3r/GUI/GUI_App.cpp | 6 - src/slic3r/GUI/GUI_App.hpp | 1 - src/slic3r/GUI/MultiTaskModel.cpp | 6 - src/slic3r/GUI/MultiTaskModel.hpp | 11 - src/slic3r/GUI/SysInfoDialog.cpp | 232 ------------------ src/slic3r/GUI/SysInfoDialog.hpp | 36 --- src/slic3r/GUI/WebUpdatePlugin.cpp | 0 src/slic3r/GUI/WebUpdatePlugin.hpp | 0 src/slic3r/GUI/wxinit.h | 15 -- src/slic3r/Utils/Profile.hpp | 19 -- 16 files changed, 1 insertion(+), 740 deletions(-) delete mode 100644 src/libslic3r/JumpPointSearch.cpp delete mode 100644 src/libslic3r/JumpPointSearch.hpp delete mode 100644 src/slic3r/GUI/ConfigExceptions.hpp delete mode 100644 src/slic3r/GUI/MultiTaskModel.cpp delete mode 100644 src/slic3r/GUI/MultiTaskModel.hpp delete mode 100644 src/slic3r/GUI/SysInfoDialog.cpp delete mode 100644 src/slic3r/GUI/SysInfoDialog.hpp delete mode 100644 src/slic3r/GUI/WebUpdatePlugin.cpp delete mode 100644 src/slic3r/GUI/WebUpdatePlugin.hpp delete mode 100644 src/slic3r/GUI/wxinit.h delete mode 100644 src/slic3r/Utils/Profile.hpp diff --git a/src/libslic3r/JumpPointSearch.cpp b/src/libslic3r/JumpPointSearch.cpp deleted file mode 100644 index 9eb18017fb..0000000000 --- a/src/libslic3r/JumpPointSearch.cpp +++ /dev/null @@ -1,349 +0,0 @@ -#include "JumpPointSearch.hpp" -#include "BoundingBox.hpp" -#include "ExPolygon.hpp" -#include "Point.hpp" -#include "libslic3r/AStar.hpp" -#include "libslic3r/KDTreeIndirect.hpp" -#include "libslic3r/Polygon.hpp" -#include "libslic3r/Polyline.hpp" -#include "libslic3r/libslic3r.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -//#define DEBUG_FILES -#ifdef DEBUG_FILES -#include "libslic3r/SVG.hpp" -#endif - -namespace Slic3r { - -// execute fn for each pixel on the line. If fn returns false, terminate the iteration -template void dda(coord_t x0, coord_t y0, coord_t x1, coord_t y1, const PointFn &fn) -{ - coord_t dx = abs(x1 - x0); - coord_t dy = abs(y1 - y0); - coord_t x = x0; - coord_t y = y0; - coord_t n = 1 + dx + dy; - coord_t x_inc = (x1 > x0) ? 1 : -1; - coord_t y_inc = (y1 > y0) ? 1 : -1; - coord_t error = dx - dy; - dx *= 2; - dy *= 2; - - for (; n > 0; --n) { - if (!fn(x, y)) return; - - if (error > 0) { - x += x_inc; - error -= dy; - } else { - y += y_inc; - error += dx; - } - } -} - -// will draw the line twice, second time with and offset of 1 in the direction of normal -// may call the fn on the same coordiantes multiple times! -template void double_dda_with_offset(coord_t x0, coord_t y0, coord_t x1, coord_t y1, const PointFn &fn) -{ - Vec2d normal = Point{y1 - y0, x1 - x0}.cast().normalized(); - normal.x() = ceil(normal.x()); - normal.y() = ceil(normal.y()); - Point start_offset = Point(x0, y0) + (normal).cast(); - Point end_offset = Point(x1, y1) + (normal).cast(); - - dda(x0, y0, x1, y1, fn); - dda(start_offset.x(), start_offset.y(), end_offset.x(), end_offset.y(), fn); -} - -template class JPSTracer -{ -public: - // Use incoming_dir [0,0] for starting points, so that all directions are checked from that point - struct Node - { - CellPositionType position; - CellPositionType incoming_dir; - }; - - JPSTracer(CellPositionType target, CellQueryFn is_passable) : target(target), is_passable(is_passable) {} - -private: - CellPositionType target; - CellQueryFn is_passable; // should return boolean whether the cell is passable or not - - CellPositionType find_jump_point(CellPositionType start, CellPositionType forward_dir) const - { - CellPositionType next = start + forward_dir; - while (next != target && is_passable(next) && !(is_jump_point(next, forward_dir))) { next = next + forward_dir; } - - if (is_passable(next)) { - return next; - } else { - return start; - } - } - - bool is_jump_point(CellPositionType pos, CellPositionType forward_dir) const - { - if (abs(forward_dir.x()) + abs(forward_dir.y()) == 2) { - // diagonal - CellPositionType horizontal_check_dir = CellPositionType{forward_dir.x(), 0}; - CellPositionType vertical_check_dir = CellPositionType{0, forward_dir.y()}; - - if (!is_passable(pos - horizontal_check_dir) && is_passable(pos + forward_dir - 2 * horizontal_check_dir)) { return true; } - - if (!is_passable(pos - vertical_check_dir) && is_passable(pos + forward_dir - 2 * vertical_check_dir)) { return true; } - - if (find_jump_point(pos, horizontal_check_dir) != pos) { return true; } - - if (find_jump_point(pos, vertical_check_dir) != pos) { return true; } - - return false; - } else { // horizontal or vertical - CellPositionType side_dir = CellPositionType(forward_dir.y(), forward_dir.x()); - - if (!is_passable(pos + side_dir) && is_passable(pos + forward_dir + side_dir)) { return true; } - - if (!is_passable(pos - side_dir) && is_passable(pos + forward_dir - side_dir)) { return true; } - - return false; - } - } - -public: - template void foreach_reachable(const Node &from, Fn &&fn) const - { - const CellPositionType & pos = from.position; - const CellPositionType & forward_dir = from.incoming_dir; - std::vector dirs_to_check{}; - - if (abs(forward_dir.x()) + abs(forward_dir.y()) == 0) { // special case for starting point - dirs_to_check = all_directions; - } else if (abs(forward_dir.x()) + abs(forward_dir.y()) == 2) { - // diagonal - CellPositionType horizontal_check_dir = CellPositionType{forward_dir.x(), 0}; - CellPositionType vertical_check_dir = CellPositionType{0, forward_dir.y()}; - - if (!is_passable(pos - horizontal_check_dir) && is_passable(pos + forward_dir - 2 * horizontal_check_dir)) { - dirs_to_check.push_back(forward_dir - 2 * horizontal_check_dir); - } - - if (!is_passable(pos - vertical_check_dir) && is_passable(pos + forward_dir - 2 * vertical_check_dir)) { - dirs_to_check.push_back(forward_dir - 2 * vertical_check_dir); - } - - dirs_to_check.push_back(horizontal_check_dir); - dirs_to_check.push_back(vertical_check_dir); - dirs_to_check.push_back(forward_dir); - - } else { // horizontal or vertical - CellPositionType side_dir = CellPositionType(forward_dir.y(), forward_dir.x()); - - if (!is_passable(pos + side_dir) && is_passable(pos + forward_dir + side_dir)) { dirs_to_check.push_back(forward_dir + side_dir); } - - if (!is_passable(pos - side_dir) && is_passable(pos + forward_dir - side_dir)) { dirs_to_check.push_back(forward_dir - side_dir); } - dirs_to_check.push_back(forward_dir); - } - - for (const CellPositionType &dir : dirs_to_check) { - CellPositionType jp = find_jump_point(pos, dir); - if (jp != pos) fn(Node{jp, dir}); - } - } - - float distance(Node a, Node b) const { return (a.position - b.position).template cast().norm(); } - - float goal_heuristic(Node n) const { return n.position == target ? -1.f : (target - n.position).template cast().norm(); } - - size_t unique_id(Node n) const { return (static_cast(uint16_t(n.position.x())) << 16) + static_cast(uint16_t(n.position.y())); } - - const std::vector all_directions{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}; -}; - -void JPSPathFinder::clear() -{ - inpassable.clear(); - max_search_box.max = Pixel(std::numeric_limits::min(), std::numeric_limits::min()); - max_search_box.min = Pixel(std::numeric_limits::max(), std::numeric_limits::max()); - add_obstacles(bed_shape); -} - -void JPSPathFinder::add_obstacles(const Lines &obstacles) -{ - auto store_obstacle = [&](coord_t x, coord_t y) { - max_search_box.max.x() = std::max(max_search_box.max.x(), x); - max_search_box.max.y() = std::max(max_search_box.max.y(), y); - max_search_box.min.x() = std::min(max_search_box.min.x(), x); - max_search_box.min.y() = std::min(max_search_box.min.y(), y); - inpassable.insert(Pixel{x, y}); - return true; - }; - - for (const Line &l : obstacles) { - Pixel start = pixelize(l.a); - Pixel end = pixelize(l.b); - double_dda_with_offset(start.x(), start.y(), end.x(), end.y(), store_obstacle); - } -} - -Polyline JPSPathFinder::find_path(const Point &p0, const Point &p1) -{ - Pixel start = pixelize(p0); - Pixel end = pixelize(p1); - if (inpassable.empty() || (start - end).cast().norm() < 3.0) { return Polyline{p0, p1}; } - - if (inpassable.find(start) != inpassable.end()) { - dda(start.x(), start.y(), end.x(), end.y(), [&](coord_t x, coord_t y) { - if (inpassable.find(Pixel(x, y)) == inpassable.end() || start == end) { // new start not found yet, and xy passable - start = Pixel(x, y); - return false; - } - return true; - }); - } - - if (inpassable.find(end) != inpassable.end()) { - dda(end.x(), end.y(), start.x(), start.y(), [&](coord_t x, coord_t y) { - if (inpassable.find(Pixel(x, y)) == inpassable.end() || start == end) { // new start not found yet, and xy passable - end = Pixel(x, y); - return false; - } - return true; - }); - } - - BoundingBox search_box = max_search_box; - search_box.max -= Pixel(1, 1); - search_box.min += Pixel(1, 1); - - BoundingBox bounding_square(Points{start, end}); - bounding_square.max += Pixel(5, 5); - bounding_square.min -= Pixel(5, 5); - coord_t bounding_square_size = 2 * std::max(bounding_square.size().x(), bounding_square.size().y()); - bounding_square.max.x() += (bounding_square_size - bounding_square.size().x()) / 2; - bounding_square.min.x() -= (bounding_square_size - bounding_square.size().x()) / 2; - bounding_square.max.y() += (bounding_square_size - bounding_square.size().y()) / 2; - bounding_square.min.y() -= (bounding_square_size - bounding_square.size().y()) / 2; - - // Intersection - limit the search box to a square area around the start and end, to fasten the path searching - search_box.max = search_box.max.cwiseMin(bounding_square.max); - search_box.min = search_box.min.cwiseMax(bounding_square.min); - - auto cell_query = [&](Pixel pixel) { return search_box.contains(pixel) && (pixel == start || pixel == end || inpassable.find(pixel) == inpassable.end()); }; - - JPSTracer tracer(end, cell_query); - using QNode = astar::QNode>; - - std::unordered_map astar_cache{}; - std::vector> out_path; - std::vector out_nodes; - - if (!astar::search_route(tracer, {start, {0, 0}}, std::back_inserter(out_nodes), astar_cache)) { - // path not found - just reconstruct the best path from astar cache. - // Note that astar_cache is NOT empty - at least the starting point should always be there - auto coordiante_func = [&astar_cache](size_t idx, size_t dim) { return float(astar_cache[idx].node.position[dim]); }; - std::vector keys; - keys.reserve(astar_cache.size()); - for (const auto &pair : astar_cache) { keys.push_back(pair.first); } - KDTreeIndirect<2, float, decltype(coordiante_func)> kd_tree(coordiante_func, keys); - size_t closest_qnode = find_closest_point(kd_tree, end.cast()); - - out_path.push_back(end); - while (closest_qnode != astar::Unassigned) { - out_path.push_back(astar_cache[closest_qnode].node.position); - closest_qnode = astar_cache[closest_qnode].parent; - } - } else { - for (const auto &node : out_nodes) { out_path.push_back(node.position); } - out_path.push_back(start); - } - -#ifdef DEBUG_FILES - auto scaled_points = [](const Points &ps) { - Points r; - for (const Point &p : ps) { r.push_back(Point::new_scale(p.x(), p.y())); } - return r; - }; - auto scaled_point = [](const Point &p) { return Point::new_scale(p.x(), p.y()); }; - ::Slic3r::SVG svg(debug_out_path(("path_jps" + std::to_string(print_z) + "_" + std::to_string(rand() % 1000)).c_str()).c_str(), - BoundingBox(scaled_point(search_box.min), scaled_point(search_box.max))); - for (const auto &p : inpassable) { svg.draw(scaled_point(p), "black", scale_(0.4)); } - for (const auto &qn : astar_cache) { svg.draw(scaled_point(qn.second.node.position), "blue", scale_(0.3)); } - svg.draw(Polyline(scaled_points(out_path)), "yellow", scale_(0.25)); - svg.draw(scaled_point(end), "purple", scale_(0.4)); - svg.draw(scaled_point(start), "green", scale_(0.4)); -#endif - - std::vector> tmp_path; - tmp_path.reserve(out_path.size()); - // Some path found, reverse and remove points that do not change direction - std::reverse(out_path.begin(), out_path.end()); - { - tmp_path.push_back(out_path.front()); // first point - for (size_t i = 1; i < out_path.size() - 1; i++) { - if ((out_path[i] - out_path[i - 1]).cast().normalized() != (out_path[i + 1] - out_path[i]).cast().normalized()) { tmp_path.push_back(out_path[i]); } - } - tmp_path.push_back(out_path.back()); // last_point - out_path = tmp_path; - } - -#ifdef DEBUG_FILES - svg.draw(Polyline(scaled_points(out_path)), "orange", scale_(0.20)); -#endif - - tmp_path.clear(); - // remove redundant jump points - there are points that change direction but are not needed - this inefficiency arises from the - // usage of grid search The removal alg tries to find the longest Px Px+k path without obstacles. If Px Px+k+1 is blocked, it will - // insert the Px+k point to result and continue search from Px+k - { - tmp_path.push_back(out_path.front()); // first point - size_t index_of_last_stored_point = 0; - for (size_t i = 1; i < out_path.size(); i++) { - if (i - index_of_last_stored_point < 2) continue; - bool passable = true; - auto store_obstacle = [&](coord_t x, coord_t y) { - if (Pixel(x, y) != start && Pixel(x, y) != end && inpassable.find(Pixel(x, y)) != inpassable.end()) { - passable = false; - return false; - } - return true; - }; - dda(tmp_path.back().x(), tmp_path.back().y(), out_path[i].x(), out_path[i].y(), store_obstacle); - if (!passable) { - tmp_path.push_back(out_path[i - 1]); - index_of_last_stored_point = i - 1; - } - } - tmp_path.push_back(out_path.back()); // last_point - out_path = tmp_path; - } - -#ifdef DEBUG_FILES - svg.draw(Polyline(scaled_points(out_path)), "red", scale_(0.15)); - svg.Close(); -#endif - - // before returing the path, transform it from pixels back to points. - // Also replace the first and last pixel by input points so that result path patches input params exactly. - for (Pixel &p : out_path) { p = unpixelize(p); } - out_path.front() = p0; - out_path.back() = p1; - - return Polyline(out_path); -} - -} // namespace Slic3r diff --git a/src/libslic3r/JumpPointSearch.hpp b/src/libslic3r/JumpPointSearch.hpp deleted file mode 100644 index b5fbf6aaf0..0000000000 --- a/src/libslic3r/JumpPointSearch.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once -#include "Line.hpp" -#ifndef SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ -#define SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ - -#include "BoundingBox.hpp" -#include "Polygon.hpp" -#include "libslic3r/Layer.hpp" -#include "libslic3r/Point.hpp" -#include "libslic3r/Polyline.hpp" -#include "libslic3r/libslic3r.h" -#include -#include - -namespace Slic3r { - -class JPSPathFinder -{ - using Pixel = Point; - std::unordered_set inpassable; - coordf_t print_z; - BoundingBox max_search_box; - Lines bed_shape; - - const coord_t resolution = scaled(1.5); - Pixel pixelize(const Point &p) { return p / resolution; } - Point unpixelize(const Pixel &p) { return p * resolution; } - -public: - JPSPathFinder() = default; - void init_bed_shape(const Points &bed_shape) { this->bed_shape = (to_lines(Polygon{bed_shape})); }; - void clear(); - void add_obstacles(const Lines &obstacles); - Polyline find_path(const Point &start, const Point &end); -}; - -} // namespace Slic3r - -#endif /* SRC_LIBSLIC3R_JUMPPOINTSEARCH_HPP_ */ diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 24ebe9a337..d9d30573fc 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -109,7 +109,6 @@ extern std::string format_memsize_MB(size_t n); extern std::string format_memsize(size_t bytes, unsigned int decimals = 1); // Return string to be added to the boost::log output to inform about the current process memory allocation. // The string is non-empty if the loglevel >= info (3) or ignore_loglevel==true. -// Latter is used to get the memory info from SysInfoDialog. extern std::string log_memory_info(bool ignore_loglevel = false); extern void disable_multi_threading(); // Returns the size of physical memory (RAM) in bytes. diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index cd9ecf100d..e8cc1759d5 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1744,9 +1744,7 @@ std::string format_diameter_to_str(double diameter, int precision) return oss.str(); } -// Returns platform-specific string to be used as log output or parsed in SysInfoDialog. -// The latter parses the string with (semi)colons as separators, it should look about as -// "desc1: value1; desc2: value2" or similar (spaces should not matter). +// Returns platform-specific string to be used as log output. std::string log_memory_info(bool ignore_loglevel) { std::string out; diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 65c8384ef2..dd13fd5353 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -416,8 +416,6 @@ set(SLIC3R_GUI_SOURCES GUI/MultiSendMachineModel.hpp GUI/MultiTaskManagerPage.cpp GUI/MultiTaskManagerPage.hpp - GUI/MultiTaskModel.cpp - GUI/MultiTaskModel.hpp GUI/NetworkTestDialog.cpp GUI/NetworkTestDialog.hpp GUI/Notebook.cpp @@ -802,7 +800,6 @@ set(SLIC3R_GUI_SOURCES Utils/PrintHost.hpp Utils/Process.cpp Utils/Process.hpp - Utils/Profile.hpp Utils/RaycastManager.cpp Utils/RaycastManager.hpp Utils/Repetier.cpp diff --git a/src/slic3r/GUI/ConfigExceptions.hpp b/src/slic3r/GUI/ConfigExceptions.hpp deleted file mode 100644 index e3c5b08cea..0000000000 --- a/src/slic3r/GUI/ConfigExceptions.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#include -#include - -#include "libslic3r/Exception.hpp" - -namespace Slic3r { - -class ConfigError : public Slic3r::RuntimeError { - using Slic3r::RuntimeError::RuntimeError; -}; - -namespace GUI { - -class ConfigGUITypeError : public ConfigError { - using ConfigError::ConfigError; -}; - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 636ba985f5..11a436e1ae 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4862,12 +4862,6 @@ void GUI_App::recreate_GUI(const wxString &msg_name) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "recreate_GUI exit"; } -void GUI_App::system_info() -{ - //SysInfoDialog dlg; - //dlg.ShowModal(); -} - void GUI_App::keyboard_shortcuts(ShortcutContext page, wxWindow* parent) { KBShortcutsDialog dlg(parent != nullptr ? parent : mainframe, page); diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 0a2ddc5f17..e03c3d280a 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -519,7 +519,6 @@ public: void check_printer_presets(); void recreate_GUI(const wxString& message); - void system_info(); void keyboard_shortcuts(ShortcutContext page, wxWindow* parent = nullptr); // the main frame when null void troubleshoot(); void load_project(wxWindow *parent, wxString& input_file) const; diff --git a/src/slic3r/GUI/MultiTaskModel.cpp b/src/slic3r/GUI/MultiTaskModel.cpp deleted file mode 100644 index b23468b93e..0000000000 --- a/src/slic3r/GUI/MultiTaskModel.cpp +++ /dev/null @@ -1,6 +0,0 @@ - -namespace Slic3r { -namespace GUI { - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/MultiTaskModel.hpp b/src/slic3r/GUI/MultiTaskModel.hpp deleted file mode 100644 index 6b233e9302..0000000000 --- a/src/slic3r/GUI/MultiTaskModel.hpp +++ /dev/null @@ -1,11 +0,0 @@ -#ifndef slic3r_MultiTaskModel_hpp_ -#define slic3r_MultiTaskModel_hpp_ - -namespace Slic3r { -namespace GUI { - - -} // namespace GUI -} // namespace Slic3r - -#endif diff --git a/src/slic3r/GUI/SysInfoDialog.cpp b/src/slic3r/GUI/SysInfoDialog.cpp deleted file mode 100644 index 585767d318..0000000000 --- a/src/slic3r/GUI/SysInfoDialog.cpp +++ /dev/null @@ -1,232 +0,0 @@ -#include "SysInfoDialog.hpp" -#include "I18N.hpp" -#include "3DScene.hpp" -#include "GUI.hpp" -#include "../Utils/UndoRedo.hpp" -#include "Plater.hpp" - -#include - -#include - -#include -#include -#include "GUI_App.hpp" -#include "MainFrame.hpp" -#include "wxExtensions.hpp" -#include "libslic3r/BlacklistedLibraryCheck.hpp" -#include "libslic3r/Color.hpp" -#include "format.hpp" - -#ifdef _WIN32 - // The standard Windows includes. - #define WIN32_LEAN_AND_MEAN - #ifndef NOMINMAX - #define NOMINMAX - #endif - #include - #include -#endif /* _WIN32 */ - -namespace Slic3r { -namespace GUI { - -std::string get_main_info(bool format_as_html) -{ - std::stringstream out; - - std::string b_start = format_as_html ? "" : ""; - std::string b_end = format_as_html ? "" : ""; - std::string line_end = format_as_html ? "
" : "\n"; - - if (!format_as_html) - out << b_start << (wxGetApp().is_editor() ? SLIC3R_APP_FULL_NAME : GCODEVIEWER_APP_NAME) << b_end << line_end; - out << b_start << "Version: " << b_end << SLIC3R_VERSION << line_end; - out << b_start << "Build: " << b_end << (wxGetApp().is_editor() ? SLIC3R_BUILD_ID : GCODEVIEWER_BUILD_ID) << line_end; - out << line_end; - out << b_start << "Operating System: " << b_end << wxPlatformInfo::Get().GetOperatingSystemFamilyName() << line_end; - out << b_start << "System Architecture: " << b_end << wxPlatformInfo::Get().GetArchName() << line_end; - out << b_start << -#if defined _WIN32 - "Windows Version: " -#else - // Hopefully some kind of unix / linux. - "System Version: " -#endif - << b_end << wxPlatformInfo::Get().GetOperatingSystemDescription() << line_end; - out << b_start << "Total RAM size [MB]: " << b_end << Slic3r::format_memsize_MB(Slic3r::total_physical_memory()); - - return out.str(); -} - -std::string get_mem_info(bool format_as_html) -{ - std::stringstream out; - - std::string b_start = format_as_html ? "" : ""; - std::string b_end = format_as_html ? "" : ""; - std::string line_end = format_as_html ? "
" : "\n"; - - std::string mem_info_str = log_memory_info(true); - std::istringstream mem_info(mem_info_str); - std::string value; - while (std::getline(mem_info, value, ':')) { - out << b_start << (value+": ") << b_end; - std::getline(mem_info, value, ';'); - out << value << line_end; - } - - const Slic3r::UndoRedo::Stack &stack = wxGetApp().plater()->undo_redo_stack_main(); - out << b_start << "RAM size reserved for the Undo / Redo stack: " << b_end << Slic3r::format_memsize_MB(stack.get_memory_limit()) << line_end; - out << b_start << "RAM size occupied by the Undo / Redo stack: " << b_end << Slic3r::format_memsize_MB(stack.memsize()) << line_end << line_end; - - return out.str(); -} - -SysInfoDialog::SysInfoDialog() - : DPIDialog(static_cast(wxGetApp().mainframe), wxID_ANY, (wxGetApp().is_editor() ? wxString(SLIC3R_APP_FULL_NAME) : wxString(GCODEVIEWER_APP_NAME)) + " - " + _L("System Information"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) -{ - wxColour bgr_clr = wxGetApp().get_window_default_clr();//wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); - SetBackgroundColour(bgr_clr); - SetFont(wxGetApp().normal_font()); - - wxBoxSizer* hsizer = new wxBoxSizer(wxHORIZONTAL); - hsizer->SetMinSize(wxSize(50 * wxGetApp().em_unit(), -1)); - - auto main_sizer = new wxBoxSizer(wxVERTICAL); - main_sizer->Add(hsizer, 1, wxEXPAND | wxALL, 10); - - // logo - m_logo_bmp = ScalableBitmap(this, wxGetApp().logo_name(), 192); - m_logo = new wxStaticBitmap(this, wxID_ANY, m_logo_bmp.bmp()); - hsizer->Add(m_logo, 0, wxALIGN_CENTER_VERTICAL); - - wxBoxSizer* vsizer = new wxBoxSizer(wxVERTICAL); - hsizer->Add(vsizer, 1, wxEXPAND|wxLEFT, 20); - - // title - { - wxStaticText* title = new wxStaticText(this, wxID_ANY, wxGetApp().is_editor() ? SLIC3R_APP_FULL_NAME : GCODEVIEWER_APP_NAME, wxDefaultPosition, wxDefaultSize); - wxFont title_font = wxGetApp().bold_font(); - title_font.SetFamily(wxFONTFAMILY_ROMAN); - title_font.SetPointSize(22); - title->SetFont(title_font); - vsizer->Add(title, 0, wxEXPAND | wxTOP, wxGetApp().em_unit()/*50*/); - } - - // main_info_text - wxFont font = get_default_font(this); - const auto text_clr = wxGetApp().get_label_clr_default();//wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT); - auto text_clr_str = encode_color(ColorRGB(text_clr.Red(), text_clr.Green(), text_clr.Blue())); - auto bgr_clr_str = encode_color(ColorRGB(bgr_clr.Red(), bgr_clr.Green(), bgr_clr.Blue())); - - const int fs = font.GetPointSize() - 1; - int size[] = { static_cast(fs*1.5), static_cast(fs*1.4), static_cast(fs*1.3), fs, fs, fs, fs }; - - m_html = new wxHtmlWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHW_SCROLLBAR_NEVER); - { - m_html->SetFonts(font.GetFaceName(), font.GetFaceName(), size); - m_html->SetBorders(2); - const auto text = wxString::Format( - "" - "" - "" - "%s" - "" - "" - "", bgr_clr_str, text_clr_str, text_clr_str, - get_main_info(true)); - m_html->SetPage(text); - vsizer->Add(m_html, 1, wxEXPAND | wxBOTTOM, wxGetApp().em_unit()); - } - - // opengl_info - m_opengl_info_html = new wxHtmlWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxHW_SCROLLBAR_AUTO); - { - m_opengl_info_html->SetMinSize(wxSize(-1, 16 * wxGetApp().em_unit())); - m_opengl_info_html->SetFonts(font.GetFaceName(), font.GetFaceName(), size); - m_opengl_info_html->SetBorders(10); - wxString blacklisted_libraries_message; -#ifdef WIN32 - std::wstring blacklisted_libraries = BlacklistedLibraryCheck::get_instance().get_blacklisted_string().c_str(); - if (! blacklisted_libraries.empty()) - blacklisted_libraries_message = wxString("
") + _L("Blacklisted libraries loaded into OrcaSlicer process:") + "
" + blacklisted_libraries; -#endif // WIN32 - const auto text = GUI::format_wxstr( - "" - "" - "" - "%s
%s
%s
%s" - "
" - "" - "", bgr_clr_str, text_clr_str, text_clr_str, - blacklisted_libraries_message, - get_mem_info(true), wxGetApp().get_gl_info(false), - "" + _L("SIMD is supported:") + " " + Eigen::SimdInstructionSetsInUse()); - - m_opengl_info_html->SetPage(text); - main_sizer->Add(m_opengl_info_html, 1, wxEXPAND | wxBOTTOM, 15); - } - - wxStdDialogButtonSizer* buttons = this->CreateStdDialogButtonSizer(wxOK); - m_btn_copy_to_clipboard = new wxButton(this, wxID_ANY, _L("Copy to Clipboard"), wxDefaultPosition, wxDefaultSize); - - buttons->Insert(0, m_btn_copy_to_clipboard, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 5); - m_btn_copy_to_clipboard->Bind(wxEVT_BUTTON, &SysInfoDialog::onCopyToClipboard, this); - - this->SetEscapeId(wxID_OK); - this->Bind(wxEVT_BUTTON, &SysInfoDialog::onCloseDialog, this, wxID_OK); - main_sizer->Add(buttons, 0, wxEXPAND | wxRIGHT | wxBOTTOM, 3); - - wxGetApp().UpdateDlgDarkUI(this, true); - -// this->Bind(wxEVT_LEFT_DOWN, &SysInfoDialog::onCloseDialog, this); -// logo->Bind(wxEVT_LEFT_DOWN, &SysInfoDialog::onCloseDialog, this); - - SetSizer(main_sizer); - main_sizer->SetSizeHints(this); -} - -void SysInfoDialog::on_dpi_changed(const wxRect &suggested_rect) -{ - m_logo_bmp.msw_rescale(); - m_logo->SetBitmap(m_logo_bmp.bmp()); - - wxFont font = get_default_font(this); - const int fs = font.GetPointSize() - 1; - int font_size[] = { static_cast(fs*1.5), static_cast(fs*1.4), static_cast(fs*1.3), fs, fs, fs, fs }; - - m_html->SetFonts(font.GetFaceName(), font.GetFaceName(), font_size); - m_html->Refresh(); - - const int& em = em_unit(); - - msw_buttons_rescale(this, em, { wxID_OK, m_btn_copy_to_clipboard->GetId() }); - - m_opengl_info_html->SetMinSize(wxSize(-1, 16 * em)); - m_opengl_info_html->SetFonts(font.GetFaceName(), font.GetFaceName(), font_size); - m_opengl_info_html->Refresh(); - - const wxSize& size = wxSize(65 * em, 55 * em); - - SetMinSize(size); - Fit(); - - Refresh(); -} - -void SysInfoDialog::onCopyToClipboard(wxEvent &) -{ - wxTheClipboard->Open(); - const auto text = get_main_info(false) + "\n" + wxGetApp().get_gl_info(true); - wxTheClipboard->SetData(new wxTextDataObject(text)); - wxTheClipboard->Close(); -} - -void SysInfoDialog::onCloseDialog(wxEvent &) -{ - this->EndModal(wxID_CLOSE); -} - -} // namespace GUI -} // namespace Slic3r diff --git a/src/slic3r/GUI/SysInfoDialog.hpp b/src/slic3r/GUI/SysInfoDialog.hpp deleted file mode 100644 index 25a59f0fa7..0000000000 --- a/src/slic3r/GUI/SysInfoDialog.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#ifndef slic3r_GUI_SysInfoDialog_hpp_ -#define slic3r_GUI_SysInfoDialog_hpp_ - -#include -#include -#include - -#include "GUI_Utils.hpp" -#include "wxExtensions.hpp" - -namespace Slic3r { -namespace GUI { - -class SysInfoDialog : public DPIDialog -{ - ScalableBitmap m_logo_bmp; - wxStaticBitmap* m_logo; - wxHtmlWindow* m_opengl_info_html; - wxHtmlWindow* m_html; - - wxButton* m_btn_copy_to_clipboard; - -public: - SysInfoDialog(); - -protected: - void on_dpi_changed(const wxRect &suggested_rect) override; - -private: - void onCopyToClipboard(wxEvent &); - void onCloseDialog(wxEvent &); -}; -} // namespace GUI -} // namespace Slic3r - -#endif diff --git a/src/slic3r/GUI/WebUpdatePlugin.cpp b/src/slic3r/GUI/WebUpdatePlugin.cpp deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/slic3r/GUI/WebUpdatePlugin.hpp b/src/slic3r/GUI/WebUpdatePlugin.hpp deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/slic3r/GUI/wxinit.h b/src/slic3r/GUI/wxinit.h deleted file mode 100644 index 4c5ab9fb36..0000000000 --- a/src/slic3r/GUI/wxinit.h +++ /dev/null @@ -1,15 +0,0 @@ -#ifndef slic3r_wxinit_hpp_ -#define slic3r_wxinit_hpp_ - -#include -#include -#include - -// Perl redefines a _ macro, so we undef this one -#undef _ - -// We do want to use translation however, so define it as __ so we can do a find/replace -// later when we no longer need to undef _ -#define __(s) wxGetTranslation((s)) - -#endif diff --git a/src/slic3r/Utils/Profile.hpp b/src/slic3r/Utils/Profile.hpp deleted file mode 100644 index 5fb1e31167..0000000000 --- a/src/slic3r/Utils/Profile.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef slic3r_GUI_Profile_hpp_ -#define slic3r_GUI_Profile_hpp_ - -// Profiling support using the Shiny intrusive profiler -//#define SLIC3R_PROFILE_GUI -#if defined(SLIC3R_PROFILE) && defined(SLIC3R_PROFILE_GUI) - #include - #define SLIC3R_GUI_PROFILE_FUNC() PROFILE_FUNC() - #define SLIC3R_GUI_PROFILE_BLOCK(name) PROFILE_BLOCK(name) - #define SLIC3R_GUI_PROFILE_UPDATE() PROFILE_UPDATE() - #define SLIC3R_GUI_PROFILE_OUTPUT(x) PROFILE_OUTPUT(x) -#else - #define SLIC3R_GUI_PROFILE_FUNC() - #define SLIC3R_GUI_PROFILE_BLOCK(name) - #define SLIC3R_GUI_PROFILE_UPDATE() - #define SLIC3R_GUI_PROFILE_OUTPUT(x) -#endif - -#endif // slic3r_GUI_Profile_hpp_ From 7d4ec3494c1b4a62ad4f2d7df3b419941adcdba5 Mon Sep 17 00:00:00 2001 From: chenshj73 <88446331+chenshj73@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:30:32 +0800 Subject: [PATCH 22/45] Fix H2C start G-code record-data comments --- resources/profiles/BBL.json | 2 +- .../Bambu Lab H2C 0.4 nozzle template machine_start_gcode.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/profiles/BBL.json b/resources/profiles/BBL.json index 6c6567d37d..da56f36ccd 100644 --- a/resources/profiles/BBL.json +++ b/resources/profiles/BBL.json @@ -1,7 +1,7 @@ { "name": "Bambulab", "url": "http://www.bambulab.com/Parameters/vendor/BBL.json", - "version": "02.08.00.14", + "version": "02.08.00.15", "force_update": "0", "description": "the initial version of BBL configurations", "machine_model_list": [ diff --git a/resources/profiles/BBL/machine/Bambu Lab H2C 0.4 nozzle template machine_start_gcode.json b/resources/profiles/BBL/machine/Bambu Lab H2C 0.4 nozzle template machine_start_gcode.json index 5132ccee2e..8d620f168d 100644 --- a/resources/profiles/BBL/machine/Bambu Lab H2C 0.4 nozzle template machine_start_gcode.json +++ b/resources/profiles/BBL/machine/Bambu Lab H2C 0.4 nozzle template machine_start_gcode.json @@ -2,5 +2,5 @@ "type": "machine", "name": "Bambu Lab H2C 0.4 nozzle template machine_start_gcode", "instantiation": "false", - "machine_start_gcode": ";===== machine: H2C =========================\n;===== date: 20260608 =====================\n\n;M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\nM993 A0 B0 C0 ; nozzle cam detection not allowed.\n\nM400\n;M73 P99\n\nM960 S10 P1 ; ext fan led\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L99 C53 D9 M99 E53 F9 N99\nM1006 A56 B9 L99 C56 D9 M99 E56 F9 N99\nM1006 A61 B9 L99 C61 D9 M99 E61 F9 N99\nM1006 A53 B9 L99 C53 D9 M99 E53 F9 N99\nM1006 A56 B9 L99 C56 D9 M99 E56 F9 N99\nM1006 A61 B18 L99 C61 D18 M99 E61 F18 N99\nM1006 W\n;=====printer start sound ===================\n\n;===== reset machine status =================\nM204 S10000\nM630 S0 P0\n\nG90\nM17 D ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM1002 set_gcode_claim_speed_level 5 ;Reset speed level\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\nG29.1 Z{+0.0} ; clear z-trim value first\nM983.1 M1\nM901 D4\nM481 S0 ; turn off cutter pos comp\nG28.140 D0; reset pre-extrude z pos\n;===== reset machine status =================\n\nM620 M ;enable remap\nM620 N ;enable hotend remap\nM620 T0 ;print tmpr\nM620 T1 ;sn -> pos\nM620 T2 ;filament_id\n\n;===== start to heat heatbed & hotend==========\n\n M104 O-80 A\n M140 D[bed_temperature_initial_layer_single]\n\n;===== start to heat heatbead & hotend==========\n\n;===== avoid end stop =================\nG91\nG380 S2 Z42 F1200\nG380 S2 Z-12 F1200\nG90\n;===== avoid end stop =================\n\n;==== set airduct mode ====\n\n{if (overall_chamber_temperature >= 40)}\n\n M145 P1 ; set airduct mode to heating mode for heating\n M106 P2 S0 ; turn off auxiliary fan\n M106 P3 S0 ; turn off chamber fan\n\n{else}\n M145 P0 ; set airduct mode to cooling mode for cooling\n M106 P2 S178 ; turn on auxiliary fan for cooling\n M106 P3 S127 ; turn on chamber fan for cooling\n\n M1002 gcode_claim_action : 29\n M191 S0 ; wait for chamber temp\n M106 P2 S0 ; turn off auxiliary fan\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\n M142 P1 R30 S35 T40 U0.3 V0.5 W0.8 O40 ; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 T45 U0.3 V0.5 W0.8 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (!is_all_bbl_filament)}\n M142 P1 R35 S40 T45 U0.3 V0.5 W0.8 O45 L1 ; set third-party PETG chamber autocooling\n {else}\n {if (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\n M142 P1 R35 S45 T50 U0.3 V0.5 W0.8 O50 L1 ; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R35 S50 T55 U0.3 V0.5 W0.8 O55 L1 ; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n {endif}\n{if(cooling_filter_enabled)}\nM145.2 P0 F0\n{else}\nM145.2 P0 F1\n{endif}\n{endif}\n;==== set airduct mode ====\n\n\n;====== cog noise reduction=================\nM982.2 S1 ; turn on cog noise reduction\n\n;===== first homing start =====\nM1002 gcode_claim_action : 13\n\nM640 S\nM640.1 R\nM641\nG28 X T300\nT1000\n\nG150.3\nM972 S24 P0 T2000 ; live-view camera foolproof\nM972 S46 P0 T5000 ; vortek anti-collision\nM640.1 S\nM640.4\n{if (first_non_support_filaments[0] != -1)}\nM640 S\nM640.8 T A{first_non_support_filaments[0]} H{first_non_support_hotend[0]}\nM640.7 L\nM641\n{endif}\n\n\n{if wipe_tower_center_pos_valid}\n M620.14 X[wipe_tower_center_pos_x] Y[wipe_tower_center_pos_y]\n{else}\n M620.14 X95.5 Y336\n{endif}\n\n\nG150.1 F18000 ; wipe mouth to avoid filament stick to heatbed\nG150.3 F18000\nM400 P200\n\nM1002 gcode_claim_action : 74 ; Heatbed surface foreign object detection\nM104 S0\n{if curr_bed_type==\"Textured PEI Plate\"}\nM972 S26 P0 C0\n{else}\nM972 S36 P0 C0 X1\n{endif}\nM972 S35 P0 C0\n\nM972 S41 P0 T5000 ; trash can anti-collision\n\nM1009 Q1 L1\nG91\nG380 S2 Z30 F1200 ; lower heatbed to move toolhead\nG90\nG1 X175 Y160 F30000\nG28 Z P0 T250\nM1009 Q1 L0\n\n\n;===== first homing end =====\n\n;===== detection start =====\n\nM1002 judge_flag build_plate_detect_flag\nM104 S0\nM622 S1\n ;M1002 gcode_claim_action : 11 ; Indentifying build plate type\n M972 S19 P0 C0 ; heatbed presence detection\n M972 S31 P0 T5000 ; toolhead camera dirty detection\n ;M1002 gcode_claim_action : 73 ; Build plate alignment detection\n M972 S34 P0 T5000 ; heatbed plate offset detection\nM623\n\nM1002 gcode_claim_action : 72 ; Hotend Type Detection\nT1001\nM972 S14 P0 T5000 ; nozzle type detection\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} T{filament_map[initial_no_support_filament_id] % 2} ; rise temp in advance\n\nG151 P{filament_map[initial_no_support_filament_id] % 2} M ; plug the heat nozzle\n\n{if max_print_z >= 145}\nM1002 gcode_claim_action : 75 ; Heatbed underside foreign object detection\nG3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n{endif}\n\n;===== detection end =====\n\nM400\n;M73 P99\n\n;===== prepare print temperature and material ==========\nM400\nM211 X0 Y0 Z0 ;turn off soft endstop\nM975 S1 ; turn on input shaping\n\nG29.2 S0 ; avoid invalid abl data\n\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60*0.8} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60*0.8} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\nM620.11 P1 I[initial_no_support_filament_id] B[initial_no_support_hotend] E0\n\n{if long_retraction_when_ec }\nM620.11 K1 I[initial_no_support_filament_id] B[initial_no_support_hotend] R{retraction_distance_when_ec} F{max((flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 K0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R0\n{endif}\n\nM628 S1\n{if filament_type[initial_no_support_filament_id] == \"TPU\"}\n M620.11 S0 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{else}\n{if (filament_type[initial_no_support_filament_id] == \"PA\") || (filament_type[initial_no_support_filament_id] == \"PA-GF\")}\n M620.11 S1 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R4 D2 E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{else}\n M620.11 S1 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R10 D8 E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{endif}\n{endif}\nM629\n\nM620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] ; switch material if AMS exist\nM1002 gcode_claim_action : 4\nM1002 set_filament_type:UNKNOWN\nM400\nT[initial_no_support_filament_id] H[initial_no_support_hotend]\nM400\nM628 S0\nM629\nM400\nM1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\nM621 S[initial_no_support_filament_id]A\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\nM400\nM106 P1 S0\n\nG91\nG1 Y-16 F60000\nG90\n\nG29.2 S1\n;===== prepare print temperature and material ==========\n\n\nM400\n;M73 P99\n\n;===== xy ofst cali start =====\n\nM1002 judge_flag auto_cali_toolhead_offset_flag\n\nM622 J2\n M1002 gcode_claim_action : 54\n M190 D[bed_temperature_initial_layer_single]\n\n M1002 gcode_claim_action : 39\n M620 D[initial_no_support_hotend]\n G383.3 U140 L{initial_no_support_filament_id}\nM623\n\nM622 J1\n M1002 gcode_claim_action : 54\n M190 D[bed_temperature_initial_layer_single]\n\n M1002 gcode_claim_action : 39\n M620 D[initial_no_support_hotend]\n G383 O2 U140 L{initial_no_support_filament_id}\nM623\n\n;===== xy ofst cali end =====\n\n;===== start to heat heatbed & hotend==========\n\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n G150.3\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M140 S[bed_temperature_initial_layer_single]\n\n ;===== set chamber temperature ==========\n {if (overall_chamber_temperature >= 40)}\n M145 P1 ; set airduct mode to heating mode\n M141 S[overall_chamber_temperature] ; Let Chamber begin to heat\n {endif}\n ;===== set chamber temperature ==========\n\n;===== start to heat heatbead & hotend==========\n\n\n\nM400\n;M73 P99\n\n;===== auto extrude cali start =========================\nM975 S1\nM1002 judge_flag extrude_cali_flag\n\nM622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\nM623\n\nM622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\nM623\n\nM622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\nM623\n\n;===== auto extrude cali end =========================\n\n{if filament_type[initial_no_support_filament_id] == \"TPU\"}\n G150.2\n G150.1\n G150.2\n G150.1\n G150.2\n G150.1\n{else}\n G150.3\n M106 P1 S0\n M400 S2\n M109 S{nozzle_temperature[initial_no_support_filament_id]} ; wait tmpr to extrude\n M83\n {if(nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.8)}\n G1 E60 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n {else}\n G1 E45 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n {endif}\n G1 E-3 F1800\n M400 P500\n G150.2\n G150.1\n{endif}\n\nG91\nG1 Y-16 F12000 ; move away from the trash bin\nG90\n\nM400\n;M73 P99\n\n;===== wipe right nozzle start =====\n\nM1002 gcode_claim_action : 14\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n {if (overall_chamber_temperature >= 40)}\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id] - 80}\n {endif}\nM106 S255 ; turn on fan to cool the nozzle\n\n;===== wipe left nozzle end =====\n\nM400\n;M73 P99\n\nM1002 judge_flag auto_cali_toolhead_offset_flag\nM622 J0\n G91\n G1 Z5 F1200\n G90\n M1012.7\n G383.7 U140 J0\nM623\n\n{if (overall_chamber_temperature >= 40)}\n M1002 gcode_claim_action : 49\n M191 S[overall_chamber_temperature] ; wait for chamber temp\n{endif}\n\nM400\n;M73 P99\n\n;===== bed leveling ==================================\n\nM1002 judge_flag g29_before_print_flag\n\nM190 S[bed_temperature_initial_layer_single]; ensure bed temp\nM109 S140 A\nM106 S0 ; turn off fan , too noisy\n\nG91\nG1 Z10 F1200\n{if (first_non_support_filaments[0] != -1) && filament_type[initial_no_support_filament_id] != \"TPU\"}\nM640 S\nM640.7 U\nM640.7 L\nM640.2 R1\nM641\n{endif}\nG90\nG1 X175 Y160 F30000\n\nM622 J1\n M1002 gcode_claim_action : 1\n G29.20 A3\n G29 A1 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n M400\nM623\n\nM622 J2\n M1002 gcode_claim_action : 1\n {if has_tpu_in_first_layer}\n G29.20 A3\n G29 A1 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {else}\n G29.20 A4\n G29 A2 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\nM623\n\nM622 J0\n G28 R\nM623\n\n;===== bed leveling end ================================\n\nG39.1 ; cali nozzle wrapped detection pos\n\nG90\nG1 Z5 F1200\nG1 X270 Y-0.5 F60000\nG28.140 S0 ; cali pre-extrude z pos\n\n;============switch again==================\nM211 X0 Y0 Z0 ;turn off soft endstop\nG91\nG1 Z6 F1200\nG90\nM1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\nM620 S[initial_no_support_filament_id]A H[initial_no_support_hotend]\nM400\nT[initial_no_support_filament_id] H[initial_no_support_hotend]\nM400\nM628 S0\nM629\nM400\nM621 S[initial_no_support_filament_id]A\n\n;============switch again==================\n\nM400\n;M73 P99\n\nM141 S[overall_chamber_temperature]\n\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n\n G90\n G1 Z5 F1200\n G1 X165 Y160 F20000\n M400 P200\n\n M970.3 Q1 A5 K0 O1\n M974 Q1 S2 P0\n\n M970.3 Q0 A5 K0 O1\n M974 Q0 S2 P0\n\n M970.2 Q2 K0 W38 Z0.01\n M974 Q2 S2 P0\n\n M975 S1\n;===== mech mode sweep end =====\n\n;===== wait temperature reaching the reference value =======\n\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off fan\n M106 P2 S0 ; turn off big fan\n ;==== set ext toodhead cooling fan ====\n {if (min_vitrification_temperature <= 50)}\n M106 P9 S255\n {endif}\n ;============set motor current==================\n M400 S1\n\n;===== wait temperature reaching the reference value =======\n\nM400\n;M73 P99\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if curr_bed_type==\"Textured PEI Plate\"}\n {if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2}\n G29.1 Z{-0.01} ; for Textured PEI Plate\n {else}\n G29.1 Z{-0.02} ; for Textured PEI Plate\n {endif}\n {else}\n {if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2}\n G29.1 Z{0.01}\n {endif}\n {endif}\nG150.1\n\nM975 S1 ; turn on mech mode supression\nM983.4 S1 ; turn on deformation compensation\nG29.2 S1 ; turn on pos comp\nG29.7 S1\n\nG90\nG1 Z5 F1200\nG1 Y295 F30000\nG1 Y265 F18000\n\n;===== nozzle load line ===============================\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n G1 Z5 F1200\n G1 X270 Y-0.5 F60000\n G28.14 R0\n G29.2 S0\n G91\n G1 Z0.8 F1200\n G90\n G1 X250 F60000\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n\n ========== record data ==========\n M1026\n M1012.8\n M1012.9\n G29.9\n ========== record data ==========\n\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M83\n{if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.8}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n{endif}\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n{endif}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n G1 X290 E10 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n G91\n G3 Z0.4 I1.217 J0 P1 F60000\n G90\n M83\n G29.2 S1 ; ensure z comp turn on\n;===== noozle load line end ===========================\n\nM400\n;M73 P99\n\nM993 A1 B1 C1 ; nozzle cam detection allowed.\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\nM1015.3 S1;enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\nM620.6 I[initial_no_support_filament_id] H[initial_no_support_hotend] W1 ;enable ams air printing detect\nM620 O1\n\nM211 Z1\nG29.99\nM1002 gcode_claim_action : 0\nM400\n" + "machine_start_gcode": ";===== machine: H2C =========================\n;===== date: 20260608 =====================\n\n;M1002 set_flag extrude_cali_flag=1\n;M1002 set_flag g29_before_print_flag=1\n;M1002 set_flag auto_cali_toolhead_offset_flag=1\n;M1002 set_flag build_plate_detect_flag=1\n\nM993 A0 B0 C0 ; nozzle cam detection not allowed.\n\nM400\n;M73 P99\n\nM960 S10 P1 ; ext fan led\n\n;=====printer start sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A53 B9 L99 C53 D9 M99 E53 F9 N99\nM1006 A56 B9 L99 C56 D9 M99 E56 F9 N99\nM1006 A61 B9 L99 C61 D9 M99 E61 F9 N99\nM1006 A53 B9 L99 C53 D9 M99 E53 F9 N99\nM1006 A56 B9 L99 C56 D9 M99 E56 F9 N99\nM1006 A61 B18 L99 C61 D18 M99 E61 F18 N99\nM1006 W\n;=====printer start sound ===================\n\n;===== reset machine status =================\nM204 S10000\nM630 S0 P0\n\nG90\nM17 D ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM1002 set_gcode_claim_speed_level 5 ;Reset speed level\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\nG29.1 Z{+0.0} ; clear z-trim value first\nM983.1 M1\nM901 D4\nM481 S0 ; turn off cutter pos comp\nG28.140 D0; reset pre-extrude z pos\n;===== reset machine status =================\n\nM620 M ;enable remap\nM620 N ;enable hotend remap\nM620 T0 ;print tmpr\nM620 T1 ;sn -> pos\nM620 T2 ;filament_id\n\n;===== start to heat heatbed & hotend==========\n\n M104 O-80 A\n M140 D[bed_temperature_initial_layer_single]\n\n;===== start to heat heatbead & hotend==========\n\n;===== avoid end stop =================\nG91\nG380 S2 Z42 F1200\nG380 S2 Z-12 F1200\nG90\n;===== avoid end stop =================\n\n;==== set airduct mode ====\n\n{if (overall_chamber_temperature >= 40)}\n\n M145 P1 ; set airduct mode to heating mode for heating\n M106 P2 S0 ; turn off auxiliary fan\n M106 P3 S0 ; turn off chamber fan\n\n{else}\n M145 P0 ; set airduct mode to cooling mode for cooling\n M106 P2 S178 ; turn on auxiliary fan for cooling\n M106 P3 S127 ; turn on chamber fan for cooling\n\n M1002 gcode_claim_action : 29\n M191 S0 ; wait for chamber temp\n M106 P2 S0 ; turn off auxiliary fan\n {if (min_vitrification_temperature <= 50)}\n {if (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\n M142 P1 R30 S35 T40 U0.3 V0.5 W0.8 O40 ; set PLA/TPU ND0.2 chamber autocooling\n {else}\n M142 P1 R30 S40 T45 U0.3 V0.5 W0.8 O45; set PLA/TPU ND0.4 chamber autocooling\n {endif}\n {else}\n {if (!is_all_bbl_filament)}\n M142 P1 R35 S40 T45 U0.3 V0.5 W0.8 O45 L1 ; set third-party PETG chamber autocooling\n {else}\n {if (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\n M142 P1 R35 S45 T50 U0.3 V0.5 W0.8 O50 L1 ; set PETG ND0.2 chamber autocooling\n {else}\n M142 P1 R35 S50 T55 U0.3 V0.5 W0.8 O55 L1 ; set PETG ND0.4 chamber autocooling\n {endif}\n {endif}\n {endif}\n{if(cooling_filter_enabled)}\nM145.2 P0 F0\n{else}\nM145.2 P0 F1\n{endif}\n{endif}\n;==== set airduct mode ====\n\n\n;====== cog noise reduction=================\nM982.2 S1 ; turn on cog noise reduction\n\n;===== first homing start =====\nM1002 gcode_claim_action : 13\n\nM640 S\nM640.1 R\nM641\nG28 X T300\nT1000\n\nG150.3\nM972 S24 P0 T2000 ; live-view camera foolproof\nM972 S46 P0 T5000 ; vortek anti-collision\nM640.1 S\nM640.4\n{if (first_non_support_filaments[0] != -1)}\nM640 S\nM640.8 T A{first_non_support_filaments[0]} H{first_non_support_hotend[0]}\nM640.7 L\nM641\n{endif}\n\n\n{if wipe_tower_center_pos_valid}\n M620.14 X[wipe_tower_center_pos_x] Y[wipe_tower_center_pos_y]\n{else}\n M620.14 X95.5 Y336\n{endif}\n\n\nG150.1 F18000 ; wipe mouth to avoid filament stick to heatbed\nG150.3 F18000\nM400 P200\n\nM1002 gcode_claim_action : 74 ; Heatbed surface foreign object detection\nM104 S0\n{if curr_bed_type==\"Textured PEI Plate\"}\nM972 S26 P0 C0\n{else}\nM972 S36 P0 C0 X1\n{endif}\nM972 S35 P0 C0\n\nM972 S41 P0 T5000 ; trash can anti-collision\n\nM1009 Q1 L1\nG91\nG380 S2 Z30 F1200 ; lower heatbed to move toolhead\nG90\nG1 X175 Y160 F30000\nG28 Z P0 T250\nM1009 Q1 L0\n\n\n;===== first homing end =====\n\n;===== detection start =====\n\nM1002 judge_flag build_plate_detect_flag\nM104 S0\nM622 S1\n ;M1002 gcode_claim_action : 11 ; Indentifying build plate type\n M972 S19 P0 C0 ; heatbed presence detection\n M972 S31 P0 T5000 ; toolhead camera dirty detection\n ;M1002 gcode_claim_action : 73 ; Build plate alignment detection\n M972 S34 P0 T5000 ; heatbed plate offset detection\nM623\n\nM1002 gcode_claim_action : 72 ; Hotend Type Detection\nT1001\nM972 S14 P0 T5000 ; nozzle type detection\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]} T{filament_map[initial_no_support_filament_id] % 2} ; rise temp in advance\n\nG151 P{filament_map[initial_no_support_filament_id] % 2} M ; plug the heat nozzle\n\n{if max_print_z >= 145}\nM1002 gcode_claim_action : 75 ; Heatbed underside foreign object detection\nG3811 Z{max_print_z} ; Detect obstacles at the bottom of the heated bed\n{endif}\n\n;===== detection end =====\n\nM400\n;M73 P99\n\n;===== prepare print temperature and material ==========\nM400\nM211 X0 Y0 Z0 ;turn off soft endstop\nM975 S1 ; turn on input shaping\n\nG29.2 S0 ; avoid invalid abl data\n\n{if ((filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG\")) && (nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2)}\nM620.10 A0 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F74.8347 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{else}\nM620.10 A0 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60*0.8} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\nM620.10 A1 F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60*0.8} H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} T{flush_temperatures[initial_no_support_filament_id]} P{nozzle_temperature_initial_layer[initial_no_support_filament_id]} S1\n{endif}\n\nM620.11 P1 I[initial_no_support_filament_id] B[initial_no_support_hotend] E0\n\n{if long_retraction_when_ec }\nM620.11 K1 I[initial_no_support_filament_id] B[initial_no_support_hotend] R{retraction_distance_when_ec} F{max((flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60), 200)}\n{else}\nM620.11 K0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R0\n{endif}\n\nM628 S1\n{if filament_type[initial_no_support_filament_id] == \"TPU\"}\n M620.11 S0 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{else}\n{if (filament_type[initial_no_support_filament_id] == \"PA\") || (filament_type[initial_no_support_filament_id] == \"PA-GF\")}\n M620.11 S1 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R4 D2 E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{else}\n M620.11 S1 L0 I[initial_no_support_filament_id] B[initial_no_support_hotend] R10 D8 E-{retraction_distances_when_cut[initial_no_support_filament_id]} F{flush_volumetric_speeds[initial_no_support_filament_id]/2.4053*60}\n{endif}\n{endif}\nM629\n\nM620 S[initial_no_support_filament_id]A H[initial_no_support_hotend] ; switch material if AMS exist\nM1002 gcode_claim_action : 4\nM1002 set_filament_type:UNKNOWN\nM400\nT[initial_no_support_filament_id] H[initial_no_support_hotend]\nM400\nM628 S0\nM629\nM400\nM1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\nM621 S[initial_no_support_filament_id]A\n\nM104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\nM400\nM106 P1 S0\n\nG91\nG1 Y-16 F60000\nG90\n\nG29.2 S1\n;===== prepare print temperature and material ==========\n\n\nM400\n;M73 P99\n\n;===== xy ofst cali start =====\n\nM1002 judge_flag auto_cali_toolhead_offset_flag\n\nM622 J2\n M1002 gcode_claim_action : 54\n M190 D[bed_temperature_initial_layer_single]\n\n M1002 gcode_claim_action : 39\n M620 D[initial_no_support_hotend]\n G383.3 U140 L{initial_no_support_filament_id}\nM623\n\nM622 J1\n M1002 gcode_claim_action : 54\n M190 D[bed_temperature_initial_layer_single]\n\n M1002 gcode_claim_action : 39\n M620 D[initial_no_support_hotend]\n G383 O2 U140 L{initial_no_support_filament_id}\nM623\n\n;===== xy ofst cali end =====\n\n;===== start to heat heatbed & hotend==========\n\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n G150.3\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M140 S[bed_temperature_initial_layer_single]\n\n ;===== set chamber temperature ==========\n {if (overall_chamber_temperature >= 40)}\n M145 P1 ; set airduct mode to heating mode\n M141 S[overall_chamber_temperature] ; Let Chamber begin to heat\n {endif}\n ;===== set chamber temperature ==========\n\n;===== start to heat heatbead & hotend==========\n\n\n\nM400\n;M73 P99\n\n;===== auto extrude cali start =========================\nM975 S1\nM1002 judge_flag extrude_cali_flag\n\nM622 J0\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\nM623\n\nM622 J1\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\nM623\n\nM622 J2\n M1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_no_support_filament_id]}\n\n G90\n M83\n M983.3 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4} A0.4 ; cali dynamic extrusion compensation\n\n M400\n M106 P1 S255\n M400 S5\n M106 P1 S0\n G150.3\nM623\n\n;===== auto extrude cali end =========================\n\n{if filament_type[initial_no_support_filament_id] == \"TPU\"}\n G150.2\n G150.1\n G150.2\n G150.1\n G150.2\n G150.1\n{else}\n G150.3\n M106 P1 S0\n M400 S2\n M109 S{nozzle_temperature[initial_no_support_filament_id]} ; wait tmpr to extrude\n M83\n {if(nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.8)}\n G1 E60 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n {else}\n G1 E45 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n {endif}\n G1 E-3 F1800\n M400 P500\n G150.2\n G150.1\n{endif}\n\nG91\nG1 Y-16 F12000 ; move away from the trash bin\nG90\n\nM400\n;M73 P99\n\n;===== wipe right nozzle start =====\n\nM1002 gcode_claim_action : 14\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n {if (overall_chamber_temperature >= 40)}\n G150 T{nozzle_temperature_initial_layer[initial_no_support_filament_id] - 80}\n {endif}\nM106 S255 ; turn on fan to cool the nozzle\n\n;===== wipe left nozzle end =====\n\nM400\n;M73 P99\n\nM1002 judge_flag auto_cali_toolhead_offset_flag\nM622 J0\n G91\n G1 Z5 F1200\n G90\n M1012.7\n G383.7 U140 J0\nM623\n\n{if (overall_chamber_temperature >= 40)}\n M1002 gcode_claim_action : 49\n M191 S[overall_chamber_temperature] ; wait for chamber temp\n{endif}\n\nM400\n;M73 P99\n\n;===== bed leveling ==================================\n\nM1002 judge_flag g29_before_print_flag\n\nM190 S[bed_temperature_initial_layer_single]; ensure bed temp\nM109 S140 A\nM106 S0 ; turn off fan , too noisy\n\nG91\nG1 Z10 F1200\n{if (first_non_support_filaments[0] != -1) && filament_type[initial_no_support_filament_id] != \"TPU\"}\nM640 S\nM640.7 U\nM640.7 L\nM640.2 R1\nM641\n{endif}\nG90\nG1 X175 Y160 F30000\n\nM622 J1\n M1002 gcode_claim_action : 1\n G29.20 A3\n G29 A1 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n M400\nM623\n\nM622 J2\n M1002 gcode_claim_action : 1\n {if has_tpu_in_first_layer}\n G29.20 A3\n G29 A1 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {else}\n G29.20 A4\n G29 A2 O X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]} R\n {endif}\n M400\nM623\n\nM622 J0\n G28 R\nM623\n\n;===== bed leveling end ================================\n\nG39.1 ; cali nozzle wrapped detection pos\n\nG90\nG1 Z5 F1200\nG1 X270 Y-0.5 F60000\nG28.140 S0 ; cali pre-extrude z pos\n\n;============switch again==================\nM211 X0 Y0 Z0 ;turn off soft endstop\nG91\nG1 Z6 F1200\nG90\nM1002 set_filament_type:{filament_type[initial_no_support_filament_id]}\nM620 S[initial_no_support_filament_id]A H[initial_no_support_hotend]\nM400\nT[initial_no_support_filament_id] H[initial_no_support_hotend]\nM400\nM628 S0\nM629\nM400\nM621 S[initial_no_support_filament_id]A\n\n;============switch again==================\n\nM400\n;M73 P99\n\nM141 S[overall_chamber_temperature]\n\n;===== mech mode sweep start =====\n M1002 gcode_claim_action : 3\n\n G90\n G1 Z5 F1200\n G1 X165 Y160 F20000\n M400 P200\n\n M970.3 Q1 A5 K0 O1\n M974 Q1 S2 P0\n\n M970.3 Q0 A5 K0 O1\n M974 Q0 S2 P0\n\n M970.2 Q2 K0 W38 Z0.01\n M974 Q2 S2 P0\n\n M975 S1\n;===== mech mode sweep end =====\n\n;===== wait temperature reaching the reference value =======\n\nM140 S[bed_temperature_initial_layer_single]\nM190 S[bed_temperature_initial_layer_single]\n\n ;========turn off light and fans =============\n M960 S1 P0 ; turn off laser\n M960 S2 P0 ; turn off laser\n M106 S0 ; turn off fan\n M106 P2 S0 ; turn off big fan\n ;==== set ext toodhead cooling fan ====\n {if (min_vitrification_temperature <= 50)}\n M106 P9 S255\n {endif}\n ;============set motor current==================\n M400 S1\n\n;===== wait temperature reaching the reference value =======\n\nM400\n;M73 P99\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n {if curr_bed_type==\"Textured PEI Plate\"}\n {if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2}\n G29.1 Z{-0.01} ; for Textured PEI Plate\n {else}\n G29.1 Z{-0.02} ; for Textured PEI Plate\n {endif}\n {else}\n {if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.2}\n G29.1 Z{0.01}\n {endif}\n {endif}\nG150.1\n\nM975 S1 ; turn on mech mode supression\nM983.4 S1 ; turn on deformation compensation\nG29.2 S1 ; turn on pos comp\nG29.7 S1\n\nG90\nG1 Z5 F1200\nG1 Y295 F30000\nG1 Y265 F18000\n\n;===== nozzle load line ===============================\n G29.2 S1 ; ensure z comp turn on\n G90\n M83\n G1 Z5 F1200\n G1 X270 Y-0.5 F60000\n G28.14 R0\n G29.2 S0\n G91\n G1 Z0.8 F1200\n G90\n G1 X250 F60000\n M104 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n\n ;========== record data ==========\n M1026\n M1012.8\n M1012.9\n G29.9\n ;========== record data ==========\n\n M400 P50\n M500 D1\n M400 S3\n M109 S{nozzle_temperature_initial_layer[initial_no_support_filament_id]}\n M83\n{if nozzle_diameter_at_nozzle_id[initial_nozzle_id] == 0.8}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n{endif}\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n{endif}\n G1 E5 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n G1 X290 E10 F{filament_max_volumetric_speed[initial_no_support_filament_id]/2.4053*60}\n G91\n G3 Z0.4 I1.217 J0 P1 F60000\n G90\n M83\n G29.2 S1 ; ensure z comp turn on\n;===== noozle load line end ===========================\n\nM400\n;M73 P99\n\nM993 A1 B1 C1 ; nozzle cam detection allowed.\n\n{if (filament_type[initial_no_support_filament_id] == \"TPU\")}\nM1015.3 S1;enable tpu clog detect\n{else}\nM1015.3 S0;disable tpu clog detect\n{endif}\n\n{if (filament_type[initial_no_support_filament_id] == \"PLA\") || (filament_type[initial_no_support_filament_id] == \"PETG\")\n || (filament_type[initial_no_support_filament_id] == \"PLA-CF\") || (filament_type[initial_no_support_filament_id] == \"PETG-CF\")}\nM1015.4 S1 K1 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;enable E air printing detect\n{else}\nM1015.4 S0 K0 H{nozzle_diameter_at_nozzle_id[initial_nozzle_id]} ;disable E air printing detect\n{endif}\n\nM620.6 I[initial_no_support_filament_id] H[initial_no_support_hotend] W1 ;enable ams air printing detect\nM620 O1\n\nM211 Z1\nG29.99\nM1002 gcode_claim_action : 0\nM400\n" } From 1abd6649c750375ff1c09401aa1ac2339da11b7a Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 13:58:28 +0200 Subject: [PATCH 23/45] Put the UV orientation tests beside the projection tests They were appended at the end of test_texture_displacement.cpp, where every other open texture-displacement branch also appends, so the four of them cannot be merged in any order without a conflict in a file none of them really disagree about. Same tests, moved next to the patch-frame test they belong with. --- tests/libslic3r/test_texture_displacement.cpp | 630 +++++++++--------- 1 file changed, 315 insertions(+), 315 deletions(-) diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 37745beaa0..03f27661bc 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1652,6 +1652,321 @@ TEST_CASE("Per-corner LSCM UVs give each triangle its own island's placement", " CHECK(differs); } +// --------------------------------------------------------------------------------------------- +// UV orientation: which way up a texture lands on the model. +// +// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and +// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the +// statement that the row a point samples gets *lower* as the point moves up the face, and "it is +// not left-right mirrored" is that the column gets higher as the point moves to the right of a +// viewer standing outside the model. Both are measured below by sampling a ramp whose value says +// which row (or column) it came from, which settles the question without having to reason about the +// uv transform at all. + +static constexpr int RAMP_N = 64; // the ramp texture's size, in texels +static constexpr float RAMP_TILING = 40.f; // mm per tile +static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over +// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an +// arc length (angle * radius), so a long step also changes the radius, and that term swamps the +// angular one. It is symmetric about the face centre, so at a short step it cancels between the two +// readings a check compares. +static constexpr float RAMP_STEP = 1.f; +// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per +// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. +static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; + +// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled +// height identifies the row. Built directly rather than through a PNG because these tests are about +// the projection, not the decoder - decode_height_texture() has its own tests. +static DecodedHeightTexture ramp_texture(bool by_row) +{ + DecodedHeightTexture t; + t.width = t.height = RAMP_N; + t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); + for (int y = 0; y < RAMP_N; ++y) + for (int x = 0; x < RAMP_N; ++x) + t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); + return t; +} + +// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well +// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. +static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) +{ + TextureDisplacementLayer layer; + layer.projection_method = method; + layer.tiling_scale = RAMP_TILING; + layer.offset = Vec2f(0.5f, 0.5f); + layer.depth_mm = 1.f; + return layer; +} + +// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two +// texels reads the ramp between them; only the direction it moves in matters here. +static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, + const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), + const Vec3f &axis = Vec3f(Vec3f::UnitZ())) +{ + return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; +} + +// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on +// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. +static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) +{ + up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); + right = Vec3f((-n).cross(up).normalized()); +} + +static const std::array, 6> CUBE_FACES = { { + { "+X", Vec3f(1.f, 0.f, 0.f) }, + { "-X", Vec3f(-1.f, 0.f, 0.f) }, + { "+Y", Vec3f(0.f, 1.f, 0.f) }, + { "-Y", Vec3f(0.f, -1.f, 0.f) }, + { "+Z", Vec3f(0.f, 0.f, 1.f) }, + { "-Z", Vec3f(0.f, 0.f, -1.f) }, +} }; + +TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", + "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + const DecodedHeightTexture cols = ramp_texture(false); + const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); + + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + + // Upright: the row read near the top of the face is nearer the image's first row. + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + + // Unmirrored: the column read to the viewer's right is further into the image. Without a + // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which + // is the mirroring a bare v flip leaves behind. + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left of the face " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") +{ + const DecodedHeightTexture rows = ramp_texture(true); + // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its + // "up" is the projector's own (view_project_up), which the default capture puts at +Y. + const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, + TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its + // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about + // which way up the image is. + if (std::abs(n.z()) > 0.9f) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : + Vec3f(0.f, 0.f, 1.f); + const Vec3f centre = n * CUBE_HALF; + const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); + const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); + INFO("row at the top " << row_up << ", at the bottom " << row_down); + CHECK(row_down - row_up > RAMP_MARGIN); + } + } +} + +TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") +{ + const DecodedHeightTexture cols = ramp_texture(false); + const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); + const TextureDisplacementLayer layer = ramp_layer(method); + + for (const auto &[name, n] : CUBE_FACES) { + // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the + // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both + // projections, and a reading taken across it compares two opposite sides of the model. + if (std::abs(n.z()) > 0.9f) + continue; + const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); + if (on_the_cut) + continue; + DYNAMIC_SECTION("method " << int(method) << ", face " << name) + { + Vec3f right, up; + outside_viewer_frame(n, right, up); + const Vec3f centre = n * CUBE_HALF; + const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); + const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); + INFO("column at the left " << col_left << ", at the right " << col_right); + CHECK(col_right - col_left > RAMP_MARGIN); + } + } +} + +TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") +{ + indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); + for (Vec3f &v : cube.vertices) + v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); + + const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); + REQUIRE_FALSE(unwrap.empty()); + REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges + + // Each chart carries one face. The four upright ones span world Z, and their v has to grow + // downwards like every other projection's - whatever the packing then did with the island. + int upright_charts = 0; + for (int c = 0; c < unwrap.chart_count; ++c) { + int top = -1, bottom = -1; + for (size_t i = 0; i < unwrap.uvs.size(); ++i) { + if (unwrap.vertex_chart[i] != c) + continue; + const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); + if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) + top = int(i); + if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) + bottom = int(i); + } + REQUIRE(top >= 0); + REQUIRE(bottom >= 0); + const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); + if (std::abs(v_top - v_bottom) < 1e-3f) + continue; // a cap: the whole chart is at one height, so it has no up + ++upright_charts; + DYNAMIC_SECTION("chart " << c) + { + INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); + CHECK(v_bottom - v_top > 1.f); + } + } + CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above +} + +TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") +{ + // The two express the same table in different shapes - one a pair of world axes for the GUI, the + // other the closed form the bake's hot path uses - so they are checked against each other here + // rather than left to drift apart. + const Vec3f p(2.f, -3.f, 7.f); + for (const auto &[name, n] : CUBE_FACES) { + DYNAMIC_SECTION("face " << name) + { + Vec3f u_axis, v_axis; + box_projection_axes(n, u_axis, v_axis); + const Vec2f planar = project_planar(p, n); + CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); + CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); + // Right-handed for a viewer outside the model: u to their right, v down, so u x v points + // into the surface. Getting this wrong is exactly what mirrors a face. + CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); + } + } +} + +// An image with a different value in each quadrant, so one bake says both which way up it landed +// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the +// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. +static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + +TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") +{ + // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four + // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex + // normal is then exactly the face normal, which is what makes the expected depth below an exact + // number instead of a tri-planar blend of three of them. + constexpr int STEPS = 4; // cells per side, so 5x5 vertices + constexpr float SPAN = 20.f; // mm, the wall's width and height + indexed_triangle_set wall; + for (int j = 0; j <= STEPS; ++j) + for (int i = 0; i <= STEPS; ++i) + wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, + SPAN * float(j) / float(STEPS)); + const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; + for (int j = 0; j < STEPS; ++j) + for (int i = 0; i < STEPS; ++i) { + // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. + wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); + wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); + } + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(wall); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.projection_method = TextureProjectionMethod::Triplanar; + layer.image_data = make_quadrant_marker_png(); + layer.depth_mm = 2.f; + // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in + // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a + // whole number of texels away from both. + layer.tiling_scale = 40.f; + layer.offset = Vec2f(0.5f, 0.75f); + // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by + // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With + // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. + layer.tile_enabled = false; + + const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); + REQUIRE(result.vertices.size() == wall.vertices.size()); + + // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's + // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value + // whose quadrant it must have read. + const std::array, 4> expected = { { + { "top-left (255)", 1, 3, 255 }, + { "top-right (170)", 3, 3, 170 }, + { "bottom-left (85)", 1, 1, 85 }, + { "bottom-right (0)", 3, 1, 0 }, + } }; + + for (const auto &[name, i, j, value] : expected) { + DYNAMIC_SECTION("quadrant " << name) + { + const size_t vi = size_t(at(i, j)); + // The whole displacement is along -Y, so this is the relief's height at that vertex. + const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); + INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); + CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); + // Nothing moved sideways or vertically: a mirrored or rotated basis would still have + // displaced along the normal, so this is here to catch the relief leaning instead. + CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); + CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); + } + } +} + TEST_CASE("The Cylindrical/Spherical patch frame is the bake's own, so a preview can share it", "[TextureDisplacement]") { // The fast preview reconstructs these two projections in the fragment shader and needs the very @@ -2174,318 +2489,3 @@ TEST_CASE("whether a layer's texture has colour agrees with its decode", "[Textu CHECK_FALSE(height_texture_has_color(TextureDisplacementLayer{})); } - -// --------------------------------------------------------------------------------------------- -// UV orientation: which way up a texture lands on the model. -// -// v = 0 is the height map's *first* pixel row, i.e. its top (DecodedHeightTexture::sample(), and -// the UV editor negates Y on screen for the same reason). So "the image is the right way up" is the -// statement that the row a point samples gets *lower* as the point moves up the face, and "it is -// not left-right mirrored" is that the column gets higher as the point moves to the right of a -// viewer standing outside the model. Both are measured below by sampling a ramp whose value says -// which row (or column) it came from, which settles the question without having to reason about the -// uv transform at all. - -static constexpr int RAMP_N = 64; // the ramp texture's size, in texels -static constexpr float RAMP_TILING = 40.f; // mm per tile -static constexpr float CUBE_HALF = 5.f; // half extent of the cube the probes walk over -// How far a probe steps off a face centre. Short on purpose: the cylindrical and spherical u is an -// arc length (angle * radius), so a long step also changes the radius, and that term swamps the -// angular one. It is symmetric about the face centre, so at a short step it cancels between the two -// readings a check compares. -static constexpr float RAMP_STEP = 1.f; -// What one step's worth of travel is in ramp rows: 2 * RAMP_STEP mm of separation, RAMP_N texels per -// RAMP_TILING mm. Halved as the margin a check demands, so "flat" cannot pass as either direction. -static constexpr double RAMP_MARGIN = RAMP_N * RAMP_STEP / RAMP_TILING; - -// A height map whose rows (or columns) carry a linear ramp: row r holds 3 + 4r, so the sampled -// height identifies the row. Built directly rather than through a PNG because these tests are about -// the projection, not the decoder - decode_height_texture() has its own tests. -static DecodedHeightTexture ramp_texture(bool by_row) -{ - DecodedHeightTexture t; - t.width = t.height = RAMP_N; - t.pixels.resize(size_t(RAMP_N) * size_t(RAMP_N)); - for (int y = 0; y < RAMP_N; ++y) - for (int x = 0; x < RAMP_N; ++x) - t.pixels[size_t(y) * size_t(RAMP_N) + size_t(x)] = uint8_t(3 + 4 * (by_row ? y : x)); - return t; -} - -// uv 0.5 at the cube's centre, and a tile wide enough that every point the probes visit stays well -// inside the one tile - so no reading is a wrapped-around neighbour of the one it is compared with. -static TextureDisplacementLayer ramp_layer(TextureProjectionMethod method) -{ - TextureDisplacementLayer layer; - layer.projection_method = method; - layer.tiling_scale = RAMP_TILING; - layer.offset = Vec2f(0.5f, 0.5f); - layer.depth_mm = 1.f; - return layer; -} - -// Which row (or column) of `ramp` the layer reads at `p`. Fractional, because a point between two -// texels reads the ramp between them; only the direction it moves in matters here. -static double ramp_index(const DecodedHeightTexture &ramp, const TextureDisplacementLayer &layer, const Vec3f &p, - const Vec3f &n, const Vec3f ¢er = Vec3f(Vec3f::Zero()), - const Vec3f &axis = Vec3f(Vec3f::UnitZ())) -{ - return (double(sample_layer_height(ramp, layer, p, n, center, axis, nullptr)) * 255.0 - 3.0) / 4.0; -} - -// How a viewer standing outside the model sees a face: their right, and their up. Up is world +Z on -// the four upright faces; on the top and bottom, where +Z is edge on, it is world +Y. -static void outside_viewer_frame(const Vec3f &n, Vec3f &right, Vec3f &up) -{ - up = (std::abs(n.z()) < 0.9f) ? Vec3f(0.f, 0.f, 1.f) : Vec3f(0.f, 1.f, 0.f); - right = Vec3f((-n).cross(up).normalized()); -} - -static const std::array, 6> CUBE_FACES = { { - { "+X", Vec3f(1.f, 0.f, 0.f) }, - { "-X", Vec3f(-1.f, 0.f, 0.f) }, - { "+Y", Vec3f(0.f, 1.f, 0.f) }, - { "-Y", Vec3f(0.f, -1.f, 0.f) }, - { "+Z", Vec3f(0.f, 0.f, 1.f) }, - { "-Z", Vec3f(0.f, 0.f, -1.f) }, -} }; - -TEST_CASE("the box projection lands a texture upright and unmirrored on all six faces of a cube", - "[TextureDisplacement]") -{ - const DecodedHeightTexture rows = ramp_texture(true); - const DecodedHeightTexture cols = ramp_texture(false); - const TextureDisplacementLayer layer = ramp_layer(TextureProjectionMethod::Triplanar); - - for (const auto &[name, n] : CUBE_FACES) { - DYNAMIC_SECTION("face " << name) - { - Vec3f right, up; - outside_viewer_frame(n, right, up); - const Vec3f centre = n * CUBE_HALF; - - // Upright: the row read near the top of the face is nearer the image's first row. - const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); - const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); - INFO("row at the top of the face " << row_up << ", at the bottom " << row_down); - CHECK(row_down - row_up > RAMP_MARGIN); - - // Unmirrored: the column read to the viewer's right is further into the image. Without a - // per-side sign this fails on exactly the three faces facing away - -X, +Y and -Z - which - // is the mirroring a bare v flip leaves behind. - const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); - const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); - INFO("column at the left of the face " << col_left << ", at the right " << col_right); - CHECK(col_right - col_left > RAMP_MARGIN); - } - } -} - -TEST_CASE("every projection method lands a texture upright on an upright face", "[TextureDisplacement]") -{ - const DecodedHeightTexture rows = ramp_texture(true); - // ViewProjected is one flat projection from the captured camera rather than a per-face map, so its - // "up" is the projector's own (view_project_up), which the default capture puts at +Y. - const auto method = GENERATE(TextureProjectionMethod::Triplanar, TextureProjectionMethod::Cylindrical, - TextureProjectionMethod::Spherical, TextureProjectionMethod::ViewProjected); - const TextureDisplacementLayer layer = ramp_layer(method); - - for (const auto &[name, n] : CUBE_FACES) { - // Only the four upright faces: on the top and bottom a cylinder's v is constant (they are its - // caps) and a sphere's is at an extremum (they are its poles), so neither says anything about - // which way up the image is. - if (std::abs(n.z()) > 0.9f) - continue; - DYNAMIC_SECTION("method " << int(method) << ", face " << name) - { - const Vec3f up = (method == TextureProjectionMethod::ViewProjected) ? layer.view_project_up : - Vec3f(0.f, 0.f, 1.f); - const Vec3f centre = n * CUBE_HALF; - const double row_up = ramp_index(rows, layer, centre + up * RAMP_STEP, n); - const double row_down = ramp_index(rows, layer, centre - up * RAMP_STEP, n); - INFO("row at the top " << row_up << ", at the bottom " << row_down); - CHECK(row_down - row_up > RAMP_MARGIN); - } - } -} - -TEST_CASE("the cylindrical and spherical wraps run left to right as seen from outside", "[TextureDisplacement]") -{ - const DecodedHeightTexture cols = ramp_texture(false); - const auto method = GENERATE(TextureProjectionMethod::Cylindrical, TextureProjectionMethod::Spherical); - const TextureDisplacementLayer layer = ramp_layer(method); - - for (const auto &[name, n] : CUBE_FACES) { - // The caps and poles are out, as above - u is undefined on the axis. So is whichever face the - // atan2 branch cut runs through, where u jumps from +pi to -pi: that is a real seam in both - // projections, and a reading taken across it compares two opposite sides of the model. - if (std::abs(n.z()) > 0.9f) - continue; - const bool on_the_cut = (method == TextureProjectionMethod::Cylindrical) ? (n.y() < -0.9f) : (n.x() < -0.9f); - if (on_the_cut) - continue; - DYNAMIC_SECTION("method " << int(method) << ", face " << name) - { - Vec3f right, up; - outside_viewer_frame(n, right, up); - const Vec3f centre = n * CUBE_HALF; - const double col_left = ramp_index(cols, layer, centre - right * RAMP_STEP, n); - const double col_right = ramp_index(cols, layer, centre + right * RAMP_STEP, n); - INFO("column at the left " << col_left << ", at the right " << col_right); - CHECK(col_right - col_left > RAMP_MARGIN); - } - } -} - -TEST_CASE("an unwrapped cube's flat charts are laid out the right way up", "[TextureDisplacement]") -{ - indexed_triangle_set cube = its_make_cube(2. * CUBE_HALF, 2. * CUBE_HALF, 2. * CUBE_HALF); - for (Vec3f &v : cube.vertices) - v -= Vec3f(CUBE_HALF, CUBE_HALF, CUBE_HALF); - - const PatchUnwrap unwrap = compute_patch_unwrap(cube, LSCM_DEFAULT_SEAM_ANGLE_DEG); - REQUIRE_FALSE(unwrap.empty()); - REQUIRE(unwrap.chart_count == 6); // one flat chart per face, cut at the 90-degree edges - - // Each chart carries one face. The four upright ones span world Z, and their v has to grow - // downwards like every other projection's - whatever the packing then did with the island. - int upright_charts = 0; - for (int c = 0; c < unwrap.chart_count; ++c) { - int top = -1, bottom = -1; - for (size_t i = 0; i < unwrap.uvs.size(); ++i) { - if (unwrap.vertex_chart[i] != c) - continue; - const float z = cube.vertices[size_t(unwrap.source_vertex[i])].z(); - if (top < 0 || z > cube.vertices[size_t(unwrap.source_vertex[size_t(top)])].z()) - top = int(i); - if (bottom < 0 || z < cube.vertices[size_t(unwrap.source_vertex[size_t(bottom)])].z()) - bottom = int(i); - } - REQUIRE(top >= 0); - REQUIRE(bottom >= 0); - const float v_top = unwrap.uvs[size_t(top)].y(), v_bottom = unwrap.uvs[size_t(bottom)].y(); - if (std::abs(v_top - v_bottom) < 1e-3f) - continue; // a cap: the whole chart is at one height, so it has no up - ++upright_charts; - DYNAMIC_SECTION("chart " << c) - { - INFO("v at the chart's topmost vertex " << v_top << ", at its bottommost " << v_bottom); - CHECK(v_bottom - v_top > 1.f); - } - } - CHECK(upright_charts == 4); // the four sides; the top and bottom faces are the caps skipped above -} - -TEST_CASE("project_planar agrees with the box projection's own axes", "[TextureDisplacement]") -{ - // The two express the same table in different shapes - one a pair of world axes for the GUI, the - // other the closed form the bake's hot path uses - so they are checked against each other here - // rather than left to drift apart. - const Vec3f p(2.f, -3.f, 7.f); - for (const auto &[name, n] : CUBE_FACES) { - DYNAMIC_SECTION("face " << name) - { - Vec3f u_axis, v_axis; - box_projection_axes(n, u_axis, v_axis); - const Vec2f planar = project_planar(p, n); - CHECK_THAT(planar.x(), WithinAbs(p.dot(u_axis), 1e-5f)); - CHECK_THAT(planar.y(), WithinAbs(p.dot(v_axis), 1e-5f)); - // Right-handed for a viewer outside the model: u to their right, v down, so u x v points - // into the surface. Getting this wrong is exactly what mirrors a face. - CHECK_THAT(u_axis.cross(v_axis).dot(n), WithinAbs(-1.f, 1e-5f)); - } - } -} - -// An image with a different value in each quadrant, so one bake says both which way up it landed -// and whether it came out mirrored. Distinct enough that a wrong quadrant cannot be mistaken for the -// right one: top-left 255, top-right 170, bottom-left 85, bottom-right 0. -static std::shared_ptr> make_quadrant_marker_png(size_t w = 32, size_t h = 32) -{ - std::vector pixels(w * h); - for (size_t y = 0; y < h; ++y) - for (size_t x = 0; x < w; ++x) - pixels[y * w + x] = (y < h / 2) ? ((x < w / 2) ? 255 : 170) : ((x < w / 2) ? 85 : 0); - const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() - / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); - REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); - - std::vector bytes; - { - std::ifstream ifs(tmp_path.string(), std::ios::binary); - bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); - } - boost::system::error_code ec; - boost::filesystem::remove(tmp_path, ec); - - REQUIRE_FALSE(bytes.empty()); - return std::make_shared>(std::move(bytes)); -} - -TEST_CASE("baking an asymmetric image puts the top of the image at the top of the model", "[TextureDisplacement]") -{ - // An upright wall in the plane y = 0, facing -Y, tessellated so that each of the image's four - // quadrants gets a vertex at its centre. A flat sheet rather than a cube because every vertex - // normal is then exactly the face normal, which is what makes the expected depth below an exact - // number instead of a tri-planar blend of three of them. - constexpr int STEPS = 4; // cells per side, so 5x5 vertices - constexpr float SPAN = 20.f; // mm, the wall's width and height - indexed_triangle_set wall; - for (int j = 0; j <= STEPS; ++j) - for (int i = 0; i <= STEPS; ++i) - wall.vertices.emplace_back(-SPAN / 2.f + SPAN * float(i) / float(STEPS), 0.f, - SPAN * float(j) / float(STEPS)); - const auto at = [](int i, int j) { return j * (STEPS + 1) + i; }; - for (int j = 0; j < STEPS; ++j) - for (int i = 0; i < STEPS; ++i) { - // Wound so that (+x) x (+z) = -y: the wall faces -Y, away from the origin. - wall.indices.emplace_back(at(i, j), at(i + 1, j), at(i + 1, j + 1)); - wall.indices.emplace_back(at(i, j), at(i + 1, j + 1), at(i, j + 1)); - } - - TextureDisplacementFacetsData facets{}; - facets[0] = paint_whole_mesh(wall); - - TextureDisplacementLayer layer; - layer.slot = 0; - layer.projection_method = TextureProjectionMethod::Triplanar; - layer.image_data = make_quadrant_marker_png(); - layer.depth_mm = 2.f; - // The wall is 20 mm across and one tile is 40 mm, so it covers the middle half of the image in - // both axes: the quadrant boundaries land on x = 0 and z = 10, and the probed vertices sit a - // whole number of texels away from both. - layer.tiling_scale = 40.f; - layer.offset = Vec2f(0.5f, 0.75f); - // A single decal rather than a repeat, so that getting the orientation wrong cannot be masked by - // the wrap bringing the flipped coordinate back into the image at a plausible-looking row. With - // tiling off, uv stays inside [0.25, 0.75] in both axes only while v runs the right way. - layer.tile_enabled = false; - - const indexed_triangle_set result = build_texture_displacement(wall, { layer }, facets, classic_options()); - REQUIRE(result.vertices.size() == wall.vertices.size()); - - // Seen from outside this face the viewer's right is +X and their up is +Z, so the image's - // top-left quadrant has to land at low x and high z. Each entry: the vertex, and the image value - // whose quadrant it must have read. - const std::array, 4> expected = { { - { "top-left (255)", 1, 3, 255 }, - { "top-right (170)", 3, 3, 170 }, - { "bottom-left (85)", 1, 1, 85 }, - { "bottom-right (0)", 3, 1, 0 }, - } }; - - for (const auto &[name, i, j, value] : expected) { - DYNAMIC_SECTION("quadrant " << name) - { - const size_t vi = size_t(at(i, j)); - // The whole displacement is along -Y, so this is the relief's height at that vertex. - const float raised = wall.vertices[vi].y() - result.vertices[vi].y(); - INFO("vertex at " << wall.vertices[vi].x() << ", " << wall.vertices[vi].z()); - CHECK_THAT(raised, WithinAbs(layer.depth_mm * float(value) / 255.f, 1e-3f)); - // Nothing moved sideways or vertically: a mirrored or rotated basis would still have - // displaced along the normal, so this is here to catch the relief leaning instead. - CHECK_THAT(result.vertices[vi].x(), WithinAbs(wall.vertices[vi].x(), 1e-4f)); - CHECK_THAT(result.vertices[vi].z(), WithinAbs(wall.vertices[vi].z(), 1e-4f)); - } - } -} From 714972821959aa4e566cb87107533dbfdec502cd Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 20:27:37 +0800 Subject: [PATCH 24/45] Fix clang-cl dependency builds failing on a precompiled header from the compiler cache --- cmake/modules/ClangClShowIncludes.cmake | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmake/modules/ClangClShowIncludes.cmake b/cmake/modules/ClangClShowIncludes.cmake index 91b9a18069..fcc48fe3f7 100644 --- a/cmake/modules/ClangClShowIncludes.cmake +++ b/cmake/modules/ClangClShowIncludes.cmake @@ -1,10 +1,18 @@ # ccache does not parse the -clang: arguments CMake uses for clang-cl's gcc-style # depfile, so a cache hit writes the object and no depfile, and Ninja then records # no headers for that object. ccache reproduces /showIncludes output on a hit. +# +# Clang also records the modification time of every input in a precompiled header +# and rejects the header once one differs. ccache caches the compile that creates +# the header, so a tree that regenerates cmake_pch.cxx gets back a header stamped +# with the old file's time, and every compile that uses it fails. The build system +# already rebuilds the header when an input changes. This covers the dependencies, +# such as wxWidgets; PrecompiledHeader.cmake does the same for OrcaSlicer's targets. foreach (_lang C CXX) if (CMAKE_${_lang}_COMPILER_ID STREQUAL "Clang" AND CMAKE_${_lang}_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") set(CMAKE_DEPFILE_FLAGS_${_lang} "/showIncludes") set(CMAKE_${_lang}_DEPFILE_FORMAT msvc) + list(APPEND CMAKE_${_lang}_COMPILE_OPTIONS_CREATE_PCH -Xclang -fno-pch-timestamp) endif () endforeach () From 4f2d2ae11ae2ebbf11a58fec8f8b56b154540c9b Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:17:41 +0800 Subject: [PATCH 25/45] Wait for the PR's Deps Cache in clang-tidy When deps/ Changes (#16374) * Wait for the PR's Deps Cache in clang-tidy When deps/ Changes * Fail the clang-tidy Deps Wait When Listing Caches Keeps Failing * Wait for Deps Through ci_deps_cache.sh in clang-tidy Reuse the macOS deps wait for Linux: the job names, workflow and cache ref are passed in, and a new 'own' mode waits for the PR's own build when it changes deps/. A PR that keeps deps/ now waits for a running base build with the same deps, and warns when it falls back to older ones. The wait ends as soon as the deps job fails. --- .github/workflows/clang_tidy.yml | 74 ++++++++++++++++++++++++++++++-- scripts/ci_deps_cache.sh | 60 ++++++++++++++++++-------- 2 files changed, 113 insertions(+), 21 deletions(-) diff --git a/.github/workflows/clang_tidy.yml b/.github/workflows/clang_tidy.yml index 2f27a0eaff..445f294fcc 100644 --- a/.github/workflows/clang_tidy.yml +++ b/.github/workflows/clang_tidy.yml @@ -16,6 +16,15 @@ on: permissions: contents: read + # Listing caches and builds while waiting for a deps cache. + actions: read + +# Read by scripts/ci_deps_cache.sh. +env: + REPO: ${{ github.repository }} + WORKFLOW: build_all.yml + BASE_REF: ${{ github.base_ref }} + JOB: build_linux (${{ vars.SELF_HOSTED && 'orca-lnx-server' || 'ubuntu-24.04' }}) concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -35,7 +44,7 @@ jobs: # The PR merge commit plus its first parent, the base it is diffed against. fetch-depth: 2 - - name: Look for changed C++ files + - name: Look for changed files id: changes run: | if git -c core.quotePath=false diff --name-only HEAD^1 -- src tests | grep -qE '\.(cpp|cc|cxx|hpp|h|hxx)$'; then @@ -43,18 +52,75 @@ jobs: else echo "No C++ changes under src/ or tests/." fi + if ! git diff --quiet HEAD^1 -- deps; then + echo "deps=true" >> "$GITHUB_OUTPUT" + fi + echo "deps-key=linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }}" >> "$GITHUB_OUTPUT" - # Parsing needs the dependency headers, not a build of this PR's deps/, so - # a PR that changes deps/ falls back to the newest cache main has. + # Parsing needs the dependency headers, not a build of this PR's deps/. + # Without an exact match the job waits for the build that saves it: this + # PR's own when it changes deps/, otherwise a running push build of the + # base branch with the same deps. With neither, a PR that keeps deps/ + # falls back to the newest cache. - name: Restore cached deps + id: deps_cache if: steps.changes.outputs.cpp == 'true' uses: actions/cache/restore@v6 with: path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep - key: linux-clang-cache-orcaslicer_deps-build-${{ hashFiles('deps/**') }} + key: ${{ steps.changes.outputs.deps-key }} + + - name: Look for a build to wait for + id: wait_check + if: steps.changes.outputs.cpp == 'true' && steps.deps_cache.outputs.cache-hit != 'true' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash scripts/ci_deps_cache.sh ${{ steps.changes.outputs.deps == 'true' && 'own' || 'check' }} + + # Linux Build Deps takes about 35 minutes on GitHub-hosted runners. The + # wait ends early when the deps job fails. + - name: Wait for the deps cache + if: steps.wait_check.outputs.wait-for != '' + timeout-minutes: 100 + env: + GH_TOKEN: ${{ github.token }} + KEY: ${{ steps.changes.outputs.deps-key }} + CACHE_REF: ${{ steps.changes.outputs.deps == 'true' && github.ref || format('refs/heads/{0}', github.base_ref) }} + KEY_INPUTS: ${{ steps.wait_check.outputs.key-inputs }} + WAIT_FOR: ${{ steps.wait_check.outputs.wait-for }} + WAIT_SINCE: ${{ steps.wait_check.outputs.wait-since }} + WAIT_MINUTES: 90 + run: bash scripts/ci_deps_cache.sh wait + + - name: Restore the deps cache after waiting + id: waited_cache + if: steps.wait_check.outputs.wait-for != '' + uses: actions/cache/restore@v6 + with: + path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep + key: ${{ steps.changes.outputs.deps-key }} + + - name: Fail without this PR's deps cache + if: steps.changes.outputs.deps == 'true' && steps.wait_check.outcome == 'success' && steps.waited_cache.outputs.cache-hit != 'true' + run: | + echo "::error::No deps cache ${{ steps.changes.outputs.deps-key }}. Check this PR's Linux Build Deps job, then rerun clang-tidy." + exit 1 + + - name: Restore the newest cached deps + id: newest_cache + if: steps.changes.outputs.deps != 'true' && steps.wait_check.outcome == 'success' && steps.waited_cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@v6 + with: + path: ${{ github.workspace }}/deps/build/OrcaSlicer_dep + key: ${{ steps.changes.outputs.deps-key }} restore-keys: linux-clang-cache-orcaslicer_deps-build- fail-on-cache-miss: true + - name: Warn about older deps + if: steps.newest_cache.outcome == 'success' && steps.newest_cache.outputs.cache-hit != 'true' + run: echo "::warning title=Older deps::No cache matches this PR's deps/, so clang-tidy parsed against ${{ steps.newest_cache.outputs.cache-matched-key }}. If it reports errors in code that uses a dependency, rerun clang-tidy once the base branch's Build all has saved the new deps." + - name: Apt-Install Dependencies if: steps.changes.outputs.cpp == 'true' && !vars.SELF_HOSTED uses: ./.github/actions/apt-install-deps diff --git a/scripts/ci_deps_cache.sh b/scripts/ci_deps_cache.sh index a27b7e2522..0c3ae85415 100644 --- a/scripts/ci_deps_cache.sh +++ b/scripts/ci_deps_cache.sh @@ -1,21 +1,27 @@ #!/usr/bin/env bash -# Lets a pull request's macOS deps build wait for a push build of the base -# branch that builds the same deps, from a Linux job. `check` runs after a cache -# miss and names a queued or running base build with the same deps/ and .github/ -# to wait for, unless the pull request changes either. `wait` waits until that -# build saves KEY. actions/cache then confirms the entry can be restored. +# Lets a pull request's macOS deps build, or its clang-tidy job, wait for a +# build that saves the deps cache it needs, from a Linux job. `check` runs after +# a cache miss and names a queued or running push build of the base branch with +# the same deps/ and .github/ to wait for, unless the pull request changes either. +# `own` names the pull request's own build instead, for clang-tidy when the pull +# request changes deps/. `wait` waits until that build saves KEY under CACHE_REF, +# or its deps job fails. actions/cache then confirms the entry can be restored. # -# Environment: GH_TOKEN, REPO, WORKFLOW_REF and BASE_REF, plus KEY_INPUTS, -# WAIT_FOR, WAIT_SINCE, WAIT_MINUTES, KEY and ARCH for `wait`. +# Environment: GH_TOKEN, REPO, WORKFLOW_REF (or WORKFLOW, the build workflow's +# file name) and BASE_REF, plus HEAD_SHA for `own`, and KEY_INPUTS, WAIT_FOR, +# WAIT_SINCE, WAIT_MINUTES, KEY, CACHE_REF (default the base branch) and ARCH +# (or JOB, the matrix job's name) for `wait`. # Writes key-inputs, wait-for and wait-since to $GITHUB_OUTPUT. set -uo pipefail -workflow=${WORKFLOW_REF%%@*} -workflow=${workflow##*/} +workflow=${WORKFLOW_REF:-} +workflow=${workflow%%@*} +workflow=${WORKFLOW:-${workflow##*/}} # Must match the job names in build_all.yml, build_check_cache.yml and build_deps.yml. -check_job_name="build_macos_arch (${ARCH:-}) / Check Cache" -deps_job_name="build_macos_arch (${ARCH:-}) / Build Deps / Build Deps" +job=${JOB:-build_macos_arch (${ARCH:-})} +check_job_name="$job / Check Cache" +deps_job_name="$job / Build Deps / Build Deps" out() { echo "$1" >> "${GITHUB_OUTPUT:-/dev/stdout}"; } @@ -63,8 +69,27 @@ check() { fi } +own() { + out "key-inputs=$(local_key_inputs HEAD)" + local id + # The build can be created a moment after this job. + for _ in 1 2 3 4 5; do + id=$(gh api -X GET "repos/$REPO/actions/workflows/$workflow/runs" \ + -f head_sha="$HEAD_SHA" -f event=pull_request -f per_page=1 --jq '.workflow_runs[0].id // empty' < /dev/null) + [ -n "$id" ] && break + sleep 30 + done + if [ -n "$id" ]; then + echo "Waiting for the deps cache of build $id of this pull request." + out "wait-for=$id" + out "wait-since=$(date +%s)" + else + echo "No build of this pull request is building its deps." + fi +} + key_saved() { - gh cache list -R "$REPO" --ref "refs/heads/$BASE_REF" --key "$KEY" --json key < /dev/null \ + gh cache list -R "$REPO" --ref "${CACHE_REF:-refs/heads/$BASE_REF}" --key "$KEY" --json key < /dev/null \ | jq -e --arg key "$KEY" 'any(.[]; .key == $key)' > /dev/null } @@ -87,11 +112,11 @@ wait_for() { key_saved && { echo "Found $KEY."; return; } sleep 20 done - echo "Build $id of $BASE_REF finished its deps without saving $KEY." + echo "Build $id finished its deps without saving $KEY." return ;; "completed cancelled") ;; completed*) - echo "The deps job of build $id of $BASE_REF did not succeed." + echo "The deps job of build $id did not succeed." return ;; # The deps job exists only once the build's cache check has succeeded. "") @@ -100,7 +125,7 @@ wait_for() { completed*) check=$(jq -r --arg name "$check_job_name" 'first(.jobs[] | select(.name == $name) | .conclusion) // ""' <<< "$jobs") if [ -z "$warned" ] && { [ -z "$check" ] || [ "$check" = success ]; }; then - echo "::warning title=Deps wait::Build $id of $BASE_REF has no job named \"$deps_job_name\". If the build jobs were renamed, update scripts/ci_deps_cache.sh. This does not affect this pull request." + echo "::warning title=Deps wait::Build $id has no job named \"$deps_job_name\". If the build jobs were renamed, update scripts/ci_deps_cache.sh. This does not affect this pull request." warned=1 fi ;; *) @@ -118,11 +143,12 @@ wait_for() { seen="$seen $id" echo "Following build $id of $BASE_REF." done - echo "No build of $BASE_REF saved $KEY." + echo "No build saved $KEY." } case "${1:-}" in check) check ;; + own) own ;; wait) wait_for ;; - *) echo "usage: $0 check|wait" >&2; exit 2 ;; + *) echo "usage: $0 check|own|wait" >&2; exit 2 ;; esac From c6a338f35f975ac9a2bdcdbde6754d720e1fd170 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 12 Oct 2026 23:18:38 +0800 Subject: [PATCH 26/45] Fix stale paint highlights and textures in the texture displacement gizmo Erase whole model, Select whole model and Capture current view update the highlight at once. A stroke released outside the 3D view ends on the next mouse move, in every paint gizmo, instead of leaving the brush in its pressed colour. The layer textures no longer show an image that was replaced. --- src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp | 9 +++++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 26 +++++++++---------- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 23 ++++++++-------- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp index 4bae9a28fe..388b04312d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoPainterBase.cpp @@ -1036,6 +1036,11 @@ bool GLGizmoPainterBase::on_mouse(const wxMouseEvent &mouse_event) Vec2d mouse_pos = mouse_coord.cast(); if (mouse_event.Moving()) { + // No button is down, yet a stroke is still open: its release went elsewhere, as a paint drag does not + // capture the mouse. End the stroke as that release would have. + if (m_button_down != Button::None) + gizmo_event(m_button_down == Button::Left ? SLAGizmoEventType::LeftUp : SLAGizmoEventType::RightUp, mouse_pos, + mouse_event.ShiftDown(), mouse_event.AltDown(), false); gizmo_event(SLAGizmoEventType::Moving, mouse_pos, mouse_event.ShiftDown(), mouse_event.AltDown(), false); return false; } @@ -1205,6 +1210,10 @@ void GLGizmoPainterBase::on_set_state() m_old_mo_id = -1; //m_iva.release_geometry(); m_triangle_selectors.clear(); + // A stroke still open goes with the selectors. Its state must not carry into the next session, where the + // first move would end it as if released and the first press would paint a line from its last position. + m_button_down = Button::None; + m_last_mouse_click = Vec2d::Zero(); //Camera& camera = wxGetApp().plater()->get_camera(); //camera.look_at(camera.get_position(), m_previous_target, Vec3d::UnitZ()); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 45ff19480f..048b005c69 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1520,7 +1520,7 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() // is box-filtered down to 128 px for a ~48 px row, and feeding it to the shader cost the preview // three quarters of the height map's detail - and, since height_tex_texel is derived from it, also // flattened the shading gradient and made the parallax march skip itself at angles where it should - // run. Cached on the image_data pointer + smoothing, so no PNG is decoded per frame. + // run. Cached per image_data and smoothing, so no PNG is decoded per frame. GLTexture *tex = get_layer_height_texture(*layer); if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0) return; @@ -4038,8 +4038,8 @@ void GLGizmoTextureDisplacement::remove_texture_layer(int slot) if (slot >= 0 && slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) { mv->texture_displacement_facet(slot).reset(); m_thumbnails[size_t(slot)].reset(); - m_thumbnail_source[size_t(slot)] = nullptr; - m_layer_expanded[size_t(slot)] = false; + m_thumbnail_source[size_t(slot)].reset(); + m_layer_expanded[size_t(slot)] = false; } if (m_picker_slot == slot) m_picker_slot = -1; @@ -4213,7 +4213,7 @@ int GLGizmoTextureDisplacement::select_visible_faces(const std::array selector.reset(); for (unsigned i : unobscured) selector.set_facet(int(front_facing[i]), EnforcerBlockerType::ENFORCER); - selector.request_update_render_data(); + selector.request_update_render_data(true); update_model_object(); m_parent.set_as_dirty(); @@ -4236,7 +4236,7 @@ void GLGizmoTextureDisplacement::select_whole_model() const size_t facet_count = v->mesh().its.indices.size(); for (size_t i = 0; i < facet_count; ++i) m_triangle_selectors[idx]->set_facet(int(i), EnforcerBlockerType::ENFORCER); - m_triangle_selectors[idx]->request_update_render_data(); + m_triangle_selectors[idx]->request_update_render_data(true); } update_model_object(); m_parent.set_as_dirty(); @@ -5342,7 +5342,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace return nullptr; const size_t slot = size_t(layer.slot); - if (m_thumbnails[slot] && m_thumbnail_source[slot] == layer.image_data.get() && + if (m_thumbnails[slot] && m_thumbnail_source[slot].lock() == layer.image_data && m_thumbnail_smoothing[slot] == layer.smoothing) return m_thumbnails[slot].get(); @@ -5355,7 +5355,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace return nullptr; m_thumbnails[slot] = std::move(texture); - m_thumbnail_source[slot] = layer.image_data.get(); + m_thumbnail_source[slot] = layer.image_data; m_thumbnail_smoothing[slot] = layer.smoothing; return m_thumbnails[slot].get(); } @@ -5364,17 +5364,17 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp { if (layer.empty() || !layer.color_enabled) return nullptr; - if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing) + if (m_color_tex && m_color_tex_source.lock() == layer.image_data && m_color_tex_smoothing == layer.smoothing) return m_color_tex.get(); std::unique_ptr texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); if (!texture) { m_color_tex.reset(); - m_color_tex_source = nullptr; + m_color_tex_source.reset(); return nullptr; } m_color_tex = std::move(texture); - m_color_tex_source = layer.image_data.get(); + m_color_tex_source = layer.image_data; m_color_tex_smoothing = layer.smoothing; return m_color_tex.get(); } @@ -5386,7 +5386,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis // One slot, not one per layer: the preview shader only ever shades the *active* layer, so a single // full-resolution upload is enough and the VRAM cost stays at one texture rather than eight. - if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing) + if (m_height_tex && m_height_tex_source.lock() == layer.image_data && m_height_tex_smoothing == layer.smoothing) return m_height_tex.get(); std::unique_ptr texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); @@ -5394,7 +5394,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDis return nullptr; m_height_tex = std::move(texture); - m_height_tex_source = layer.image_data.get(); + m_height_tex_source = layer.image_data; m_height_tex_smoothing = layer.smoothing; return m_height_tex.get(); } @@ -6281,7 +6281,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (v->is_model_part()) { ++idx; m_triangle_selectors[idx]->reset(); - m_triangle_selectors[idx]->request_update_render_data(); + m_triangle_selectors[idx]->request_update_render_data(true); } update_model_object(); m_parent.set_as_dirty(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index a520fba426..631254313c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -924,25 +924,26 @@ private: bool m_preview_job_pending = false; void queue_preview_job(); - // Per-slot GPU thumbnail cache for the layer list panel, keyed by the image_data pointer that - // was current the last time each thumbnail was built (see get_layer_thumbnail()). - std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnails; - std::array m_thumbnail_source{}; + // Per-slot GPU thumbnail cache for the layer list panel, each keyed by the image_data it was built from + // (see get_layer_thumbnail()). Held weakly rather than by address, as decode_height_texture()'s cache is: + // an image freed and replaced by another at the same address must not pass for it. + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnails; + std::array>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_source; // The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it. - std::array m_thumbnail_smoothing{}; + std::array m_thumbnail_smoothing{}; // Full-resolution height texture for the preview shader, keyed the same way (see // get_layer_height_texture()). A smoothing change re-uploads it, so the fast preview shows the // blur the bake will apply. - std::unique_ptr m_height_tex; - const void *m_height_tex_source = nullptr; - float m_height_tex_smoothing = -1.f; + std::unique_ptr m_height_tex; + std::weak_ptr> m_height_tex_source; + float m_height_tex_smoothing = -1.f; // The same, for the layer's *colour*, which the fast preview quantizes per fragment so it shows // the image at texel resolution rather than at the mesh's. Null for a grayscale texture. - std::unique_ptr m_color_tex; - const void *m_color_tex_source = nullptr; - float m_color_tex_smoothing = -1.f; + std::unique_ptr m_color_tex; + std::weak_ptr> m_color_tex_source; + float m_color_tex_smoothing = -1.f; // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures; From 1a9201690da0f40c8d7c900357203a8d3317de3c Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:18 +0800 Subject: [PATCH 27/45] Draw Preview Segments With 16-Bit Indices (#16389) The segment draw switched to an element buffer of 8-bit indices. GCN 1/2 GPUs cannot read 8-bit indices in hardware, so the driver converts them, and on a Mac Pro 2013 FirePro under Windows Boot Camp the toolpaths come out as scattered spikes. 16-bit indices are supported everywhere and keep the eight shader runs per segment. --- src/libvgcode/src/SegmentTemplate.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libvgcode/src/SegmentTemplate.cpp b/src/libvgcode/src/SegmentTemplate.cpp index f925e0a860..3887f4128f 100644 --- a/src/libvgcode/src/SegmentTemplate.cpp +++ b/src/libvgcode/src/SegmentTemplate.cpp @@ -21,7 +21,9 @@ namespace libvgcode { // the triangles that share it: the shader runs 8 times per segment instead of 24. static constexpr const std::array VERTEX_DATA = { 0, 1, 2, 3, 4, 5, 6, 7 }; -static constexpr const std::array INDEX_DATA = { +// 16-bit indices: GCN 1/2 GPUs have no 8-bit index support, and some of their drivers +// emulate it wrongly in instanced draws. +static constexpr const std::array INDEX_DATA = { 0, 1, 2, // front spike 0, 2, 3, // front spike 0, 3, 4, // right/bottom body @@ -37,7 +39,7 @@ void SegmentTemplate::init() if (m_vao_id != 0) return; - m_size_in_bytes_gpu += (VERTEX_DATA.size() + INDEX_DATA.size()) * sizeof(uint8_t); + m_size_in_bytes_gpu += VERTEX_DATA.size() * sizeof(uint8_t) + INDEX_DATA.size() * sizeof(uint16_t); int curr_vertex_array; glsafe(glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &curr_vertex_array)); @@ -61,7 +63,7 @@ void SegmentTemplate::init() // and restored together with the vao. glsafe(glGenBuffers(1, &m_ibo_id)); glsafe(glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ibo_id)); - glsafe(glBufferData(GL_ELEMENT_ARRAY_BUFFER, INDEX_DATA.size() * sizeof(uint8_t), INDEX_DATA.data(), GL_STATIC_DRAW)); + glsafe(glBufferData(GL_ELEMENT_ARRAY_BUFFER, INDEX_DATA.size() * sizeof(uint16_t), INDEX_DATA.data(), GL_STATIC_DRAW)); glsafe(glBindBuffer(GL_ARRAY_BUFFER, curr_array_buffer)); glsafe(glBindVertexArray(curr_vertex_array)); @@ -94,7 +96,7 @@ void SegmentTemplate::render(size_t count) glsafe(glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &curr_vertex_array)); glsafe(glBindVertexArray(m_vao_id)); - glsafe(glDrawElementsInstanced(GL_TRIANGLES, static_cast(INDEX_DATA.size()), GL_UNSIGNED_BYTE, + glsafe(glDrawElementsInstanced(GL_TRIANGLES, static_cast(INDEX_DATA.size()), GL_UNSIGNED_SHORT, nullptr, static_cast(count))); glsafe(glBindVertexArray(curr_vertex_array)); } From 626350269737627e341b56dfbe2ad86971c9dc59 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 13 Oct 2026 00:15:07 +0800 Subject: [PATCH 28/45] Free a sharing object's own layers only when it takes over the shared ones --- src/libslic3r/Print.cpp | 14 +++-- src/libslic3r/Support/TreeSupport.cpp | 9 ++-- tests/fff_print/test_print.cpp | 76 +++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index b636345d60..e15d9b18cd 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -2971,11 +2971,6 @@ void Print::auto_assign_extruders(ModelObject* model_object) const void PrintObject::set_shared_object(PrintObject *object) { - // Orca: from now on m_layers / m_support_layers only alias the shared object's layers, so release the - // ones this object still owns (it may have sliced itself before it became shareable again). - // Both are no-ops once m_shared_object is set, so this cannot free layers owned by another object. - clear_support_layers(); - clear_layers(); m_shared_object = object; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": this=%1%, found shared object from %2%")%this%m_shared_object; } @@ -2996,6 +2991,15 @@ void PrintObject::clear_shared_object() void PrintObject::copy_layers_from_shared_object() { if (m_shared_object) { + // Orca: Print::process() calls this once per slice for each sharing object, after clear_shared_object() has + // emptied any earlier alias, so the vectors hold only layers this object sliced itself before it started sharing + // (or nothing). Free them here, as clear_support_layers() / clear_layers() would; those skip deletion while + // m_shared_object is set. The belt-only data they also free is always empty here, because belt printers never + // share layers. + for (SupportLayer *layer : m_support_layers) + delete layer; + for (Layer *layer : m_layers) + delete layer; m_layers.clear(); m_support_layers.clear(); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 41668e012c..5187e73a25 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -1976,8 +1976,8 @@ coordf_t TreeSupport::get_radius(const SupportNode* node) return node->radius; } -// Orca: these are hit up to several times per node per layer in drop_nodes(), so hand out a -// reference into the TreeSupportData cache instead of copying the ExPolygons out of it. +// Orca: drop_nodes() calls these several times per node per layer, so they return a reference into the +// TreeSupportData cache. The cache only ever inserts, so its entries never move while nodes drop in parallel. const ExPolygons& TreeSupport::get_avoidance(coordf_t radius, size_t obj_layer_nr) { return m_ts_data->get_avoidance(radius, obj_layer_nr); @@ -2805,9 +2805,10 @@ void TreeSupport::draw_circles() #endif // SUPPORT_TREE_DEBUG_TO_SVG SupportLayerPtrs& ts_layers = m_object->support_layers(); - // Orca: the vector owns its layers, so the dropped ones have to be deleted, not just unlinked. + // Orca: the vector owns its layers, so the dropped ones are deleted before they are erased. // std::stable_partition (unlike std::remove_if) leaves exactly the dropped layers in the tail. - auto iter = std::stable_partition(ts_layers.begin(), ts_layers.end(), [](SupportLayer* ts_layer) { return ts_layer->height >= EPSILON; }); + // Only heights below EPSILON are dropped; written as !(height < EPSILON), a NaN height is kept. + auto iter = std::stable_partition(ts_layers.begin(), ts_layers.end(), [](SupportLayer* ts_layer) { return !(ts_layer->height < EPSILON); }); for (auto it = iter; it != ts_layers.end(); ++it) delete *it; ts_layers.erase(iter, ts_layers.end()); diff --git a/tests/fff_print/test_print.cpp b/tests/fff_print/test_print.cpp index 58f8d12e39..4b76de4b55 100644 --- a/tests/fff_print/test_print.cpp +++ b/tests/fff_print/test_print.cpp @@ -43,6 +43,7 @@ #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/Exception.hpp" #include "libslic3r/LifecycleEvents.hpp" +#include "libslic3r/Utils.hpp" #include "test_helpers.hpp" #include "test_utils.hpp" @@ -156,6 +157,81 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i } } +// Two copies of one object share a single slice. When a copy that has sliced itself becomes identical to an +// earlier object, it keeps its own layers while that object slices and only then takes the shared layers over. +TEST_CASE("An object that starts sharing another object's slices keeps its own layers until it takes them over", "[Print][Regression]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "raft_layers", 1 }, // each object gets support layers of its own + { "sparse_infill_density", "20%" }, // object_a's override below differs from this + }); + // Activates the slicing-pipeline hook installed for the second slice below. + config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"probe"})); + + Print print; + Model model; + init_print({cube(20)}, print, model, config); + ModelObject *object_a = model.objects.front(); + // add_object() gives the copy extruder 1, and objects only share slices when their configs match. + object_a->config.set_key_value("extruder", new ConfigOptionInt(1)); + ModelObject *object_b = model.add_object(*object_a); // shares object_a's mesh + for (ModelInstance *instance : object_b->instances) + instance->set_offset(instance->get_offset() + Vec3d(40., 0., 0.)); + // A per-object override makes the objects differ, so each slices itself. + object_a->config.set_key_value("sparse_infill_density", new ConfigOptionPercent(40)); + + print.apply(model, config); + print.process(); + REQUIRE(print.objects().size() == 2); + // The non-const accessors hand out the layer vectors themselves, which the checks below compare. + PrintObject *print_b = print.get_object(1); + REQUIRE(print_b->model_object()->id() == object_b->id()); // Print slices its own copy of the model + REQUIRE(print_b->get_shared_object() == nullptr); + const LayerPtrs own_layers = print_b->layers(); + const SupportLayerPtrs own_support_layers = print_b->support_layers(); + REQUIRE(!own_layers.empty()); + REQUIRE(!own_support_layers.empty()); + + // Without the override the objects are identical again: print_a redoes the steps the setting invalidated, + // and print_b shares it. + object_a->config.erase("sparse_infill_density"); + print.apply(model, config); + REQUIRE(print.objects().size() == 2); + PrintObject *print_a = print.get_object(0); + + // Erasing the override invalidates print_a's posPrepareInfill and the steps after it. Its hook is the first one + // print_a fires, and it runs after print_b starts sharing but before print_b takes the shared layers over. + bool hook_fired = false; + bool shared_while_a_runs = false; + bool kept_layers = false; + bool kept_support_layers = false; + { + Print::set_slicing_pipeline_hook_fn([&](Print &, const PrintObject *object, SlicingPipelineStepPlugin) { + if (object != print_a || hook_fired) + return; + hook_fired = true; + shared_while_a_runs = print_b->get_shared_object() == print_a; + kept_layers = print_b->layers() == own_layers; + kept_support_layers = print_b->support_layers() == own_support_layers; + }); + const ScopeGuard reset_hook([] { Print::set_slicing_pipeline_hook_fn(nullptr); }); + print.process(); + } + + REQUIRE(hook_fired); + CHECK(print.objects()[1] == print_b); + CHECK(shared_while_a_runs); + CHECK(kept_layers); + CHECK(kept_support_layers); + CHECK(print_b->get_shared_object() == print_a); + REQUIRE(!print_a->layers().empty()); + CHECK(print_b->layers() == print_a->layers()); + CHECK(print_b->support_layers() == print_a->support_layers()); +} + // --------------------------------------------------------------------------- // Print::validate() warning collection // From c63bd5d474e3d23ab30f7bcc557dbb672109d604 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 13 Oct 2026 00:18:51 +0800 Subject: [PATCH 29/45] Switch to the Fast view while UV islands are selected in the texture gizmo The island highlight was hidden under the Normal view's relief. Normal comes back once no island is selected, unless another view was picked in the meantime. --- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 86 ++++++++++++---- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 28 ++++++ src/slic3r/GUI/UVEditorCanvas.cpp | 7 +- src/slic3r/GUI/UVEditorCanvas.hpp | 4 +- tests/slic3rutils/CMakeLists.txt | 1 + .../slic3rutils/test_texture_island_view.cpp | 99 +++++++++++++++++++ 6 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 tests/slic3rutils/test_texture_island_view.cpp diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 048b005c69..768f16f567 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -562,6 +562,11 @@ void GLGizmoTextureDisplacement::on_shutdown() m_shaded_island_delta = Eigen::Matrix::Identity(); m_island_drag_active = false; m_island_move_set.clear(); + // The pane closes below, taking the highlight with it, and the view must not open the next session in a + // Fast the user never picked. Set directly: nothing is to be queued against a gizmo that is closing. + if (m_island_view.switched) + m_use_shaded_preview = false; + m_island_view = IslandViewFollow{}; m_adjust_texture_mode = false; m_seam_edit_mode = false; m_adjust_drag_handle = AdjustHandle::None; @@ -727,18 +732,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() // The UV editor's island selection, shown on the model. Polled here rather than pushed: the pane // changes its selection in its own mouse handling, and a compare of a few ints per frame is free. - { - const TextureDisplacementLayer *al = active_layer(); - const UVEditorCanvas *uv_canvas = wxGetApp().plater()->get_uv_editor_canvas(); - if (m_show_uv_editor && al != nullptr && al->projection_method == TextureProjectionMethod::LSCM && - uv_canvas != nullptr && !m_uv_editor_unwrap.empty()) { - if (uv_canvas->selected_islands() != m_island_overlay_selection) - rebuild_island_overlay(uv_canvas->selected_islands()); - render_island_overlay(); - } else if (m_island_overlay_glmodel.is_initialized()) { - m_island_overlay_glmodel.reset(); - m_island_overlay_selection.clear(); - } + if (const std::vector *islands = island_highlight_selection()) { + if (*islands != m_island_overlay_selection) + rebuild_island_overlay(*islands); + render_island_overlay(); + } else if (m_island_overlay_glmodel.is_initialized()) { + m_island_overlay_glmodel.reset(); + m_island_overlay_selection.clear(); } // Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera @@ -1926,6 +1926,16 @@ void GLGizmoTextureDisplacement::render_paint_overlay(GLModel &overlay) shader->stop_using(); } +const std::vector *GLGizmoTextureDisplacement::island_highlight_selection() +{ + const TextureDisplacementLayer *al = active_layer(); + const UVEditorCanvas *uv_canvas = wxGetApp().plater()->get_uv_editor_canvas(); + if (m_show_uv_editor && al != nullptr && al->projection_method == TextureProjectionMethod::LSCM && + uv_canvas != nullptr && !m_uv_editor_unwrap.empty()) + return &uv_canvas->selected_islands(); + return nullptr; +} + void GLGizmoTextureDisplacement::rebuild_island_overlay(const std::vector &selection) { m_island_overlay_glmodel.reset(); @@ -2651,6 +2661,37 @@ void GLGizmoTextureDisplacement::apply_view_mode(int mode) m_parent.set_as_dirty(); } +int GLGizmoTextureDisplacement::view_mode() const +{ + return m_use_shaded_preview ? 1 : + m_uv_check_mode == UVCheckMode::Checker ? 2 : + m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0; +} + +int GLGizmoTextureDisplacement::IslandViewFollow::step(bool shown_now, int view, bool debug_open) +{ + shown = shown_now; + if (!shown) { + // The view is back to Normal already if a debug stage took it, and Normal applied over a stage would + // queue a preview job that replaces the stage being inspected. + const bool restore = switched && view == 1; + switched = keep_normal = false; + return restore ? 0 : -1; + } + if (view != 0 || keep_normal || debug_open) + return -1; + switched = true; + return 1; +} + +void GLGizmoTextureDisplacement::follow_island_highlight() +{ + const std::vector *islands = island_highlight_selection(); + const int view = m_island_view.step(islands != nullptr && !islands->empty(), view_mode(), m_debug_stage >= 0); + if (view >= 0) + apply_view_mode(view); +} + void GLGizmoTextureDisplacement::push_uv_pane_state() { UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas(); @@ -2891,7 +2932,8 @@ void GLGizmoTextureDisplacement::run_uv_command(int cmd, float value) rebuild_preview(); } } - // FrameAll/ToggleSnap are handled inside the canvas; ProjectFromView is not wired yet. + // FrameAll/ToggleSnap are handled inside the canvas; ProjectFromView is not wired yet. SelectionChanged + // only asks for this frame: follow_island_highlight() and the overlay read the selection back themselves. m_parent.set_as_dirty(); } @@ -5835,6 +5877,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float GizmoImguiBegin(get_name(), flags); ensure_panel_icons(); process_uv_commands(); // clicks from the UV editor pane, run here where the GL context is current + // After the pane's commands, which can close it or change the view, and before the View row, so the row + // lights the view this frame ends up in and a click on it has the last word. + follow_island_highlight(); const float previous_body_h = m_panel_body_h; const float previous_footer_h = m_panel_footer_h; @@ -6323,10 +6368,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // ---- View: Normal / Fast / Checker / Distortion as one group, Wireframe on its own ---- // The underlying state stays m_use_shaded_preview + m_uv_check_mode. { - const int cur_mode = m_use_shaded_preview ? 1 : - m_uv_check_mode == UVCheckMode::Checker ? 2 : - m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0; + const int cur_mode = view_mode(); int new_mode = cur_mode; + int picked = -1; // the button clicked, the active one included bool wf_toggle = false; bool open_uv_editor = false; // Checker and Distortion both draw *the unwrap* - the first the texture grid laid over it, the second @@ -6349,19 +6393,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + m_imgui->scaled(2.6f))); if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, icon_md, _L("Normal"), _L("Normal - the real geometry, exactly what Bake will produce. Slower to update"))) - new_mode = 0; + new_mode = picked = 0; ImGui::SameLine(0.f, gap_s); if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, icon_md, _L("Fast"), _L("Fast - the relief is only shaded on, not built, and only for the layer you are " "editing. Updates instantly while you paint"))) - new_mode = 1; + new_mode = picked = 1; ImGui::SameLine(0.f, gap_s); if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"), _L("Checker - a test grid instead of the texture. Where the squares stay square the " "texture is undistorted; where they stretch, it will too. Opens the UV editor if " "it is closed"), uv_view_na)) { - new_mode = 2; + new_mode = picked = 2; open_uv_editor = true; } ImGui::SameLine(0.f, gap_s); @@ -6369,7 +6413,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float _L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if " "it is closed"), uv_view_na)) { - new_mode = 3; + new_mode = picked = 3; open_uv_editor = true; } vsep(icon_md); @@ -6393,6 +6437,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (new_mode == cur_mode) update_uv_editor(); // otherwise apply_view_mode() below does it } + if (picked >= 0) + m_island_view.picked(picked); if (new_mode != cur_mode) apply_view_mode(new_mode); if (wf_toggle) { diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 631254313c..5b754edfe9 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -197,6 +197,27 @@ public: }; static PaintedColors painted_colors(const TriangleMesh &mesh, const TriangleSelector::TriangleSplittingData &paint); + // The view while the UV editor's selected islands are highlighted on the model. The highlight lies on the + // undisplaced surface, so the Normal view's relief hides it wherever it rises: Normal gives way to Fast + // while islands are selected and comes back once none are. A view the user clicks in the View row is + // theirs - the pane's background buttons are not a pick, Height only meaning "the texture view". + struct IslandViewFollow + { + bool shown = false; // the highlight, as of the last step() + bool switched = false; // Fast was switched on for the highlight, so Normal comes back once it is gone + bool keep_normal = false; // the user picked Normal in the View row while the highlight was shown + // The view to apply this frame (0 Normal, 1 Fast), or -1 for none. `view` is the one on screen, numbered as + // apply_view_mode() takes it; `debug_open` a debug stage on screen, which holds Normal itself. + int step(bool shown_now, int view, bool debug_open); + // A View row button clicked, the active one included. The row is drawn after step() in the same frame, + // so `shown` is this frame's. + void picked(int view) + { + switched = false; + keep_normal = shown && view == 0; + } + }; + void render_painter_gizmo() override; // Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/ @@ -513,6 +534,11 @@ private: // The panel's view modes: 0 Normal, 1 Fast, 2 Checker, 3 Distortion. Shared by the View row and the pane's // background buttons, so both switch views the same way. void apply_view_mode(int mode); + // The view on screen, numbered as apply_view_mode() takes it. + int view_mode() const; + // Run once a frame, after the pane's commands: switches the view as m_island_view says. + void follow_island_highlight(); + IslandViewFollow m_island_view; // The pane header's thumbnail of the active layer's texture, rebuilt only when its image changes. static constexpr int UV_THUMB_PX = 64; const void *m_uv_thumb_source = nullptr; @@ -744,6 +770,8 @@ private: // in place. Rebuilt whenever the pane's selection differs from the one it was built for. GLModel m_island_overlay_glmodel; std::vector m_island_overlay_selection; + // The pane's selection while the overlay applies - the pane open over an unwrapped LSCM layer - else null. + const std::vector *island_highlight_selection(); void rebuild_island_overlay(const std::vector &selection); void render_island_overlay(); // Set on every paint event, cleared when the overlay is rebuilt in render_painter_gizmo(). Kept diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index 2432026689..a4a9901822 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -939,7 +939,8 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) m_active_edge = (hit.first >= 0 && is_edge_selected(hit)) ? hit : std::pair{ -1, -1 }; m_gesture = (m_active_edge.first >= 0) ? Gesture::MoveEdge : Gesture::Pan; } else { - const int hit = island_at(uv); + const std::vector selection_before = m_selection; + const int hit = island_at(uv); if (hit >= 0) { if (evt.ShiftDown()) { // Shift adds to the selection (and makes the clicked one the new primary). @@ -969,6 +970,10 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) m_selected_island = -1; } m_gesture = (m_selected_island >= 0) ? Gesture::MoveIsland : Gesture::Pan; + // The owner shows the selection on the model and would not hear of this click otherwise in time: a + // click that clears the selection starts no gesture, and one that sets it reports only on release. + if (m_selection != selection_before) + run_command(Command::SelectionChanged); } grab_mouse(); Refresh(); diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 2fc1055cb8..a01d058983 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -108,10 +108,12 @@ public: // the command callback - the canvas has the selection and the view, the gizmo has the layer. // `value` carries the new setting for the Set* commands (an angle, a flag as 0/1, or an index into // SelectMode / Background) and is 0 otherwise. + // SelectionChanged is sent by the canvas itself when a click changes the island selection. It carries + // nothing: the owner reads selected_islands() back, and only needs telling that it should look. enum class Command { FrameAll, ToggleSnap, AverageScale, CutSelectedIsland, ProjectFromView, JoinSelected, UnjoinSelected, Unwrap, SetSeamAngle, SetConnectIslands, SetSelectMode, SetMarkSeams, SetSeamPath, ClearSeams, ClearUVEdits, - SetBackground, PickTexture, PaneClosed + SetBackground, PickTexture, PaneClosed, SelectionChanged }; void run_command(Command cmd, float value = 0.f); using CommandFn = std::function; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index db56a510ac..2fe6512d9e 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_json_depth.cpp test_shortcuts.cpp test_texture_color_palette.cpp + test_texture_island_view.cpp test_file_url.cpp test_user_manager.cpp ../fff_print/test_helpers.cpp diff --git a/tests/slic3rutils/test_texture_island_view.cpp b/tests/slic3rutils/test_texture_island_view.cpp new file mode 100644 index 0000000000..9655c86115 --- /dev/null +++ b/tests/slic3rutils/test_texture_island_view.cpp @@ -0,0 +1,99 @@ +// How the texture displacement gizmo's view follows the UV editor's island highlight. The rules live in +// libslic3r_gui; this is the suite that links it. Same Windows include prologue as +// test_texture_color_palette.cpp (wx pulls in ; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it). +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include +#include + +#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp" + +using Follow = Slic3r::GUI::GLGizmoTextureDisplacement::IslandViewFollow; + +namespace { + +// The View row's indices, and step()'s "apply nothing". +constexpr int NORMAL = 0, FAST = 1, CHECKER = 2, DISTORTION = 3, NONE = -1; + +} // namespace + +TEST_CASE("a Normal view gives way to Fast while islands are selected and comes back once none are", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + CHECK(f.step(false, NORMAL, false) == NONE); + CHECK(f.step(true, NORMAL, false) == FAST); + CHECK(f.step(true, FAST, false) == NONE); + CHECK(f.step(false, FAST, false) == NORMAL); + CHECK(f.step(false, NORMAL, false) == NONE); +} + +TEST_CASE("a Fast, Checker or Distortion view is left alone by the island highlight", "[TextureIslandView][TextureDisplacement]") +{ + const int view = GENERATE(FAST, CHECKER, DISTORTION); + Follow f; + CHECK(f.step(true, view, false) == NONE); + CHECK(f.step(false, view, false) == NONE); +} + +TEST_CASE("arriving in Normal with islands already selected switches to Fast", "[TextureIslandView][TextureDisplacement]") +{ + // The pane's Height button over Checker lands in Normal with the islands still selected. + Follow f; + CHECK(f.step(true, CHECKER, false) == NONE); + CHECK(f.step(true, NORMAL, false) == FAST); + CHECK(f.step(false, FAST, false) == NORMAL); +} + +TEST_CASE("Normal picked while islands are selected stays until they are deselected", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + REQUIRE(f.step(true, NORMAL, false) == FAST); + f.picked(NORMAL); + CHECK(f.step(true, NORMAL, false) == NONE); + // A debug stage opening and closing over it changes nothing. + CHECK(f.step(true, NORMAL, true) == NONE); + CHECK(f.step(true, NORMAL, false) == NONE); + CHECK(f.step(false, NORMAL, false) == NONE); + CHECK(f.step(true, NORMAL, false) == FAST); +} + +TEST_CASE("Fast picked while it was switched on stays after deselecting", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + REQUIRE(f.step(true, NORMAL, false) == FAST); + f.picked(FAST); + CHECK(f.step(false, FAST, false) == NONE); +} + +TEST_CASE("Normal picked with nothing selected does not hold off the next switch", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + CHECK(f.step(false, NORMAL, false) == NONE); + f.picked(NORMAL); + CHECK(f.step(true, NORMAL, false) == FAST); +} + +TEST_CASE("a debug stage holds Normal while it is open, and the switch follows once it closes", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + REQUIRE(f.step(true, NORMAL, false) == FAST); + // The stage takes Normal itself, with the islands still selected. + CHECK(f.step(true, NORMAL, true) == NONE); + CHECK(f.step(true, NORMAL, false) == FAST); +} + +TEST_CASE("islands deselected while a debug stage is open apply no view over it", "[TextureIslandView][TextureDisplacement]") +{ + Follow f; + REQUIRE(f.step(true, NORMAL, false) == FAST); + CHECK(f.step(false, NORMAL, true) == NONE); + CHECK(f.step(false, NORMAL, false) == NONE); +} From 1494de66dec11be6c3a37f73cb78bf96c9e8009f Mon Sep 17 00:00:00 2001 From: Fernando Marino` Date: Mon, 12 Oct 2026 21:26:20 +0200 Subject: [PATCH 30/45] Bracket a toolchange's own gcode against FanMover splitting its moves (#15904) FanMover could split a move inside change_filament_gcode to place a delayed fan command. It computed the split point from the last position the G-code set, but on Bambu printers the firmware moves the head during the T command, so the inserted waypoint sent the head to a spot the template never visits (#15789). Both toolchange paths, set_extruder() and the Type1 append_tcr(), now wrap the template output in ;_FAN_MOVER_SKIP_START/END tags when fan speed-up or kick-start is on. FanMover keeps fan commands inside them where they are and drops the tags from the output. multifilament_config() also sizes the per-filament options that the wipe tower reads by index. --- src/libslic3r/GCode.cpp | 15 +++++- src/libslic3r/GCode/FanMover.cpp | 5 ++ tests/fff_print/test_helpers.cpp | 5 +- tests/fff_print/test_multifilament.cpp | 73 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index ec7ef83a3d..b79a386324 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -1033,6 +1033,15 @@ static std::vector get_path_of_change_filament(const Print& print) return buf; } + // FanMover consumes these markers (and drops them) to avoid splitting a G1 inside custom gcode. + // Only written when FanMover runs, the same gate as its construction in GCode::process_layers. + static std::string wrap_custom_gcode_for_fan_mover(const FullPrintConfig &config, const std::string &gcode) + { + if (config.fan_speedup_time.value == 0 && config.fan_kickstart.value <= 0) + return gcode; + return ";_FAN_MOVER_SKIP_START\n" + gcode + ";_FAN_MOVER_SKIP_END\n"; + } + std::string WipeTowerIntegration::append_tcr(GCode& gcodegen, const WipeTower::ToolChangeResult& tcr, int new_filament_id, double z) const { if (new_filament_id != -1 && new_filament_id != tcr.new_tool) @@ -1384,6 +1393,9 @@ static std::vector get_path_of_change_filament(const Print& print) toolchange_gcode_str = gcodegen.placeholder_parser_process("change_filament_gcode", change_filament_gcode, new_filament_id, &config); check_add_eol(toolchange_gcode_str); + // Type1 wipe tower path (every BBL printer): bracket the same way set_extruder() does, + // so FanMover doesn't split a G1 inside this custom gcode either. + toolchange_gcode_str = wrap_custom_gcode_for_fan_mover(gcodegen.config(), toolchange_gcode_str); //BBS { @@ -10895,7 +10907,8 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo toolchange_gcode_parsed = placeholder_parser_process("change_filament_gcode", change_filament_gcode, new_filament_id, &dyn_config); check_add_eol(toolchange_gcode_parsed); - gcode += toolchange_gcode_parsed; + // toolchange_gcode_parsed is still read below (get_last_z_from_gcode, custom_gcode_changes_tool). + gcode += wrap_custom_gcode_for_fan_mover(m_config, toolchange_gcode_parsed); //BBS { diff --git a/src/libslic3r/GCode/FanMover.cpp b/src/libslic3r/GCode/FanMover.cpp index d303cbbac6..d49ca8b114 100644 --- a/src/libslic3r/GCode/FanMover.cpp +++ b/src/libslic3r/GCode/FanMover.cpp @@ -445,6 +445,11 @@ void FanMover::_process_gcode_line(GCodeReader& reader, const GCodeReader::GCode std::string extrusion_string = line.raw().substr(6, line.raw().size() - 6); current_role = ExtrusionEntity::string_to_role(extrusion_string); } + if (line.raw().rfind(";_FAN_MOVER_SKIP_", 0) == 0) { + // Internal markers: read here, then dropped from the output. + m_is_custom_gcode = line.raw().rfind(";_FAN_MOVER_SKIP_START", 0) == 0; + time = -1; + } if (line.raw().size() > 16) { if (line.raw().rfind("; custom gcode", 0) != std::string::npos) { if (line.raw().rfind("; custom gcode end", 0) != std::string::npos) diff --git a/tests/fff_print/test_helpers.cpp b/tests/fff_print/test_helpers.cpp index c7302d4c4b..daa8132d5d 100644 --- a/tests/fff_print/test_helpers.cpp +++ b/tests/fff_print/test_helpers.cpp @@ -223,7 +223,10 @@ DynamicPrintConfig multifilament_config(unsigned int filaments, std::initializer // These are read by filament id during export but absent from filament_option_keys(), // so set_num_filaments leaves them size 1; size them too, else out-of-range access. const auto &defaults = FullPrintConfig::defaults(); - for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode" }) + for (const char *key : { "filament_type", "filament_vendor", "filament_start_gcode", "filament_change_length", "filament_change_length_nc", + "filament_ramming_travel_time", "filament_ramming_travel_time_nc", + "filament_ramming_volumetric_speed", "filament_ramming_volumetric_speed_nc", + "nozzle_temperature", "nozzle_temperature_initial_layer" }) static_cast(config.option(key, true))->resize(filaments, defaults.option(key)); // flush_volumes_matrix must be sized filaments*filaments or export rejects it. diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index e1f70571b8..54a98d2268 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -315,6 +315,79 @@ TEST_CASE("Per-object wall filament override is honored", "[MultiFilament]") CHECK(tools_for_role(gcode, "infill") == std::set{ 0 }); // infill not overridden: stays on F1 } +// FanMover's guard against splitting a G1 inside custom gcode keys off the ";_FAN_MOVER_SKIP_START/END" +// markers GCode::set_extruder() must emit around change_filament_gcode's output. Direct +// toolchanges and Type2's wipe-tower toolchange (append_tcr2() calls set_extruder()) both go +// through it; Type1's wipe-tower toolchange (WipeTowerIntegration::append_tcr(), append_tcr2's only +// sibling caller of change_filament_gcode) needs the same bracketing of its own. +TEST_CASE("Toolchange gcode is bracketed against FanMover splitting its moves", "[MultiFilament]") +{ + const std::string custom_gcode = "; fan full\nM106 P1 S255\nM400 S3\nG1 X77 F5000\nG1 X91 F3000\n"; + + // "direct": no prime tower, GCode::set_extruder() runs the toolchange. By-object sequencing + // with a per-object filament override is what makes one happen at all without a tower. + // "type1": a Type1 prime tower routes the toolchange through WipeTowerIntegration::append_tcr() + // instead (append_tcr2, Type2's equivalent, itself calls set_extruder() - so Type2 needs no + // separate case here). + const std::string path = GENERATE(as{}, "direct", "type1"); + CAPTURE(path); + + std::string gcode_str; + if (path == "direct") { + const std::vector> per_object_overrides = { + {}, { { "outer_wall_filament_id", "2" }, { "inner_wall_filament_id", "2" } } + }; + DynamicPrintConfig config = multifilament_config(2, { + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + { "print_sequence", "by object" }, + { "change_filament_gcode", custom_gcode }, + { "fan_speedup_time", 0.5 }, + { "fan_kickstart", 0.1 }, + }); + gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides); + } else { + const std::vector> per_object_overrides = { + { { "extruder", "1" } }, { { "extruder", "2" } } + }; + DynamicPrintConfig config = multifilament_config(2, { + { "change_filament_gcode", custom_gcode }, + { "fan_speedup_time", 0.5 }, + { "fan_kickstart", 0.1 }, + { "enable_prime_tower", true }, + { "wipe_tower_type", "type1" }, + { "prime_tower_width", 35 }, + { "wipe_tower_x", 120 }, + { "wipe_tower_y", 120 }, + { "printable_area", "0x0,200x0,200x200,0x200" }, + { "skirt_loops", 0 }, + { "brim_type", "no_brim" }, + }); + gcode_str = slice_with_object_overrides({ cube(20), cube(20) }, config, per_object_overrides); + } + + // The resolved-settings config footer echoes "change_filament_gcode = ..." with its value + // escaped, so drop it before counting or its own copy of "G1 X77 F5000" is miscounted. + const size_t footer_start = gcode_str.find("; CONFIG_BLOCK_START"); + REQUIRE(footer_start != std::string::npos); + gcode_str.erase(footer_start); + + auto count_occurrences = [](const std::string &haystack, const std::string &needle) { + size_t count = 0, pos = 0; + while ((pos = haystack.find(needle, pos)) != std::string::npos) { + ++count; + pos += needle.size(); + } + return count; + }; + const size_t total_waypoints = count_occurrences(gcode_str, "G1 X77 F5000"); + const size_t bracketed = count_occurrences(gcode_str, "M400 S3\nG1 X77 F5000"); + REQUIRE(total_waypoints > 0); + CHECK(bracketed == total_waypoints); + // FanMover drops its own markers; only the start-of-print toolchange, which it never processes, keeps them. + CHECK(count_occurrences(gcode_str, ";_FAN_MOVER_SKIP_START") <= 1); +} + // With wait_for_temp_on_wipe_tower the blocking M109 moves from right after the Tn command to // a stop point parked beside the wipe tower (heat-up drool falls next to the tower, not onto // its top): tagged with _WAIT_FOR_TEMP_ON_WIPE_TOWER, after the toolchange and before the From f127b758ed9e82b4a1ac8f5040a32ee255e9bb5a Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:17:38 +0200 Subject: [PATCH 31/45] CLI: Don't rotate objects when arranging unless --allow-rotations is passed (#15033) The CLI's arrange no longer rotates objects unless --allow-rotations is passed, which matches the GUI's default. STL, OBJ and STEP inputs are arranged even without --arrange, so a part that only fits the bed when rotated now exits with -50 unless --allow-rotations is given. The CLI prints that reason to stderr. --- src/OrcaSlicer.cpp | 15 ++++- src/libslic3r/PrintConfig.cpp | 2 +- tests/cli/CMakeLists.txt | 8 +++ tests/cli/test_cli_arrange_rotations.sh | 89 +++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100755 tests/cli/test_cli_arrange_rotations.sh diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 1b764fe468..c42ae676cc 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -1463,7 +1463,7 @@ int CLI::run(int argc, char **argv) //int arrange_option; int plate_to_slice = 0, filament_count = 0, duplicate_count = 0, real_duplicate_count = 0, current_extruder_count = 1, new_extruder_count = 1, current_printer_variant_count = 1, current_print_variant_count = 1, new_printer_variant_count = 1; bool first_file = true, is_bbl_3mf = false, need_arrange = true, has_thumbnails = false, up_config_to_date = false, normative_check = true, duplicate_single_object = false, use_first_fila_as_default = false, minimum_save = false, enable_timelapse = false; - bool allow_rotations = true, skip_modified_gcodes = false, avoid_extrusion_cali_region = false, skip_useless_pick = false, allow_newer_file = false, current_is_multi_extruder = false, new_is_multi_extruder = false, allow_mix_temp = false, enable_wrapping_detect = false; + bool allow_rotations = false, skip_modified_gcodes = false, avoid_extrusion_cali_region = false, skip_useless_pick = false, allow_newer_file = false, current_is_multi_extruder = false, new_is_multi_extruder = false, allow_mix_temp = false, enable_wrapping_detect = false; Semver file_version; std::map orients_requirement; std::vector project_presets; @@ -5142,6 +5142,7 @@ int CLI::run(int argc, char **argv) BOOST_LOG_TRIVIAL(info) << "finished model pre-process commands\n"; bool oriented_or_arranged = false; + bool arranged_without_rotations = false; //BBS: add orient and arrange logic here for (auto& model : m_models) { @@ -5409,6 +5410,7 @@ int CLI::run(int argc, char **argv) BOOST_LOG_TRIVIAL(info) << boost::format("start plate %1%'s arranging...") % (i + 1); arrangement::arrange(selected, unselected, beds, arrange_cfg); //arrangement::arrange(unprintable, {}, beds, arrange_cfg); + arranged_without_rotations |= !arrange_cfg.allow_rotations; BOOST_LOG_TRIVIAL(info) << boost::format("finished plate %1%'s arranging") % (i + 1); //step-4: postprocess the bed index and result @@ -5881,6 +5883,7 @@ int CLI::run(int argc, char **argv) BOOST_LOG_TRIVIAL(info) << boost::format("start %1% th arranging...")%arrange_count; arrangement::arrange(selected, unselected, beds, arrange_cfg); arrangement::arrange(unprintable, {}, beds, arrange_cfg); + arranged_without_rotations |= !arrange_cfg.allow_rotations; BOOST_LOG_TRIVIAL(info) << boost::format("finished %1% th arranging...")%arrange_count; //Step-4:postprocess by partplate list&&apply the result @@ -6425,7 +6428,15 @@ int CLI::run(int argc, char **argv) if (count == 0) { BOOST_LOG_TRIVIAL(error) << "plate "<< index+1<< ": Nothing to be sliced, Either the print is empty or no object is fully inside the print volume before apply." << std::endl; - record_exit_reson(outfile_dir, CLI_NO_SUITABLE_OBJECTS, index+1, cli_errors[CLI_NO_SUITABLE_OBJECTS], sliced_info); + std::string no_objects_reason = cli_errors[CLI_NO_SUITABLE_OBJECTS]; + if (arranged_without_rotations) { + //ORCA: result.json is only written on Linux, so also print the reason to stderr. + no_objects_reason = "No object is fully inside the print volume. Arrange ran without rotations." + " If an object only fits the bed when rotated, pass --allow-rotations." + " Otherwise the plate is empty or an object is larger than the printable area or height."; + boost::nowide::cerr << "plate " << index+1 << ": " << no_objects_reason << std::endl; + } + record_exit_reson(outfile_dir, CLI_NO_SUITABLE_OBJECTS, index+1, no_objects_reason, sliced_info); flush_and_exit(CLI_NO_SUITABLE_OBJECTS); } else if ((plate_to_slice != 0) || pre_check) { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 9e3958bc25..b081f14248 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -13085,7 +13085,7 @@ CLIMiscConfigDef::CLIMiscConfigDef() def = this->add("allow_rotations", coBool); def->label = L("Allow rotation when arranging"); def->tooltip = L("If enabled, Arrange will allow rotation when placing objects."); - def->set_default_value(new ConfigOptionBool(true)); + def->set_default_value(new ConfigOptionBool(false)); def = this->add("align_to_y_axis", coBool); def->label = L("Align to Y axis when arranging"); diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt index dddebee566..d48fe479cf 100644 --- a/tests/cli/CMakeLists.txt +++ b/tests/cli/CMakeLists.txt @@ -31,6 +31,14 @@ set_tests_properties(cli_project_missing_keys PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 900) +add_test(NAME cli_arrange_rotations + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_arrange_rotations.sh $ ${ORCA_CLI_TEST_PYTHON} + ${CMAKE_SOURCE_DIR}/resources/profiles/BBL) +set_tests_properties(cli_arrange_rotations PROPERTIES + LABELS "CLI;RequiresApp" + SKIP_RETURN_CODE 77 + TIMEOUT 900) + add_test(NAME cli_malformed_input COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_malformed_input.sh $ ${ORCA_CLI_TEST_PYTHON}) set_tests_properties(cli_malformed_input PROPERTIES diff --git a/tests/cli/test_cli_arrange_rotations.sh b/tests/cli/test_cli_arrange_rotations.sh new file mode 100755 index 0000000000..e0f9d33adc --- /dev/null +++ b/tests/cli/test_cli_arrange_rotations.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# End-to-end check of the CLI's arrange rotation policy. +# +# Arrange is translate-only unless --allow-rotations is passed, so a bar that fits the bed only +# diagonally is not placed and the plate ends up with nothing fully inside the print volume. The +# run must then say that rotations were off, on stderr as well as in result.json, because +# result.json is written on Linux only. Passing the flag must place the same bar and slice it, and +# an object that fits axis-aligned must be unaffected by the default. +# +# usage: test_cli_arrange_rotations.sh +set -u + +BIN="${1:-}" +PY="${2:-python3}" +PROFILES="${3:-}" +# 77 is the test's SKIP_RETURN_CODE. +[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; } +[ -d "$PROFILES" ] || { echo "FAIL: profiles directory not found: $PROFILES"; exit 1; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-arrange-rotations.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +# box : an ASCII STL box, so the test needs no fixture. +box() { + "$PY" - "$@" <<'EOF' +import sys + +out, sx, sy, sz = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]) +v = [(x, y, z) for z in (0, sz) for y in (0, sy) for x in (0, sx)] +with open(out, "w") as f: + f.write("solid box\n") + for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)): + for tri in ((v[a], v[b], v[c]), (v[a], v[c], v[d])): + f.write("facet normal 0 0 0\nouter loop\n") + for p in tri: + f.write("vertex %g %g %g\n" % p) + f.write("endloop\nendfacet\n") + f.write("endsolid box\n") +EOF +} + +# Longer than the 256 mm bed edge, shorter than its 362 mm diagonal: it fits only turned. +box "$WORK/bar.stl" 300 10 5 +# Fits axis-aligned, so the default must not change anything for it. +box "$WORK/cube.stl" 10 10 10 + +# slice [option...] -> $WORK//{rc,stdout,stderr,result.json} +slice() { + local out="$WORK/$1" input="$2"; shift 2 + mkdir -p "$out" + timeout 300 "$BIN" --datadir "$out/datadir" \ + --load-settings "$PROFILES/machine/Bambu Lab X1 Carbon 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \ + --load-defaultfila "$@" --slice 0 --outputdir "$out" "$input" \ + > "$out/stdout" 2> "$out/stderr" + echo "$?" > "$out/rc" +} + +# exit_code : the CLI's negative code as the shell saw it, e.g. -50 arrives as 206. +exit_code() { "$PY" -c "import sys; rc = int(open(sys.argv[1]).read()); print(rc - 256 if rc > 127 else rc)" "$WORK/$1/rc"; } + +fail() { echo "FAIL: $*"; exit 1; } + +# 1. Default: no flag, so no rotations, so the bar does not fit and the reason names rotation. +slice default "$WORK/bar.stl" +[ "$(exit_code default)" = "-50" ] || fail "default: expected -50, got $(exit_code default)" +grep -q "Arrange ran without rotations" "$WORK/default/stderr" \ + || fail "default: stderr does not mention rotations: $(cat "$WORK/default/stderr")" +"$PY" - "$WORK/default/result.json" <<'EOF' || exit 1 +import json, sys + +result = json.load(open(sys.argv[1])) +if result["return_code"] != -50: + sys.exit("FAIL: result.json return_code %s, expected -50" % result["return_code"]) +if "Arrange ran without rotations" not in result["error_string"]: + sys.exit("FAIL: result.json error_string does not mention rotations: %s" % result["error_string"]) +EOF + +# 2. The flag is coBool, so it takes no value: it must place the same bar and slice it. +slice rotations "$WORK/bar.stl" --allow-rotations +[ "$(exit_code rotations)" = "0" ] || fail "--allow-rotations: expected 0, got $(exit_code rotations); $(tail -n 5 "$WORK/rotations/stdout")" +[ -s "$WORK/rotations/plate_1.gcode" ] || fail "--allow-rotations: no g-code written" + +# 3. An object that fits axis-aligned is unaffected by the default, and says nothing about rotation. +slice cube "$WORK/cube.stl" +[ "$(exit_code cube)" = "0" ] || fail "cube: expected 0, got $(exit_code cube); $(tail -n 5 "$WORK/cube/stdout")" +[ -s "$WORK/cube/plate_1.gcode" ] || fail "cube: no g-code written" +grep -q "Arrange ran without rotations" "$WORK/cube/stderr" && fail "cube: unexpected rotation message on a successful run" + +echo "PASS: arrange rotation default, its failure message, and the opt-in flag" From 58a71331d0b89f6a664ee695d6614c38a076b62e Mon Sep 17 00:00:00 2001 From: Downtime Date: Mon, 12 Oct 2026 15:32:59 -0500 Subject: [PATCH 32/45] fix(gui): balance ImGui style stacks in gizmo windows (#16269) Pop the FramePadding style var before end() in the move and rotate gizmo windows. It was pushed inside the window and popped after End(), which failed ImGui's style stack check in Debug builds. --- src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp index bb76a171b8..be3e961e09 100644 --- a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp +++ b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp @@ -925,8 +925,9 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper, m_last_active_item = current_active_id; last_move_input_window_width = ImGui::GetWindowWidth(); + ImGui::PopStyleVar(); imgui_wrapper->end(); - ImGui::PopStyleVar(2); + ImGui::PopStyleVar(); ImGuiWrapper::pop_toolbar_style(); } @@ -1131,10 +1132,11 @@ void GizmoObjectManipulation::do_render_rotate_window(ImGuiWrapper *imgui_wrappe m_last_active_item = current_active_id; last_rotate_input_window_width = ImGui::GetWindowWidth(); + ImGui::PopStyleVar(); imgui_wrapper->end(); // BBS - ImGui::PopStyleVar(2); + ImGui::PopStyleVar(); ImGuiWrapper::pop_toolbar_style(); } From f0e9cdc470f3eba904a9a6cc2109e3793522b103 Mon Sep 17 00:00:00 2001 From: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:24:25 -0300 Subject: [PATCH 33/45] raft expansion (#16416) Update ConfigManipulation.cpp Co-authored-by: Accelerator <22743445+acceleratorlastorder@users.noreply.github.com> Co-authored-by: Ian Bassi <12130714+ianalexis@users.noreply.github.com> --- src/slic3r/GUI/ConfigManipulation.cpp | 4 ++++ src/slic3r/GUI/Tab.cpp | 1 + 2 files changed, 5 insertions(+) diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index da5acf8272..cd7e44b5d6 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -1105,6 +1105,10 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in toggle_field("support_filament", have_support_material || have_skirt); toggle_line("raft_contact_distance", have_raft && !have_support_soluble); + // Orca:Hide raft expansion when raft is not enabled + toggle_line("raft_expansion", have_raft); + // Orca: Slim, Strong and Hybrid tree supports size their raft by first layer expansion only. + toggle_field("raft_expansion", !(is_tree(support_type) && (support_style == smsTreeSlim || support_style == smsTreeStrong || support_style == smsTreeHybrid))); // Orca: First-layer density is available for supports broadly. toggle_field("raft_first_layer_density", have_support_material); diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 456bfb82fd..772d3efb5d 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -3098,6 +3098,7 @@ void TabPrint::build() optgroup = page->new_optgroup(L("Raft"), L"param_raft"); optgroup->append_single_option_line("raft_layers", "support_settings_raft"); optgroup->append_single_option_line("raft_contact_distance", "support_settings_raft"); + optgroup->append_single_option_line("raft_expansion", "support_settings_raft"); optgroup = page->new_optgroup(L("Filament for Supports"), L"param_support_filament"); optgroup->append_single_option_line("support_filament", "support_settings_filament#base"); From 3dd547c8be444585fdd3e954f097825ccf01f78b Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:23:55 +0200 Subject: [PATCH 34/45] CLI: Fix the -50 error reason assuming a 3mf upload (#16339) The reason recorded for CLI_NO_SUITABLE_OBJECTS (-50) assumed a 3mf being uploaded. The CLI also slices STL, OBJ and STEP input, so the reason now states what both -50 sites check: the plate is empty, or no object on it is both printable and fully inside the print volume. The exit code is unchanged. Adds tests/cli/test_cli_no_suitable_objects.sh. --- src/OrcaSlicer.cpp | 2 +- tests/cli/CMakeLists.txt | 8 +++ tests/cli/test_cli_no_suitable_objects.sh | 83 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100755 tests/cli/test_cli_no_suitable_objects.sh diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index c42ae676cc..0d0aea0954 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -206,7 +206,7 @@ std::map cli_errors = { {CLI_OBJECT_ORIENT_FAILED, "An error occurred when auto-orienting object(s)."}, {CLI_MODIFIED_PARAMS_TO_PRINTER, "Found modified parameter in printer preset in the 3mf file, which should not be changed."}, {CLI_FILE_VERSION_NOT_SUPPORTED, "Unsupported 3MF version. Please make sure the 3MF file was created with the official version of Bambu Studio, not a beta version."}, - {CLI_NO_SUITABLE_OBJECTS, "One of the plate is empty or has no object fully inside it. Please check that the 3mf contains no empty plate in Orca Slicer before uploading."}, + {CLI_NO_SUITABLE_OBJECTS, "The plate has nothing to slice: it is empty, or no object on it is both printable and fully inside the print volume."}, {CLI_VALIDATE_ERROR, "There are some incorrect slicing parameters in the 3mf. Please verify the slicing of all plates in Orca Slicer before uploading."}, {CLI_OBJECTS_PARTLY_INSIDE, "Some objects are located over the boundary of the heated bed."}, {CLI_EXPORT_CACHE_DIRECTORY_CREATE_FAILED, "Failed creating directory when exporting cache data."}, diff --git a/tests/cli/CMakeLists.txt b/tests/cli/CMakeLists.txt index d48fe479cf..3f2982c453 100644 --- a/tests/cli/CMakeLists.txt +++ b/tests/cli/CMakeLists.txt @@ -39,6 +39,14 @@ set_tests_properties(cli_arrange_rotations PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 900) +add_test(NAME cli_no_suitable_objects + COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_no_suitable_objects.sh $ ${ORCA_CLI_TEST_PYTHON} + ${CMAKE_SOURCE_DIR}/resources/profiles/BBL) +set_tests_properties(cli_no_suitable_objects PROPERTIES + LABELS "CLI;RequiresApp" + SKIP_RETURN_CODE 77 + TIMEOUT 900) + add_test(NAME cli_malformed_input COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_malformed_input.sh $ ${ORCA_CLI_TEST_PYTHON}) set_tests_properties(cli_malformed_input PROPERTIES diff --git a/tests/cli/test_cli_no_suitable_objects.sh b/tests/cli/test_cli_no_suitable_objects.sh new file mode 100755 index 0000000000..dad9dd4120 --- /dev/null +++ b/tests/cli/test_cli_no_suitable_objects.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# The reason recorded for CLI_NO_SUITABLE_OBJECTS (-50) has to fit the input the CLI was given. +# Any input format is arranged and sliced, so an object larger than the bed empties the plate just +# as a 3mf with an empty plate does, and the recorded reason must not send such a caller off to +# inspect a 3mf or an upload that is not part of their run. +# +# usage: test_cli_no_suitable_objects.sh +set -u + +BIN="${1:-}" +PY="${2:-python3}" +PROFILES="${3:-}" +# 77 is the test's SKIP_RETURN_CODE. +[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; } +[ -d "$PROFILES" ] || { echo "FAIL: profiles directory not found: $PROFILES"; exit 1; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-no-suitable-objects.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +# box : an ASCII STL box, so the test needs no fixture. +box() { + "$PY" - "$@" <<'EOF' +import sys + +out, sx, sy, sz = sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]) +v = [(x, y, z) for z in (0, sz) for y in (0, sy) for x in (0, sx)] +with open(out, "w") as f: + f.write("solid box\n") + for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)): + for tri in ((v[a], v[b], v[c]), (v[a], v[c], v[d])): + f.write("facet normal 0 0 0\nouter loop\n") + for p in tri: + f.write("vertex %g %g %g\n" % p) + f.write("endloop\nendfacet\n") + f.write("endsolid box\n") +EOF +} + +# Wider than the 256 mm bed and wider than its 362 mm diagonal, so no placement can fit it. +box "$WORK/slab.stl" 400 400 5 +# Fits, so the run must succeed: proves the harness itself is sound. +box "$WORK/cube.stl" 10 10 10 + +# slice [option...] -> $WORK//{rc,stdout,stderr,result.json} +slice() { + local out="$WORK/$1" input="$2"; shift 2 + mkdir -p "$out" + timeout 300 "$BIN" --datadir "$out/datadir" \ + --load-settings "$PROFILES/machine/Bambu Lab X1 Carbon 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \ + "$@" --slice 0 --outputdir "$out" "$input" \ + > "$out/stdout" 2> "$out/stderr" + echo "$?" > "$out/rc" +} + +# exit_code : the CLI's negative code as the shell saw it, e.g. -50 arrives as 206. +exit_code() { "$PY" -c "import sys; rc = int(open(sys.argv[1]).read()); print(rc - 256 if rc > 127 else rc)" "$WORK/$1/rc"; } + +fail() { echo "FAIL: $*"; exit 1; } + +# --allow-rotations keeps this on the generic reason: a translate-only arrange has its own, +# more specific one. The slab is wider than the 362 mm bed diagonal, so rotation cannot place it. +slice slab "$WORK/slab.stl" --allow-rotations +[ "$(exit_code slab)" = "-50" ] || fail "slab: expected -50, got $(exit_code slab); $(tail -n 5 "$WORK/slab/stdout")" +"$PY" - "$WORK/slab/result.json" <<'EOF' || exit 1 +import json, sys + +result = json.load(open(sys.argv[1])) +if result["return_code"] != -50: + sys.exit("FAIL: result.json return_code %s, expected -50" % result["return_code"]) +reason = result["error_string"] +if not reason.strip(): + sys.exit("FAIL: result.json carries no error_string") +# The run was handed an STL and asked for g-code: neither a 3mf nor an upload exists to check. +for word in ("3mf", "3MF", "upload"): + if word in reason: + sys.exit("FAIL: error_string mentions %r for an STL run: %s" % (word, reason)) +EOF + +slice cube "$WORK/cube.stl" +[ "$(exit_code cube)" = "0" ] || fail "cube: expected 0, got $(exit_code cube); $(tail -n 5 "$WORK/cube/stdout")" +[ -s "$WORK/cube/plate_1.gcode" ] || fail "cube: no g-code written" + +echo "PASS: the no-suitable-objects reason fits a non-3mf run" From a28c9c3a827bbae9a2fcfe0434bdfabaecdb91e4 Mon Sep 17 00:00:00 2001 From: Downtime Date: Mon, 12 Oct 2026 18:25:17 -0500 Subject: [PATCH 35/45] fix(gui): guard gizmo actions before deferred initialization (#16268) GLGizmosManager can be called while its gizmo list is empty, either at startup before the canvas has loaded the gizmos or after their init() failed. reset_all_states(), open_gizmo(), open_gizmo_by_shortcut() and activate_gizmo() now return early in that case. This fixes a Debug-build assert when a new project resets the gizmos at startup, and an out-of-bounds read in release builds when a gizmo shortcut key is pressed after gizmo init failed. activate_gizmo(Undefined) still returns true, since no gizmo is open. The reset_all_states() guard was first proposed in #16074 by kenrap. Co-authored-by: kenrap <101818165+kenrap@users.noreply.github.com> --- src/slic3r/GUI/Gizmos/GLGizmosManager.cpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index d840754ac2..41d4811664 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -420,7 +420,10 @@ void GLGizmosManager::refresh_on_off_state() void GLGizmosManager::reset_all_states() { - if (! m_enabled || m_serializing) + // The restore/new-project event can run before deferred OpenGL resource + // loading has populated the gizmo list. There is nothing to reset until + // initialization completes. + if (! m_enabled || m_serializing || m_gizmos.empty()) return; const EType current = get_current_type(); @@ -435,6 +438,9 @@ void GLGizmosManager::reset_all_states() bool GLGizmosManager::open_gizmo(EType type) { + if (m_gizmos.empty()) + return false; + int idx = static_cast(type); // re-open same type cause closing @@ -522,7 +528,7 @@ bool GLGizmosManager::is_running() const bool GLGizmosManager::open_gizmo_by_shortcut(Shortcut shortcut) { - if (!m_enabled) + if (!m_enabled || m_gizmos.empty()) return false; // The text tool opens without a selection because it creates its own object. @@ -1427,10 +1433,12 @@ void GLGizmosManager::update_hover_state(const EType &type) bool GLGizmosManager::activate_gizmo(EType type) { - assert(!m_gizmos.empty()); - // already activated - if (m_current == type) return true; + if (m_current == type) + return true; + + if (m_gizmos.empty()) + return false; if (m_current != Undefined) { // clean up previous gizmo From 6700d4c5df1cde1a58d7f347928b0acf999fa307 Mon Sep 17 00:00:00 2001 From: Alexandre Folle de Menezes Date: Mon, 12 Oct 2026 20:57:57 -0300 Subject: [PATCH 36/45] Verify and improve AI pt_BR translations (#16377) --- localization/i18n/pt_BR/OrcaSlicer_pt_BR.po | 1662 +------------------ 1 file changed, 85 insertions(+), 1577 deletions(-) diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index 57860cf67d..b4d3f0f85b 100644 --- a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po +++ b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po @@ -4,7 +4,7 @@ msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-10-11 16:21+0800\n" -"PO-Revision-Date: 2026-07-26 11:14-0300\n" +"PO-Revision-Date: 2026-10-11 16:22-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: Portuguese, Brazilian\n" "Language: pt_BR\n" @@ -251,7 +251,6 @@ msgstr "Padrão" msgid "TPU High Flow" msgstr "TPU Alto fluxo" -# AI Translated msgid "E3D High Flow" msgstr "E3D Alto Fluxo" @@ -519,7 +518,6 @@ msgstr "Pintar" msgid "Choose filament" msgstr "Escolher filamento" -# AI Translated msgid "Edge detection" msgstr "Detecção de arestas" @@ -1569,19 +1567,15 @@ msgstr "O traço da forma (%1%) é muito fino (largura mínima é %2% mm)." msgid "Stroke of shape (%1%) contains unsupported: %2%." msgstr "O traço da forma (%1%) contém não suportado: %2%." -# AI Translated msgid "Not valid state please report reproduction steps on github" msgstr "Estado inválido, informe os passos para reproduzir no GitHub" -# AI Translated msgid "No embossed file" msgstr "Nenhum arquivo em relevo" -# AI Translated msgid "Missing svg file in embossed shape" msgstr "Arquivo SVG ausente na forma em relevo" -# AI Translated msgid "Missing data of svg file" msgstr "Dados do arquivo SVG ausentes" @@ -1671,7 +1665,6 @@ msgstr "Espelhar horizontalmente" msgid "Change SVG Type" msgstr "Alterar tipo de SVG" -# AI Translated msgid "SVG shape" msgstr "Forma SVG" @@ -1704,14 +1697,12 @@ msgstr "Sem recurso" msgid "Vertex" msgstr "Vértice" -# AI Translated msgid "Edge" msgstr "Aresta" msgid "Plane" msgstr "Plano" -# AI Translated msgid "Point on edge" msgstr "Ponto na aresta" @@ -1721,7 +1712,6 @@ msgstr "Ponto no círculo" msgid "Point on plane" msgstr "Ponto no plano" -# AI Translated msgid "Center of edge" msgstr "Centro da aresta" @@ -1946,7 +1936,6 @@ msgstr "" "\n" "Abra apenas anexos de projetos em que você confia. Abrir mesmo assim?" -# AI Translated msgid "Open attachment" msgstr "Abrir anexo" @@ -3445,10 +3434,9 @@ msgstr "Verificar o estado dos serviços de sistema atuais" msgid "code" msgstr "código" -# AI Translated #. TRN Short for "description" of a connection error msgid "desc" -msgstr "desc." +msgstr "descr" #. TRN Short for "additional information" about a connection error msgid "info" @@ -4808,7 +4796,7 @@ msgid "" msgstr "" "A torre de purga não funciona quando a Altura de Camada Adaptativa ou a Altura de Camada de Suporte Independente está ativada.\n" "Qual você deseja manter?\n" -"SIM — Manter a Torre de Preparo\n" +"SIM — Manter a Torre de Purga\n" "NÃO — Manter a Altura de Camada Adaptativa e a Altura de Camada de Suporte Independente" msgid "" @@ -4819,7 +4807,7 @@ msgid "" msgstr "" "A torre de purga não funciona quando a Altura de Camada Adaptativa está ativa.\n" "Qual você deseja manter?\n" -"SIM — Manter a Torre de Preparo\n" +"SIM — Manter a Torre de Purga\n" "NÃO — Manter a Altura de Camada Adaptativa" msgid "" @@ -4830,7 +4818,7 @@ msgid "" msgstr "" "A torre de purga não funciona quando a Altura da Camada de Suporte Independente está ativa.\n" "Qual você deseja manter?\n" -"SIM — Manter a Torre de Preparo\n" +"SIM — Manter a Torre de Purga\n" "NÃO — Manter a Altura da Camada de Suporte Independente" msgid "" @@ -5777,7 +5765,7 @@ msgid "Printed up to layer %1%" msgstr "" msgid "Weight" -msgstr "" +msgstr "Peso" msgid "Volume" msgstr "Volume" @@ -5868,7 +5856,6 @@ msgctxt "Axis" msgid "Y" msgstr "Y" -# AI Translated msgctxt "Axis" msgid "Z" msgstr "Z" @@ -8159,7 +8146,7 @@ msgid "Tools" msgstr "Ferramentas" msgid "G-code" -msgstr "" +msgstr "G-code" msgid "How the mode is labeled in the plate's IDEX/IQEX mode selector. Required — a mode with no name cannot be selected." msgstr "" @@ -8461,7 +8448,7 @@ msgid "Enabling traditional timelapse photography may cause surface imperfection msgstr "A habilitação da fotografia tradicional em timelapse pode causar imperfeições na superfície. É recomendado mudar para o modo suave." msgid "Smooth mode for timelapse is enabled, but the prime tower is off, which may cause print defects. Please enable the prime tower, re-slice and print again." -msgstr "O modo suave para timelapse está ativado, mas a torre de purga está desativada, o que pode causar defeitos na impressão. Ative a torre de purga, fatie novamente e imprima de novo." +msgstr "O modo suave para timelapse está ativado, mas a torre de purga está desativada, o que pode causar defeitos na impressão. Ative a torre de purga, refatie e imprima de novo." msgid "Expand sidebar" msgstr "Expandir barra lateral" @@ -9073,7 +9060,7 @@ msgid "Unable to log in to the Flashforge printer." msgstr "Não é possível fazer login na impressora Flashforge." msgid "Is the printer ready? Is the print sheet in place, empty and clean?" -msgstr "A impressora está pronta? O folha de impressão está no lugar, vazia e limpa?" +msgstr "A impressora está pronta? A placa de impressão está no lugar, vazia e limpa?" msgid "Upload and Print" msgstr "Enviar e Imprimir" @@ -9600,7 +9587,7 @@ msgid "Auto slice after changes" msgstr "Fatiar automaticamente após mudanças" msgid "If enabled, OrcaSlicer will re-slice automatically whenever slicing-related settings change." -msgstr "Se ativado, o OrcaSlicer irá re-fatiar automaticamente sempre que as configurações relacionadas ao fatiamento forem alteradas." +msgstr "Se ativado, o OrcaSlicer irá refatiar automaticamente sempre que as configurações relacionadas ao fatiamento forem alteradas." msgid "Delay in seconds before auto slicing starts, allowing multiple edits to be grouped. Use 0 to slice immediately." msgstr "Atraso em segundos antes do início do fatiamento automático, permitindo que várias edições sejam agrupadas. Use 0 para fatiar imediatamente." @@ -10749,15 +10736,12 @@ msgstr "Descrição do erro" msgid "Extra info" msgstr "Informação extra" -# AI Translated msgid "The Filament Track Switch installed on the printer does not match the slicing file. Please re-slice to avoid print quality issues." -msgstr "O Filament Track Switch instalado na impressora não corresponde ao arquivo de fatiamento. Por favor, refaça o fatiamento para evitar problemas de qualidade de impressão." +msgstr "O Filament Track Switch instalado na impressora não corresponde ao arquivo de fatiamento. Por favor, refatie para evitar problemas de qualidade de impressão." -# AI Translated msgid "This print requires a Filament Track Switch. Please install it first." msgstr "Esta impressão requer um Filament Track Switch. Por favor, instale-o primeiro." -# AI Translated msgid "The Filament Track Switch has not been setup. Please setup it first." msgstr "O Filament Track Switch não foi configurado. Por favor, configure-o primeiro." @@ -10798,7 +10782,6 @@ msgstr "O filamento não corresponde ao filamento no espaço do AMS. Atualize o msgid "The selected printer (%s) is incompatible with the print file configuration (%s). Please adjust the printer preset in the prepare page or choose a compatible printer on this page." msgstr "A impressora selecionada (%s) é incompatível com a configuração do arquivo de impressão (%s). Ajuste a predefinição da impressora na página de preparo ou escolha uma impressora compatível nesta página." -# AI Translated #, c-format, boost-format msgid "The selected printer (%s) has an unknown model, so compatibility with the print file configuration (%s) cannot be verified. Please verify the printer preset before sending." msgstr "A impressora selecionada (%s) tem um modelo desconhecido, então a compatibilidade com a configuração do arquivo de impressão (%s) não pode ser verificada. Verifique a predefinição da impressora antes de enviar." @@ -10876,11 +10859,9 @@ msgstr "Limpar" msgid "Preparing print job" msgstr "Preparando trabalho de impressão" -# AI Translated msgid "Failed to upload the file to the printer's storage. Please try again." msgstr "Falha ao enviar o arquivo para o armazenamento da impressora. Tente novamente." -# AI Translated msgid "Send Failed" msgstr "Falha no envio" @@ -10891,11 +10872,9 @@ msgstr "O comprimento do nome excede o limite." msgid "Cost %dg filament and %d changes more than optimal grouping." msgstr "O custo de %dg de filamento e %d muda mais do que o agrupamento ideal." -# AI Translated msgid "Right-Ext" msgstr "Ext-Direita" -# AI Translated msgid "Left-Ext" msgstr "Ext-Esquerda" @@ -10916,7 +10895,7 @@ msgid "Please refresh the nozzle information and try again." msgstr "Por favor, atualize as informações do bico e tente novamente." msgid "Please re-slice to avoid filament waste." -msgstr "Por favor, fatie novamente para evitar desperdício de filamento." +msgstr "Por favor, refatie para evitar desperdício de filamento." msgid "The reported hotend information may be unreliable." msgstr "As informações reportadas sobre o hotend podem não ser confiáveis." @@ -11060,7 +11039,6 @@ msgstr "Não é possível enviar um trabalho de impressão para uma placa vazia. msgid "Storage needs to be inserted to record timelapse." msgstr "O armazenamento precisa estar inserido para gravar o timelapse." -# AI Translated msgid "The selected printer model could not be identified, so compatibility with the print file configuration cannot be verified. Please verify the printer preset before sending." msgstr "O modelo da impressora selecionada não pôde ser identificado, então a compatibilidade com a configuração do arquivo de impressão não pode ser verificada. Verifique a predefinição da impressora antes de enviar." @@ -11128,7 +11106,6 @@ msgstr "A impressora deve estar na mesma LAN do OrcaSlicer." msgid "The printer does not support sending to printer storage." msgstr "A impressora não suporta envio para o armazenamento da impressora." -# AI Translated msgid "Failed to initialize the printer file transfer. Please check the connection and try again." msgstr "Falha ao inicializar a transferência de arquivos da impressora. Verifique a conexão e tente novamente." @@ -11284,13 +11261,13 @@ msgid "A prime tower is required for clumping detection. There may be flaws on t msgstr "Uma torre de purga é necessária para a detecção de aglomeração. Podem ocorrer falhas no modelo sem a torre de purga. Tem certeza de que deseja desativar a torre de purga?" msgid "Enabling both precise Z height and the prime tower may cause slicing errors. Do you still want to enable?" -msgstr "Habilitar a altura Z precisa e a torre de preparação juntas pode causar erros de fatiamento. Deseja habilitar mesmo assim?" +msgstr "Habilitar a altura Z precisa e a torre de purga juntas pode causar erros de fatiamento. Deseja habilitar mesmo assim?" msgid "A prime tower is required for clumping detection. There may be flaws on the model without prime tower. Do you still want to enable clumping detection?" msgstr "Uma torre de purga é necessária para a detecção de aglomeração. Podem ocorrer falhas no modelo sem o Prime Tower. Tem certeza de que deseja ativar a detecção de aglomeração?" msgid "Enabling both precise Z height and the prime tower may cause slicing errors. Do you still want to enable precise Z height?" -msgstr "Habilitar a altura Z precisa e a torre de preparação juntas pode causar erros de fatiamento. Deseja habilitar a altura Z precisa mesmo assim?" +msgstr "Habilitar a altura Z precisa e a torre de purga juntas pode causar erros de fatiamento. Deseja habilitar a altura Z precisa mesmo assim?" msgid "Smooth timelapse needs a prime tower on every layer, which is not compatible with \"No sparse layers\". \"No sparse layers\" has been turned off." msgstr "O timelapse suave precisa de uma torre de purga em todas as camadas, o que não é compatível com \"Sem camadas esparsas\". \"Sem camadas esparsas\" foi desativado." @@ -11404,10 +11381,9 @@ msgstr "Informação adicional:" msgid "vendor" msgstr "fornecedor" -# AI Translated #. TRN Short for "version" msgid "ver" -msgstr "versão" +msgstr "ver" msgid "printer model" msgstr "modelo de impressora" @@ -12526,11 +12502,9 @@ msgstr "[Ação Necessária] " msgid "[Action Required]" msgstr "[Ação Necessária]" -# AI Translated msgid "Printer update service is unavailable." msgstr "O serviço de atualização de impressoras não está disponível." -# AI Translated msgid "Failed to check for new printers." msgstr "Falha ao verificar novas impressoras." @@ -12546,12 +12520,10 @@ msgstr "Mover Vista" msgid "Rotate View" msgstr "Rotacionar Vista" -# AI Translated #, c-format, boost-format msgid "Use %s or %s, or a key that does not type a character." msgstr "Use %s ou %s, ou uma tecla que não digite um caractere." -# AI Translated msgctxt "Keyboard Shortcut" msgid "Key" msgstr "Tecla" @@ -12559,15 +12531,12 @@ msgstr "Tecla" msgid "Left mouse" msgstr "Mouse esquerdo" -# AI Translated msgid "Available anywhere in the window, even while typing in a text field." msgstr "Disponível em qualquer lugar da janela, mesmo ao digitar em um campo de texto." -# AI Translated msgid "Run favorite 1 to 10" msgstr "Executar favorito 1 a 10" -# AI Translated msgid "Pin or unpin the selected action" msgstr "Fixar ou desafixar a ação selecionada" @@ -12575,11 +12544,9 @@ msgctxt "Keyboard Shortcut" msgid "Tab" msgstr "Tab" -# AI Translated msgid "Switch to the next main tab" msgstr "Mudar para a próxima aba principal" -# AI Translated msgid "Available while the 3D view on the Prepare tab has focus." msgstr "Disponível enquanto a vista 3D da aba Preparar estiver em foco." @@ -12610,11 +12577,9 @@ msgstr "Mouse direito" msgid "Zoom View" msgstr "Aproximar Vista" -# AI Translated msgid "Painting" msgstr "Pintura" -# AI Translated msgid "Available while a painting gizmo is open: supports, seam, fuzzy skin or color painting." msgstr "Disponível enquanto um gizmo de pintura estiver aberto: suportes, costura, textura difusa ou pintura de cores." @@ -12627,7 +12592,6 @@ msgstr "Suporte/Pintura em cores: ajustar o raio da caneta" msgid "Objects list" msgstr "Lista de objetos" -# AI Translated msgid "Available while the object list has focus." msgstr "Disponível enquanto a lista de objetos estiver em foco." @@ -12647,43 +12611,35 @@ msgstr "Clique do mouse" msgid "Select the object/part and mouse click to change the name" msgstr "Selecione o objeto/peça e clique do mouse para mudar o nome" -# AI Translated msgid "Available while the 3D view on the Preview tab has focus." msgstr "Disponível enquanto a vista 3D da aba Pré-visualizar estiver em foco." msgid "Move slider 5x faster" -msgstr "Mover o controle deslizante 5 vezes mais rápido" +msgstr "Mover o controle deslizante 5x mais rápido" -# AI Translated msgid "Scroll slider 5x faster" -msgstr "Rolar o controle deslizante 5 vezes mais rápido" +msgstr "Rolar o controle deslizante 5x mais rápido" msgid "Not customizable" msgstr "Não personalizável" -# AI Translated #, c-format, boost-format msgid "%s is assigned to %s. Reassign it to %s?" msgstr "%s está atribuído a %s. Reatribuí-lo a %s?" -# AI Translated #, c-format, boost-format msgid "The default %s is assigned to %s. Reassign it to %s?" msgstr "O %s padrão está atribuído a %s. Reatribuí-lo a %s?" -# AI Translated msgid "Global shortcuts also apply while typing." msgstr "Os atalhos globais também funcionam durante a digitação." -# AI Translated msgid "A key that types a character cannot be a global shortcut." msgstr "Uma tecla que digita um caractere não pode ser um atalho global." -# AI Translated msgid "Esc cancels, Enter confirms." msgstr "Esc cancela, Enter confirma." -# AI Translated #, c-format, boost-format msgid "" "Press the new shortcut for\n" @@ -12692,20 +12648,16 @@ msgstr "" "Pressione o novo atalho para\n" "\"%s\"" -# AI Translated msgid "Unbind" msgstr "Desvincular" -# AI Translated msgid "The system uses this shortcut, so it cannot be assigned." msgstr "O sistema usa este atalho, então ele não pode ser atribuído." -# AI Translated #, c-format, boost-format msgid "Shift and Ctrl with this key belong to %s and cannot be assigned." msgstr "Shift e Ctrl com esta tecla pertencem a %s e não podem ser atribuídos." -# AI Translated #, c-format, boost-format msgid "Already assigned to %s. Press OK to reassign it." msgstr "Já atribuído a %s. Pressione OK para reatribuí-lo." @@ -12785,9 +12737,8 @@ msgstr "Abrir arquivo de certificado CA" msgid "HTTPS CA file is optional. It is only needed if you use HTTPS with a self-signed certificate." msgstr "O arquivo CA HTTPS é opcional. É necessário apenas se você usar HTTPS com um certificado autoassinado." -# AI Translated msgid "This system uses HTTPS certificates from the system Certificate Store or Keychain. To use a custom CA file, import it there." -msgstr "Este sistema usa certificados HTTPS do repositório de certificados do sistema ou do Keychain. Para usar um arquivo CA personalizado, importe-o lá." +msgstr "Este sistema usa certificados HTTPS do Repositório de Certificados ou Chaveiro do sistema. Para usar um arquivo CA personalizado, importe-o lá." msgid "Printer model" msgstr "Modelo de impressora" @@ -12862,18 +12813,15 @@ msgstr "Laser 40W" msgid "Cutting Module" msgstr "Módulo de Corte" -# AI Translated msgid "Active Cutting Module" msgstr "Módulo de Corte Ativo" -# AI Translated msgid "Ultrasonic Cutting Module" msgstr "Módulo de Corte Ultrassônico" msgid "Auto Fire Extinguishing System" msgstr "Sistema Automático de Extinção de Incêndio" -# AI Translated msgid "Filament Track Switch" msgstr "Filament Track Switch" @@ -13018,32 +12966,26 @@ msgstr "Erro de agrupamento: %1% não pode ser colocado no bico direito" msgid "Group error in manual mode. Please check nozzle count or regroup." msgstr "Erro de agrupamento no modo manual. Por favor, verifique o número de bicos ou reagrupe." -# AI Translated #, boost-format msgid "failed to process some intersections (%1%)" msgstr "falha ao processar algumas interseções (%1%)" -# AI Translated #, boost-format msgid "multiple intersections with a perimeter, only one was used (%1%)" msgstr "múltiplas interseções com um perímetro, apenas uma foi usada (%1%)" -# AI Translated #, boost-format msgid "a perimeter is fully inside a modifier, the modifier was not applied to it (%1%)" -msgstr "um perímetro está totalmente dentro de um modificador; o modificador não foi aplicado a ele (%1%)" +msgstr "um perímetro está totalmente dentro de um modificador, o modificador não foi aplicado a ele (%1%)" -# AI Translated #, boost-format msgid "modifier \"%1%\" of \"%2%\" had no effect on the seam (it might not reach the centerline of the printed perimeter)" msgstr "o modificador \"%1%\" de \"%2%\" não teve efeito na costura (talvez não alcance a linha central do perímetro impresso)" -# AI Translated #, boost-format msgid "modifier \"%1%\" of \"%2%\" (%3% in total) had no effect on the seam (it might not reach the centerline of the printed perimeter)" msgstr "o modificador \"%1%\" de \"%2%\" (%3% no total) não teve efeito na costura (talvez não alcance a linha central do perímetro impresso)" -# AI Translated msgid "Seam placement may differ from expected." msgstr "A posição da costura pode diferir do esperado." @@ -13185,7 +13127,7 @@ msgid " is too close to clumping detection area, there may be collisions when pr msgstr " está muito perto da área de detecção de aglomeração, podendo haver colisões durante a impressão." msgid "Prime Tower" -msgstr "Torre de Preparo" +msgstr "Torre de Purga" msgid " is too close to others, and collisions may be caused.\n" msgstr " está muito perto de outros, e colisões podem ocorrer.\n" @@ -13267,7 +13209,7 @@ msgid "The project contains multiple managed belt purge towers. Reload the plate msgstr "" msgid "Enabling both precise Z height and the prime tower may cause slicing errors." -msgstr "Habilitar a altura Z precisa e a torre de preparação juntas pode causar erros de fatiamento." +msgstr "Habilitar a altura Z precisa e a torre de purga juntas pode causar erros de fatiamento." msgid "A prime tower is required for clumping detection; otherwise, there may be flaws on the model." msgstr "Uma torre de purga é necessária para a detecção de aglomeração, podem ocorrer falhas no modelo." @@ -14323,9 +14265,8 @@ msgstr "Combine várias bordas em uma só quando estiverem próximas umas das ou msgid "Brim ears" msgstr "Orelhas da borda" -# AI Translated msgid "Only draw brim over the sharp edges of the model." -msgstr "Desenhe a borda apenas sobre as arestas afiadas do modelo." +msgstr "Desenhe a borda apenas sobre as arestas vivas do modelo." msgid "Brim ear max angle" msgstr "Ângulo máximo da orelha da borda" @@ -14913,19 +14854,15 @@ msgstr "" "\n" "A taxa de fluxo final do objeto é este valor multiplicado pela taxa de fluxo do filamento." -# AI Translated msgid "Enable pressure advance" msgstr "Habilitar Pressure Advance" -# AI Translated msgid "Enable pressure advance, auto calibration result will be overwritten once enabled." msgstr "Habilitar Pressure Advance, o resultado da calibração automática será sobrescrito uma vez habilitado." -# AI Translated msgid "Pressure advance (Klipper) AKA Linear advance factor (Marlin)." -msgstr "Pressure Advance (Klipper), também conhecido como fator de Linear Advance (Marlin)." +msgstr "Pressure Advance (Klipper), também conhecido como Fator de Linear Advance (Marlin)." -# AI Translated msgid "Enable adaptive pressure advance (beta)" msgstr "Habilitar Pressure Advance adaptativo (beta)" @@ -14946,7 +14883,6 @@ msgstr "" "Quando habilitado, o valor do Pressure Advance acima é substituído. No entanto, um valor padrão razoável acima é fortemente recomendado para atuar como um fallback e para quando a ferramenta for trocada.\n" "\n" -# AI Translated msgid "Adaptive pressure advance measurements (beta)" msgstr "Medidas do Pressure Advance adaptativo (beta)" @@ -14975,7 +14911,6 @@ msgstr "" "2. Anote o valor de PA ideal para cada velocidade e aceleração de fluxo volumétrico. Você pode encontrar o número do fluxo selecionando fluxo no menu suspenso do esquema de cores e movendo o controle deslizante horizontal sobre as linhas do padrão de PA. O número deve estar visível na parte inferior da página. O valor de PA ideal deve diminuir quanto maior for o fluxo volumétrico. Se não estiver, confirme se sua extrusora está funcionando corretamente. Quanto mais lento e com menos aceleração você imprimir, maior será o intervalo de valores de PA aceitáveis. Se nenhuma diferença for visível, use o valor de PA do teste mais rápido\n" "3. Insira os tripletos de valores de PA, Fluxo e Acelerações na caixa de texto aqui e salve seu perfil de filamento." -# AI Translated msgid "Enable adaptive pressure advance within features (beta)" msgstr "Habilitar Pressure Advance adaptativo nos recursos (beta)" @@ -14992,9 +14927,8 @@ msgstr "" "\n" "Esta é uma opção experimental, pois se o perfil de PA não for definido com precisão, causará problemas de uniformidade." -# AI Translated msgid "Static pressure advance for bridges" -msgstr "Pressure Advance estático para pontes" +msgstr "Pressure advance estático para pontes" # AI Translated msgid "" @@ -15254,13 +15188,13 @@ msgid "Interface layer pre-extrusion distance" msgstr "Distância de pré-extrusão da camada de interface" msgid "Pre-extrusion distance for prime tower interface layer (where different materials meet)." -msgstr "Distância de pré-extrusão para a camada de interface da torre de preparação (onde diferentes materiais se encontram)." +msgstr "Distância de pré-extrusão para a camada de interface da torre de purga (onde diferentes materiais se encontram)." msgid "Interface layer pre-extrusion length" msgstr "Comprimento de pré-extrusão da camada de interface" msgid "Pre-extrusion length for prime tower interface layer (where different materials meet)." -msgstr "Comprimento de pré-extrusão para a camada de interface da torre de preparação (onde diferentes materiais se encontram)." +msgstr "Comprimento de pré-extrusão para a camada de interface da torre de purga (onde diferentes materiais se encontram)." msgid "Tower ironing area" msgstr "Area de alisamento da torre" @@ -15275,13 +15209,13 @@ msgid "Interface layer purge length" msgstr "Comprimento de purga da camada de interface" msgid "Purge length for prime tower interface layer (where different materials meet)." -msgstr "Comprimento de purga para a camada de interface da torre de preparação (onde diferentes materiais se encontram)." +msgstr "Comprimento de purga para a camada de interface da torre de purga (onde diferentes materiais se encontram)." msgid "Interface layer print temperature" msgstr "Temperatura de impressão da camada de interface" msgid "Print temperature for prime tower interface layer (where different materials meet). If set to -1, use max recommended nozzle temperature." -msgstr "Temperatura de impressão para a camada de interface da torre de preparação (onde diferentes materiais se encontram). Se definida como -1, use a temperatura máxima recomendada do bico." +msgstr "Temperatura de impressão para a camada de interface da torre de purga (onde diferentes materiais se encontram). Se definida como -1, use a temperatura máxima recomendada do bico." msgid "Speed of the last cooling move" msgstr "Velocidade do último movimento de resfriamento" @@ -15534,10 +15468,10 @@ msgid "Linear" msgstr "Linear" msgid "Quadratic" -msgstr "" +msgstr "Quadrático" msgid "Exponential" -msgstr "" +msgstr "Exponencial" msgid "Z-buckling bias optimization (experimental)" msgstr "Otimização de tendência à flambagem em Z (experimental)" @@ -16252,11 +16186,9 @@ msgstr "Preenchimento simétrico no eixo Y" msgid "If the model has two parts that are symmetric about the Y axis, and you want these parts to have symmetric textures, please click this option on one of the parts." msgstr "Se o modelo tiver duas partes simétricas em relação ao eixo Y e você quiser que essas partes tenham texturas simétricas, clique nesta opção em uma das partes." -# AI Translated msgid "Fill pattern tops" msgstr "Preencher os topos do padrão" -# AI Translated msgid "Choose this option if you want to completely fill in the tops of the infill pattern" msgstr "Escolha esta opção se quiser preencher completamente os topos do padrão de preenchimento" @@ -17295,7 +17227,6 @@ msgstr "Bowden" msgid "Hybrid" msgstr "Híbrido" -# AI Translated msgid "Extra High Flow" msgstr "Fluxo Extra Alto" @@ -17708,7 +17639,6 @@ msgstr "Passos de pré-aquecimento" msgid "Insert multiple preheat commands (e.g. M104.1). Only useful for Prusa XL. For other printers, please set it to 1." msgstr "Insire múltiplos comandos de pré-aquecimento (ex.: M104.1). Útil apenas para Prusa XL. Para outras impressoras, defina como 1." -# AI Translated msgid "G-code written at the very top of the output file, before any other content. Useful for adding metadata that printer firmware reads from the first lines of the file (e.g. estimated print time, filament usage). Supports placeholders like {print_time_total_sec}, {print_time_day}, {print_time_hour}, {print_time_minute}, {print_time_sec} and {used_filament_length}." msgstr "G-code escrito no início do arquivo de saída, antes de qualquer outro conteúdo. Útil para adicionar metadados que o firmware da impressora lê nas primeiras linhas do arquivo (ex.: tempo estimado de impressão, uso de filamento). Suporta marcadores como {print_time_total_sec}, {print_time_day}, {print_time_hour}, {print_time_minute}, {print_time_sec} e {used_filament_length}." @@ -17865,7 +17795,6 @@ msgstr "Sem camadas esparsas (beta)" msgid "If enabled, the wipe tower will not be printed on layers with no tool changes. On layers with a tool change, extruder will travel downward to print the wipe tower, so the tower ends up below the model and the toolhead has to reach down to it. Layouts where that would collide with an already printed object are rejected. Has no effect with smooth timelapse or clumping detection, which need a tower on every layer." msgstr "Se ativado, a torre de purga não será impressa nas camadas sem mudanças de ferramenta. Nas camadas com mudança de ferramenta, a extrusora descerá para imprimir a torre de purga, de modo que a torre fica abaixo do modelo e a cabeça da ferramenta precisa descer até ela. Disposições em que isso colidiria com um objeto já impresso são rejeitadas. Não tem efeito com o timelapse suave nem com a detecção de aglomeração no bico, que precisam de uma torre em todas as camadas." -# AI Translated msgid "Combine sparse layers" msgstr "Combinar camadas esparsas" @@ -18662,11 +18591,9 @@ msgstr "Largura da nervura" msgid "Rib width is always less than half the prime tower side length." msgstr "A largura da nervura sempre é menor do que a metade da largura da torre de purga." -# AI Translated msgid "Fillet wall" msgstr "Parede com filete" -# AI Translated msgid "The wall of prime tower will fillet." msgstr "A parede da torre de purga terá um filete." @@ -19246,11 +19173,9 @@ msgstr "Permitir rotações ao arranjar" msgid "If enabled, Arrange will allow rotation when placing objects." msgstr "Se habilitado, o arranjo permitirá rotações ao posicionar objetos." -# AI Translated msgid "Align to Y axis when arranging" msgstr "Alinhar ao eixo Y ao organizar" -# AI Translated msgid "If enabled, Arrange will turn each object so its long side runs along the Y axis before placing it. When not given, it is on for i3 printers and off for the others, as in the GUI." msgstr "Se ativado, Organizar girará cada objeto para que seu lado mais longo fique ao longo do eixo Y antes de colocá-lo. Quando não informado, fica ativado para impressoras i3 e desativado para as demais, como na interface gráfica." @@ -19440,42 +19365,33 @@ msgstr "Fil. usado" msgid "Total length of filament used in the print." msgstr "Comprimento total de filamento usado na impressão." -# AI Translated msgid "Print time (total seconds)" msgstr "Tempo de impressão (total em segundos)" msgid "Total estimated print time in seconds. Replaced with actual value during post-processing." msgstr "Tempo total estimado de impressão em segundos. Substituído pelo valor real durante o pós-processamento." -# AI Translated msgid "Print time (days component)" msgstr "Tempo de impressão (componente de dias)" -# AI Translated msgid "Estimated print time day component (normal mode). Replaced with actual value during post-processing." msgstr "Componente de dias do tempo estimado de impressão (modo normal). Substituído pelo valor real durante o pós-processamento." -# AI Translated msgid "Print time (hours component)" msgstr "Tempo de impressão (componente de horas)" -# AI Translated msgid "Estimated print time hour component (normal mode). Replaced with actual value during post-processing." msgstr "Componente de horas do tempo estimado de impressão (modo normal). Substituído pelo valor real durante o pós-processamento." -# AI Translated msgid "Print time (minutes component)" msgstr "Tempo de impressão (componente de minutos)" -# AI Translated msgid "Estimated print time minute component (normal mode). Replaced with actual value during post-processing." msgstr "Componente de minutos do tempo estimado de impressão (modo normal). Substituído pelo valor real durante o pós-processamento." -# AI Translated msgid "Print time (seconds component)" msgstr "Tempo de impressão (componente de segundos)" -# AI Translated msgid "Estimated print time second component (normal mode). Replaced with actual value during post-processing." msgstr "Componente de segundos do tempo estimado de impressão (modo normal). Substituído pelo valor real durante o pós-processamento." @@ -19935,20 +19851,17 @@ msgstr "material com significativa contração/expansão térmica, como…" msgid "materials with inaccurate filament diameter" msgstr "materiais com diâmetro de filamento impreciso" -# AI Translated #, boost-format msgid "%1% Flow Rate Calibrated" -msgstr "%1% Taxa de fluxo calibrada" +msgstr "%1% Taxa de Fluxo Calibrada" -# AI Translated #, boost-format msgid "%1% Temperature Calibrated" -msgstr "%1% Temperatura calibrada" +msgstr "%1% Temperatura Calibrada" -# AI Translated #, boost-format msgid "%1% Max Vol Speed Calibrated" -msgstr "%1% Vel. vol. máx. calibrada" +msgstr "%1% Vel. Vol. Máx. Calibrada" msgid "We found the best Flow Dynamics Calibration Factor" msgstr "Encontramos o melhor Fator de Calibração de Dinâmica de Fluxo" @@ -20309,7 +20222,6 @@ msgstr "Temp. final: " msgid "Temp step: " msgstr "Passo de Temperatura: " -# AI Translated #. TRN %s is the temperature unit #, c-format, boost-format msgid "Supported range: 170%s - 500%s" @@ -20696,7 +20608,6 @@ msgstr "Calibrar antes da impressão" msgid "Filament Mapping:" msgstr "Mapeamnto de Filamnto:" -# AI Translated #. TRN External spool slot, followed by its filament type #, c-format, boost-format msgid "Ext - %s" @@ -20777,23 +20688,19 @@ msgstr "Testar bing.com:" msgid "Log Info" msgstr "Informações do Registro" -# AI Translated #. TRN %s is the name of the tested website #, c-format, boost-format msgid "test %s start..." msgstr "iniciando teste de %s..." -# AI Translated #, c-format, boost-format msgid "test %s ip resolved = %s" msgstr "teste de %s: IP resolvido = %s" -# AI Translated #, c-format, boost-format msgid "test %s failed" msgstr "teste de %s falhou" -# AI Translated #, c-format, boost-format msgid "test %s ok" msgstr "teste de %s ok" @@ -21338,15 +21245,12 @@ msgstr "Sessão válida não detectada. Prosseguir com o login no 3DPrinterOS?" msgid "Success!" msgstr "Sucesso!" -# AI Translated msgid "Generate API Key" msgstr "Gerar Chave da API" -# AI Translated msgid "API Key created. Go to the physical printer and hit \"authorize\" on the screen, then run \"Test\" again." msgstr "Chave da API criada. Vá até a impressora física e toque em \"authorize\" (autorizar) na tela; depois execute \"Testar\" novamente." -# AI Translated msgid "API Key created." msgstr "Chave da API criada." @@ -21361,10 +21265,10 @@ msgstr "Substituir a aba do dispositivo do BambuLab pela webui do host de impres #, c-format, boost-format msgid "On this system, %s uses HTTPS certificates from the system Certificate Store or Keychain." -msgstr "Neste sistema, %s usa certificados HTTPS da loja de certificados do sistema ou do Keychain." +msgstr "Neste sistema, %s usa certificados HTTPS do Repositório de Certificados ou Chaveiro do sistema." msgid "To use a custom CA file, please import your CA file into Certificate Store / Keychain." -msgstr "Para usar um arquivo CA personalizado, importe seu arquivo CA para a loja de certificados / Keychain." +msgstr "Para usar um arquivo CA personalizado, importe seu arquivo CA para o Repositório de Certificados / Chaveiro." msgid "Login/Test" msgstr "Login/Teste" @@ -21376,9 +21280,8 @@ msgid "Detect Creality K-series printer" msgstr "Detectar impressora Creality da série K" msgid "Click Scan to look for K-series printers on your network." -msgstr "Clique para buscar impressora Creality da série K na sua rede." +msgstr "Clique em Buscar para procurar impressoras Creality da série K na sua rede." -# AI Translated msgid "Scan" msgstr "Buscar" @@ -21388,7 +21291,6 @@ msgstr "Usar Selecionada" msgid "Scanning the LAN for K-series printers... this takes a few seconds." msgstr "Buscando impressoras série K na rede… isso leva alguns segundos." -# AI Translated msgid "(unknown K-series)" msgstr "(série K desconhecida)" @@ -21492,7 +21394,6 @@ msgstr "log(s)" msgid "Choose where to save the exported JSON file" msgstr "Escolha onde salvar o arquivo JSON exportado" -# AI Translated msgid "JSON files (*.json)|*.json" msgstr "Arquivos JSON (*.json)|*.json" @@ -21766,7 +21667,6 @@ msgstr "Nome da tarefa" msgid "Actions" msgstr "Ações" -# AI Translated #. TRN Button that jumps to the page number typed next to it msgid "Go" msgstr "Ir" @@ -21877,7 +21777,6 @@ msgstr " para definir o número de bicos" msgid "Set the physical nozzle count..." msgstr "Definir o número de bicos físicos…" -# AI Translated #, c-format, boost-format msgid "(Std: %d, HF: %d)" msgstr "(Pad.: %d, AF: %d)" @@ -22313,7 +22212,6 @@ msgstr "" msgid "Save preset bundle" msgstr "Salvar pacote de predefinições" -# AI Translated msgid "Orca Preset Bundle (*.orca_bundle)|*.orca_bundle" msgstr "Pacote de predefinições Orca (*.orca_bundle)|*.orca_bundle" @@ -22860,7 +22758,6 @@ msgstr "Referência / Curva (plano / eixo / sist. coord. / hélice / projeção) msgid "Reference plane (offset / tilt / midplane / tangent / two edges / coincident)" msgstr "Plano de referência (deslocamento / inclinado / plano médio / tangente / duas arestas / coincidente)" -# AI Translated msgid "Axis" msgstr "Eixo" @@ -23606,17 +23503,14 @@ msgstr "Arestas" msgid "Size (r/dist)" msgstr "Tamanho (r/dist)" -# AI Translated msgctxt "Axis" msgid "XY" msgstr "XY" -# AI Translated msgctxt "Axis" msgid "XZ" msgstr "XZ" -# AI Translated msgctxt "Axis" msgid "YZ" msgstr "YZ" @@ -24110,9 +24004,8 @@ msgstr "Expressão" msgid "Field" msgstr "Campo" -# AI Translated msgid "Expr" -msgstr "Expr." +msgstr "Expr" # AI Translated msgid "(no bindings)" @@ -25118,11 +25011,9 @@ msgstr "Fixo" msgid "Revolute" msgstr "De revolução" -# AI Translated msgid "Slider" -msgstr "Deslizante" +msgstr "Controle Deslizante" -# AI Translated msgid "Cylindrical" msgstr "Cilíndrico" @@ -25301,89 +25192,68 @@ msgstr "recurso %d" msgid "Deleted %s, the feature this body was made from — Ctrl+Z restores it" msgstr "Recurso excluído: %s, aquele a partir do qual este corpo foi criado — Ctrl+Z o restaura" -# AI Translated msgid "Select the FEATURE that created this body (or use New Design)" msgstr "Selecione o RECURSO que criou este corpo (ou use Novo design)" -# AI Translated msgid "Select a feature in the tree first" -msgstr "Selecione primeiro um recurso na árvore" +msgstr "Selecione um recurso na árvore primeiro" -# AI Translated #, c-format, boost-format msgid "Body %d shown" msgstr "Corpo %d exibido" -# AI Translated #, c-format, boost-format msgid "Body %d hidden" msgstr "Corpo %d oculto" -# AI Translated msgid "Feature shown" msgstr "Recurso exibido" -# AI Translated msgid "Feature hidden" msgstr "Recurso oculto" -# AI Translated msgid "Draw a sketch first, then Constrain" -msgstr "Desenhe primeiro um esboço e depois use Restringir" +msgstr "Desenhe um esboço primeiro e depois use Restringir" -# AI Translated msgid "Select a sketch in the tree first" -msgstr "Selecione primeiro um esboço na árvore" +msgstr "Selecione um esboço na árvore primeiro" -# AI Translated msgid "Selected feature is not a sketch" msgstr "O recurso selecionado não é um esboço" -# AI Translated msgid "Pick 1-2 lines, then a constraint; right-click exits" msgstr "Selecione 1-2 linhas e depois uma restrição; clique com o botão direito para sair" -# AI Translated msgid "Pick 1-2 entities, then a constraint or dimension; right-click exits" msgstr "Selecione 1-2 entidades e depois uma restrição ou cota; clique com o botão direito para sair" -# AI Translated msgid "Pick an entity first" -msgstr "Selecione primeiro uma entidade" +msgstr "Selecione uma entidade primeiro" -# AI Translated msgid "Pick two entities first" -msgstr "Selecione primeiro duas entidades" +msgstr "Selecione duas entidades primeiro" -# AI Translated msgid "Horizontal and Vertical apply to a line" msgstr "Horizontal e Vertical se aplicam a uma linha" -# AI Translated msgid "Angle applies between two lines" msgstr "Ângulo se aplica entre duas linhas" -# AI Translated msgid "Collinear needs two lines" msgstr "Colinear precisa de duas linhas" -# AI Translated msgid "Parallel, perpendicular and equal length apply to two lines" msgstr "Paralelo, perpendicular e comprimento igual se aplicam a duas linhas" -# AI Translated msgid "Equal radius needs two circles or arcs" -msgstr "Raio igual precisa de dois círculos ou arcos" +msgstr "Mesmo raio precisa de dois círculos ou arcos" -# AI Translated msgid "Concentric needs two circles or arcs" msgstr "Concêntrico precisa de dois círculos ou arcos" -# AI Translated msgid "Tangent needs a line and a circle/arc, or two circles/arcs" msgstr "Tangente precisa de uma linha e um círculo/arco, ou de dois círculos/arcos" -# AI Translated msgid "This constraint needs two entities with a point to join" msgstr "Esta restrição precisa de duas entidades com um ponto para unir" @@ -25391,37 +25261,29 @@ msgstr "Esta restrição precisa de duas entidades com um ponto para unir" msgid "This dimension needs two entities with a point to measure between" msgstr "Esta cota precisa de duas entidades com pontos entre os quais medir" -# AI Translated msgid "Midpoint needs a point and a line" msgstr "Ponto médio precisa de um ponto e uma linha" -# AI Translated msgid "Symmetric needs two points or two lines + an axis" msgstr "Simétrico precisa de dois pontos ou duas linhas + um eixo" -# AI Translated msgid "Symmetric needs two points or two lines" msgstr "Simétrico precisa de dois pontos ou duas linhas" -# AI Translated msgid "Symmetric: pick two entities, then an axis line" msgstr "Simétrico: selecione duas entidades e depois uma linha de eixo" -# AI Translated msgid "Radius/Diameter needs a circle or arc" msgstr "Raio/Diâmetro precisa de um círculo ou arco" -# AI Translated msgid "Unsupported constraint" msgstr "Restrição não suportada" -# AI Translated msgid "Constraint rejected (over-constrained)" -msgstr "Restrição rejeitada (esboço superrestrito)" +msgstr "Restrição rejeitada (restrito demais)" -# AI Translated msgid "Applied constraint · its badge is on the sketch — click the badge to remove it" -msgstr "Restrição aplicada · o ícone dela está no esboço — clique no ícone para removê-la" +msgstr "Restrição aplicada · o distintivo dela está no esboço — clique no distintivo para removê-la" # AI Translated #, c-format, boost-format @@ -25430,26 +25292,21 @@ msgid_plural "%s — %d selected. Press Esc for the Select tool, click one lin msgstr[0] "%s — %d selecionado. Pressione Esc para a ferramenta Selecionar, clique em uma linha e depois Shift+clique ou Ctrl+clique na outra." msgstr[1] "%s — %d selecionados. Pressione Esc para a ferramenta Selecionar, clique em uma linha e depois Shift+clique ou Ctrl+clique na outra." -# AI Translated msgid "Applied constraint" msgstr "Restrição aplicada" -# AI Translated #. TRN Design sketch: abbreviation of "center", appended to a point tag such as "L3·Ctr" msgid "·Ctr" -msgstr "·Ctr" +msgstr "·Cntr" -# AI Translated #, c-format, boost-format msgid "Fix %s" msgstr "Fixar %s" -# AI Translated #, c-format, boost-format msgid "Lock X %s" msgstr "Travar X %s" -# AI Translated #, c-format, boost-format msgid "Lock Y %s" msgstr "Travar Y %s" @@ -25458,71 +25315,55 @@ msgctxt "Sketch Constraint" msgid "Equal" msgstr "Igual" -# AI Translated #, c-format, boost-format msgid "Symmetric %s — %s / %s" msgstr "Simétrico %s — %s / %s" -# AI Translated #, c-format, boost-format msgid "Symmetric about Y axis %s — %s" msgstr "Simétrico em relação ao eixo Y %s — %s" -# AI Translated #, c-format, boost-format msgid "Symmetric about X axis %s — %s" msgstr "Simétrico em relação ao eixo X %s — %s" -# AI Translated msgid "On line" msgstr "Na linha" -# AI Translated msgid "On edge" msgstr "Na aresta" -# AI Translated msgid "Constraint" msgstr "Restrição" -# AI Translated #, c-format, boost-format msgid "Constraints (%d)" msgstr "Restrições (%d)" -# AI Translated msgid "No constraints yet" msgstr "Nenhuma restrição ainda" -# AI Translated msgid "Delete constraint" msgstr "Excluir restrição" -# AI Translated msgid "Constraint deleted" msgstr "Restrição excluída" -# AI Translated msgid "Press Constrain on a sketch first" msgstr "Pressione Restringir em um esboço primeiro" -# AI Translated msgid "Pick the entity, then a mirror-axis line" msgstr "Selecione a entidade e depois uma linha de eixo de espelhamento" -# AI Translated msgid "Mirror axis must be a line" msgstr "O eixo de espelhamento deve ser uma linha" -# AI Translated msgid "Mirror produced nothing" msgstr "O espelhamento não produziu nada" -# AI Translated msgid "Offset distance (+left / -right of direction)" msgstr "Distância de deslocamento (+esquerda / -direita da direção)" -# AI Translated msgid "Offset collapsed the entity" msgstr "O deslocamento colapsou a entidade" @@ -25542,19 +25383,15 @@ msgstr "Raio do filete" msgid "Fillet failed (parallel lines or radius too large)" msgstr "Falha no filete (linhas paralelas ou raio grande demais)" -# AI Translated msgid "Pick two lines to chamfer" msgstr "Selecione duas linhas para o chanfro" -# AI Translated msgid "Chamfer needs two lines" msgstr "O chanfro precisa de duas linhas" -# AI Translated msgid "Chamfer distance" msgstr "Distância do chanfro" -# AI Translated msgid "Chamfer failed (parallel lines or distance too large)" msgstr "Falha no chanfro (linhas paralelas ou distância grande demais)" @@ -26048,30 +25885,24 @@ msgctxt "Design" msgid "Loft" msgstr "Loft" -# AI Translated msgctxt "Design" msgid "Thicken" msgstr "Espessar" -# AI Translated msgctxt "Design" msgid "Join" msgstr "Unir" -# AI Translated msgid "Fuse the tool body into the target — one solid, no seam" -msgstr "Fundir o corpo da ferramenta ao alvo — um único sólido, sem emenda" +msgstr "Fundir o corpo da ferramenta ao alvo — um único sólido, sem costura" -# AI Translated msgctxt "Design" msgid "Subtract" msgstr "Subtrair" -# AI Translated msgid "Cut the tool body out of the target" msgstr "Recortar o corpo da ferramenta do alvo" -# AI Translated msgctxt "Design" msgid "Intersect" msgstr "Intersectar" @@ -26802,10 +26633,10 @@ msgid "Horizontal slider - Move active thumb Right" msgstr "Controle deslizante horizontal - Mover o polegar ativo para a direita" msgid "Horizontal slider - Move to start position" -msgstr "Barra deslizante horizontal — Mover para a posição inicial" +msgstr "Controle deslizante horizontal — Mover para a posição inicial" msgid "Horizontal slider - Move to last position" -msgstr "Barra deslizante horizontal — Mover para a última posição" +msgstr "Controle deslizante horizontal — Mover para a última posição" msgid "Camera view - Default" msgstr "Vista da câmera - Padrão" @@ -28020,19 +27851,15 @@ msgstr "*Dicas: se você mudou a posição do carretel na máquina, feche esta p msgid "Summary: This is currently the most suitable position for placement." msgstr "Resumo: esta é atualmente a posição mais adequada para o posicionamento." -# AI Translated msgid "?" msgstr "?" -# AI Translated msgid "Wiki->" msgstr "Wiki->" -# AI Translated msgid "Note:" -msgstr "Observação:" +msgstr "Nota:" -# AI Translated msgctxt "Texture Displacement" msgid "Bake" msgstr "Assar" @@ -28051,11 +27878,11 @@ msgstr "Pintar deslocamento de textura" # AI Translated msgid "Mark texture seam" -msgstr "Marcar emenda da textura" +msgstr "Marcar costura da textura" # AI Translated msgid "Mark texture seam path" -msgstr "Marcar caminho de emenda da textura" +msgstr "Marcar caminho de costura da textura" # AI Translated #, boost-format @@ -28070,7 +27897,6 @@ msgstr "Escala média das ilhas" msgid "Select an island in the UV editor first." msgstr "Selecione primeiro uma ilha no editor UV." -# AI Translated msgid "Cut texture island" msgstr "Cortar ilha da textura" @@ -28078,31 +27904,24 @@ msgstr "Cortar ilha da textura" msgid "This island has no neighbour it shares an edge with." msgstr "Esta ilha não tem nenhuma vizinha com a qual compartilhe uma aresta." -# AI Translated msgid "Join texture island" msgstr "Unir ilha da textura" -# AI Translated msgid "Unjoin texture island" msgstr "Desunir ilha da textura" -# AI Translated msgid "Clear texture seams" -msgstr "Limpar emendas da textura" +msgstr "Limpar costuras da textura" -# AI Translated msgid "Clear texture UV edits" msgstr "Limpar edições UV da textura" -# AI Translated msgid "Apply texture projection frame" msgstr "Aplicar moldura de projeção da textura" -# AI Translated msgid "Move texture island" msgstr "Mover ilha da textura" -# AI Translated msgid "Edit texture UV" msgstr "Editar UV da textura" @@ -28130,29 +27949,23 @@ msgstr "Imagens (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp" msgid "Choose a texture image (height map)" msgstr "Escolha uma imagem de textura (mapa de altura)" -# AI Translated msgid "Choose a texture" msgstr "Escolha uma textura" -# AI Translated msgid "Remove from My textures" msgstr "Remover de Minhas texturas" -# AI Translated msgid "Built-in" msgstr "Integradas" -# AI Translated msgid "No textures found." msgstr "Nenhuma textura encontrada." -# AI Translated msgid "My textures" msgstr "Minhas texturas" -# AI Translated msgid "Import your own image as a height map. It is converted to the format the slicer bakes from and saved to your personal texture folder, kept separate from the textures shipped with OrcaSlicer." -msgstr "Importe sua própria imagem como mapa de altura. Ela é convertida para o formato a partir do qual o fatiador assa e salva na sua pasta pessoal de texturas, separada das texturas fornecidas com o OrcaSlicer." +msgstr "Importe sua própria imagem como mapa de altura. Ela é convertida para o formato a partir do qual o fatiador assa e salva na sua pasta pessoal de texturas, mantida separada das texturas fornecidas com o OrcaSlicer." # AI Translated msgid "PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it." @@ -28213,111 +28026,84 @@ msgstr "Pinte primeiro a área que deseja suavizar - a suavização só afeta a msgid "Nothing to smooth - the painted area is only one triangle deep, so with \"Ignore outer ring\" on there are no interior vertices to relax." msgstr "Nada a suavizar - a área pintada tem apenas um triângulo de profundidade, então, com \"Ignorar anel externo\" ativado, não há vértices internos para relaxar." -# AI Translated msgid "Smooth texture displacement" msgstr "Suavizar deslocamento de textura" -# AI Translated msgid "Remesh model for texture displacement" msgstr "Remalhar modelo para deslocamento de textura" -# AI Translated msgid "Remeshing did not change the model. It may be non-manifold (open edges or edges shared by more than two triangles), or the target edge length may already be met." msgstr "O remalhamento não alterou o modelo. Ele pode ser não-manifold (arestas abertas ou arestas compartilhadas por mais de dois triângulos), ou o comprimento de aresta alvo pode já ter sido atingido." -# AI Translated msgid "Nothing is painted, there is nothing to bake." msgstr "Nada está pintado, não há nada para assar." -# AI Translated msgid "Bake texture displacement" msgstr "Assar deslocamento de textura" -# AI Translated msgid "Nothing is painted, so there are no bake stages to capture." msgstr "Nada está pintado, então não há etapas de assamento para capturar." -# AI Translated msgid "Bake stages (debug)" msgstr "Etapas do assamento (depuração)" -# AI Translated msgid "Capturing..." msgstr "Capturando..." -# AI Translated msgid "Capture stages" msgstr "Capturar etapas" -# AI Translated msgid "Run the same bake the Bake button runs, keeping the mesh after every stage, and step through them here. Nothing is committed to the model - this is for finding which stage a bad result came from." msgstr "Executa o mesmo assamento que o botão Assar executa, mantendo a malha após cada etapa, e permite percorrê-las aqui. Nada é aplicado ao modelo - serve para descobrir de qual etapa veio um resultado ruim." -# AI Translated msgid "Check topology" msgstr "Verificar topologia" -# AI Translated msgid "Count open and non-manifold edges after each stage, which is how a stage that tore the mesh is spotted. It scans every edge, so it adds noticeably to the capture on a fine bake." msgstr "Conta as arestas abertas e não-manifold após cada etapa, que é como se identifica uma etapa que rasgou a malha. Analisa todas as arestas, então aumenta consideravelmente a captura em um assamento fino." -# AI Translated msgid "< Previous" msgstr "< Anterior" -# AI Translated msgid "Next >" msgstr "Próximo >" -# AI Translated msgid "triangles" msgstr "triângulos" -# AI Translated msgid "vertices" msgstr "vértices" -# AI Translated msgid "Open edges" msgstr "Arestas abertas" -# AI Translated msgid "Non-manifold" msgstr "Não-manifold" -# AI Translated msgid "Degenerate" msgstr "Degenerados" -# AI Translated msgid "Write stage meshes" msgstr "Gravar malhas das etapas" -# AI Translated msgid "Wrote the stage meshes as OBJ files to:" msgstr "As malhas das etapas foram gravadas como arquivos OBJ em:" -# AI Translated msgid "Could not write the stage meshes." msgstr "Não foi possível gravar as malhas das etapas." -# AI Translated msgid "Write every captured stage out as an OBJ file, so they can be opened side by side in a mesh viewer." msgstr "Grava cada etapa capturada como um arquivo OBJ, para que possam ser abertas lado a lado em um visualizador de malhas." -# AI Translated msgid "The painted area could not be carried onto the remeshed model, so nothing was changed. Switch to Pro mode and remesh before painting." msgstr "A área pintada não pôde ser transferida para o modelo remalhado, então nada foi alterado. Mude para o modo Pro e remalhe antes de pintar." -# AI Translated msgid "Pro" msgstr "Pro" -# AI Translated msgid "Standard - pick a texture, paint where it goes, press Bake. Everything the mesh needs is done for you in that one step." msgstr "Padrão - escolha uma textura, pinte onde ela vai e pressione Assar. Tudo o que a malha precisa é feito por você nessa única etapa." -# AI Translated msgid "Pro - the mesh preparation is yours to run: Remesh, Subdivide and Bake as separate steps, with every setting on show. For when Standard's result is not what you wanted and you know why." msgstr "Pro - a preparação da malha fica por sua conta: Remalhar, Subdividir e Assar como etapas separadas, com todas as configurações à mostra. Para quando o resultado do Padrão não é o que você queria e você sabe por quê." @@ -28358,7 +28144,6 @@ msgstr "Pincel - pinte sobre a superfície arrastando" msgid "Face - click individual triangles" msgstr "Face - clique em triângulos individuais" -# AI Translated msgid "Connected area" msgstr "Área conectada" @@ -28366,7 +28151,6 @@ msgstr "Área conectada" msgid "Connected area - flood-fill the region reachable without crossing an edge sharper than the angle threshold" msgstr "Área conectada - preenche a região alcançável sem cruzar uma aresta mais acentuada que o limiar de ângulo" -# AI Translated msgid "Wait for the bake to finish." msgstr "Aguarde o assamento terminar." @@ -28410,14 +28194,12 @@ msgstr "Esfera - pinta apenas dentro de uma esfera ao redor do ponto sob o curso msgid "Angle threshold - the fill stops at edges sharper than this" msgstr "Limiar de ângulo - o preenchimento para em arestas mais acentuadas que isto" -# AI Translated msgid "Click a triangle to paint it" msgstr "Clique em um triângulo para pintá-lo" msgid "Only for a layer mapped with Unwrap (LSCM) - set the active layer's Mapping to Unwrap to use this view." msgstr "" -# AI Translated msgid "Normal - the real geometry, exactly what Bake will produce. Slower to update" msgstr "Normal - a geometria real, exatamente o que Assar vai produzir. Atualiza mais devagar" @@ -28425,14 +28207,12 @@ msgstr "Normal - a geometria real, exatamente o que Assar vai produzir. Atualiza msgid "Fast - the relief is only shaded on, not built, and only for the layer you are editing. Updates instantly while you paint" msgstr "Rápido - o relevo é apenas sombreado, não construído, e só para a camada que você está editando. Atualiza instantaneamente enquanto você pinta" -# AI Translated msgid "Checker" msgstr "Xadrez" msgid "Checker - a test grid instead of the texture. Where the squares stay square the texture is undistorted; where they stretch, it will too. Opens the UV editor if it is closed" msgstr "" -# AI Translated msgid "Distortion" msgstr "Distorção" @@ -28447,7 +28227,6 @@ msgstr "Malha - sobrepõe as arestas da malha; independente da vista acima" msgid "Rebuilds the preview as soon as anything changes. Turn it off on a heavy model if painting or dragging a slider starts to stutter - the preview then waits until you let go." msgstr "Reconstrói a pré-visualização assim que algo muda. Desative em um modelo pesado se pintar ou arrastar um controle deslizante começar a travar - a pré-visualização então espera até você soltar." -# AI Translated msgid "Texture layers" msgstr "Camadas de textura" @@ -28559,37 +28338,31 @@ msgstr "" msgid "The most mixed filaments a bake may add. They are picked from the texture's colors, and only the ones the bake actually uses are created." msgstr "" -# AI Translated #, boost-format msgid "%1% printable colors from %2% filaments" msgstr "%1% cores imprimíveis a partir de %2% filamentos" -# AI Translated msgid "Denoise" -msgstr "Reduzir ruído" +msgstr "Reduzir Ruído" # AI Translated msgid "Cleans up single stray triangles of the wrong color, which detail finer than the mesh leaves behind. Raise it if the result looks speckled, lower it if small features are being swallowed." msgstr "Remove triângulos isolados com a cor errada, deixados por detalhes mais finos que a malha. Aumente se o resultado parecer salpicado; diminua se pequenos detalhes estiverem sendo engolidos." -# AI Translated msgctxt "Texture Displacement" msgid "Mapping" msgstr "Mapeamento" -# AI Translated msgid "Triplanar (blended)" msgstr "Triplanar (mesclado)" -# AI Translated msgid "Triplanar - projects the texture from all three axes at once and blends between them, so a patch wrapping around a sharp edge has no seam." -msgstr "Triplanar - projeta a textura a partir dos três eixos ao mesmo tempo e mescla entre eles, de modo que uma área que envolve uma aresta viva não tenha emenda." +msgstr "Triplanar - projeta a textura a partir dos três eixos ao mesmo tempo e mescla entre eles, de modo que uma área que envolve uma aresta viva não tenha costura." # AI Translated msgid "Cylindrical - wraps the texture around the painted area's own center, for round shapes." msgstr "Cilíndrico - envolve a textura ao redor do próprio centro da área pintada, para formas redondas." -# AI Translated msgid "Spherical" msgstr "Esférico" @@ -28597,15 +28370,12 @@ msgstr "Esférico" msgid "Spherical - wraps the texture around the painted area's own center in both directions." msgstr "Esférico - envolve a textura ao redor do próprio centro da área pintada, nas duas direções." -# AI Translated msgid "Unwrap (LSCM)" msgstr "Desdobrar (LSCM)" -# AI Translated msgid "Unwrap - flattens the painted area and maps the texture onto it with as little stretching as possible. The area is cut into pieces at its sharp edges first (see Seam angle), so each piece can lie flat on its own." -msgstr "Desdobrar - achata a área pintada e mapeia a textura sobre ela com o mínimo de estiramento possível. Primeiro a área é cortada em pedaços nas suas arestas vivas (veja Ângulo de emenda), para que cada pedaço possa ficar plano por conta própria." +msgstr "Desdobrar - achata a área pintada e mapeia a textura sobre ela com o mínimo de estiramento possível. Primeiro a área é cortada em pedaços nas suas arestas vivas (veja Ângulo de costura), para que cada pedaço possa ficar plano por conta própria." -# AI Translated msgid "From view" msgstr "Da vista" @@ -28613,13 +28383,12 @@ msgstr "Da vista" msgid "From view - projects straight onto the painted area from where you are looking, like a slide projector." msgstr "Da vista - projeta diretamente sobre a área pintada a partir de onde você está olhando, como um projetor de slides." -# AI Translated msgid "Open UV editor" msgstr "Abrir editor UV" # AI Translated msgid "Opens the UV editor, where you can see how the texture is laid out over the painted area, cut seams and move the pieces around." -msgstr "Abre o editor UV, onde você vê como a textura está distribuída sobre a área pintada, corta emendas e move os pedaços." +msgstr "Abre o editor UV, onde você vê como a textura está distribuída sobre a área pintada, corta costuras e move os pedaços." # AI Translated msgid "Not unwrapped yet" @@ -28786,9 +28555,8 @@ msgstr "Seguir detalhes da textura" msgid "Spends the triangles where the texture actually bends - packed along ridges and edges, sparse over flat ground - instead of spreading them evenly. The same detail for fewer triangles on most textures." msgstr "Gasta os triângulos onde a textura realmente se curva - concentrados ao longo de cristas e arestas, esparsos em áreas planas - em vez de distribuí-los uniformemente. O mesmo detalhe com menos triângulos na maioria das texturas." -# AI Translated msgid "Max edge" -msgstr "Aresta máx." +msgstr "Aresta máx" # AI Translated msgid "Target edge" @@ -28810,9 +28578,8 @@ msgstr "Detalhe" msgid "How far the mesh may sit from the shape the texture describes, in millimetres. Smaller follows fine detail and costs triangles; larger only chases the big features." msgstr "Quanto a malha pode se afastar da forma descrita pela textura, em milímetros. Menor segue detalhes finos e custa triângulos; maior só acompanha as formas grandes." -# AI Translated msgid "Min edge" -msgstr "Aresta mín." +msgstr "Aresta mín" # AI Translated msgid "A floor on triangle size, so a sharp step in the texture cannot be chased forever. Lower it for finer relief, raise it if the count runs away at hard edges." @@ -28863,19 +28630,15 @@ msgstr "Pré-visualizar subdivisão" msgid "Shows what the refinement would produce as a wireframe, without touching the model." msgstr "Mostra o que o refinamento produziria como malha, sem alterar o modelo." -# AI Translated msgid "Subdivide" msgstr "Subdividir" -# AI Translated msgid "Refines the painted area to the target edge length and carries your paint onto the finer mesh. The rest of the model is left as it is." msgstr "Refina a área pintada até o comprimento de aresta alvo e transfere sua pintura para a malha mais fina. O restante do modelo fica como está." -# AI Translated msgid "Replaces the model's geometry with the subdivided mesh and clears any not-yet-baked paint on it (already-baked relief is unaffected)." msgstr "Substitui a geometria do modelo pela malha subdividida e limpa qualquer pintura ainda não assada nela (o relevo já assado não é afetado)." -# AI Translated msgid "Remeshing" msgstr "Remalhamento" @@ -28883,23 +28646,18 @@ msgstr "Remalhamento" msgid "Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an even mesh; this is what makes one out of an uneven import." msgstr "Tamanho dos triângulos com que o modelo inteiro é reconstruído, em milímetros. O deslocamento funciona melhor em uma malha uniforme; isto cria uma a partir de uma importação irregular." -# AI Translated msgid "Keep sharp edges" msgstr "Manter arestas vivas" -# AI Translated msgid "Holds hard edges and open borders in place while the rest is remeshed. Without it the remesher slides vertices along the surface and rounds every crisp edge off - a cube comes back with wobbly edges." -msgstr "Mantém arestas vivas e bordas abertas no lugar enquanto o restante é remalhado. Sem isso, o remalhador desliza os vértices ao longo da superfície e arredonda toda aresta nítida - um cubo volta com arestas onduladas." +msgstr "Mantém arestas vivas e bordas abertas fixas enquanto o restante remalhado. Sem isso, o remalhador desliza os vértices ao longo da superfície e arredonda todas as arestas nítidas - um cubo voltaria com arestas onduladas." -# AI Translated msgid "How sharp a fold has to be, in degrees, before the remesher treats it as an edge worth keeping. Lower protects more of the model's shape; higher rebuilds more of it evenly." -msgstr "Quão acentuada uma dobra precisa ser, em graus, para que o remalhador a trate como uma aresta a ser mantida. Menor protege mais da forma do modelo; maior reconstrói mais dele de forma uniforme." +msgstr "Quão acentuada uma dobra precisa ser, em graus, para que o remalhador a trate como uma aresta a ser mantida. Menor protege mais da forma do modelo; maior reconstrói mais dele uniformemente." -# AI Translated msgid "Remesh" msgstr "Remalhar" -# AI Translated msgid "Rebuilds the whole model with triangles close to this edge length - splitting the big ones and merging the small ones - so displacement has an even density to work with. Replaces the geometry; your paint is carried onto the new triangles spatially, so it survives (already-baked relief is kept too)." msgstr "Reconstrói o modelo inteiro com triângulos próximos deste comprimento de aresta - dividindo os grandes e mesclando os pequenos - para que o deslocamento tenha uma densidade uniforme para trabalhar. Substitui a geometria; sua pintura é transferida espacialmente para os novos triângulos, então ela sobrevive (o relevo já assado também é mantido)." @@ -28944,19 +28702,15 @@ msgstr "Ignorar anel externo" msgid "Keeps the outer ring of the painted area out of the smoothing. Its neighbours outside the paint never move, so smoothing it drags the relief down and leaves the pattern half-melted at the border." msgstr "Mantém o anel externo da área pintada fora da suavização. Seus vizinhos fora da pintura nunca se movem, então suavizá-lo puxa o relevo para baixo e deixa o padrão meio derretido na borda." -# AI Translated msgid "Smooth baked mesh now" msgstr "Suavizar malha assada agora" -# AI Translated msgid "Applies the smoothing above to the model itself, right now, within the painted area. For relief that is already baked in - anything not yet baked is smoothed by Bake." msgstr "Aplica a suavização acima ao próprio modelo, agora mesmo, dentro da área pintada. Para relevo que já foi assado - o que ainda não foi assado é suavizado por Assar." -# AI Translated msgid "Experimental: classic bake pipeline" msgstr "Experimental: pipeline de assamento clássico" -# AI Translated msgid "Bake by moving the vertices the mesh already has, after preparing it (remesh, adaptive subdivision, and a cut along sharp steps in the texture). Keeps the topology, which is what colors need. The default pipeline instead refines, cleans up sliver triangles, displaces and simplifies in one run; nothing needs preparing first, but it does not produce colors yet." msgstr "Assa movendo os vértices que a malha já tem, depois de prepará-la (remalhamento, subdivisão adaptativa e um corte ao longo dos degraus bruscos da textura). Mantém a topologia, que é o que as cores precisam. O pipeline padrão, em vez disso, refina, limpa triângulos estreitos, desloca e simplifica de uma só vez; nada precisa ser preparado antes, mas ainda não produz cores." @@ -28970,9 +28724,8 @@ msgstr "Orçamento" msgid "%d k (auto)" msgstr "%d k (automático)" -# AI Translated msgid "How many thousand triangles this bake may spend on the area you painted. Relief already baked into the rest of the model is kept on top of it, so a second bake somewhere else gets the same budget as the first. Raise it if the warning below Resolution says the detail will not fit; 0 keeps every triangle the refinement produced, however many that is." -msgstr "Quantos milhares de triângulos este assamento pode gastar na área que você pintou. O relevo já assado no restante do modelo é mantido além disso, então um segundo assamento em outro lugar recebe o mesmo orçamento que o primeiro. Aumente se o aviso abaixo de Resolução disser que o detalhe não cabe; 0 mantém todos os triângulos que o refinamento produziu, quantos forem." +msgstr "Quantos milhares de triângulos este assamento pode gastar na área que você pintou. O relevo já assado no restante do modelo é mantido além disso, então um segundo assamento em outro lugar recebe o mesmo orçamento que o primeiro. Aumente se o aviso abaixo de Resolução disser que o detalhe não cabe; 0 preserva todos os triângulos produzidos pelo refinamento, independentemente de quantos sejam." # AI Translated msgid "Align mesh to texture edges" @@ -29004,16 +28757,13 @@ msgstr "%.2f mm (automático)" msgid "Chosen from the size of the model: %1$.2f mm triangles, budget %2% k. Untick Auto to set them yourself." msgstr "Escolhido pelo tamanho do modelo: triângulos de %1$.2f mm, orçamento de %2% k. Desmarque Auto para defini-los você mesmo." -# AI Translated msgid "How fine the mesh is made under the paint, in millimetres. It has to be smaller than the detail you want out of the texture - a 0.5 mm groove needs triangles well under 0.5 mm. Smaller costs triangles fast: halving it needs four times as many, and once they no longer fit the budget the bake simplifies back down and the detail goes with it." -msgstr "Quão fina a malha fica sob a pintura, em milímetros. Precisa ser menor que o detalhe que você quer tirar da textura - um sulco de 0,5 mm precisa de triângulos bem abaixo de 0,5 mm. Menor custa triângulos rapidamente: reduzir pela metade exige quatro vezes mais, e quando eles não cabem mais no orçamento, o assamento simplifica de volta e o detalhe vai junto." +msgstr "Quão fina a malha fica sob a pintura, em milímetros. Precisa ser menor que o detalhe que você quer tirar da textura - um sulco de 0,5 mm precisa de triângulos bem abaixo de 0,5 mm. Menor custa triângulos rapidamente: reduzir pela metade exige quatro vezes mais e quando eles não cabem mais no orçamento o assamento simplifica de volta e o detalhe vai junto." -# AI Translated #, boost-format msgid "Needs about %1% triangles, budget %2% - the bake will simplify back down and lose detail." msgstr "Precisa de cerca de %1% triângulos, orçamento de %2% - o assamento vai simplificar de volta e perder detalhes." -# AI Translated msgid "How many thousand triangles the refinement may add to the model. The triangle that fits worst is always split first, so even a run that spends the lot has spent it where it shows most. Raise it if the relief still looks coarse." msgstr "Quantos milhares de triângulos o refinamento pode adicionar ao modelo. O triângulo que se ajusta pior é sempre dividido primeiro, então mesmo uma execução que gaste tudo terá gasto onde mais aparece. Aumente se o relevo ainda parecer grosseiro." @@ -29021,25 +28771,20 @@ msgstr "Quantos milhares de triângulos o refinamento pode adicionar ao modelo. msgid "Preparing..." msgstr "Preparando..." -# AI Translated msgid "Baking..." msgstr "Assando..." -# AI Translated msgid "Nothing is painted yet. The UV editor's seam tool is on, so strokes on the model mark seams instead of painting - turn it off in the pane to paint." -msgstr "Nada foi pintado ainda. A ferramenta de emenda do editor UV está ativa, então os traços no modelo marcam emendas em vez de pintar - desative-a no painel para pintar." +msgstr "Nada foi pintado ainda. A ferramenta de costura do editor UV está ativa, então os traços no modelo marcam costuras em vez de pintar - desative-a no painel para pintar." -# AI Translated msgid "Nothing is painted yet." msgstr "Nada foi pintado ainda." -# AI Translated msgid "Turn the painted height maps into real geometry, by moving the vertices that are already there. Use Subdivide first if the mesh is too coarse to show the detail." msgstr "Transforma os mapas de altura pintados em geometria real, movendo os vértices que já existem. Use Subdividir antes se a malha for grosseira demais para mostrar o detalhe." -# AI Translated msgid "Turn the painted height maps into real geometry. The mesh is remeshed to an even density and refined where the texture bends first, so the detail has vertices to land on - all in one step." -msgstr "Transforma os mapas de altura pintados em geometria real. Primeiro a malha é remalhada para uma densidade uniforme e refinada onde a textura se curva, para que o detalhe tenha vértices onde cair - tudo em uma etapa." +msgstr "Transforma os mapas de altura pintados em geometria real. A malha é remalhada para uma densidade uniforme e refinada onde a textura se curva primeiro, para que o detalhe tenha vértices onde cair - tudo em uma etapa." # AI Translated msgid "Stops the bake. Whatever it had already finished stays on the model, and can be undone." @@ -29049,22 +28794,18 @@ msgstr "Interrompe o assamento. O que já tiver sido concluído permanece no mod msgid "Closes the tool without baking. Your paint, layers and settings stay with the model." msgstr "Fecha a ferramenta sem assar. Sua pintura, camadas e configurações permanecem com o modelo." -# AI Translated #, boost-format msgid "%1% k" msgstr "%1% k" -# AI Translated #, boost-format msgid "Model: %1% triangles - the bake refines to the resolution above, up to its budget" msgstr "Modelo: %1% triângulos - o assamento refina até a resolução acima, dentro do seu orçamento" -# AI Translated #, boost-format msgid "Bake moves existing vertices - the model stays at %1% triangles" msgstr "Assar move os vértices existentes - o modelo continua com %1% triângulos" -# AI Translated #, boost-format msgid "Bake result up to ~%1% k triangles" msgstr "Resultado do assamento até ~%1% k triângulos" @@ -29091,43 +28832,33 @@ msgstr "Saindo da pintura de deslocamento de textura" msgid "Texture displacement editing" msgstr "Edição de deslocamento de textura" -# AI Translated msgid "Choose SLA archive:" msgstr "Escolha o arquivo SLA:" -# AI Translated msgid "Import file" msgstr "Importar arquivo" -# AI Translated msgid "Import model and profile" msgstr "Importar modelo e perfil" -# AI Translated msgid "Import profile only" msgstr "Importar apenas o perfil" -# AI Translated msgid "Import model only" msgstr "Importar apenas o modelo" -# AI Translated msgid "Accurate" msgstr "Precisa" -# AI Translated msgid "Balanced" msgstr "Equilibrada" -# AI Translated msgid "Quick" msgstr "Rápida" -# AI Translated msgid "Baking texture displacement" msgstr "Assando deslocamento de textura" -# AI Translated msgid "The bake produced no displacement, so nothing was changed and the paint was kept. Check that the painted layer has a texture and a non-zero depth." msgstr "O assamento não produziu deslocamento, então nada foi alterado e a pintura foi mantida. Verifique se a camada pintada tem uma textura e uma profundidade diferente de zero." @@ -29136,7 +28867,6 @@ msgstr "O assamento não produziu deslocamento, então nada foi alterado e a pin msgid "The triangle budget limited the detail: this resolution needs %1% triangles, the budget kept %2%. Raise Budget or use a coarser Resolution for the full detail." msgstr "O orçamento de triângulos limitou o detalhe: esta resolução precisa de %1% triângulos, o orçamento manteve %2%. Aumente Orçamento ou use uma Resolução mais grosseira para obter o detalhe completo." -# AI Translated msgid "Capturing bake stages" msgstr "Capturando etapas do assamento" @@ -30169,7 +29899,7 @@ msgstr "Desdobrar" # AI Translated msgid "Seam angle" -msgstr "Ângulo de emenda" +msgstr "Ângulo de costura" # AI Translated msgid "Edges sharper than this are cut, and the pieces either side of them are flattened separately. Lower it to cut more: each piece then lies flat with less stretching, at the cost of the texture not running continuously across the cut. Takes effect at the next Unwrap." @@ -30194,15 +29924,13 @@ msgstr "" # AI Translated msgid "Mark seams - click edges on the model to cut the unwrap along them. The edge under the cursor is highlighted yellow; click to mark it red, click a red edge again to unmark it. Painting is paused while this is on." -msgstr "Marcar emendas - clique em arestas no modelo para cortar o desdobramento ao longo delas. A aresta sob o cursor fica destacada em amarelo; clique para marcá-la em vermelho, clique novamente em uma aresta vermelha para desmarcá-la. A pintura fica pausada enquanto isto estiver ativo." +msgstr "Marcar costuras - clique em arestas no modelo para cortar o desdobramento ao longo delas. A aresta sob o cursor fica destacada em amarelo; clique para marcá-la em vermelho, clique novamente em uma aresta vermelha para desmarcá-la. A pintura fica pausada enquanto isto estiver ativo." -# AI Translated msgid "Path - instead of clicking every edge, click a start point and then an end point: the whole shortest path between them is seamed at once. Available while marking seams." -msgstr "Caminho - em vez de clicar em cada aresta, clique em um ponto inicial e depois em um ponto final: todo o caminho mais curto entre eles recebe a emenda de uma vez. Disponível ao marcar emendas." +msgstr "Caminho - em vez de clicar em cada aresta, clique em um ponto inicial e depois em um ponto final: todo o caminho mais curto entre eles recebe a costura de uma vez. Disponível ao marcar costuras." -# AI Translated msgid "Clear seams - remove every seam marked on this layer" -msgstr "Limpar emendas - remove todas as emendas marcadas nesta camada" +msgstr "Limpar costuras - remove todas as costuras marcadas nesta camada" # AI Translated msgid "Average scale - give every island the same texel density" @@ -30236,11 +29964,9 @@ msgstr "" msgid "What is selected, and the exact figures of the move, rotation or scale while you drag one." msgstr "" -# AI Translated msgid "No layer mapped with Unwrap" msgstr "Nenhuma camada mapeada com Desdobrar" -# AI Translated #, c-format, boost-format msgid "%.1f mm tile" msgstr "ladrilho de %.1f mm" @@ -30250,11 +29976,10 @@ msgstr "" # AI Translated msgid "Out of date - the paint, the seams or the seam angle changed since this unwrap was made. Press to unwrap again." -msgstr "Desatualizado - a pintura, as emendas ou o ângulo de emenda mudaram desde que este desdobramento foi feito. Pressione para desdobrar novamente." +msgstr "Desatualizado - a pintura, as costuras ou o ângulo de costura mudaram desde que este desdobramento foi feito. Pressione para desdobrar novamente." -# AI Translated msgid "Flatten the painted area into UV islands. It is computed only when you press this, not on every edit - so paint, change the seam angle or mark seams first, then press Unwrap." -msgstr "Achata a área pintada em ilhas UV. Só é calculado quando você pressiona isto, não a cada edição - então pinte, altere o ângulo de emenda ou marque emendas primeiro e depois pressione Desdobrar." +msgstr "Achata a área pintada em ilhas UV. Só é calculado quando você pressiona isso, não a cada edição - então pinte, altere o ângulo de costura ou marque costuras primeiro e depois pressione Desdobrar." msgid "Turn Mark seams on first - Path is a quicker way of marking them." msgstr "" @@ -30685,7 +30410,6 @@ msgstr "" "O plugin de pós-processamento %1% falhou.\n" "Erro: %2%" -# AI Translated #, boost-format msgid "" "Post-processing plugin %1% failed.\n" @@ -30695,19 +30419,16 @@ msgid "" msgstr "" "O plugin de pós-processamento %1% falhou.\n" "\n" -"Espera-se que o plugin de pós-processamento modifique o arquivo G-code %2% no próprio local, mas o arquivo G-code foi excluído.\n" +"É esperado que o plugin de pós-processamento modifique o arquivo G-code %2% diretamente, mas o arquivo G-code foi excluído.\n" "Verifique a implementação do plugin.\n" -# AI Translated #, boost-format msgid "Failed making a temporary copy of G-code file %1% before running a post-processing script: %2%" msgstr "Falha ao criar uma cópia temporária do arquivo G-code %1% antes de executar um script de pós-processamento: %2%" -# AI Translated msgid "Post-processor can't find exported gcode file" msgstr "O pós-processador não consegue encontrar o arquivo G-code exportado" -# AI Translated #, boost-format msgid "" "Post-processing script %1% on file %2% failed.\n" @@ -30716,7 +30437,6 @@ msgstr "" "O script de pós-processamento %1% no arquivo %2% falhou.\n" "Código de erro: %3%" -# AI Translated #, boost-format msgid "" "Post-processing script %1% on file %2% failed.\n" @@ -30729,7 +30449,6 @@ msgstr "" "Saída:\n" "%4%" -# AI Translated #, boost-format msgid "" "Post-processing script %1% failed.\n" @@ -30739,36 +30458,29 @@ msgid "" msgstr "" "O script de pós-processamento %1% falhou.\n" "\n" -"Espera-se que o script de pós-processamento altere o arquivo G-code %2% no próprio local, mas o arquivo G-code foi excluído e provavelmente salvo com um novo nome.\n" -"Ajuste o script de pós-processamento para alterar o G-code no próprio local e consulte o manual para saber como, opcionalmente, renomear o arquivo G-code pós-processado.\n" +"Espera-se que o script de pós-processamento altere o arquivo G-code %2% diretamente, mas o arquivo G-code foi excluído e provavelmente salvo com um novo nome.\n" +"Ajuste o script de pós-processamento para alterar o G-code diretamente e consulte o manual para saber como renomear opcionalmente o arquivo G-code pós-processado.\n" -# AI Translated msgid "Unable to parse desired new path from output name file" msgstr "Não foi possível analisar o novo caminho desejado a partir do arquivo de nome de saída" -# AI Translated #, boost-format msgid "Output directory does not exist: %1%" msgstr "O diretório de saída não existe: %1%" -# AI Translated #, boost-format msgid "Failed reading a file %1% carrying the final name / path of a G-code file: %2%" -msgstr "Falha ao ler o arquivo %1% que contém o nome / caminho final de um arquivo G-code: %2%" +msgstr "Falha ao ler o arquivo %1% que contém o nome/caminho final de um arquivo G-code: %2%" -# AI Translated msgid "The printing task exceeds the limit, supporting a maximum of 6 printers." -msgstr "A tarefa de impressão excede o limite; são suportadas no máximo 6 impressoras." +msgstr "A tarefa de impressão excede o limite, são suportadas no máximo 6 impressoras." -# AI Translated msgid "Elapsed time:" msgstr "Tempo decorrido:" -# AI Translated msgid "Estimated time:" msgstr "Tempo estimado:" -# AI Translated msgid "Remaining time:" msgstr "Tempo restante:" @@ -30982,8 +30694,7 @@ msgstr "" "Divida suas impressões em placas\n" "Você sabia que pode dividir um modelo que tem diversas peças em placas individuais distintas prontas para imprimir? Isso simplifica o processo de manter o controle de todas as peças." -#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer -#: Height] +#: resources/data/hints.ini: [hint:Speed up your print with Adaptive Layer Height] msgid "" "Speed up your print with Adaptive Layer Height\n" "Did you know that you can print a model even faster by using the Adaptive Layer Height option? Check it out!" @@ -31055,8 +30766,7 @@ msgstr "" "Melhorar a resistência\n" "Você sabia que pode usar mais voltas de parede e maior densidade de preenchimento esparso mais alta para melhorar a resistência do modelo?" -#: resources/data/hints.ini: [hint:When do you need to print with the printer -#: door opened] +#: resources/data/hints.ini: [hint:When do you need to print with the printer door opened] msgid "" "When do you need to print with the printer door opened?\n" "Did you know that opening the printer door can reduce the probability of extruder/hotend clogging when printing lower temperature filament with a higher enclosure temperature? There is more info about this in the Wiki." @@ -31104,10 +30814,6 @@ msgstr "" #~ msgid "The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer." #~ msgstr "O arquivo 3MF foi criado pelo Bambu Studio. Algumas configurações podem ser diferentes do OrcaSlicer." -# AI Translated -#~ msgid "Cannot undo or redo while an operation is running. Stop it first." -#~ msgstr "Não é possível desfazer ou refazer enquanto uma operação está em andamento. Interrompa-a primeiro." - #, c-format, boost-format #~ msgid "%s: %s" #~ msgstr "%s: %s" @@ -31129,340 +30835,9 @@ msgstr "" #~ msgid "Video tutorial" #~ msgstr "Tutorial em vídeo" -# AI Translated -#~ msgid "Primitive" -#~ msgstr "Primitivo" - -# AI Translated -#~ msgid "Empty mesh generated" -#~ msgstr "Malha vazia gerada" - -# AI Translated -#~ msgid "Box" -#~ msgstr "Caixa" - -# AI Translated -#~ msgid "Quick:" -#~ msgstr "Rápido:" - -# AI Translated -#~ msgid "Width (X)" -#~ msgstr "Largura (X)" - -# AI Translated -#~ msgid "Depth (Y)" -#~ msgstr "Profundidade (Y)" - -# AI Translated -#~ msgid "Height (Z)" -#~ msgstr "Altura (Z)" - -# AI Translated -#~ msgid "Bottom R" -#~ msgstr "R inferior" - -# AI Translated -#~ msgid "Top R" -#~ msgstr "R superior" - -# AI Translated -#~ msgid "Major R" -#~ msgstr "R maior" - -# AI Translated -#~ msgid "Minor R" -#~ msgstr "R menor" - -# AI Translated -#~ msgid "All edges" -#~ msgstr "Todas as arestas" - -# AI Translated -#~ msgid "Top edges" -#~ msgstr "Arestas superiores" - -# AI Translated -#~ msgid "Bottom edges" -#~ msgstr "Arestas inferiores" - -# AI Translated -#~ msgid "Lateral edges" -#~ msgstr "Arestas laterais" - -# AI Translated -#~ msgid "Mesh resolution" -#~ msgstr "Resolução da malha" - -# AI Translated -#~ msgid "Add Shape" -#~ msgstr "Adicionar forma" - -# AI Translated -#~ msgid "No outer profile" -#~ msgstr "Nenhum perfil externo" - -# AI Translated -#~ msgid "Failed to make outer face" -#~ msgstr "Falha ao criar a face externa" - -# AI Translated -#~ msgid "Failed to build face with holes" -#~ msgstr "Falha ao construir a face com furos" - -# AI Translated -#~ msgid "Revolve failed" -#~ msgstr "Falha na revolução" - -# AI Translated -#~ msgid "Profile" -#~ msgstr "Perfil" - -# AI Translated -#~ msgid "Start new profile (for holes)" -#~ msgstr "Iniciar novo perfil (para furos)" - -# AI Translated -#~ msgid "Segments" -#~ msgstr "Segmentos" - -# AI Translated -#~ msgid "Sides" -#~ msgstr "Lados" - -# AI Translated -#~ msgid "Click on canvas to draw" -#~ msgstr "Clique na tela para desenhar" - -# AI Translated -#~ msgid "Snap to grid" -#~ msgstr "Ajustar à grade" - -# AI Translated -#~ msgid "Grid step" -#~ msgstr "Passo da grade" - -# AI Translated -#~ msgid "Outer" -#~ msgstr "Externo" - -# AI Translated -#~ msgid "closed" -#~ msgstr "fechado" - -# AI Translated -#, boost-format -#~ msgid "%1% %2%: %3% points %4%" -#~ msgstr "%1% %2%: %3% pontos %4%" - -# AI Translated -#~ msgid "XY (Top)" -#~ msgstr "XY (Topo)" - -# AI Translated -#~ msgid "XZ (Front)" -#~ msgstr "XZ (Frente)" - -# AI Translated -#~ msgid "YZ (Side)" -#~ msgstr "YZ (Lateral)" - -# AI Translated -#~ msgid "Revolve deg" -#~ msgstr "Revolução (graus)" - -# AI Translated -#~ msgid "Set to 0 for extrude, >0 for revolve" -#~ msgstr "Defina como 0 para extrusão, >0 para revolução" - -# AI Translated -#~ msgid "Pocket (cut)" -#~ msgstr "Cavidade (corte)" - -# AI Translated -#, c-format -#~ msgid "%zu closed profile" -#~ msgid_plural "%zu closed profiles" -#~ msgstr[0] "%zu perfil fechado" -#~ msgstr[1] "%zu perfis fechados" - -# AI Translated -#~ msgid "Draw a closed profile to enable" -#~ msgstr "Desenhe um perfil fechado para ativar" - -# AI Translated -#~ msgid "Empty result" -#~ msgstr "Resultado vazio" - -# AI Translated -#, boost-format -#~ msgid "Extrude: %1%" -#~ msgstr "Extrusão: %1%" - -# AI Translated -#, boost-format -#~ msgid "Revolve: %1%" -#~ msgstr "Revolução: %1%" - -# AI Translated -#~ msgid "No object selected" -#~ msgstr "Nenhum objeto selecionado" - -# AI Translated -#~ msgid "Face failed" -#~ msgstr "Falha na face" - -# AI Translated -#~ msgid "Face with holes failed" -#~ msgstr "Falha na face com furos" - -# AI Translated -#~ msgid "Tool mesh empty" -#~ msgstr "Malha da ferramenta vazia" - -# AI Translated -#~ msgid "Pocket added (negative volume)" -#~ msgstr "Cavidade adicionada (volume negativo)" - -# AI Translated -#, boost-format -#~ msgid "Pocket: %1%" -#~ msgstr "Cavidade: %1%" - -# AI Translated -#~ msgid "Needs the active layer mapped with Unwrap (LSCM)." -#~ msgstr "Requer a camada ativa mapeada com Desdobrar (LSCM)." - -# AI Translated -#~ msgid "Checker - a test grid instead of the texture. Where the squares stay square the texture is undistorted; where they stretch, it will too" -#~ msgstr "Xadrez - uma grade de teste no lugar da textura. Onde os quadrados continuam quadrados, a textura não está distorcida; onde eles esticam, ela também vai esticar" - -# AI Translated -#~ msgid "Distortion - blue-to-red stretch heatmap over the unwrap" -#~ msgstr "Distorção - mapa de calor de estiramento, de azul a vermelho, sobre o desdobramento" - -# AI Translated -#~ msgid "Mix by" -#~ msgstr "Misturar por" - -# AI Translated -#~ msgctxt "Texture Displacement" -#~ msgid "Surface" -#~ msgstr "Superfície" - -# AI Translated -#~ msgid "" -#~ "Layers: the two filaments alternate between print layers, which blends smoothly on upright surfaces but disappears on flat-facing ones, where a whole layer is a single band.\n" -#~ "Surface: a fine checkerboard across the surface, which works at any angle but can read as texture rather than as a blend.\n" -#~ "Automatic: layers on upright faces; flat-facing faces take the nearer single filament, since a checkerboard there shows as a pattern." -#~ msgstr "" -#~ "Camadas: os dois filamentos se alternam entre as camadas de impressão, o que mistura suavemente em superfícies verticais, mas desaparece nas horizontais, onde uma camada inteira é uma única faixa.\n" -#~ "Superfície: um xadrez fino sobre a superfície, que funciona em qualquer ângulo, mas pode parecer textura em vez de mistura.\n" -#~ "Automático: camadas nas faces verticais; as faces horizontais recebem o filamento único mais próximo, já que ali um xadrez aparece como padrão." - -# AI Translated -#~ msgid "Island - move, rotate and scale whole islands" -#~ msgstr "Ilha - mover, girar e escalar ilhas inteiras" - -# AI Translated -#~ msgid "Vertex - drag vertices to reshape; Shift/Ctrl to multi-select" -#~ msgstr "Vértice - arraste vértices para remodelar; Shift/Ctrl para seleção múltipla" - -# AI Translated -#~ msgid "Edge - drag edges to reshape; Shift/Ctrl to multi-select" -#~ msgstr "Aresta - arraste arestas para remodelar; Shift/Ctrl para seleção múltipla" - -# AI Translated -#~ msgid "Frame all islands (Home)" -#~ msgstr "Enquadrar todas as ilhas (Home)" - #~ msgid "(Experimental) Use printer agents instead of print hosts" #~ msgstr "(Experimental) Usar agentes de impressora em vez de hosts de impressão" -# AI Translated -#~ msgid "FEATURES" -#~ msgstr "RECURSOS" - -# AI Translated -#~ msgid "Click a face or a reference plane in the viewport, then a sketch tool" -#~ msgstr "Clique em uma face ou em um plano de referência na janela de visualização e depois em uma ferramenta de esboço" - -# AI Translated -#~ msgid "Click a face or a reference plane, then a sketch tool." -#~ msgstr "Clique em uma face ou em um plano de referência e depois em uma ferramenta de esboço." - -# AI Translated -#, c-format, boost-format -#~ msgid "%s plane selected — right-click for the drawing tools" -#~ msgstr "Plano %s selecionado — clique com o botão direito para ver as ferramentas de desenho" - -# AI Translated -#~ msgid "" -#~ "Nothing yet — import a STEP or a mesh from the toolbar,\n" -#~ "or click a reference plane and right-click it to start a sketch." -#~ msgstr "" -#~ "Nada ainda — importe um STEP ou uma malha pela barra de ferramentas,\n" -#~ "ou clique em um plano de referência e clique nele com o botão direito para iniciar um esboço." - -# AI Translated -#~ msgid "Click a face or a reference plane, then pick a drawing tool" -#~ msgstr "Clique em uma face ou em um plano de referência e depois escolha uma ferramenta de desenho" - -# AI Translated -#~ msgid "XY" -#~ msgstr "XY" - -# AI Translated -#~ msgid "XZ" -#~ msgstr "XZ" - -# AI Translated -#~ msgid "YZ" -#~ msgstr "YZ" - -# AI Translated -#~ msgid "Equal" -#~ msgstr "Igual" - -# AI Translated -#~ msgid "Perpendicular" -#~ msgstr "Perpendicular" - -# AI Translated -#~ msgid "Midpoint" -#~ msgstr "Ponto médio" - -# AI Translated -#~ msgid "Equal length" -#~ msgstr "Comprimento igual" - -# AI Translated -#~ msgid "Equal radius" -#~ msgstr "Raio igual" - -# AI Translated -#~ msgid "Collinear" -#~ msgstr "Colinear" - -# AI Translated -#~ msgid "Symmetric about the vertical axis" -#~ msgstr "Simétrico em relação ao eixo vertical" - -# AI Translated -#~ msgid "Symmetric about the horizontal axis" -#~ msgstr "Simétrico em relação ao eixo horizontal" - -# AI Translated -#~ msgid "Fix point (anchor in place)" -#~ msgstr "Fixar ponto (ancorar no lugar)" - -# AI Translated -#~ msgid "Horizontal distance" -#~ msgstr "Distância horizontal" - -# AI Translated -#~ msgid "Vertical distance" -#~ msgstr "Distância vertical" - #~ msgid "Face recognition" #~ msgstr "Reconhecimento facial" @@ -31500,10 +30875,6 @@ msgstr "" #~ msgid "Please resolve the slicing errors and publish again." #~ msgstr "Por favor, resolva os erros de fatiamento e publique novamente." -# AI Translated -#~ msgid "Treat sketch endpoints within 0.001 mm as one joint and weld the loop shut. Off: only exactly coincident endpoints join, so a loop with a tiny gap is shown as open instead of being closed for you." -#~ msgstr "Trata extremidades do esboço a menos de 0,001 mm umas das outras como uma só junção e fecha o laço. Desativado: somente extremidades exatamente coincidentes se unem, então um laço com uma pequena folga é mostrado como aberto em vez de ser fechado para você." - #~ msgid "Slice all plates to obtain time and filament estimation" #~ msgstr "Fatiar todas as placas para obter estimativa de tempo e filamento" @@ -31751,860 +31122,21 @@ msgstr "" #~ msgid "Standard profile for 0.8mm nozzle, prioritizing speed." #~ msgstr "Perfil padrão para bico de 0,8 mm, priorizando a velocidade." -# AI Translated -#~ msgid "Slot — two centerline ends, then end radius" -#~ msgstr "Rasgo — as duas extremidades da linha central e depois o raio da extremidade" - -# AI Translated -#~ msgid "Isometric view, fitted" -#~ msgstr "Vista isométrica, ajustada" - -# AI Translated -#~ msgid "Coord Sys" -#~ msgstr "Sist. coord." - -# AI Translated -#~ msgid "Assembly: align two CoordSys features (fastened, planar, revolute, slider, cylindrical)" -#~ msgstr "Montagem: alinhar dois recursos de sistema de coordenadas (fixo, planar, de revolução, deslizante, cilíndrico)" - -# AI Translated -#~ msgid "Click points; click the first point to close the loop, right-click to end the chain" -#~ msgstr "Clique nos pontos; clique no primeiro ponto para fechar o laço, clique com o botão direito para encerrar a cadeia" - -# AI Translated -#~ msgid "Click two centerline ends, then a point for the end radius" -#~ msgstr "Clique nas duas extremidades da linha central e depois em um ponto para o raio da extremidade" - -# AI Translated -#~ msgid "Click control points; double-click or right-click to finish" -#~ msgstr "Clique nos pontos de controle; clique duplo ou clique com o botão direito para terminar" - -# AI Translated -#~ msgid "Click a segment to trim it back to its nearest intersection; right-click exits" -#~ msgstr "Clique em um segmento para apará-lo até a interseção mais próxima; clique com o botão direito para sair" - -# AI Translated -#~ msgid "Click a line or arc to extend it to the nearest entity; right-click exits" -#~ msgstr "Clique em uma linha ou arco para estendê-lo até a entidade mais próxima; clique com o botão direito para sair" - -# AI Translated -#~ msgid "✓ Confirm" -#~ msgstr "✓ Confirmar" - -# AI Translated -#~ msgid "✗ Cancel" -#~ msgstr "✗ Cancelar" - -# AI Translated -#~ msgid "Undo (Ctrl+Z)" -#~ msgstr "Desfazer (Ctrl+Z)" - -# AI Translated -#~ msgid "Redo (Ctrl+Shift+Z)" -#~ msgstr "Refazer (Ctrl+Shift+Z)" - -# AI Translated -#~ msgid "Taper °" -#~ msgstr "Conicidade °" - -# AI Translated -#~ msgid "Total angle°" -#~ msgstr "Ângulo total°" - -# AI Translated -#~ msgid "Union (join)" -#~ msgstr "Unir (juntar)" - -# AI Translated -#~ msgid "Subtract (cut)" -#~ msgstr "Subtrair (cortar)" - -# AI Translated -#~ msgid "Angle°" -#~ msgstr "Ângulo°" - -# AI Translated -#~ msgid "CS A (fixed)" -#~ msgstr "SC A (fixo)" - -# AI Translated -#~ msgid "CS B (moves)" -#~ msgstr "SC B (móvel)" - -# AI Translated -#~ msgid "Move body" -#~ msgstr "Mover corpo" - -# AI Translated -#~ msgid "Select a body row first, then move it" -#~ msgstr "Selecione primeiro a linha de um corpo e depois mova-o" - -# AI Translated -#~ msgid "+" -#~ msgstr "+" - -# AI Translated -#, c-format, boost-format -#~ msgid "Body %d: edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body" -#~ msgstr "Corpo %d: aresta %d selecionada — Filete/Chanfro para arredondá-la ou chanfrá-la, ou clique novamente para o corpo inteiro" - -# AI Translated -#, c-format, boost-format -#~ msgid "Edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body" -#~ msgstr "Aresta %d selecionada — Filete/Chanfro para arredondá-la ou chanfrá-la, ou clique novamente para o corpo inteiro" - -# AI Translated -#~ msgid "Text to insert:" -#~ msgstr "Texto a inserir:" - -# AI Translated -#~ msgid "STEP import failed: " -#~ msgstr "Falha ao importar STEP: " - -# AI Translated -#~ msgid "Could not read the OBJ file: " -#~ msgstr "Não foi possível ler o arquivo OBJ: " - -# AI Translated -#~ msgid "Mesh conversion failed: " -#~ msgstr "Falha na conversão da malha: " - -# AI Translated -#~ msgid "Mesh import failed: " -#~ msgstr "Falha ao importar a malha: " - -# AI Translated -#~ msgid "Drag a corner to scale, the center to move — right-click when done" -#~ msgstr "Arraste um canto para dimensionar, o centro para mover — clique com o botão direito ao terminar" - -# AI Translated -#~ msgid "Recompute error: " -#~ msgstr "Erro de recálculo: " - -# AI Translated -#~ msgid "Mate needs two CoordSys features — create them first" -#~ msgstr "O posicionamento precisa de dois recursos de sistema de coordenadas — crie-os primeiro" - -# AI Translated -#~ msgid "Mate: CS A and CS B must be different CoordSys features" -#~ msgstr "Posicionamento: SC A e SC B devem ser recursos de sistema de coordenadas diferentes" - -# AI Translated -#~ msgid "No interference — need at least two solid bodies to check" -#~ msgstr "Sem interferência — são necessários pelo menos dois corpos sólidos para verificar" - -# AI Translated -#, c-format -#~ msgid "%zu interference pair, worst %.2f mm³" -#~ msgid_plural "%zu interference pairs, worst %.2f mm³" -#~ msgstr[0] "%zu par com interferência, pior %.2f mm³" -#~ msgstr[1] "%zu pares com interferência, pior %.2f mm³" - -# AI Translated -#~ msgid "" -#~ "Interference pairs:\n" -#~ "\n" -#~ msgstr "" -#~ "Pares com interferência:\n" -#~ "\n" - -# AI Translated -#~ msgid "Interference" -#~ msgstr "Interferência" - -# AI Translated -#~ msgid "Select a solid body first — its mass properties are what is reported" -#~ msgstr "Selecione primeiro um corpo sólido — são as propriedades de massa dele que são informadas" - -# AI Translated -#~ msgid "Mass properties could not be computed for this body" -#~ msgstr "Não foi possível calcular as propriedades de massa deste corpo" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s: sheet body — %.2f cm² of surface, no volume" -#~ msgstr "%s: corpo de superfície — %.2f cm² de superfície, sem volume" - -# AI Translated -#, c-format, boost-format -#~ msgid "" -#~ "%s\n" -#~ "\n" -#~ "Sheet body (open shell)\n" -#~ "Surface area: %.2f cm²\n" -#~ "\n" -#~ "A sheet encloses no material, so it has no volume. Thicken it into a solid to get one." -#~ msgstr "" -#~ "%s\n" -#~ "\n" -#~ "Corpo de superfície (casca aberta)\n" -#~ "Área de superfície: %.2f cm²\n" -#~ "\n" -#~ "Uma superfície não envolve material, então não tem volume. Espesse-a para transformá-la em um sólido e obter um volume." - -# AI Translated -#~ msgid "Mass properties" -#~ msgstr "Propriedades de massa" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s: %.3f cm³, %.2f cm²" -#~ msgstr "%s: %.3f cm³, %.2f cm²" - -# AI Translated -#, c-format, boost-format -#~ msgid "" -#~ "%s\n" -#~ "\n" -#~ "Volume: %.3f cm³\n" -#~ "Surface area: %.2f cm²" -#~ msgstr "" -#~ "%s\n" -#~ "\n" -#~ "Volume: %.3f cm³\n" -#~ "Área de superfície: %.2f cm²" - -# AI Translated -#~ msgid "Boolean needs two solid bodies — add or import a second one" -#~ msgstr "A operação booleana precisa de dois corpos sólidos — adicione ou importe um segundo" - -# AI Translated -#~ msgid "Line — click the end point, or type the length" -#~ msgstr "Linha — clique no ponto final ou digite o comprimento" - -# AI Translated -#~ msgid "Polyline — click the next point · click the start point to close it · right-click to end the chain" -#~ msgstr "Polilinha — clique no próximo ponto · clique no ponto inicial para fechá-la · clique com o botão direito para encerrar a cadeia" - -# AI Translated -#~ msgid "Circle — click to set the radius, or type it" -#~ msgstr "Círculo — clique para definir o raio ou digite-o" - -# AI Translated -#~ msgid "Spline — click the next control point · right-click to finish the curve" -#~ msgstr "Spline — clique no próximo ponto de controle · clique com o botão direito para terminar a curva" - -# AI Translated -#~ msgid "Trim — click a segment where it crosses another entity · right-click to exit" -#~ msgstr "Aparar — clique em um segmento onde ele cruza outra entidade · clique com o botão direito para sair" - -# AI Translated -#~ msgid "Extend — click a line or arc to grow it out to the nearest entity · right-click to exit" -#~ msgstr "Estender — clique em uma linha ou arco para prolongá-lo até a entidade mais próxima · clique com o botão direito para sair" - -# AI Translated -#~ msgid "Fillet — drag the arrow, or click the number to type the radius · click empty space to apply · right-click cancels" -#~ msgstr "Filete — arraste a seta ou clique no número para digitar o raio · clique em um espaço vazio para aplicar · o botão direito cancela" - -# AI Translated -#~ msgid "Chamfer — drag the arrow, or click the number to type the setback · click empty space to apply · right-click cancels" -#~ msgstr "Chanfro — arraste a seta ou clique no número para digitar o recuo · clique em um espaço vazio para aplicar · o botão direito cancela" - -# AI Translated -#~ msgid "Offset — click the entity to offset" -#~ msgstr "Deslocamento — clique na entidade a deslocar" - -# AI Translated -#~ msgid "Offset — drag the arrow to either side, or click the number to type the distance · click empty space to apply · right-click cancels" -#~ msgstr "Deslocamento — arraste a seta para qualquer lado ou clique no número para digitar a distância · clique em um espaço vazio para aplicar · o botão direito cancela" - -# AI Translated -#~ msgid "Mirror — axis set · now click the entities to mirror · right-click cancels" -#~ msgstr "Espelhar — eixo definido · agora clique nas entidades a espelhar · o botão direito cancela" - -# AI Translated -#, c-format, boost-format -#~ msgid "Mirror — axis set · %d to mirror · click another to add or remove it · click empty space to apply" -#~ msgstr "Espelhar — eixo definido · %d a espelhar · clique em outra para adicioná-la ou removê-la · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Move — drag the handle, or click the number to type the distance · click empty space to apply" -#~ msgstr "Mover — arraste a alça ou clique no número para digitar a distância · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Rotate — drag the handle, or click the number to type the angle · click empty space to apply" -#~ msgstr "Rotacionar — arraste a alça ou clique no número para digitar o ângulo · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Scale — drag the handle, or click the number to type the factor · click empty space to apply" -#~ msgstr "Dimensionar — arraste a alça ou clique no número para digitar o fator · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Array — drag the handle to set the step, click the count to type it · click empty space to apply" -#~ msgstr "Matriz — arraste a alça para definir o passo, clique na quantidade para digitá-la · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Polar array — drag the handle to set the sweep, click the count to type it · click empty space to apply" -#~ msgstr "Matriz polar — arraste a alça para definir o ângulo total, clique na quantidade para digitá-la · clique em um espaço vazio para aplicar" - -# AI Translated -#~ msgid "Drag a corner to scale, the center to move · right-click when done" -#~ msgstr "Arraste um canto para dimensionar, o centro para mover · clique com o botão direito ao terminar" - -# AI Translated -#~ msgid "Coord Sys added" -#~ msgstr "Sist. coord. adicionado" - -# AI Translated -#~ msgid "Could not restore the CAD model: " -#~ msgstr "Não foi possível restaurar o modelo CAD: " - -# AI Translated -#~ msgid "Boolean needs two bodies — create or import a second solid" -#~ msgstr "A operação booleana precisa de dois corpos — crie ou importe um segundo sólido" - -# AI Translated -#~ msgid "Edit rejected: " -#~ msgstr "Edição rejeitada: " - -# AI Translated -#, c-format, boost-format -#~ msgid "" -#~ "Delete %s?\n" -#~ "\n" -#~ "That is the feature this body was made from. Features built on it may be removed or stop working." -#~ msgstr "" -#~ "Excluir %s?\n" -#~ "\n" -#~ "Esse é o recurso a partir do qual este corpo foi criado. Os recursos construídos sobre ele podem ser removidos ou parar de funcionar." - -# AI Translated -#~ msgid "Delete body" -#~ msgstr "Excluir corpo" - -# AI Translated -#~ msgid "STEP export failed: " -#~ msgstr "Falha ao exportar STEP: " - -# AI Translated -#~ msgid "Coord Sys ready" -#~ msgstr "Sist. coord. pronto" - -# AI Translated -#~ msgid "Mate needs at least two CoordSys features" -#~ msgstr "O posicionamento precisa de pelo menos dois recursos de sistema de coordenadas" - -# AI Translated -#~ msgid "Mate: CS A and CS B must be different" -#~ msgstr "Posicionamento: SC A e SC B devem ser diferentes" - -# AI Translated -#~ msgid "Invalid: " -#~ msgstr "Inválido: " - -# AI Translated -#~ msgid "Variable name:" -#~ msgstr "Nome da variável:" - -# AI Translated -#, c-format, boost-format -#~ msgid "Expression for '%s':" -#~ msgstr "Expressão para '%s':" - -# AI Translated -#~ msgid "Seg" -#~ msgstr "Seg." - -# AI Translated -#~ msgid "CLOSED" -#~ msgstr "FECHADO" - -# AI Translated -#, boost-format -#~ msgid "%1% %2%: %3% pts %4%" -#~ msgstr "%1% %2%: %3% pts %4%" - -# AI Translated -#~ msgid "Pocket (Cut)" -#~ msgstr "Cavidade (Corte)" - -# AI Translated -#~ msgid "Clear All" -#~ msgstr "Limpar tudo" - -# AI Translated -#~ msgid "Hollow this object" -#~ msgstr "Esvaziar este objeto" - -# AI Translated -#~ msgid "Preview hollowed and drilled model" -#~ msgstr "Pré-visualizar modelo esvaziado e perfurado" - -# AI Translated -#~ msgid "Closing distance" -#~ msgstr "Distância de fechamento" - -# AI Translated -#~ msgid "Hole diameter" -#~ msgstr "Diâmetro do furo" - -# AI Translated -#~ msgid "Hole depth" -#~ msgstr "Profundidade do furo" - -# AI Translated -#~ msgid "Remove selected holes" -#~ msgstr "Remover furos selecionados" - -# AI Translated -#~ msgid "Remove all holes" -#~ msgstr "Remover todos os furos" - -# AI Translated -#~ msgid "Clipping of view" -#~ msgstr "Recorte da vista" - #~ msgid "Reset direction" #~ msgstr "Redefinir direção" -# AI Translated -#~ msgid "Show supports" -#~ msgstr "Mostrar suportes" - -# AI Translated -#~ msgid "Hollow and drill" -#~ msgstr "Esvaziar e perfurar" - #~ msgid "Head diameter" #~ msgstr "Diâmetro da cabeça" -# AI Translated -#~ msgid "Lock supports under new islands" -#~ msgstr "Travar suportes sob novas ilhas" - #~ msgid "Remove selected points" #~ msgstr "Remover pontos selecionados" -# AI Translated -#~ msgid "Remove all points" -#~ msgstr "Remover todos os pontos" - -# AI Translated -#~ msgid "Apply changes" -#~ msgstr "Aplicar alterações" - -# AI Translated -#~ msgid "Discard changes" -#~ msgstr "Descartar alterações" - -# AI Translated -#~ msgid "Minimal points distance" -#~ msgstr "Distância mínima entre pontos" - -# AI Translated -#~ msgid "Support points density" -#~ msgstr "Densidade dos pontos de suporte" - -#~ msgid "Auto-generate points" -#~ msgstr "Pontos gerados automaticamente" - -# AI Translated -#~ msgid "Manual editing" -#~ msgstr "Edição manual" - -# AI Translated -#~ msgid "SLA Support Points" -#~ msgstr "Pontos de Suporte SLA" - -# AI Translated -#~ msgid "Do you want to save your manually edited support points?" -#~ msgstr "Deseja salvar os pontos de suporte editados manualmente?" - -# AI Translated -#~ msgid "Save support points?" -#~ msgstr "Salvar pontos de suporte?" - -# AI Translated -#~ msgid "Autogeneration will erase all manually edited points." -#~ msgstr "A geração automática apagará todos os pontos editados manualmente." - -# AI Translated -#~ msgid "Are you sure you want to do it?" -#~ msgstr "Tem certeza de que deseja fazer isso?" - -# AI Translated -#~ msgid "SLA gizmo keyboard shortcuts" -#~ msgstr "Atalhos de teclado do gizmo SLA" - -# AI Translated -#~ msgid "Note: some shortcuts work in (non)editing mode only." -#~ msgstr "Observação: alguns atalhos funcionam apenas dentro ou apenas fora do modo de edição." - #~ msgid "Left click" #~ msgstr "Clique esquerdo" -# AI Translated -#~ msgid "Add point" -#~ msgstr "Adicionar ponto" - #~ msgid "Right click" #~ msgstr "Clique direito" -# AI Translated -#~ msgid "Remove point" -#~ msgstr "Remover ponto" - -# AI Translated -#~ msgid "Move point" -#~ msgstr "Mover ponto" - -# AI Translated -#~ msgid "Add point to selection" -#~ msgstr "Adicionar ponto à seleção" - -# AI Translated -#~ msgid "Remove point from selection" -#~ msgstr "Remover ponto da seleção" - -# AI Translated -#~ msgid "Select by rectangle" -#~ msgstr "Selecionar por retângulo" - -# AI Translated -#~ msgid "Deselect by rectangle" -#~ msgstr "Desmarcar por retângulo" - -# AI Translated -#~ msgid "Select all points" -#~ msgstr "Selecionar todos os pontos" - -# AI Translated -#~ msgid "Move clipping plane" -#~ msgstr "Mover plano de recorte" - -# AI Translated -#~ msgid "Reset clipping plane" -#~ msgstr "Redefinir plano de recorte" - -# AI Translated -#~ msgid "Switch to editing mode" -#~ msgstr "Mudar para o modo de edição" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Union" -#~ msgstr "Unir" - -# AI Translated -#~ msgid "target is not a sheet body" -#~ msgstr "o alvo não é um corpo de superfície" - -# AI Translated -#~ msgid "Drill a hole, centred on a picked face or placed on a plane" -#~ msgstr "Fazer um furo, centralizado em uma face selecionada ou colocado em um plano" - -# AI Translated -#~ msgid "Shell needs a solid body" -#~ msgstr "A casca precisa de um corpo sólido" - -# AI Translated -#~ msgid "Split needs a solid body" -#~ msgstr "A divisão precisa de um corpo sólido" - -# AI Translated -#~ msgid "Pattern on curve needs a body and a curve" -#~ msgstr "O padrão em curva precisa de um corpo e de uma curva" - -# AI Translated -#~ msgid "A mate needs two coordinate systems" -#~ msgstr "Um posicionamento precisa de dois sistemas de coordenadas" - -# AI Translated -#~ msgid "Align needs a body" -#~ msgstr "O alinhamento precisa de um corpo" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Coord Sys" -#~ msgstr "Sist. coord." - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Mass" -#~ msgstr "Massa" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Colour" -#~ msgstr "Cor" - -# AI Translated -#~ msgid "Set the selected body's display colour" -#~ msgstr "Definir a cor de exibição do corpo selecionado" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Centre rectangle" -#~ msgstr "Retângulo pelo centro" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Centre circle" -#~ msgstr "Círculo pelo centro" - -# AI Translated -#~ msgctxt "Design" -#~ msgid "Centre-point arc" -#~ msgstr "Arco pelo centro" - -# AI Translated -#~ msgid "Triangle — click centre, then a vertex" -#~ msgstr "Triângulo — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Square — click centre, then a vertex" -#~ msgstr "Quadrado — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Pentagon — click centre, then a vertex" -#~ msgstr "Pentágono — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Hexagon — click centre, then a vertex" -#~ msgstr "Hexágono — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Octagon — click centre, then a vertex" -#~ msgstr "Octógono — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Dodecagon — click centre, then a vertex" -#~ msgstr "Dodecágono — clique no centro e depois em um vértice" - -# AI Translated -#~ msgid "Type text; its outline is added to this sketch as editable lines" -#~ msgstr "Digite um texto; o contorno dele é adicionado a este esboço como linhas editáveis" - -# AI Translated -#~ msgid "Major" -#~ msgstr "Eixo maior" - -# AI Translated -#~ msgid "Minor" -#~ msgstr "Eixo menor" - -# AI Translated -#~ msgid "Extend: click a line/arc that can reach another live entity" -#~ msgstr "Estender: clique em uma linha/arco que possa alcançar outra entidade ativa" - -# AI Translated -#~ msgid "Trim: click a segment where it crosses another live entity" -#~ msgstr "Aparar: clique em um segmento onde ele cruza outra entidade ativa" - -# AI Translated -#~ msgid "closed profile(s)" -#~ msgstr "perfil(is) fechado(s)" - -#~ msgid "Failed to generate cali G-code" -#~ msgstr "Falha ao gerar o G-code de calibração" - -# AI Translated -#~ msgid "Multiply" -#~ msgstr "Multiplicar" - -# AI Translated -#~ msgid " and " -#~ msgstr " e " - -# AI Translated -#~ msgid "cannot be mixed. Please select the same filament type." -#~ msgstr "não podem ser misturados. Selecione o mesmo tipo de filamento." - -# AI Translated -#~ msgid "An error occurred loading " -#~ msgstr "Ocorreu um erro ao carregar " - -# AI Translated -#~ msgid "/" -#~ msgstr "/" - -# AI Translated -#, c-format, boost-format -#~ msgid "Load %s to " -#~ msgstr "Carregar %s em " - -# AI Translated -#~ msgid "In the Design tab, draw a mate connector as a small face instead of the conventional disc with a roll quadrant. A face's orientation is read without being learned. Turn this off for the conventional CAD representation." -#~ msgstr "Na aba Design, desenha um conector de posicionamento como uma pequena face em vez do disco convencional com um quadrante de giro. A orientação de uma face é lida sem precisar ser aprendida. Desative para a representação CAD convencional." - -# AI Translated -#~ msgid "Expert" -#~ msgstr "Especialista" - -# AI Translated -#~ msgid "Simple" -#~ msgstr "Simples" - -# AI Translated -#~ msgid "Click points; click first / right-click to close the loop" -#~ msgstr "Clique nos pontos; clique no primeiro / clique com o botão direito para fechar o laço" - -# AI Translated -#~ msgid "X hint X" -#~ msgstr "Dica de X: X" - -# AI Translated -#~ msgid "X hint Y" -#~ msgstr "Dica de X: Y" - -# AI Translated -#~ msgid "X hint Z" -#~ msgstr "Dica de X: Z" - -# AI Translated -#~ msgid " — the eye suppresses this mate" -#~ msgstr " — o olho suprime este posicionamento" - -# AI Translated -#~ msgid "Del" -#~ msgstr "Excl." - -# AI Translated -#~ msgid "Sketch kept — Finish to commit it, Cancel to discard" -#~ msgstr "Esboço mantido — Terminar para confirmá-lo, Cancelar para descartá-lo" - -# AI Translated -#, c-format, boost-format -#~ msgid "Editing: Sketch %d · N = look normal to the plane · Finish or Cancel in the toolbar" -#~ msgstr "Editando: Esboço %d · N = olhar normal ao plano · Terminar ou Cancelar na barra de ferramentas" - -# AI Translated -#~ msgid "Editing sketch — drag a handle or click a quote to edit" -#~ msgstr "Editando esboço — arraste uma alça ou clique em uma cota para editar" - -# AI Translated -#, c-format, boost-format -#~ msgid "Showing %s actions" -#~ msgstr "Mostrando %s ações" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s actions" -#~ msgstr "%s ações" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s tabs" -#~ msgstr "%s abas" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s matches" -#~ msgstr "%s correspondências" - -# AI Translated -#, c-format, boost-format -#~ msgid "%s · on %s" -#~ msgstr "%s · em %s" - -# AI Translated -#, c-format, boost-format -#~ msgid "Sketching on %s — pick a tool" -#~ msgstr "Esboçando em %s — escolha uma ferramenta" - -# AI Translated -#, c-format, boost-format -#~ msgid "" -#~ "Drawing on %s.\n" -#~ "Pick a tool, or press Menu for the list." -#~ msgstr "" -#~ "Desenhando em %s.\n" -#~ "Escolha uma ferramenta ou pressione Menu para ver a lista." - -# AI Translated -#~ msgid "Colour" -#~ msgstr "Cor" - -# AI Translated -#, c-format, boost-format -#~ msgid "Body %d " -#~ msgstr "Corpo %d " - -# AI Translated -#~ msgid "vertex selected — click again for the whole body" -#~ msgstr "vértice selecionado — clique novamente para o corpo inteiro" - -# AI Translated -#~ msgid "selected (whole body) — right-click for what applies to it" -#~ msgstr "selecionado (corpo inteiro) — clique com o botão direito para ver o que se aplica a ele" - -# AI Translated -#, c-format, boost-format -#~ msgid "face %d selected — right-click to push/pull it, or click again for the whole body" -#~ msgstr "face %d selecionada — clique com o botão direito para empurrá-la/puxá-la, ou clique novamente para o corpo inteiro" - -# AI Translated -#, c-format, boost-format -#~ msgid "edge %d selected — Fillet/Chamfer to dress it, or click again for the whole body" -#~ msgstr "aresta %d selecionada — Filete/Chanfro para arredondá-la ou chanfrá-la, ou clique novamente para o corpo inteiro" - -# AI Translated -#, c-format, boost-format -#~ msgid "placed (%.1f, %.1f, %.1f) mm — drag arrows to move, rings to rotate" -#~ msgstr "colocado em (%.1f, %.1f, %.1f) mm — arraste as setas para mover, os anéis para rotacionar" - -# AI Translated -#, c-format, boost-format -#~ msgid "%d degrees of freedom" -#~ msgstr "%d graus de liberdade" - -# AI Translated -#, c-format, boost-format -#~ msgid "Imported %d solid(s) — pick a face or edge, then Fillet / Cut / Shell to modify" -#~ msgstr "%d sólido(s) importado(s) — selecione uma face ou aresta e depois Filete / Cortar / Casca para modificar" - -# AI Translated -#, c-format, boost-format -#~ msgid "Imported as an open shell (not watertight): %d boundary edge(s), %d non-manifold edge(s) — %d triangles → %d faces. The source mesh has holes or duplicated geometry; boolean features may fail on it" -#~ msgstr "Importado como uma casca aberta (não estanque): %d aresta(s) de borda, %d aresta(s) não-manifold — %d triângulos → %d faces. A malha de origem tem furos ou geometria duplicada; recursos booleanos podem falhar nela" - -# AI Translated -#, c-format, boost-format -#~ msgid "Sketch added on %s — select it, then right-click to Extrude" -#~ msgstr "Esboço adicionado em %s — selecione-o e clique com o botão direito para extrudar" - -# AI Translated -#, c-format -#~ msgid "%zu interference pairs, worst %.2f mm³" -#~ msgstr "%zu pares com interferência, pior %.2f mm³" - -# AI Translated -#, c-format, boost-format -#~ msgid "the picked face of Body %d" -#~ msgstr "a face selecionada do Corpo %d" - -# AI Translated -#~ msgid "the picked face" -#~ msgstr "a face selecionada" - -# AI Translated -#, c-format, boost-format -#~ msgid "%d selected · Del removes them · Shift-click adds · double-click takes the whole loop" -#~ msgstr "%d selecionado(s) · Del os remove · Shift+clique adiciona · clique duplo seleciona o laço inteiro" - -# AI Translated -#, c-format, boost-format -#~ msgid "Section view — showing the %s half" -#~ msgstr "Vista de seção — mostrando a metade %s" - -# AI Translated -#~ msgid "upper" -#~ msgstr "superior" - -# AI Translated -#~ msgid "lower" -#~ msgstr "inferior" - -# AI Translated -#, c-format -#~ msgid "Redo (%zu more)" -#~ msgstr "Refazer (mais %zu)" - -# AI Translated -#, c-format -#~ msgid "Undo (%zu more)" -#~ msgstr "Desfazer (mais %zu)" - #~ msgid "Section View" #~ msgstr "Vista de Seção" @@ -32617,10 +31149,6 @@ msgstr "" #~ msgid "Switch Camera View" #~ msgstr "Trocar Vista da Câmera" -# AI Translated -#~ msgid "Renders cast shadows on the plate, other objects, and each object onto itself in realistic view." -#~ msgstr "Renderiza sombras projetadas na mesa, em outros objetos e de cada objeto sobre si mesmo na visualização realista." - #~ msgid "Support/Color Painting: adjust section position" #~ msgstr "Suporte/Pintura em cores: ajustar a posição da seção" @@ -32630,14 +31158,6 @@ msgstr "" #~ msgid "Displays current viewport FPS in the top-right corner." #~ msgstr "Exibe o FPS atual da janela de visualização no canto superior direito." -# AI Translated -#~ msgid "Publish 3MF Wiki" -#~ msgstr "Documentação de Publicar 3MF" - -# AI Translated -#~ msgid "Publish 3MF Video Guide" -#~ msgstr "Guia em vídeo de Publicar 3MF" - #~ msgid "Toolbar" #~ msgstr "Barra de Ferramentas" @@ -32722,18 +31242,6 @@ msgstr "" #~ msgid " minimum drying temperature is " #~ msgstr " temperatura mínima de secagem é " -# AI Translated -#~ msgid "needs" -#~ msgstr "precisa de" - -# AI Translated -#~ msgid "not enabled" -#~ msgstr "não ativado" - -# AI Translated -#~ msgid "material not published" -#~ msgstr "material não publicado" - #~ msgid "Select the language" #~ msgstr "Selecione o idioma" @@ -32918,7 +31426,7 @@ msgstr "" #~ msgstr "O conteúdo da predefinição é muito grande para ser sincronizado com a nuvem (excede 1 MB). Reduza o tamanho da predefinição removendo configurações personalizadas ou use-o apenas localmente." #~ msgid "Enable adaptive pressure advance for overhangs (beta)" -#~ msgstr "Habilitar pressure advance adaptativo para saliências (beta)" +#~ msgstr "Habilitar Pressure Advance adaptativo para saliências (beta)" #~ msgid "Pressure advance for bridges" #~ msgstr "Pressure advance para pontes" @@ -32928,7 +31436,7 @@ msgstr "" #~ "\n" #~ "A lower PA value when printing bridges helps reduce the appearance of slight under extrusion immediately after bridges. This is caused by the pressure drop in the nozzle when printing in the air and a lower PA helps counteract this." #~ msgstr "" -#~ "Valor do pressure advance para pontes. Defina como 0 para desabilitar.\n" +#~ "Valor do Pressure Advance para pontes. Defina como 0 para desabilitar.\n" #~ "\n" #~ "Um valor de PA mais baixo ao imprimir pontes ajuda a reduzir a aparência de leve subextrusão imediatamente após as pontes. Isso é causado pela queda de pressão no bico ao imprimir no ar e um PA mais baixo ajuda a neutralizar isso." @@ -33137,7 +31645,7 @@ msgstr "" #~ msgstr "" #~ "Habilite esta opção para desacelerar a impressão em áreas onde os perímetros podem ter se curvado para cima. Por exemplo, uma desaceleração adicional será aplicada ao imprimir saliências em cantos afiados, como a parte frontal do casco Benchy, reduzindo a curvatura que se acumula por várias camadas.\n" #~ "\n" -#~ "Geralmente, é recomendável ter esta opção ativada, a menos que o resfriamento da impressora seja potente o suficiente ou a velocidade de impressão lenta o suficiente para que a curvatura do perímetro não aconteça. Se estiver imprimindo com uma alta velocidade de perímetro externo, este parâmetro pode introduzir pequenos artefatos ao desacelerar devido à grande variação nas velocidades de impressão. Se você notar artefatos, certifique-se de que seu pressure advance esteja ajustado corretamente.\n" +#~ "Geralmente, é recomendável ter esta opção ativada, a menos que o resfriamento da impressora seja potente o suficiente ou a velocidade de impressão lenta o suficiente para que a curvatura do perímetro não aconteça. Se estiver imprimindo com uma alta velocidade de perímetro externo, este parâmetro pode introduzir pequenos artefatos ao desacelerar devido à grande variação nas velocidades de impressão. Se você notar artefatos, certifique-se de que seu Pressure Advance esteja ajustado corretamente.\n" #~ "\n" #~ "Nota: quando esta opção estiver habilitada, os perímetros de saliência são tratados como saliências, o que significa que a velocidade de saliência é aplicada mesmo se o perímetro de saliência for parte de uma ponte. Por exemplo, quando os perímetros estiverem 100% salientes, sem nenhuma parede apoiando-os por baixo, a velocidade de saliência de 100% será aplicada." @@ -33424,7 +31932,7 @@ msgstr "" #~ msgstr "A posição da torre de purga ultrapassou os limites da placa de impressão e foi reposicionada para a borda válida mais próxima." #~ msgid "Enabling both precise Z height and the prime tower may cause the size of prime tower to increase. Do you still want to enable?" -#~ msgstr "Habilitar a altura Z precisa e a torre de preparação juntas pode causar o aumento de tamanho da torre de purga. Deseja habilitar mesmo assim?" +#~ msgstr "Habilitar a altura Z precisa e a torre de purga juntas pode causar o aumento de tamanho da torre de purga. Deseja habilitar mesmo assim?" #~ msgid "Missing BambuSource component registered for media playing! Please re-install BambuStudio or seek after-sales help." #~ msgstr "Componente BambuSource ausente registrado para reprodução de mídia! Reinstale o Bambu Studio ou procure ajuda pós-venda." @@ -34351,7 +32859,7 @@ msgstr "" #~ msgstr "Diâmetros de bico diferentes e diâmetros de filamento diferentes não são permitidos quando a torre de purga está ativa." #~ msgid "Ooze prevention is currently not supported with the prime tower enabled." -#~ msgstr "A prevenção de vazamento atualmente não é suportada com a torre de prepao ativa." +#~ msgstr "A prevenção de vazamento atualmente não é suportada com a torre de purga ativa." #~ msgid "Interlocking depth of a segmented region. Zero disables this feature." #~ msgstr "Profundidade de intertravamento de uma região segmentada. Zero desativa essa funcionalidade." From a379ec4a67d47328f2e52d8b6da4fd7c00ed6027 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 12 Oct 2026 19:36:45 -0500 Subject: [PATCH 37/45] ci(macOS): retry stapler staple after notarization (#16424) stapler fetches the notarization ticket from Apple's CloudKit server, and a dropped connection there (NSURLError -1005, exit 68) failed the whole macOS universal job twice on 2026-10-12, after notarization had already been accepted. Rerunning the job passed, but repeats the full build and both notarizations. Wrap both stapler calls in the existing retry helper already used for hdiutil in this step. --- .github/workflows/build_orca.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index f29d7f0cc6..b1955126da 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -341,12 +341,12 @@ jobs: # Notarize main DMG xcrun notarytool submit "OrcaSlicer_Mac_universal_${{ env.ver }}.dmg" --apple-id "${{ secrets.APPLE_DEV_ACCOUNT }}" --team-id "${{ secrets.TEAM_ID }}" --password "${{ secrets.APP_PWD }}" --wait - xcrun stapler staple OrcaSlicer_Mac_universal_${{ env.ver }}.dmg + retry xcrun stapler staple OrcaSlicer_Mac_universal_${{ env.ver }}.dmg # Notarize profile validator DMG if it exists if [ -f "OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg" ]; then xcrun notarytool submit "OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg" --apple-id "${{ secrets.APPLE_DEV_ACCOUNT }}" --team-id "${{ secrets.TEAM_ID }}" --password "${{ secrets.APP_PWD }}" --wait - xcrun stapler staple OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg + retry xcrun stapler staple OrcaSlicer_profile_validator_Mac_universal_${{ env.ver }}.dmg fi - name: Create DMG without notary From 075b03975f066199e4104ae0bc681577fceef6dd Mon Sep 17 00:00:00 2001 From: HMITCHR <265609730+HMITCHR@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:55:02 -0600 Subject: [PATCH 38/45] Read the initial filament start G-code through get_at() (#16080) A filament_start_gcode list shorter than the filament count made the Bambu start-of-export path throw std::out_of_range. Use get_at() like the other filament_start_gcode reads, and add a regression test. --- src/libslic3r/GCode.cpp | 2 +- tests/fff_print/test_gcodewriter.cpp | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index b79a386324..f423681c9d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -4262,7 +4262,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato config.set_key_value("nozzle_diameter_at_nozzle_id", new ConfigOptionFloats(get_nozzle_diameters_by_nozzle_id(group_result.get()))); config.set_key_value("nozzle_volume_types", new ConfigOptionStrings(get_nozzle_volume_types_by_nozzle_id(group_result.get()))); config.set_key_value("layer_num", new ConfigOptionInt(m_layer_index)); - std::string filament_start_gcode = this->placeholder_parser_process("filament_start_gcode", print.config().filament_start_gcode.values.at(initial_non_support_extruder_id), initial_non_support_extruder_id,&config); + std::string filament_start_gcode = this->placeholder_parser_process("filament_start_gcode", print.config().filament_start_gcode.get_at(initial_non_support_extruder_id), initial_non_support_extruder_id,&config); file.writeln(filament_start_gcode); // Mark the first filament used in print. Multi-nozzle printers (H2C) get ";VT%d H%d" where // H = dynamic ? nozzle_id : -1; existing single-nozzle printers keep the bare ";VT%d" so their diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index 170dfabae3..d491527db6 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -1748,3 +1748,29 @@ TEST_CASE("Percent accelerations resolve against the option they are a percentag REQUIRE(accelerations_by_role[role_name] == std::set{ value }); } } + +TEST_CASE("Initial filament start G-code tolerates a list shorter than the filament count", "[GCodeWriter]") +{ + // Two filaments but only one filament_start_gcode entry, and the only object prints on + // filament 2, so the BBL export starts on a filament past the end of the list. + DynamicPrintConfig config = multifilament_config(2); + config.set_key_value("filament_start_gcode", new ConfigOptionStrings({"; filament start"})); + + Model model; + auto *obj = model.add_object(); + obj->add_volume(cube(20)); + obj->add_instance(); + obj->config.set_key_value("extruder", new ConfigOptionInt(2)); + + Print print; + print.is_BBL_printer() = true; + arrange_objects_on_test_bed(model, config); + for (auto *mo : model.objects) { + mo->ensure_on_bed(); + print.auto_assign_extruders(mo); + } + print.apply(model, config); + print.validate(); + + REQUIRE_NOTHROW(Slic3r::Test::gcode(print)); +} From 6d35ff6b9a75a0a1fd6e06da29d0e882892c6226 Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Mon, 12 Oct 2026 21:59:17 -0300 Subject: [PATCH 39/45] Rewrite Fix Model on CGAL 6.2, fix Split to parts, remove Win10 SDK leftovers (#16366) * Add CGAL-based mesh repair, drop Netfabb remnants Add repair_mesh() in MeshRepair.cpp, a CGAL-based repair that replaces MeshBoolean::cgal::repair(). It welds cracks, cancels coincident triangles, fixes orientation, fills holes (including planar annular regions), removes local self-intersections, and unites crossing shells with libigl's exact boolean while following the nonzero winding rule used by slicing. FixModelByCgal now repairs each volume in place without splitting it into parts, and reports whether the mesh was modified so unchanged meshes keep their painting and ids. The texture import repair prompt no longer depends on the Windows SDK, and the FIX_THROUGH_CGAL_ALWAYS conditionals are removed. The commented-out Netfabb/WIN10SDK build code is deleted. libslic3r_cgal now builds with CGAL_LINKED_WITH_TBB and links TBB. Add docs/HLSD/mesh-repair.md and unit tests in test_mesh_repair.cpp. * Parallelize repair while preserving separate shells Repair connected mesh units and shells independently in parallel, keep crossing shells separate, and limit self-intersection remeshing to local defects. Update mesh-repair documentation and tests for the revised behavior. * Make mesh repair deterministic and report its progress Flags written from parallel loops were std::vector, whose neighbouring bits share a word, so shells could be kept or dropped at random; they are now std::vector. Holes of up to 100 edges are filled with the search over all triangles: the search restricted to a 3D Delaunay triangulation picks between equally good fills differently from run to run. Shells of fewer than four faces are dropped, as the volumes of two triangles back to back need not cancel exactly, and a shell that self-intersection removal changed is split again so the pieces it cut off are dropped too. repair_mesh() takes a progress callback in place of the cancel callback, and Fix Model reports each stage. The object's bounding box is now invalidated even when a cancel or an error stops the repair after some volumes were replaced. The edge-use sort, union-find and box-overlap code shared by several stages is factored out, the topology analysed for welding is reused to build the work units, the orientation pass checks only the edges it cut, and the output is written from the shells without joining them first. * Fix Split to parts crashing on flat pieces and staying offered after a repair ModelVolume::split() added every piece to the object and then deleted the pieces without a convex hull. When the first piece was flat, as often in a triangle soup, that deleted the volume being split while split() and its caller kept using it. A part made only of flat pieces lost all its volumes, and a soup added thousands of pieces, saving a backup of the object for each one it deleted. Pieces without a convex hull are now dropped before any joins the object, the volume being split keeps the first remaining piece, and a part with no solid piece is left whole. Other parts of the object without a convex hull are no longer deleted as a side effect. ModelVolume caches whether its mesh can be split, and set_mesh() kept the cached value, so after Fix Model, Simplify or Emboss replaced a mesh, Split to parts could still be offered for a single piece. set_mesh() and reset_mesh() now clear the cache. * Import STEP surface models with Split compound enabled Splitting a compound kept only its solids, so a surface model, which OCCT reads as a compound of loose faces, gave nothing to mesh and the import failed with "Meshing of a model file failed or no valid shape". Each shell outside a solid now becomes a part too, and loose faces stay together as one part, as they do without splitting. * Add Fix All for plates and re-enable --repair on the command line ModelVolume::repair() applies repair_mesh() to a part and replaces the mesh only when the repair changed it. Fix Model, the new Fix All and Fix All Plates items of the plate context menu, and the command line's --repair option, which had been commented out since the Win10 SDK repair was dropped, all use it. With Keep painted feature after mesh change off, Fix Model cleared the painting of every part of an object before repairing. Now only a part whose mesh changes loses its painting, so fixing a whole project does not wipe it. The notification counts the models that needed no repair instead of listing them as repaired, and objects that did not change are not refreshed. --- .github/workflows/build_orca.yml | 4 - CMakeLists.txt | 41 - docs/HLSD/mesh-repair.md | 135 ++ src/OrcaSlicer.cpp | 17 +- src/libslic3r/CMakeLists.txt | 5 +- src/libslic3r/Format/STEP.cpp | 20 +- src/libslic3r/MeshBoolean.cpp | 88 -- src/libslic3r/MeshBoolean.hpp | 3 - src/libslic3r/MeshRepair.cpp | 1215 +++++++++++++++++ src/libslic3r/MeshRepair.hpp | 28 + src/libslic3r/Model.cpp | 61 +- src/libslic3r/Model.hpp | 18 +- src/libslic3r/PrintConfig.cpp | 7 +- .../TextureToColor/TextureToColor.cpp | 6 +- src/slic3r/GUI/GUI_Factories.cpp | 10 + src/slic3r/GUI/GUI_ObjectList.cpp | 120 +- src/slic3r/GUI/GUI_ObjectList.hpp | 6 +- src/slic3r/GUI/Plater.cpp | 35 +- src/slic3r/GUI/Plater.hpp | 1 + src/slic3r/GUI/TextureImportDialog.cpp | 43 +- src/slic3r/Utils/FixModelByCgal.cpp | 245 +--- src/slic3r/Utils/FixModelByCgal.hpp | 5 +- tests/data/surface_model_geometric_set.step | 35 + tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_mesh_repair.cpp | 255 ++++ tests/libslic3r/test_model.cpp | 103 ++ tests/libslic3r/test_step.cpp | 21 + 27 files changed, 2053 insertions(+), 475 deletions(-) create mode 100644 docs/HLSD/mesh-repair.md create mode 100644 src/libslic3r/MeshRepair.cpp create mode 100644 src/libslic3r/MeshRepair.hpp create mode 100644 tests/data/surface_model_geometric_set.step create mode 100644 tests/libslic3r/test_mesh_repair.cpp diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index b1955126da..e49e157358 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -460,10 +460,6 @@ jobs: - name: Build slicer Win if: runner.os == 'Windows' working-directory: ${{ github.workspace }} - # Orca: Removed Netfabb STL fixing service support in favor of CGAL. - # env: - # WindowsSdkDir: 'C:\Program Files (x86)\Windows Kits\10\' - # WindowsSDKVersion: '10.0.26100.0\' # --tests builds the unit tests too; the unit_tests_windows_* jobs run them. run: | # cache-path is the install directory inside the deps build directory. diff --git a/CMakeLists.txt b/CMakeLists.txt index a208c5e7a2..7f1586f222 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -497,47 +497,6 @@ endif() # Once we get rid of Perl and libslic3r is linked statically, we can get rid of -fPIC set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# WIN10SDK_PATH is used to point CMake to the WIN10 SDK installation directory. -# We pick it from environment if it is not defined in another way -# ORCA: Removed Netfabb STL fixing service support in favor of CGAL. -# if(WIN32) -# if(NOT DEFINED WIN10SDK_PATH) -# if(DEFINED ENV{WIN10SDK_PATH}) -# set(WIN10SDK_PATH "$ENV{WIN10SDK_PATH}") -# endif() -# endif() -# if(DEFINED WIN10SDK_PATH) -# #BBS: modify win10sdk_path -# if (EXISTS "${WIN10SDK_PATH}/winrt/windows.graphics.printing3d.h") -# set(WIN10SDK_INCLUDE_PATH "${WIN10SDK_PATH}") -# else() -# message("WIN10SDK_PATH is invalid: ${WIN10SDK_PATH}") -# message("${WIN10SDK_PATH}/winrt/windows.graphics.printing3d.h was not found") -# message("STL fixing by the Netfabb service will not be compiled") -# unset(WIN10SDK_PATH) -# endif() -# else() -# # Try to use the default Windows 10 SDK path. -# if (DEFINED ENV{WindowsSdkDir} AND DEFINED ENV{WindowsSDKVersion}) -# set(WIN10SDK_INCLUDE_PATH "$ENV{WindowsSdkDir}/Include/$ENV{WindowsSDKVersion}") -# else () -# set(WIN10SDK_INCLUDE_PATH "C:/Program Files (x86)/Windows Kits/10/Include/10.0.26100.0") -# endif () -# if (NOT EXISTS "${WIN10SDK_INCLUDE_PATH}/winrt/windows.graphics.printing3d.h") -# message("${WIN10SDK_INCLUDE_PATH}/winrt/windows.graphics.printing3d.h was not found") -# message("STL fixing by the Netfabb service will not be compiled") -# unset(WIN10SDK_INCLUDE_PATH) -# endif() -# endif() -# if(WIN10SDK_INCLUDE_PATH) -# message("Building with Win10 Netfabb STL fixing service support") -# add_definitions(-DHAS_WIN10SDK) -# include_directories(SYSTEM "${WIN10SDK_INCLUDE_PATH}") -# else() -# message("Building without Win10 Netfabb STL fixing service support") -# endif() -# endif() - if (APPLE) message("OS X SDK Path: ${CMAKE_OSX_SYSROOT}") if (CMAKE_OSX_DEPLOYMENT_TARGET) diff --git a/docs/HLSD/mesh-repair.md b/docs/HLSD/mesh-repair.md new file mode 100644 index 0000000000..145c904769 --- /dev/null +++ b/docs/HLSD/mesh-repair.md @@ -0,0 +1,135 @@ +# Mesh repair — High Level Design + +## Purpose and scope + +Mesh repair turns a broken triangle mesh into closed, outward oriented shells that bound a solid, so that the +mesh slices predictably and the other mesh tools (booleans, cut, painting) get valid input. It runs on demand: +the *Fix Model* command of the context menu of an object or part and of the warning icon in the object list, *Fix +All* and *Fix All Plates* in the context menu of a plate, the prompt the Cut tool shows when a cut leaves open edges, +the *Repair and import* choice of texture import, and the `--repair` option of the command line. It is built on CGAL +and is the only repair the application has, on every platform. + +`repair_mesh()` in `src/libslic3r/MeshRepair.cpp` does the work on one `TriangleMesh`, and `ModelVolume::repair()` +applies it to a part: it replaces the mesh only when the repair changed it, and remaps or drops the painting of a +replaced mesh. The GUI side, `fix_model_with_cgal_gui()` in `src/slic3r/Utils/FixModelByCgal.cpp`, runs it on a +worker thread for each volume of an object, or for one volume, behind a progress dialog. `repair_mesh()` reports its +progress between stages through a callback, which cancels the repair by throwing. + +## What the repaired mesh is + +The repair keeps what the slicer would print from the input. The slicer fills a layer where the winding number of +the input surface is not zero (`MeshSlicingParams::SlicingMode::Regular` uses Clipper's nonzero fill rule), so: + +- Shells crossing each other print as their union. The repair keeps them as separate shells, as the parts of an + object are kept, and makes each of them an outward oriented solid. +- A shell inside a solid that is oriented like it adds nothing, and the repair removes it. +- A shell inside a solid that is oriented inward is a cavity, and stays one. +- A whole mesh turned inside out prints as a solid, and the repair turns it outward. + +On top of that, it closes holes and cracks, gives inconsistently oriented triangles the orientation of their +neighbours, drops degenerate, duplicate and stray flat geometry, and removes small self-intersections inside a shell, +such as slivers and folds. Where none of this applies, it reports the mesh as unmodified and the caller keeps the +original mesh, its painting and its object id. + +A volume is repaired in place and is never split into several parts: splitting by connectivity would turn a cavity +into a separate solid part and fill it. Each solid of the result is a shell of its own, so *Split to parts* still +separates them. Open pieces of the input whose borders meet exactly, which are pieces of one surface rather than +solids, come out as the one shell they form. + +## Pipeline + +The input is treated as a triangle soup. Every stage after the first only runs on what the previous ones could +not settle, so a valid mesh passes through quickly. + +### Valid parts first + +Parts of the input that are connected by vertex indices and are already closed, consistently oriented 2-manifolds +skip the soup stages and go straight to the shell stages. Merging their points with coincident points of other +parts would only make non-manifold junctions out of solids that touch, for example parts of an assembly exported +together or voxels written as separate cubes. + +### Soup clean-up + +The rest of the soup has its coincident points merged. Triangles with repeated corners are dropped. Coincident +triangles of opposite orientation are a wall between two solids touching face to face, so they cancel in pairs; +of the remaining copies of a triangle one is kept. A connected region that would vanish entirely through +cancelling is a two-sided export instead (every face written in both orientations), and keeps one copy of each +triangle. + +Open edges then get their vertices welded within a tolerance of 1e-4 of the bounding box diagonal, which closes the +cracks some exporters leave between triangles that should share corners. A vertex is never welded further than a +quarter of its shortest edge, so welding cannot collapse fine detail. A soup that stays mostly open after welding has +cracks wider than that tolerance, and is welded again with ten and then a hundred times the tolerance. + +### Work units + +The soup is cut into units repaired independently and in parallel: its connected parts, except that open parts whose +bounding boxes touch share a unit, so that cracks and T-junctions between separately exported patches still close. + +### Building the mesh + +A unit whose edges are each shared by at most two triangles, running in opposite directions, and whose vertices each +have a single fan of triangles, becomes a `Surface_mesh` directly. Any other unit is oriented first. Triangles are +flipped so that neighbours across an edge shared by two of them agree, each part keeping the orientation the +majority of its area had, which keeps inward oriented cavities inward. Edges where that is impossible or that more +than two triangles share are cut, and each fan of triangles around a vertex gets its own copy of the vertex. If that +still leaves an invalid mesh, CGAL's `orient_polygon_soup()` orients the unit instead, and the parts it flipped +against their majority are reversed back. The linear pass exists because `orient_polygon_soup()` is several times +slower on large soups that need only a few fixes. + +### Closing + +Compatible borders are stitched, parts that can be stitched once reversed are merged, and borders still open are +snapped onto each other within the weld tolerance, which closes T-junctions. Exactly degenerate faces are removed. + +Holes are filled last. Planar border loops that face each other in one plane, one inside the other, bound a single +region, such as a missing annulus at the end of a tube or a cap with features standing on it. Such a group is +triangulated at once with a 2D constrained Delaunay triangulation that fills only the faces at odd nesting depth, +so the openings inside the region stay open. The patch is used only if it closes every loop with the orientation +of the triangles around it. Every other loop is filled on its own with `triangulate_hole()`, which uses a 2D +constrained Delaunay triangulation when the loop is planar. Otherwise it searches the best triangulation among all +triangles over the loop for loops of up to 100 edges, and among the triangles of a 3D Delaunay triangulation for +longer ones, as the full search is cubic in the length of the loop. Beyond 200 edges the full search is not even +used as a fallback, and a fan around the loop's centroid closes a loop nothing else could. + +The closed unit is split into its shells. The largest stays in place and the others are copied out, as converting +the whole mesh again costs seconds on large models. + +### Shells + +The shells of all units, together with the valid parts set aside at the start, are handled independently and in +parallel. Shells enclosing no volume, which hole filling makes out of stray flat patches, are dropped. + +Self-intersections inside a shell are removed locally with `experimental::remove_self_intersections()`, which +remeshes the region around them. It is used only for local defects: a shell whose intersections involve more than +5 % of its faces is left as it is, and a result that changes the enclosed volume by more than 0.1 % is undone. On +heavily overlapping ornamental models the remeshing otherwise cuts parts down to slivers, while on genuine defects +it changes the volume by thousandths of a percent. Overlaps left in place slice correctly. Remeshing can cut +small pieces off a shell, so a shell it changed is split again and its pieces enclosing no volume are dropped. + +Shells crossing others are found through their bounding boxes and an AABB tree per shell, and are made outward +oriented solids. For every other shell the winding number just outside it is summed from the orientation of the +shells containing it, tested from a point on its largest face. The shell bounds the solid only if the winding +number is zero on exactly one of its sides; it is then oriented so that the solid is inside, or dropped otherwise. + +Crossing shells are not united. Slicing unites them anyway, uniting them exactly costs tens of seconds on models +made of many overlapping pieces, and it would fuse parts that *Split to parts* should keep apart. + +## Robustness + +The repair avoids `experimental::autorefine_and_remove_self_intersections()` and corefinement, which can crash on +messy input, and uses only steps whose failures it can detect. A step that fails keeps its input. CGAL precondition +failures surface as `repair_mesh()` returning false with the message as the error. The output is written in float, +as every mesh in the application is. + +The result does not depend on thread scheduling. `libslic3r_cgal` is built with `CGAL_LINKED_WITH_TBB`. Units and +shells are processed in parallel, but each on its own data, and their results are gathered in a fixed order; +sorts compare complete keys, and the detected self-intersections are put in order before they are used. Snapping +stays sequential, as its parallel version gives different results from run to run. Holes are filled with the full +search wherever it is affordable, because the search among Delaunay triangles can choose between equally good fills +differently from run to run, so only non-planar holes of more than 100 edges may still vary. On some inputs +`experimental::remove_self_intersections()` itself places a few vertices differently from run to run. + +## Verification + +`tests/libslic3r/test_mesh_repair.cpp` covers each kind of defect above on small meshes with known volumes. diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 0d0aea0954..2a158f4b12 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -99,6 +99,7 @@ #include "libslic3r/Preset.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/GCode.hpp" +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/ModelArrange.hpp" #include "libslic3r/Platform.hpp" @@ -5130,9 +5131,19 @@ int CLI::run(int argc, char **argv) } } } else if (opt_key == "repair") { - // Models are repaired by default. - //for (auto &model : m_models) - // model.repair(); + if (m_config.opt_bool(opt_key)) + for (Model &model : m_models) + for (ModelObject *object : model.objects) { + for (ModelVolume *volume : object->volumes) { + MeshRepairStats stats; + std::string error; + if (!volume->repair(true, &stats, &error)) + BOOST_LOG_TRIVIAL(warning) << "repair of " << object->name << " / " << volume->name << " failed: " << error; + else if (stats.modified) + BOOST_LOG_TRIVIAL(info) << "repaired " << object->name << " / " << volume->name; + } + object->invalidate_bounding_box(); + } } else { boost::nowide::cerr << "error: option not implemented yet: " << opt_key << std::endl; record_exit_reson(outfile_dir, CLI_UNSUPPORTED_OPERATION, 0, cli_errors[CLI_UNSUPPORTED_OPERATION], sliced_info); diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index 1a7080d5c7..afa3b61bc1 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -625,6 +625,7 @@ add_library(libslic3r_cgal STATIC CutSurface.hpp CutSurface.cpp IntersectionPoints.hpp IntersectionPoints.cpp MeshBoolean.hpp MeshBoolean.cpp + MeshRepair.hpp MeshRepair.cpp TryCatchSignal.hpp TryCatchSignal.cpp Triangulation.hpp Triangulation.cpp ) @@ -652,7 +653,9 @@ if (IS_CLANG_CL) target_compile_options(libslic3r_cgal PRIVATE -Wno-overriding-option) endif () -target_link_libraries(libslic3r_cgal PRIVATE ${_cgal_tgt} admesh libigl mcut boost_libs) +# Lets CGAL algorithms given CGAL::Parallel_if_available_tag run on TBB. +target_compile_definitions(libslic3r_cgal PRIVATE CGAL_LINKED_WITH_TBB) +target_link_libraries(libslic3r_cgal PRIVATE ${_cgal_tgt} admesh libigl mcut boost_libs TBB::tbb) if (MSVC AND "${CMAKE_SIZEOF_VOID_P}" STREQUAL "4") # 32 bit MSVC workaround target_compile_definitions(libslic3r_cgal PRIVATE CGAL_DO_NOT_USE_MPZF) diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index 962c4cffb9..4f40093c73 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -236,19 +236,29 @@ static void getNamedSolids(const TopLoc_Location& location, int i = 0; switch (shape_type) { case TopAbs_COMPOUND: - if (!isSplitCompound) { - namedSolids.emplace_back(TopoDS::Compound(transform.Shape()), fullName); - break; - } case TopAbs_COMPSOLID: if (!isSplitCompound) { - namedSolids.emplace_back(TopoDS::CompSolid(transform.Shape()), fullName); + namedSolids.emplace_back(transform.Shape(), fullName); } else { for (explorer.Init(transform.Shape(), TopAbs_SOLID); explorer.More(); explorer.Next()) { i++; const TopoDS_Shape& currentShape = explorer.Current(); namedSolids.emplace_back(TopoDS::Solid(currentShape), fullName + "-SOLID-" + std::to_string(i)); } + // Surface models have no solids: each shell outside a solid is a part, and loose faces stay together. + i = 0; + for (explorer.Init(transform.Shape(), TopAbs_SHELL, TopAbs_SOLID); explorer.More(); explorer.Next()) + namedSolids.emplace_back(explorer.Current(), fullName + "-SHELL-" + std::to_string(++i)); + TopoDS_Compound loose_faces; + TopoDS_Builder builder; + builder.MakeCompound(loose_faces); + bool has_loose_faces = false; + for (explorer.Init(transform.Shape(), TopAbs_FACE, TopAbs_SHELL); explorer.More(); explorer.Next()) { + builder.Add(loose_faces, explorer.Current()); + has_loose_faces = true; + } + if (has_loose_faces) + namedSolids.emplace_back(loose_faces, fullName); } break; case TopAbs_SOLID: diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 37e3c84b3f..074eff3121 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -29,8 +29,6 @@ #include #include #include -#include -#include #include #include #include @@ -50,7 +48,6 @@ #include #include #include -#include #include // BBS: for segment #include @@ -658,91 +655,6 @@ bool empty(const CGALMesh &mesh) return mesh.m.is_empty(); } -bool repair(TriangleMesh& mesh, RepairedMeshErrors* repaired_errors, std::string* error) -{ - namespace PMP = CGAL::Polygon_mesh_processing; - - if (mesh.empty()) - return true; - - try { - // 1) Convert to polygon soup - std::vector<_EpicMesh::Point> points; - std::vector> polygons; - - points.reserve(mesh.its.vertices.size()); - polygons.reserve(mesh.its.indices.size()); - - for (const auto& v : mesh.its.vertices) - points.emplace_back(v.x(), v.y(), v.z()); - - for (const auto& f : mesh.its.indices) - polygons.push_back({size_t(f[0]), size_t(f[1]), size_t(f[2])}); - - // 2) Aggressive soup cleanup - PMP::repair_polygon_soup(points, polygons); - - // 3) Convert soup → mesh - _EpicMesh cgal_mesh; - PMP::polygon_soup_to_polygon_mesh(points, polygons, cgal_mesh); - - // 4) Remove degenerate geometry - PMP::remove_degenerate_faces(cgal_mesh); - PMP::remove_isolated_vertices(cgal_mesh); - - // 5) Fix remaining non-manifold vertices - PMP::duplicate_non_manifold_vertices(cgal_mesh); - - // 6) Boolean union (keeps only outer shell) - _EpicMesh tmp; - if (PMP::corefine_and_compute_union(cgal_mesh, cgal_mesh, tmp)) { - cgal_mesh = std::move(tmp); - } - // If it fails, continue anyway with previous mesh - - // 7) Fill holes - if (!CGAL::is_closed(cgal_mesh)) { - using halfedge_descriptor = boost::graph_traits<_EpicMesh>::halfedge_descriptor; - - std::vector borders; - CGAL::extract_boundary_cycles(cgal_mesh, std::back_inserter(borders)); - - for (halfedge_descriptor h : borders) { - PMP::triangulate_and_refine_hole(cgal_mesh, h); - } - } - - // 8) Final validity check - if (!CGAL::is_closed(cgal_mesh)) { - if (error) - *error = "Repair failed: mesh still open after hole filling."; - return false; - } - - // 9) Ensure outward orientation - if (!PMP::does_bound_a_volume(cgal_mesh)) - PMP::orient_to_bound_a_volume(cgal_mesh); - - // 10) Convert back - indexed_triangle_set its = cgal_to_indexed_triangle_set(cgal_mesh); - - RepairedMeshErrors errs{}; - errs.facets_removed = 0; - errs.edges_fixed = 0; - - mesh = TriangleMesh(std::move(its), errs); - - if (repaired_errors) - *repaired_errors = errs; - - return true; - } catch (const std::exception& e) { - if (error) - *error = e.what(); - return false; - } -} - CGALMeshPtr clone(const CGALMesh &m) { return CGALMeshPtr{new CGALMesh{m}}; diff --git a/src/libslic3r/MeshBoolean.hpp b/src/libslic3r/MeshBoolean.hpp index 6ced9b4646..b229a4e724 100644 --- a/src/libslic3r/MeshBoolean.hpp +++ b/src/libslic3r/MeshBoolean.hpp @@ -77,9 +77,6 @@ TriangleMesh merge(std::vector meshes); bool does_bound_a_volume(const CGALMesh &mesh); bool empty(const CGALMesh &mesh); -// Repair a mesh using CGAL. Returns true on success. Optionally returns a summary of repairs and an error string. -bool repair(TriangleMesh &mesh, RepairedMeshErrors *repaired_errors = nullptr, std::string *error = nullptr); - // Real UV unwrap of an open mesh patch via CGAL's LSCM (Least Squares Conformal Maps) surface // parameterization. Returns one UV coordinate per input vertex (same indexing as `mesh.vertices`), // or nullopt if `mesh` isn't a single topological disk -- LSCM needs exactly one connected diff --git a/src/libslic3r/MeshRepair.cpp b/src/libslic3r/MeshRepair.cpp new file mode 100644 index 0000000000..df76836109 --- /dev/null +++ b/src/libslic3r/MeshRepair.cpp @@ -0,0 +1,1215 @@ +#include "MeshRepair.hpp" + +#include "I18N.hpp" +#include "TriangleMesh.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +namespace { + +namespace PMP = CGAL::Polygon_mesh_processing; + +using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel; +using Point = Kernel::Point_3; +using Vector = Kernel::Vector_3; +using Mesh = CGAL::Surface_mesh; +using Vertex = Mesh::Vertex_index; +using Halfedge = Mesh::Halfedge_index; +using Face = Mesh::Face_index; +using Triangle = std::array; +using Tree = CGAL::AABB_tree>>; +using Box = CGAL::Box_intersection_d::Box_with_info_d; + +constexpr std::size_t NONE = std::size_t(-1); + +class UnionFind +{ +public: + explicit UnionFind(std::size_t n) : m_parent(n) { std::iota(m_parent.begin(), m_parent.end(), std::size_t(0)); } + + std::size_t find(std::size_t i) + { + while (m_parent[i] != i) + i = m_parent[i] = m_parent[m_parent[i]]; + return i; + } + + // The smaller index becomes the root, so results do not depend on the order of unions. + void unite(std::size_t a, std::size_t b) + { + a = find(a); + b = find(b); + if (a != b) + m_parent[std::max(a, b)] = std::min(a, b); + } + +private: + std::vector m_parent; +}; + +template void erase_marked(Container &items, const Mask &marked) +{ + std::size_t out = 0; + for (std::size_t i = 0; i < items.size(); ++i) + if (!marked[i]) + items[out++] = std::move(items[i]); + items.resize(out); +} + +template void parallel_for_each(std::size_t n, Fn &&fn) +{ + tbb::parallel_for(tbb::blocked_range(0, n, 1), [&fn](const tbb::blocked_range &range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) + fn(i); + }); +} + +// Triangles sharing a vertex belong to one part. +UnionFind connect_vertices(std::size_t num_points, const std::vector &triangles) +{ + UnionFind parts(num_points); + for (const Triangle &t : triangles) { + parts.unite(t[0], t[1]); + parts.unite(t[0], t[2]); + } + return parts; +} + +bool bbox_contains(const CGAL::Bbox_3 &outer, const CGAL::Bbox_3 &inner, double tolerance = 0.) +{ + return outer.xmin() - tolerance <= inner.xmin() && outer.ymin() - tolerance <= inner.ymin() && outer.zmin() - tolerance <= inner.zmin() && + outer.xmax() + tolerance >= inner.xmax() && outer.ymax() + tolerance >= inner.ymax() && outer.zmax() + tolerance >= inner.zmax(); +} + +std::vector> overlapping_boxes(std::vector &boxes) +{ + std::vector> pairs; + CGAL::box_self_intersection_d(boxes.begin(), boxes.end(), [&pairs](const Box &a, const Box &b) { + pairs.emplace_back(std::min(a.info(), b.info()), std::max(a.info(), b.info())); + }, std::ptrdiff_t(10), CGAL::Box_intersection_d::CLOSED); + std::sort(pairs.begin(), pairs.end()); + return pairs; +} + +double bbox_diagonal(const std::vector &points) +{ + CGAL::Bbox_3 bb; + for (const Point &p : points) + bb += p.bbox(); + return points.empty() ? 0. : std::sqrt(CGAL::square(bb.x_span()) + CGAL::square(bb.y_span()) + CGAL::square(bb.z_span())); +} + +// One use of an edge by a triangle: a <= b are its vertices, corner = 3 * triangle + position of the edge's first vertex. +struct EdgeUse +{ + std::size_t a, b, corner; + bool operator<(const EdgeUse &rhs) const { return std::tie(a, b, corner) < std::tie(rhs.a, rhs.b, rhs.corner); } +}; + +std::vector sorted_edge_uses(const std::vector &triangles) +{ + std::vector uses; + uses.reserve(3 * triangles.size()); + for (std::size_t f = 0; f < triangles.size(); ++f) + for (std::size_t k = 0; k < 3; ++k) { + const auto [a, b] = std::minmax(triangles[f][k], triangles[f][(k + 1) % 3]); + uses.push_back({a, b, 3 * f + k}); + } + tbb::parallel_sort(uses.begin(), uses.end()); + return uses; +} + +// Calls fn(first, last) with the range of uses of each edge. +template void for_each_edge(const std::vector &uses, Fn &&fn) +{ + for (std::size_t i = 0; i < uses.size();) { + std::size_t j = i + 1; + while (j < uses.size() && uses[j].a == uses[i].a && uses[j].b == uses[i].b) + ++j; + fn(i, j); + i = j; + } +} + +bool same_direction(const std::vector &triangles, const EdgeUse &e1, const EdgeUse &e2) +{ + return triangles[e1.corner / 3][e1.corner % 3] == triangles[e2.corner / 3][e2.corner % 3]; +} + +struct SoupTopology +{ + std::vector open_face; // on an edge used by one triangle + std::vector bad_face; // on an edge used by more than two triangles, or twice in one direction + std::vector pinched; // vertex whose triangles form several fans + std::vector border; // vertices of the edges used by one triangle + double open_fraction = 0.; +}; + +// The fans around each vertex are found by joining the corners of triangles across their shared edges. +SoupTopology analyze_topology(std::size_t num_points, const std::vector &triangles) +{ + SoupTopology topology{std::vector(triangles.size(), 0), std::vector(triangles.size(), 0), std::vector(num_points, 0)}; + const std::vector uses = sorted_edge_uses(triangles); + UnionFind fans(3 * triangles.size()); + std::size_t num_edges = 0; + for_each_edge(uses, [&](std::size_t i, std::size_t j) { + const EdgeUse &e = uses[i]; + ++num_edges; + if (j - i == 1 && e.a != e.b) { + topology.open_face[e.corner / 3] = 1; + topology.border.push_back(e.a); + topology.border.push_back(e.b); + } else if (j - i != 2 || e.a == e.b || same_direction(triangles, e, uses[i + 1])) { + for (std::size_t k = i; k < j; ++k) + topology.bad_face[uses[k].corner / 3] = 1; + } else { + const std::size_t c = e.corner, n = uses[i + 1].corner; + fans.unite(c, n - n % 3 + (n + 1) % 3); + fans.unite(c - c % 3 + (c + 1) % 3, n); + } + }); + topology.open_fraction = num_edges == 0 ? 0. : double(topology.border.size() / 2) / double(num_edges); + std::sort(topology.border.begin(), topology.border.end()); + topology.border.erase(std::unique(topology.border.begin(), topology.border.end()), topology.border.end()); + + std::vector fan_of(num_points, NONE); + for (std::size_t c = 0; c < 3 * triangles.size(); ++c) { + const std::size_t v = triangles[c / 3][c % 3], root = fans.find(c); + if (fan_of[v] == NONE) + fan_of[v] = root; + else if (fan_of[v] != root) + topology.pinched[v] = 1; + } + return topology; +} + +// orient_polygon_soup() needs resizable polygons. +void orient_with_cgal(std::vector &points, std::vector &triangles) +{ + std::vector> polygons(triangles.size()); + for (std::size_t i = 0; i < triangles.size(); ++i) + polygons[i].assign(triangles[i].begin(), triangles[i].end()); + PMP::orient_polygon_soup(points, polygons); + for (std::size_t i = 0; i < triangles.size(); ++i) + std::copy(polygons[i].begin(), polygons[i].end(), triangles[i].begin()); +} + +// The triangles of a group renumbered over the vertices they use. +void extract_group(const std::vector &points, const std::vector &triangles, const std::vector &group, + std::vector &group_points, std::vector &group_triangles) +{ + std::vector vertices; + vertices.reserve(3 * group.size()); + for (std::size_t f : group) + vertices.insert(vertices.end(), triangles[f].begin(), triangles[f].end()); + std::sort(vertices.begin(), vertices.end()); + vertices.erase(std::unique(vertices.begin(), vertices.end()), vertices.end()); + group_points.clear(); + for (std::size_t v : vertices) + group_points.push_back(points[v]); + group_triangles.clear(); + for (std::size_t f : group) { + Triangle &t = group_triangles.emplace_back(); + for (std::size_t k = 0; k < 3; ++k) + t[k] = std::lower_bound(vertices.begin(), vertices.end(), triangles[f][k]) - vertices.begin(); + } +} + +// Groups not known to be valid polygon meshes are made into ones first. +std::vector soup_to_meshes(const std::vector &points, const std::vector &triangles, + const std::vector> &groups, bool known_valid) +{ + std::vector meshes(groups.size()); + parallel_for_each(groups.size(), [&](std::size_t i) { + std::vector group_points; + std::vector group_triangles; + extract_group(points, triangles, groups[i], group_points, group_triangles); + if (!known_valid && !PMP::is_polygon_soup_a_polygon_mesh(group_triangles)) + orient_with_cgal(group_points, group_triangles); + PMP::polygon_soup_to_polygon_mesh(group_points, group_triangles, meshes[i]); + }); + return meshes; +} + +std::vector split_shells(Mesh &mesh) +{ + mesh.collect_garbage(); + std::vector face_part(mesh.number_of_faces()); + const std::size_t num_parts = PMP::connected_components(mesh, CGAL::make_property_map(face_part)); + std::vector part_size(num_parts, 0); + for (std::size_t part : face_part) + ++part_size[part]; + const std::size_t largest = std::max_element(part_size.begin(), part_size.end()) - part_size.begin(); + + // The small shells are copied out and the largest one stays in place. + std::vector shells; + if (num_parts > 1) { + std::vector points; + for (Vertex v : mesh.vertices()) + points.push_back(mesh.point(v)); + std::vector triangles; + std::vector> groups(num_parts); + for (Face f : mesh.faces()) + if (face_part[f] != largest) { + Triangle &t = triangles.emplace_back(); + std::size_t k = 0; + for (Vertex v : CGAL::vertices_around_face(mesh.halfedge(f), mesh)) + t[k++] = v; + groups[face_part[f]].push_back(triangles.size() - 1); + } + groups.erase(groups.begin() + largest); + std::vector nonmanifold; + PMP::non_manifold_vertices(mesh, std::back_inserter(nonmanifold)); + shells = soup_to_meshes(points, triangles, groups, nonmanifold.empty()); + PMP::keep_connected_components(mesh, std::vector{largest}, CGAL::make_property_map(face_part)); + mesh.collect_garbage(); + } + shells.insert(shells.begin(), std::move(mesh)); + return shells; +} + +// Valid closed parts skip the soup repair, which would weld them to coincident parts into non-manifold junctions. +std::vector take_closed_shells(const std::vector &points, std::vector &triangles) +{ + const SoupTopology topology = analyze_topology(points.size(), triangles); + UnionFind parts = connect_vertices(points.size(), triangles); + std::vector part_ok(points.size(), 1); + for (std::size_t f = 0; f < triangles.size(); ++f) + for (std::size_t v : triangles[f]) + if (topology.open_face[f] || topology.bad_face[f] || topology.pinched[v]) + part_ok[parts.find(v)] = 0; + + std::vector group_of(points.size(), NONE); + std::vector> groups; + std::vector taken(triangles.size(), 0); + for (std::size_t f = 0; f < triangles.size(); ++f) + if (const std::size_t r = parts.find(triangles[f][0]); part_ok[r]) { + if (group_of[r] == NONE) { + group_of[r] = groups.size(); + groups.emplace_back(); + } + groups[group_of[r]].push_back(f); + taken[f] = 1; + } + std::vector shells = soup_to_meshes(points, triangles, groups, true); + erase_marked(triangles, taken); + return shells; +} + +// Opposite coincident triangles cancel in pairs, unless that empties a whole region (a two-sided export). +void remove_duplicate_triangles(std::size_t num_points, std::vector &triangles) +{ + struct Entry + { + std::array key; + bool odd; + std::size_t idx; + }; + std::vector entries; + for (std::size_t i = 0; i < triangles.size(); ++i) { + const Triangle &t = triangles[i]; + const std::size_t r = std::min_element(t.begin(), t.end()) - t.begin(); + const std::size_t b = t[(r + 1) % 3], c = t[(r + 2) % 3]; + entries.push_back({{t[r], std::min(b, c), std::max(b, c)}, b > c, i}); + } + tbb::parallel_sort(entries.begin(), entries.end(), [](const Entry &l, const Entry &r) { return std::tie(l.key, l.idx) < std::tie(r.key, r.idx); }); + + std::vector remove(triangles.size(), 0); + std::vector cancelled; + for (std::size_t i = 0; i < entries.size();) { + std::size_t j = i + 1; + while (j < entries.size() && entries[j].key == entries[i].key) + ++j; + if (j - i > 1) { + std::size_t num_odd = 0; + for (std::size_t k = i; k < j; ++k) + num_odd += entries[k].odd; + const bool odd_wins = 2 * num_odd > j - i; + bool kept = 2 * num_odd == j - i; + if (kept) + cancelled.push_back(entries[i].idx); + for (std::size_t k = i; k < j; ++k) { + if (!kept && entries[k].odd == odd_wins) + kept = true; + else + remove[entries[k].idx] = 1; + } + } + i = j; + } + if (!cancelled.empty()) { + UnionFind regions = connect_vertices(num_points, triangles); + std::vector survives(num_points, 0); + for (std::size_t i = 0; i < triangles.size(); ++i) + if (!remove[i]) + survives[regions.find(triangles[i][0])] = 1; + for (std::size_t i : cancelled) + if (!survives[regions.find(triangles[i][0])]) + remove[i] = 0; + } + erase_marked(triangles, remove); +} + +void clean_soup(std::vector &points, std::vector &triangles) +{ + std::vector order(points.size()); + std::iota(order.begin(), order.end(), std::size_t(0)); + tbb::parallel_sort(order.begin(), order.end(), [&points](std::size_t a, std::size_t b) { + return std::make_tuple(points[a].x(), points[a].y(), points[a].z(), a) < std::make_tuple(points[b].x(), points[b].y(), points[b].z(), b); + }); + std::vector merged(points.size()); + for (std::size_t i = 0; i < order.size(); ++i) + merged[order[i]] = i > 0 && points[order[i]] == points[order[i - 1]] ? merged[order[i - 1]] : order[i]; + std::vector degenerate(triangles.size(), 0); + for (std::size_t f = 0; f < triangles.size(); ++f) { + Triangle &t = triangles[f]; + for (std::size_t &v : t) + v = merged[v]; + degenerate[f] = t[0] == t[1] || t[1] == t[2] || t[0] == t[2]; + } + erase_marked(triangles, degenerate); + remove_duplicate_triangles(points.size(), triangles); + + std::vector compact(points.size(), NONE); + std::vector used; + for (Triangle &t : triangles) + for (std::size_t &v : t) { + if (compact[v] == NONE) { + compact[v] = used.size(); + used.push_back(points[v]); + } + v = compact[v]; + } + points = std::move(used); +} + +// Closes cracks between open edges; no vertex moves by more than a quarter of its shortest edge. +std::size_t weld_border_vertices(const std::vector &points, std::vector &triangles, const std::vector &border, + double tolerance) +{ + if (border.empty()) + return 0; + std::vector radius(points.size(), tolerance); + for (const Triangle &t : triangles) + for (std::size_t k = 0; k < 3; ++k) { + const double r = 0.25 * std::sqrt(CGAL::squared_distance(points[t[k]], points[t[(k + 1) % 3]])); + radius[t[k]] = std::min(radius[t[k]], r); + radius[t[(k + 1) % 3]] = std::min(radius[t[(k + 1) % 3]], r); + } + + using Cell = std::array; + auto cell_of = [tolerance](const Point &p) { + return Cell{int64_t(std::floor(p.x() / tolerance)), int64_t(std::floor(p.y() / tolerance)), int64_t(std::floor(p.z() / tolerance))}; + }; + std::vector> grid; + for (std::size_t v : border) + grid.emplace_back(cell_of(points[v]), v); + tbb::parallel_sort(grid.begin(), grid.end()); + + UnionFind uf(points.size()); + for (const auto &[c, v] : grid) + for (int64_t dx = -1; dx <= 1; ++dx) + for (int64_t dy = -1; dy <= 1; ++dy) + for (int64_t dz = -1; dz <= 1; ++dz) { + const Cell n{c[0] + dx, c[1] + dy, c[2] + dz}; + for (auto it = std::lower_bound(grid.begin(), grid.end(), std::make_pair(n, std::size_t(0))); it != grid.end() && it->first == n; ++it) + if (const std::size_t u = it->second; + u < v && CGAL::squared_distance(points[u], points[v]) <= CGAL::square(std::min(radius[u], radius[v]))) + uf.unite(u, v); + } + std::size_t welded = 0; + for (std::size_t v : border) + welded += uf.find(v) != v; + if (welded > 0) + for (Triangle &t : triangles) + for (std::size_t &v : t) + v = uf.find(v); + return welded; +} + +// Orients each part as most of its area was and gives each fan its own vertex; false if the mesh is still invalid. +bool orient_soup(std::vector &points, std::vector &triangles, std::size_t &reversed_parts) +{ + const std::size_t num_faces = triangles.size(); + const std::vector uses = sorted_edge_uses(triangles); + std::vector twin(3 * num_faces, NONE); + std::vector> cut; // edges used by more than two triangles + for_each_edge(uses, [&](std::size_t i, std::size_t j) { + if (j - i == 2) { + twin[uses[i].corner] = uses[i + 1].corner; + twin[uses[i + 1].corner] = uses[i].corner; + } else if (j - i > 2) + cut.emplace_back(i, j); + }); + + std::vector flip(num_faces, -1); + std::vector part_of(num_faces); + std::vector vote; + std::vector conflict(3 * num_faces, 0); + std::vector stack; + for (std::size_t seed = 0; seed < num_faces; ++seed) { + if (flip[seed] >= 0) + continue; + const std::size_t part = vote.size(); + vote.push_back(0.); + flip[seed] = 0; + stack.push_back(seed); + while (!stack.empty()) { + const std::size_t f = stack.back(); + stack.pop_back(); + part_of[f] = part; + const Triangle &t = triangles[f]; + const double area = std::sqrt(CGAL::squared_area(points[t[0]], points[t[1]], points[t[2]])); + vote[part] += flip[f] ? -area : area; + for (std::size_t k = 0; k < 3; ++k) { + const std::size_t c = 3 * f + k, n = twin[c]; + if (n == NONE) + continue; + const std::size_t g = n / 3; + // Triangles agree when they run along their shared edge in opposite directions. + const signed char want = flip[f] ^ (t[k] == triangles[g][n % 3]); + if (flip[g] < 0) { + flip[g] = want; + stack.push_back(g); + } else if (flip[g] != want) + conflict[c] = conflict[n] = 1; + } + } + } + + // Corners keep their input vertex in original, through the flips below. + std::vector original(3 * num_faces); + std::vector part_reversed(vote.size(), 0); + for (std::size_t f = 0; f < num_faces; ++f) { + if (flip[f] != (vote[part_of[f]] < 0.)) { + std::swap(triangles[f][1], triangles[f][2]); + part_reversed[part_of[f]] = 1; + } + for (std::size_t k = 0; k < 3; ++k) + original[3 * f + k] = triangles[f][k]; + } + reversed_parts = std::count(part_reversed.begin(), part_reversed.end(), 1); + + auto corner_of = [&original](std::size_t f, std::size_t v) { return 3 * f + (original[3 * f] == v ? 0 : original[3 * f + 1] == v ? 1 : 2); }; + UnionFind fans(3 * num_faces); + for (const EdgeUse &e : uses) + if (const std::size_t n = twin[e.corner]; n != NONE && e.corner < n && !conflict[e.corner]) { + fans.unite(corner_of(e.corner / 3, e.a), corner_of(n / 3, e.a)); + fans.unite(corner_of(e.corner / 3, e.b), corner_of(n / 3, e.b)); + } + std::vector fan_vertex(3 * num_faces, NONE); + std::vector used(points.size(), 0); + for (std::size_t c = 0; c < 3 * num_faces; ++c) { + std::size_t © = fan_vertex[fans.find(c)]; + if (copy == NONE) { + const std::size_t v = original[c]; + copy = used[v] ? points.size() : v; + if (used[v]) + points.push_back(points[v]); + used[v] = 1; + } + triangles[c / 3][c % 3] = copy; + } + + // Only edges that were cut can still be shared wrongly: by more than two triangles, or twice in one direction. + for (std::size_t k = 0; k < uses.size(); ++k) + if (conflict[uses[k].corner] && uses[k].corner < twin[uses[k].corner]) + cut.emplace_back(k, k + 2); + for (const auto &[i, j] : cut) { + // Each use as its copied endpoints and whether it runs from a to b. + std::vector> copies; + for (std::size_t k = i; k < j; ++k) { + const std::size_t f = uses[k].corner / 3, ka = corner_of(f, uses[k].a) % 3, kb = corner_of(f, uses[k].b) % 3; + const auto [ca, cb] = std::minmax(triangles[f][ka], triangles[f][kb]); + copies.push_back({ca, cb, std::size_t((ka + 1) % 3 == kb)}); + } + std::sort(copies.begin(), copies.end()); + for (std::size_t k = 0; k + 1 < copies.size(); ++k) + if (copies[k][0] == copies[k + 1][0] && copies[k][1] == copies[k + 1][1] && + (copies[k][2] == copies[k + 1][2] || (k + 2 < copies.size() && copies[k + 2][0] == copies[k][0] && copies[k + 2][1] == copies[k][1]))) + return false; + } + return true; +} + +// orient_polygon_soup() orients each part arbitrarily; give it back the orientation most of its input had. +std::size_t keep_input_orientation(Mesh &mesh, const std::vector &points_before, const std::vector &triangles_before, + const std::vector &points, const std::vector &triangles) +{ + if (mesh.number_of_faces() != triangles.size()) + return 0; + auto fcc = mesh.add_property_map("f:repair_part").first; + const std::size_t num_parts = PMP::connected_components(mesh, fcc); + std::vector vote(num_parts, 0.); + std::size_t i = 0; + for (Face f : mesh.faces()) { + const Triangle &a = triangles_before[i]; + const Triangle &b = triangles[i++]; + bool same = false; + for (std::size_t r = 0; r < 3 && !same; ++r) { + same = true; + for (std::size_t k = 0; k < 3 && same; ++k) + same = points_before[a[k]] == points[b[(k + r) % 3]]; + } + vote[fcc[f]] += same ? PMP::face_area(f, mesh) : -PMP::face_area(f, mesh); + } + std::vector to_reverse; + for (Face f : mesh.faces()) + if (vote[fcc[f]] < 0.) + to_reverse.push_back(f); + mesh.remove_property_map(fcc); + if (!to_reverse.empty()) + PMP::reverse_face_orientations(to_reverse, mesh); + return std::count_if(vote.begin(), vote.end(), [](double v) { return v < 0.; }); +} + +void close_cracks(Mesh &mesh, double tolerance) +{ + PMP::merge_reversible_connected_components(mesh); + PMP::stitch_borders(mesh); + if (CGAL::is_closed(mesh)) + return; + // T-junctions, which welding cannot close. + PMP::experimental::snap_borders(mesh, CGAL::Constant_property_map(tolerance)); + PMP::stitch_borders(mesh); +} + +struct BorderLoop +{ + Halfedge halfedge; + std::vector halfedges; + Vector normal; // unit normal a face closing the loop would have + double offset = 0.; + bool planar = false; + CGAL::Bbox_3 bbox; +}; + +struct CdtFaceInfo +{ + int nesting = -1; +}; +using CdtVb = CGAL::Triangulation_vertex_base_with_info_2; +using CdtFb = CGAL::Constrained_triangulation_face_base_2>; +using Cdt = CGAL::Constrained_Delaunay_triangulation_2, CGAL::No_constraint_intersection_tag>; + +// Number of constrained edges crossed to reach each face from the outside. +void mark_nesting(Cdt &cdt) +{ + std::vector> seeds{{cdt.infinite_face(), 0}}; + while (!seeds.empty()) { + auto [seed, level] = seeds.back(); + seeds.pop_back(); + if (seed->info().nesting != -1) + continue; + seed->info().nesting = level; + std::vector queue{seed}; + while (!queue.empty()) { + Cdt::Face_handle f = queue.back(); + queue.pop_back(); + for (int i = 0; i < 3; ++i) { + Cdt::Face_handle n = f->neighbor(i); + if (n->info().nesting != -1) + continue; + if (cdt.is_constrained({f, i})) + seeds.emplace_back(n, level + 1); + else { + n->info().nesting = level; + queue.push_back(n); + } + } + } + } +} + +// Fills a planar region bounded by nested loops, such as a missing annulus, leaving its openings open. +bool fill_planar_region(Mesh &mesh, const std::vector &loops) +{ + const Vector &n = loops.front()->normal; + const Vector u = std::abs(n.x()) < 0.9 ? CGAL::cross_product(n, Vector(1, 0, 0)) : CGAL::cross_product(n, Vector(0, 1, 0)); + const Vector e1 = u / std::sqrt(u.squared_length()); + const Vector e2 = CGAL::cross_product(n, e1); + + Cdt cdt; + std::size_t num_vertices = 0; + std::set> border_edges; + try { + for (const BorderLoop *loop : loops) { + std::vector vh; + for (Halfedge h : loop->halfedges) { + const Vector p = mesh.point(mesh.source(h)) - CGAL::ORIGIN; + vh.push_back(cdt.insert(Kernel::Point_2(p * e1, p * e2))); + vh.back()->info() = mesh.source(h); + border_edges.emplace(mesh.source(h), mesh.target(h)); + } + num_vertices += vh.size(); + for (std::size_t k = 0; k < vh.size(); ++k) + cdt.insert_constraint(vh[k], vh[(k + 1) % vh.size()]); + } + } catch (const std::exception &) { + return false; + } + // Loop vertices projecting onto the same point were merged by the triangulation. + if (cdt.number_of_vertices() != num_vertices) + return false; + mark_nesting(cdt); + + std::vector> patch; + std::set> patch_edges; + for (Cdt::Face_handle f : cdt.finite_face_handles()) + if (f->info().nesting % 2 == 1) { + const std::array &t = patch.emplace_back(std::array{f->vertex(0)->info(), f->vertex(1)->info(), f->vertex(2)->info()}); + for (int k = 0; k < 3; ++k) + patch_edges.emplace(t[k], t[(k + 1) % 3]); + } + // The patch has to close every loop with the orientation of the triangles around it. + for (const auto &e : border_edges) + if (patch_edges.count(e) == 0) + return false; + + std::vector added; + for (const auto &t : patch) { + const Face f = mesh.add_face(t[0], t[1], t[2]); + if (f == Mesh::null_face()) { + for (Face a : added) + mesh.remove_face(a); + return false; + } + added.push_back(f); + } + return true; +} + +// Last resort for a loop the hole filling could not close: a fan around its centroid. +void fill_with_fan(Mesh &mesh, const BorderLoop &loop) +{ + Vector sum(0, 0, 0); + for (Halfedge h : loop.halfedges) + sum = sum + (mesh.point(mesh.source(h)) - CGAL::ORIGIN); + const Vertex c = mesh.add_vertex(CGAL::ORIGIN + sum / double(loop.halfedges.size())); + for (Halfedge h : loop.halfedges) + mesh.add_face(mesh.source(h), mesh.target(h), c); +} + +std::size_t fill_holes(Mesh &mesh, double tolerance) +{ + std::vector borders; + CGAL::extract_boundary_cycles(mesh, std::back_inserter(borders)); + std::vector loops(borders.size()); + for (std::size_t i = 0; i < borders.size(); ++i) { + BorderLoop &loop = loops[i]; + loop.halfedge = borders[i]; + Vector newell(0, 0, 0); + for (Halfedge h : CGAL::halfedges_around_face(borders[i], mesh)) { + loop.halfedges.push_back(h); + newell = newell + CGAL::cross_product(mesh.point(mesh.source(h)) - CGAL::ORIGIN, mesh.point(mesh.target(h)) - CGAL::ORIGIN); + loop.bbox += mesh.point(mesh.source(h)).bbox(); + } + if (const double len = std::sqrt(newell.squared_length()); len > 0.) { + loop.normal = newell / len; + loop.offset = (mesh.point(mesh.source(loop.halfedge)) - CGAL::ORIGIN) * loop.normal; + loop.planar = std::all_of(loop.halfedges.begin(), loop.halfedges.end(), [&](Halfedge h) { + return std::abs((mesh.point(mesh.source(h)) - CGAL::ORIGIN) * loop.normal - loop.offset) <= tolerance; + }); + } + } + + // Planar loops facing each other in the same plane, one inside the other, bound one region. + UnionFind regions(loops.size()); + std::vector grouped(loops.size(), 0); + for (std::size_t a = 0; a < loops.size(); ++a) + for (std::size_t b = 0; b < loops.size(); ++b) { + const BorderLoop &la = loops[a], &lb = loops[b]; + if (a == b || !la.planar || !lb.planar || la.normal * lb.normal > -0.99 || !bbox_contains(la.bbox, lb.bbox, tolerance)) + continue; + if (std::all_of(lb.halfedges.begin(), lb.halfedges.end(), [&](Halfedge h) { + return std::abs((mesh.point(mesh.source(h)) - CGAL::ORIGIN) * la.normal - la.offset) <= tolerance; + })) { + regions.unite(a, b); + grouped[a] = grouped[b] = 1; + } + } + std::map> groups; + for (std::size_t i = 0; i < loops.size(); ++i) + if (grouped[i]) + groups[regions.find(i)].push_back(i); + + std::size_t filled = 0; + std::vector done(loops.size(), 0); + for (auto &[root, members] : groups) { + // The outermost loop sets the orientation of the region. + auto extent = [&loops](std::size_t i) { return loops[i].bbox.x_span() + loops[i].bbox.y_span() + loops[i].bbox.z_span(); }; + std::sort(members.begin(), members.end(), [&extent](std::size_t l, std::size_t r) { return extent(l) > extent(r); }); + std::vector group; + for (std::size_t i : members) + group.push_back(&loops[i]); + if (fill_planar_region(mesh, group)) { + filled += members.size(); + for (std::size_t i : members) + done[i] = 1; + } + } + + for (std::size_t i = 0; i < loops.size(); ++i) { + if (done[i]) + continue; + ++filled; + std::vector patch; + // Searching all triangles is cubic in the loop length; the search among Delaunay ones varies from run to run. + const std::size_t size = loops[i].halfedges.size(); + PMP::triangulate_hole(mesh, loops[i].halfedge, + CGAL::parameters::use_2d_constrained_delaunay_triangulation(true) + .use_delaunay_triangulation(size > 100) + .do_not_use_cubic_algorithm(size > 200) + .face_output_iterator(std::back_inserter(patch))); + if (patch.empty() && CGAL::is_border(loops[i].halfedge, mesh)) + fill_with_fan(mesh, loops[i]); + } + return filled; +} + +double signed_volume(const Mesh &mesh) +{ + double vol = 0.; + for (Face f : mesh.faces()) { + const Halfedge h = mesh.halfedge(f); + vol += CGAL::determinant(mesh.point(mesh.source(h)) - CGAL::ORIGIN, mesh.point(mesh.target(h)) - CGAL::ORIGIN, + mesh.point(mesh.target(mesh.next(h))) - CGAL::ORIGIN); + } + return vol / 6.; +} + +// Returns the pairs removed. Widespread overlaps are left to slicing: remeshing them can cut a part down to a sliver. +std::size_t remove_local_self_intersections(Mesh &shell) +{ + std::vector> pairs; + PMP::self_intersections(shell, std::back_inserter(pairs)); + if (pairs.empty()) + return 0; + std::set faces; + for (const auto &[a, b] : pairs) { + faces.insert(a); + faces.insert(b); + } + if (20 * faces.size() > shell.number_of_faces()) + return 0; + const double volume = signed_volume(shell); + Mesh backup = shell; + try { + PMP::experimental::remove_self_intersections(faces, shell, CGAL::parameters::preserve_genus(false)); + shell.collect_garbage(); + if (CGAL::is_valid_polygon_mesh(shell) && CGAL::is_closed(shell) && std::abs(signed_volume(shell) - volume) <= 1e-3 * std::abs(volume)) + return pairs.size(); + } catch (const std::exception &) { + } + shell = std::move(backup); + return 0; +} + +bool surfaces_intersect(const Mesh &mesh, const Tree &other) +{ + for (Face f : mesh.faces()) { + const Halfedge h = mesh.halfedge(f); + if (other.do_intersect(Kernel::Triangle_3(mesh.point(mesh.source(h)), mesh.point(mesh.target(h)), mesh.point(mesh.target(mesh.next(h)))))) + return true; + } + return false; +} + +Point inner_sample(const Mesh &shell) +{ + Face best; + double best_area = -1.; + for (Face f : shell.faces()) + if (const double a = PMP::face_area(f, shell); a > best_area) { + best_area = a; + best = f; + } + const Halfedge h = shell.halfedge(best); + return CGAL::centroid(shell.point(shell.source(h)), shell.point(shell.target(h)), shell.point(shell.target(shell.next(h)))); +} + +// Orients and filters the shells by the nonzero winding rule of slicing. Crossing shells stay apart, as parts do. +void resolve_shells(std::vector &shells, MeshRepairStats &stats, const std::function &progress) +{ + const std::size_t n = shells.size(); + std::vector bbox(n); + std::vector sign(n); + std::vector boxes; + for (std::size_t i = 0; i < n; ++i) { + bbox[i] = PMP::bbox(shells[i]); + sign[i] = signed_volume(shells[i]) < 0. ? -1 : 1; + boxes.emplace_back(bbox[i], i); + } + const std::vector> overlaps = overlapping_boxes(boxes); + + std::vector> enclosing(n); + std::vector needs_tree(n, 0); + for (const auto &[a, b] : overlaps) { + needs_tree[shells[a].number_of_faces() < shells[b].number_of_faces() ? b : a] = 1; + if (bbox_contains(bbox[a], bbox[b])) + enclosing[b].push_back(a); + if (bbox_contains(bbox[b], bbox[a])) + enclosing[a].push_back(b); + } + for (const std::vector &outer : enclosing) + for (std::size_t k : outer) + needs_tree[k] = 1; + // Built before any query: an AABB tree finishes its construction lazily on the first one. + std::vector> trees(n); + parallel_for_each(n, [&](std::size_t i) { + if (needs_tree[i]) { + trees[i] = std::make_unique(shells[i].faces().begin(), shells[i].faces().end(), shells[i]); + trees[i]->build(); + } + }); + + std::vector hit(overlaps.size(), 0); + parallel_for_each(overlaps.size(), [&](std::size_t i) { + const auto [a, b] = overlaps[i]; + const bool a_smaller = shells[a].number_of_faces() < shells[b].number_of_faces(); + hit[i] = surfaces_intersect(shells[a_smaller ? a : b], *trees[a_smaller ? b : a]); + }); + std::vector crossing(n, 0); + for (std::size_t i = 0; i < overlaps.size(); ++i) + if (hit[i]) + crossing[overlaps[i].first] = crossing[overlaps[i].second] = 1; + progress(95); + + // Crossing shells are solids; any other shell must separate zero from nonzero winding. + std::vector drop(n, 0); + std::vector target(n, 1); + parallel_for_each(n, [&](std::size_t j) { + if (crossing[j]) + return; + const Point sample = inner_sample(shells[j]); + int winding = 0; + for (std::size_t k : enclosing[j]) + if (CGAL::Side_of_triangle_mesh(*trees[k])(sample) == CGAL::ON_BOUNDED_SIDE) + winding += crossing[k] ? 1 : sign[k]; + const bool solid_outside = winding != 0; + const bool solid_inside = winding + sign[j] != 0; + drop[j] = solid_outside == solid_inside; + target[j] = solid_inside ? 1 : -1; + }); + trees.clear(); + + for (std::size_t i = 0; i < n; ++i) + if (drop[i]) + ++stats.removed_shells; + else if (target[i] != sign[i]) { + PMP::reverse_face_orientations(shells[i]); + ++stats.reversed_shells; + } + erase_marked(shells, drop); +} + +// Connected parts of the soup; open parts with touching bounding boxes share a unit, so cracks between them close. +std::vector> soup_units(const std::vector &points, const std::vector &triangles, + const SoupTopology &topology, double tolerance) +{ + UnionFind parts = connect_vertices(points.size(), triangles); + std::vector part_index(points.size(), NONE); + std::vector part_of_face(triangles.size()); + std::vector part_bbox; + std::vector part_open; + for (std::size_t f = 0; f < triangles.size(); ++f) { + std::size_t &index = part_index[parts.find(triangles[f][0])]; + if (index == NONE) { + index = part_bbox.size(); + part_bbox.emplace_back(); + part_open.push_back(0); + } + part_of_face[f] = index; + for (std::size_t v : triangles[f]) + part_bbox[index] += points[v].bbox(); + part_open[index] = part_open[index] || topology.open_face[f]; + } + + std::vector boxes; + for (std::size_t i = 0; i < part_bbox.size(); ++i) + if (part_open[i]) { + const CGAL::Bbox_3 &b = part_bbox[i]; + boxes.emplace_back(CGAL::Bbox_3(b.xmin() - tolerance, b.ymin() - tolerance, b.zmin() - tolerance, b.xmax() + tolerance, + b.ymax() + tolerance, b.zmax() + tolerance), i); + } + UnionFind units(part_bbox.size()); + for (const auto &[a, b] : overlapping_boxes(boxes)) + units.unite(a, b); + + std::vector unit_index(part_bbox.size(), NONE); + std::vector> out; + for (std::size_t f = 0; f < triangles.size(); ++f) { + std::size_t &index = unit_index[units.find(part_of_face[f])]; + if (index == NONE) { + index = out.size(); + out.emplace_back(); + } + out[index].push_back(f); + } + return out; +} + +struct UnitResult +{ + std::vector shells; + std::size_t reversed = 0; + std::size_t filled = 0; + bool closed = true; +}; + +UnitResult repair_unit(const std::vector &points, const std::vector &triangles, const std::vector &faces, + const SoupTopology &topology, double tolerance) +{ + std::vector unit_points; + std::vector unit_triangles; + extract_group(points, triangles, faces, unit_points, unit_triangles); + bool oriented_manifold = true; + for (std::size_t f : faces) { + oriented_manifold = oriented_manifold && !topology.bad_face[f]; + for (std::size_t v : triangles[f]) + oriented_manifold = oriented_manifold && !topology.pinched[v]; + } + + UnitResult result; + Mesh mesh; + if (oriented_manifold) + PMP::polygon_soup_to_polygon_mesh(unit_points, unit_triangles, mesh); + else { + std::vector oriented_points = unit_points; + std::vector oriented_triangles = unit_triangles; + if (orient_soup(oriented_points, oriented_triangles, result.reversed)) + PMP::polygon_soup_to_polygon_mesh(oriented_points, oriented_triangles, mesh); + else { + const std::vector points_before = unit_points; + const std::vector triangles_before = unit_triangles; + orient_with_cgal(unit_points, unit_triangles); + PMP::polygon_soup_to_polygon_mesh(unit_points, unit_triangles, mesh); + result.reversed = keep_input_orientation(mesh, points_before, triangles_before, unit_points, unit_triangles); + } + } + + if (!CGAL::is_closed(mesh)) + close_cracks(mesh, tolerance); + PMP::remove_degenerate_faces(mesh); + if (!CGAL::is_closed(mesh)) { + // Rejoin vertices the orientation split where holes break their fan, so each gap is one loop. + PMP::merge_duplicated_vertices_in_boundary_cycles(mesh); + result.filled = fill_holes(mesh, tolerance); + PMP::remove_degenerate_faces(mesh); + } + PMP::remove_isolated_vertices(mesh); + mesh.collect_garbage(); + result.closed = CGAL::is_closed(mesh); + if (result.closed && !mesh.is_empty()) + result.shells = split_shells(mesh); + return result; +} + +// Returns false when no closed shell was left. +bool repair_soup(std::vector &points, std::vector &triangles, std::vector &shells, MeshRepairStats &stats, + const std::function &progress) +{ + const double tolerance = 1e-4 * bbox_diagonal(points); + shells = take_closed_shells(points, triangles); + stats.modified = !triangles.empty(); + progress(10); + + if (!triangles.empty()) { + const std::size_t num_triangles = triangles.size(); + clean_soup(points, triangles); + SoupTopology topology = analyze_topology(points.size(), triangles); + // A soup that stays mostly open has cracks wider than the tolerance, so try wider ones. + for (double t = tolerance; t > 0. && t <= 100. * tolerance; t *= 10.) { + if (const std::size_t welded = weld_border_vertices(points, triangles, topology.border, t); welded > 0) { + stats.welded_vertices += welded; + clean_soup(points, triangles); + topology = analyze_topology(points.size(), triangles); + } + if (topology.open_fraction < 0.5) + break; + } + stats.removed_triangles = num_triangles - triangles.size(); + progress(25); + + // Independent parts of the soup are repaired in parallel. + const std::vector> units = soup_units(points, triangles, topology, tolerance); + std::vector results(units.size()); + parallel_for_each(units.size(), [&](std::size_t i) { results[i] = repair_unit(points, triangles, units[i], topology, tolerance); }); + for (UnitResult &result : results) { + if (!result.closed) + return false; + stats.reversed_shells += result.reversed; + stats.filled_holes += result.filled; + for (Mesh &shell : result.shells) + shells.emplace_back(std::move(shell)); + } + progress(60); + } + + // Shells are independent from here on. Hole filling and remeshing leave some that enclose nothing. + auto flat = [tolerance](const Mesh &shell) { + // Fewer than four faces are two triangles back to back, whose volumes need not cancel exactly. + return shell.number_of_faces() < 4 || std::abs(signed_volume(shell)) <= 0.1 * tolerance * PMP::area(shell); + }; + std::vector> pieces(shells.size()); + std::vector pairs(shells.size(), 0), removed(shells.size(), 0); + parallel_for_each(shells.size(), [&](std::size_t i) { + if (flat(shells[i])) + removed[i] = 1; + else if ((pairs[i] = remove_local_self_intersections(shells[i])) == 0) + pieces[i].emplace_back(std::move(shells[i])); + else + for (Mesh &piece : split_shells(shells[i])) + if (flat(piece)) + ++removed[i]; + else + pieces[i].emplace_back(std::move(piece)); + }); + stats.removed_shells += std::accumulate(removed.begin(), removed.end(), std::size_t(0)); + stats.self_intersections += std::accumulate(pairs.begin(), pairs.end(), std::size_t(0)); + shells.clear(); + for (std::vector &shell_pieces : pieces) + std::move(shell_pieces.begin(), shell_pieces.end(), std::back_inserter(shells)); + progress(90); + resolve_shells(shells, stats, progress); + + stats.modified = stats.modified || stats.removed_shells > 0 || stats.reversed_shells > 0 || stats.self_intersections > 0; + return !shells.empty(); +} + +// Repairs its into result, which is left empty when the input needs no change. +bool repair_its(const indexed_triangle_set &its, indexed_triangle_set &result, MeshRepairStats &stats, const std::function &progress) +{ + std::vector points; + points.reserve(its.vertices.size()); + for (const stl_vertex &v : its.vertices) + points.emplace_back(v.x(), v.y(), v.z()); + std::vector triangles; + triangles.reserve(its.indices.size()); + for (const stl_triangle_vertex_indices &f : its.indices) + if (its.vertices[f[0]].allFinite() && its.vertices[f[1]].allFinite() && its.vertices[f[2]].allFinite()) + triangles.push_back({std::size_t(f[0]), std::size_t(f[1]), std::size_t(f[2])}); + const bool dropped = triangles.size() != its.indices.size(); + + std::vector shells; + const bool ok = repair_soup(points, triangles, shells, stats, progress); + stats.modified = stats.modified || dropped; + if (!ok || !stats.modified) + return ok; + + for (Mesh &shell : shells) { + shell.collect_garbage(); + const int offset = int(result.vertices.size()); + for (Vertex v : shell.vertices()) { + const Point &p = shell.point(v); + result.vertices.emplace_back(float(p.x()), float(p.y()), float(p.z())); + } + for (Face f : shell.faces()) { + const Halfedge h = shell.halfedge(f); + result.indices.emplace_back(offset + int(shell.source(h)), offset + int(shell.target(h)), offset + int(shell.target(shell.next(h)))); + } + } + return true; +} + +} // namespace + +bool repair_mesh(TriangleMesh &mesh, MeshRepairStats *stats_out, std::string *error, const std::function &progress) +{ + if (mesh.its.indices.empty()) + return true; + + MeshRepairStats stats; + indexed_triangle_set result; + bool ok = false; + try { + ok = repair_its(mesh.its, result, stats, progress ? progress : [](int) {}); + } catch (const CGAL::Failure_exception &ex) { + if (error) + *error = ex.what(); + return false; + } + if (stats_out) + *stats_out = stats; + if (!ok) { + if (error) + *error = _u8L("The mesh does not enclose any volume."); + return false; + } + BOOST_LOG_TRIVIAL(info) << "repair_mesh: welded " << stats.welded_vertices << " vertices, removed " << stats.removed_triangles + << " triangles, filled " << stats.filled_holes << " holes, reversed " << stats.reversed_shells << " and removed " + << stats.removed_shells << " shells, fixed " << stats.self_intersections << " self-intersections"; + if (stats.modified) + mesh = TriangleMesh(std::move(result)); + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/MeshRepair.hpp b/src/libslic3r/MeshRepair.hpp new file mode 100644 index 0000000000..0bbb6e94a5 --- /dev/null +++ b/src/libslic3r/MeshRepair.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace Slic3r { + +class TriangleMesh; + +struct MeshRepairStats +{ + std::size_t welded_vertices = 0; + std::size_t removed_triangles = 0; + std::size_t reversed_shells = 0; + std::size_t filled_holes = 0; + std::size_t removed_shells = 0; + std::size_t self_intersections = 0; + // False when the input already was closed, consistently oriented and free of self-intersections. + bool modified = false; +}; + +// Returns false, leaving the mesh untouched, if no solid can be built. progress gets the percentage done and may +// throw to cancel. See docs/HLSD/mesh-repair.md. +bool repair_mesh(TriangleMesh &mesh, MeshRepairStats *stats = nullptr, std::string *error = nullptr, + const std::function &progress = {}); + +} // namespace Slic3r diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 6cca66e44b..d096bd2158 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -29,6 +29,7 @@ #include "TriangleMeshSlicer.hpp" #include "TriangleSelector.hpp" #include "MaterialType.hpp" +#include "MeshRepair.hpp" #include "Format/AMF.hpp" #include "Format/svg.hpp" @@ -2946,6 +2947,18 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) if (text_configuration.has_value()) text_configuration.reset(); + // Parts without a convex hull enclose no volume; drop them here, as deleting volumes later could delete this one. + std::vector> parts; + for (TriangleMesh &mesh : meshes) + if (!mesh.empty()) { + std::unique_ptr part(new ModelVolume(object, *this, std::move(mesh))); + if (const std::shared_ptr &hull = part->get_convex_hull_shared_ptr(); + hull != nullptr && !hull->its.vertices.empty() && !hull->its.indices.empty()) + parts.emplace_back(std::move(part)); + } + if (parts.empty()) + return 1; + std::optional saved_painting = remap_paint ? save_painting() : std::optional{}; @@ -2956,26 +2969,22 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) unsigned int extruder_counter = 0; const Vec3d offset = this->get_offset(); - for (TriangleMesh &mesh : meshes) { - if (mesh.empty()) - // Repair may have removed unconnected triangles, thus emptying the mesh. - continue; - + for (std::unique_ptr &part : parts) { if (idx == 0) { - this->set_mesh(std::move(mesh)); - this->calculate_convex_hull(); + this->set_mesh(part->m_mesh); + m_convex_hull = part->m_convex_hull; this->invalidate_convex_hull_2d(); // Assign a new unique ID, so that a new GLVolume will be generated. this->set_new_unique_id(); - // reset the source to disable reload from disk - this->source = ModelVolume::Source(); // BBS: reset facet annotations this->reset_extra_facets(); } else - this->object->volumes.insert(this->object->volumes.begin() + (++ivolume), new ModelVolume(object, *this, std::move(mesh))); + this->object->volumes.insert(this->object->volumes.begin() + (++ivolume), part.release()); + // reset the source to disable reload from disk + this->object->volumes[ivolume]->source = ModelVolume::Source(); this->object->volumes[ivolume]->set_offset(Vec3d::Zero()); this->object->volumes[ivolume]->center_geometry_after_creation(); this->object->volumes[ivolume]->translate(offset); @@ -2989,21 +2998,29 @@ size_t ModelVolume::split(unsigned int max_extruders, bool remap_paint) ++ idx; } - // discard volumes for which the convex hull was not generated or is degenerate - size_t i = 0; - while (i < this->object->volumes.size()) { - const std::shared_ptr &hull = this->object->volumes[i]->get_convex_hull_shared_ptr(); - if (hull == nullptr || hull->its.vertices.empty() || hull->its.indices.empty()) { - this->object->delete_volume(i); - --idx; - --i; - } - ++i; - } - return idx; } +bool ModelVolume::repair(bool keep_painting, MeshRepairStats *stats_out, std::string *error, const std::function &progress) +{ + TriangleMesh mesh = this->mesh(); + MeshRepairStats stats; + const bool ok = repair_mesh(mesh, &stats, error, progress); + if (stats_out) + *stats_out = stats; + if (!ok || !stats.modified) + return ok; + + const std::optional saved_painting = keep_painting ? save_painting() : std::nullopt; + this->set_mesh(std::move(mesh)); + this->calculate_convex_hull(); + this->invalidate_convex_hull_2d(); + // Assign a new unique ID, so that a new GLVolume will be generated. + this->set_new_unique_id(); + this->restore_painting(saved_painting); + return true; +} + void ModelVolume::translate(const Vec3d& displacement) { set_offset(get_offset() + displacement); diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 4bb1794b46..dcaea5ee97 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -77,6 +77,7 @@ class Preset; class BBLProject; class KeyStore; +struct MeshRepairStats; namespace UndoRedo { class StackImpl; @@ -887,13 +888,13 @@ public: // The triangular model. const TriangleMesh& mesh() const { return *m_mesh.get(); } std::shared_ptr mesh_ptr() const { return m_mesh; } - void set_mesh(const TriangleMesh &mesh) { m_mesh = std::make_shared(mesh); } - void set_mesh(TriangleMesh &&mesh) { m_mesh = std::make_shared(std::move(mesh)); } - void set_mesh(const indexed_triangle_set &mesh) { m_mesh = std::make_shared(mesh); } - void set_mesh(indexed_triangle_set &&mesh) { m_mesh = std::make_shared(std::move(mesh)); } - void set_mesh(std::shared_ptr &mesh) { m_mesh = mesh; } - void set_mesh(std::unique_ptr &&mesh) { m_mesh = std::move(mesh); } - void reset_mesh() { m_mesh = std::make_shared(); } + void set_mesh(const TriangleMesh &mesh) { m_mesh = std::make_shared(mesh); m_is_splittable = -1; } + void set_mesh(TriangleMesh &&mesh) { m_mesh = std::make_shared(std::move(mesh)); m_is_splittable = -1; } + void set_mesh(const indexed_triangle_set &mesh) { m_mesh = std::make_shared(mesh); m_is_splittable = -1; } + void set_mesh(indexed_triangle_set &&mesh) { m_mesh = std::make_shared(std::move(mesh)); m_is_splittable = -1; } + void set_mesh(std::shared_ptr &mesh) { m_mesh = mesh; m_is_splittable = -1; } + void set_mesh(std::unique_ptr &&mesh) { m_mesh = std::move(mesh); m_is_splittable = -1; } + void reset_mesh() { m_mesh = std::make_shared(); m_is_splittable = -1; } const std::shared_ptr& get_mesh_shared_ptr() const { return m_mesh; } // Configuration parameters specific to an object model geometry or a modifier volume, // overriding the global Slic3r settings and the ModelObject settings. @@ -1043,6 +1044,9 @@ public: // Return the number of volumes created from this one. // This is useful to assign different materials to different volumes of an object. size_t split(unsigned int max_extruders, bool remap_paint); + // Replaces the mesh by its repair_mesh() result if that changed it. False if no solid can be built. + bool repair(bool keep_painting, MeshRepairStats *stats = nullptr, std::string *error = nullptr, + const std::function &progress = {}); void translate(double x, double y, double z) { translate(Vec3d(x, y, z)); } void translate(const Vec3d& displacement); void scale(const Vec3d& scaling_factors); diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index b081f14248..ac584e5b77 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -12866,10 +12866,11 @@ CLITransformConfigDef::CLITransformConfigDef() //def->cli = "orient|o"; def->set_default_value(new ConfigOptionInt(0)); - /*def = this->add("repair", coBool); + def = this->add("repair", coBool); def->label = L("Repair"); - def->tooltip = L("Repair the model's meshes if it is non-manifold mesh."); - def->set_default_value(new ConfigOptionBool(false));*/ + def->tooltip = L("Repair the meshes of the models as Fix Model does: close holes and cracks, orient them outward and " + "remove self-intersections."); + def->set_default_value(new ConfigOptionBool(false)); def = this->add("rotate", coFloat); def->label = L("Rotate"); diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp index 15a2a641af..e016a549a2 100644 --- a/src/libslic3r/TextureToColor/TextureToColor.cpp +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -673,14 +673,14 @@ static bool repair_cluster_smooth( [&]() { return cancelled(); }, &repair_error); if (repaired) { if (cancelled()) return false; - BOOST_LOG_TRIVIAL(info) << log_prefix << ": Windows 3D mesh repair finished."; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh repair finished."; if (!resample_face_colors(TriMesh(std::move(repaired_its)))) return false; } else { - BOOST_LOG_TRIVIAL(warning) << log_prefix << ": Windows 3D mesh repair failed: " << repair_error; + BOOST_LOG_TRIVIAL(warning) << log_prefix << ": mesh repair failed: " << repair_error; } } else { - BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without Windows 3D repair."; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without repair."; } } } diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 5cbda924b5..f4508f47ac 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1952,6 +1952,16 @@ void MenuFactory::create_plate_menu() }, "", nullptr, []() { return !plater()->get_partplate_list().get_selected_plate()->get_objects().empty(); }, m_parent); + // fix objects on current plate + append_menu_item(menu, wxID_ANY, _L("Fix All"), _L("Fix all models on the current plate"), + [](wxCommandEvent&) { obj_list()->fix_all_through_cgal(false); }, "", nullptr, + []() { return !plater()->get_partplate_list().get_selected_plate()->empty(); }, m_parent); + + // fix objects on all plates + append_menu_item(menu, wxID_ANY, _L("Fix All Plates"), _L("Fix all models on all plates"), + [](wxCommandEvent&) { obj_list()->fix_all_through_cgal(true); }, "", nullptr, + []() { return !plater()->model().objects.empty(); }, m_parent); + // orient objects on current plate append_menu_item(menu, wxID_ANY, _L("Auto Rotate"), _L("Auto rotate current plate"), [](wxCommandEvent&) { diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index 4a733d3342..40ba495349 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -6297,30 +6297,44 @@ void ObjectList::rename_item() } void ObjectList::fix_through_cgal() +{ + std::vector obj_idxs, vol_idxs; + get_selection_indexes(obj_idxs, vol_idxs); + fix_through_cgal(obj_idxs, vol_idxs); +} + +void ObjectList::fix_all_through_cgal(bool all_plates) +{ + std::vector obj_idxs; + if (all_plates) { + for (int obj_idx = 0; obj_idx < int(m_objects->size()); ++obj_idx) + obj_idxs.push_back(obj_idx); + } else { + for (const ModelObject *mo : wxGetApp().plater()->get_partplate_list().get_selected_plate()->get_objects_on_this_plate()) + obj_idxs.push_back(int(std::find(m_objects->begin(), m_objects->end(), mo) - m_objects->begin())); + std::sort(obj_idxs.begin(), obj_idxs.end()); + obj_idxs.erase(std::unique(obj_idxs.begin(), obj_idxs.end()), obj_idxs.end()); + } + fix_through_cgal(obj_idxs, {}); +} + +void ObjectList::fix_through_cgal(const std::vector &obj_idxs, const std::vector &vol_idxs) { // Do not fix anything when a gizmo is open. There might be issues with updates // and what is worse, the snapshot time would refer to the internal stack. - if (!wxGetApp().plater()->get_view3D_canvas3D()->get_gizmos_manager().check_gizmos_closed_except(GLGizmosManager::Undefined)) + if (obj_idxs.empty() || !wxGetApp().plater()->get_view3D_canvas3D()->get_gizmos_manager().check_gizmos_closed_except(GLGizmosManager::Undefined)) return; // model_name std::vector succes_models; // model_name failing reason std::vector> failed_models; - - std::vector obj_idxs, vol_idxs; - get_selection_indexes(obj_idxs, vol_idxs); + size_t unchanged_models = 0; + bool painting_removed = false; std::vector model_names; - // clear selections from the non-broken models if any exists - // and than fill names of models to repairing if (vol_idxs.empty()) { -#if !FIX_THROUGH_CGAL_ALWAYS - for (int i = int(obj_idxs.size())-1; i >= 0; --i) - if (object(obj_idxs[i])->get_repaired_errors_count() == 0) - obj_idxs.erase(obj_idxs.begin()+i); -#endif // FIX_THROUGH_CGAL_ALWAYS for (int obj_idx : obj_idxs) if (object(obj_idx)) model_names.push_back(object(obj_idx)->name); @@ -6328,31 +6342,26 @@ void ObjectList::fix_through_cgal() else { ModelObject* obj = object(obj_idxs.front()); if (obj) { -#if !FIX_THROUGH_CGAL_ALWAYS - for (int i = int(vol_idxs.size()) - 1; i >= 0; --i) - if (obj->get_repaired_errors_count(vol_idxs[i]) == 0) - vol_idxs.erase(vol_idxs.begin() + i); -#endif // FIX_THROUGH_CGAL_ALWAYS for (int vol_idx : vol_idxs) model_names.push_back(obj->volumes[vol_idx]->name); } } auto plater = wxGetApp().plater(); + const bool keep_painting = GUI::wxGetApp().app_config->get_bool("keep_painting"); - auto fix_and_update_progress = [this, plater, model_names](const int obj_idx, const int vol_idx, - int model_idx, - ProgressDialog& progress_dlg, - std::vector& succes_models, - std::vector>& failed_models) + auto fix_and_update_progress = [&](const int obj_idx, const int vol_idx, int model_idx, ProgressDialog& progress_dlg) { - if (!object(obj_idx)) + ModelObject *mo = object(obj_idx); + if (!mo) return false; const std::string& model_name = model_names[model_idx]; wxString msg = _L("Repairing model object"); if (model_names.size() == 1) msg += ": " + from_u8(model_name) + "\n"; + else if (model_names.size() > 10) + msg += ": " + from_u8(model_name) + wxString::Format(" (%d/%d)", model_idx + 1, int(model_names.size())) + "\n"; else { msg += ":\n"; for (int i = 0; i < int(model_names.size()); ++i) @@ -6360,33 +6369,29 @@ void ObjectList::fix_through_cgal() msg += "\n"; } - const bool keep_painting = GUI::wxGetApp().app_config->get_bool("keep_painting"); - if (!keep_painting) { - plater->clear_before_change_mesh(obj_idx); - } - const size_t volumes_before = object(obj_idx)->volumes.size(); + auto count_painted = [mo]() { return std::count_if(mo->volumes.begin(), mo->volumes.end(), [](const ModelVolume *mv) { return mv->is_any_painted(); }); }; + const auto painted_before = count_painted(); std::string res; - if (!fix_model_with_cgal_gui(*(object(obj_idx)), vol_idx, progress_dlg, msg, res, keep_painting)) + bool modified = false; + if (!fix_model_with_cgal_gui(*mo, vol_idx, progress_dlg, msg, res, keep_painting, &modified)) return false; - //wxGetApp().plater()->changed_mesh(obj_idx); - object(obj_idx)->ensure_on_bed(); - plater->changed_mesh(obj_idx); - const size_t volumes_after = object(obj_idx)->volumes.size(); - if (volumes_after != volumes_before) - add_volumes_to_object_in_list(obj_idx); + if (modified) { + painting_removed = painting_removed || count_painted() < painted_before; + mo->ensure_on_bed(); + plater->changed_mesh(obj_idx); + plater->get_partplate_list().notify_instance_update(obj_idx, 0); + plater->sidebar().obj_list()->update_plate_values_for_items(); + update_item_error_icon(obj_idx, vol_idx); + update_info_items(obj_idx); + } - plater->get_partplate_list().notify_instance_update(obj_idx, 0); - plater->sidebar().obj_list()->update_plate_values_for_items(); - - if (res.empty()) + if (!res.empty()) + failed_models.push_back({ model_name, res }); + else if (modified) succes_models.push_back(model_name); else - failed_models.push_back({ model_name, res }); - - update_item_error_icon(obj_idx, vol_idx); - update_info_items(obj_idx); - + ++unchanged_models; return true; }; @@ -6399,11 +6404,7 @@ void ObjectList::fix_through_cgal() if (vol_idxs.empty()) { int vol_idx{ -1 }; for (int obj_idx : obj_idxs) { -#if !FIX_THROUGH_CGAL_ALWAYS - if (object(obj_idx)->get_repaired_errors_count(vol_idx) == 0) - continue; -#endif // FIX_THROUGH_CGAL_ALWAYS - if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg, succes_models, failed_models)) + if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg)) break; model_idx++; } @@ -6411,7 +6412,7 @@ void ObjectList::fix_through_cgal() else { int obj_idx{ obj_idxs.front() }; for (int vol_idx : vol_idxs) { - if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg, succes_models, failed_models)) + if (!fix_and_update_progress(obj_idx, vol_idx, model_idx, progress_dlg)) break; model_idx++; } @@ -6419,20 +6420,29 @@ void ObjectList::fix_through_cgal() // Close the progress dialog progress_dlg.Update(100, ""); + if (painting_removed) + plater->notify_painting_removed(); + // Show info notification - wxString msg; + std::vector blocks; wxString bullet_suf = "\n - "; if (!succes_models.empty()) { - msg = _L_PLURAL("The following model object has been repaired", "The following model objects have been repaired", succes_models.size()) + ":"; + wxString block = _L_PLURAL("The following model object has been repaired", "The following model objects have been repaired", succes_models.size()) + ":"; for (auto& model : succes_models) - msg += bullet_suf + from_u8(model); - msg += "\n\n"; + block += bullet_suf + from_u8(model); + blocks.push_back(block); } if (!failed_models.empty()) { - msg += _L_PLURAL("Failed to repair the following model object", "Failed to repair the following model objects", failed_models.size()) + ":\n"; + wxString block = _L_PLURAL("Failed to repair the following model object", "Failed to repair the following model objects", failed_models.size()) + ":"; for (auto& model : failed_models) - msg += bullet_suf + from_u8(model.first) + ": " + _(model.second); + block += bullet_suf + from_u8(model.first) + ": " + _(model.second); + blocks.push_back(block); } + if (unchanged_models > 0) + blocks.push_back(format_wxstr(_L_PLURAL("%1$d model object needed no repair.", "%1$d model objects needed no repair.", unchanged_models), unchanged_models)); + wxString msg; + for (const wxString &block : blocks) + msg += (msg.IsEmpty() ? "" : "\n\n") + block; if (msg.IsEmpty()) msg = _L("Repairing was canceled"); plater->get_notification_manager()->push_notification(NotificationType::CgalFinished, NotificationManager::NotificationLevel::PrintInfoShortNotificationLevel, into_u8(msg)); diff --git a/src/slic3r/GUI/GUI_ObjectList.hpp b/src/slic3r/GUI/GUI_ObjectList.hpp index 03bffb6cdc..85dc3943f9 100644 --- a/src/slic3r/GUI/GUI_ObjectList.hpp +++ b/src/slic3r/GUI/GUI_ObjectList.hpp @@ -47,9 +47,6 @@ typedef double coordf_t; typedef std::pair t_layer_height_range; typedef std::map t_layer_config_ranges; -// Manifold mesh may contain self-intersections, so we want to always allow fixing the mesh. -#define FIX_THROUGH_CGAL_ALWAYS 1 - namespace GUI { enum class Shortcut : uint8_t; @@ -463,6 +460,9 @@ public: void split_instances(); void rename_item(); void fix_through_cgal(); + // Fixes every object on the selected plate, or on all plates. + void fix_all_through_cgal(bool all_plates); + void fix_through_cgal(const std::vector &obj_idxs, const std::vector &vol_idxs); void simplify(); void smooth_mesh(); void update_item_error_icon(const int obj_idx, int vol_idx) const ; diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 8339e4d9d9..dd2d0e60d3 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -14984,24 +14984,8 @@ bool Plater::priv::can_fix_through_cgal() const std::vector obj_idxs, vol_idxs; sidebar->obj_list()->get_selection_indexes(obj_idxs, vol_idxs); -#if FIX_THROUGH_CGAL_ALWAYS - // Fixing always. + // A closed mesh may still self-intersect, so fixing is always allowed. return ! obj_idxs.empty() || ! vol_idxs.empty(); -#else // FIX_THROUGH_CGAL_ALWAYS - // Fixing only if the model is not manifold. - if (vol_idxs.empty()) { - for (auto obj_idx : obj_idxs) - if (model.objects[obj_idx]->get_repaired_errors_count() > 0) - return true; - return false; - } - - int obj_idx = obj_idxs.front(); - for (auto vol_idx : vol_idxs) - if (model.objects[obj_idx]->get_repaired_errors_count(vol_idx) > 0) - return true; - return false; -#endif // FIX_THROUGH_CGAL_ALWAYS } bool Plater::priv::can_simplify() const @@ -22041,13 +22025,16 @@ void Plater::clear_before_change_mesh(int obj_idx) mv->mmu_segmentation_facets.reset(); mv->fuzzy_skin_facets.reset(); } - if (paint_removed) { - // snapshot_time is captured by copy so the lambda knows where to undo/redo to. - get_notification_manager()->push_notification( - NotificationType::CustomSupportsAndSeamRemovedAfterRepair, - NotificationManager::NotificationLevel::PrintInfoNotificationLevel, - _u8L("Custom supports and color painting were removed before repairing.")); - } + if (paint_removed) + notify_painting_removed(); +} + +void Plater::notify_painting_removed() +{ + get_notification_manager()->push_notification( + NotificationType::CustomSupportsAndSeamRemovedAfterRepair, + NotificationManager::NotificationLevel::PrintInfoNotificationLevel, + _u8L("Custom supports and color painting were removed before repairing.")); } void Plater::changed_mesh(int obj_idx) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 0770e7e8d9..b3a2954b33 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -599,6 +599,7 @@ public: void reslice_SLA_until_step(SLAPrintObjectStep step, const ModelObject &object, bool postpone_error_messages = false); void clear_before_change_mesh(int obj_idx); + void notify_painting_removed(); void changed_mesh(int obj_idx); void changed_object(ModelObject &object); diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index bc50cabc8c..a785847670 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -14,7 +14,7 @@ #include "Widgets/StateColor.hpp" #include "Widgets/StaticLine.hpp" #include "libslic3r/ColorDecomposeRecipe.hpp" -#include "libslic3r/MeshBoolean.hpp" +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/TriangleSelector.hpp" #include @@ -2436,25 +2436,25 @@ void TextureImportDialog::start_computation(bool auto_color, bool initial) settings.target_colors_num = auto_color ? 0 : (size_t)m_param_color_count; settings.smooth_weight = m_param_smooth / 10.0; settings.mesh_repair_decision = m_mesh_repair_decision; - // BBS repairs the mesh through the Windows 3D SDK, which is only available on Windows - // builds that ship the SDK. Orca's CGAL-based repair (MeshBoolean::cgal::repair) works - // on all three platforms, so use that instead. settings.mesh_repair_callback = [](const indexed_triangle_set& mesh, indexed_triangle_set& repaired_mesh, std::function progress_callback, std::function cancel_callback, std::string* error_message) -> bool { - if (cancel_callback && cancel_callback()) - return false; if (progress_callback) progress_callback(_u8L("Repairing mesh").c_str(), 0); + struct Canceled {}; TriangleMesh tm(mesh); - if (!MeshBoolean::cgal::repair(tm, nullptr, error_message)) - return false; - - if (cancel_callback && cancel_callback()) + try { + if (!repair_mesh(tm, nullptr, error_message, [&cancel_callback](int) { + if (cancel_callback && cancel_callback()) + throw Canceled(); + })) + return false; + } catch (const Canceled &) { return false; + } repaired_mesh = tm.its; if (progress_callback) progress_callback(_u8L("Repairing mesh").c_str(), 100); @@ -2683,12 +2683,12 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) m_progress_dlg = nullptr; } -#ifdef HAS_WIN10SDK Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, - _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it with Windows 3D repair service before importing."), - _L("Mesh repair"), wxYES_NO | wxICON_WARNING | wxYES_DEFAULT); + _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it before importing."), + _L("Mesh repair"), wxYES_NO | wxCANCEL | wxICON_WARNING | wxYES_DEFAULT); dlg.SetButtonLabel(wxID_YES, _L("Import without repair")); dlg.SetButtonLabel(wxID_NO, _L("Repair and import"), true); + dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); // "Repair and import" is the recommended action here, so the accent moves off the default YES // button onto NO. MsgDialog::add_button already styled both as ButtonType::Choice, so restyling // with the same type swaps only the palette and leaves the geometry alone. @@ -2704,17 +2704,7 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) dlg.Fit(); dlg.CenterOnParent(); int ret = dlg.ShowModal(); - m_mesh_repair_decision = (ret == wxID_NO) - ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport - : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; -#else - Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, - _L("Please note that the mesh has non-manifold geometry or open boundaries."), - _L("Mesh issue"), wxOK | wxCANCEL | wxICON_WARNING | wxOK_DEFAULT); - dlg.SetButtonLabel(wxID_OK, _L("Continue"), true); - dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); - int ret = dlg.ShowModal(); - if (ret != wxID_OK) { + if (ret == wxID_CANCEL) { m_cancel_flag = true; if (initial) { m_initial_computation_cancelled = true; @@ -2727,8 +2717,9 @@ void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) } return; } - m_mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; -#endif + m_mesh_repair_decision = (ret == wxID_NO) + ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport + : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; start_computation(auto_color, initial); } diff --git a/src/slic3r/Utils/FixModelByCgal.cpp b/src/slic3r/Utils/FixModelByCgal.cpp index 09dad529fe..081d9dcf0d 100644 --- a/src/slic3r/Utils/FixModelByCgal.cpp +++ b/src/slic3r/Utils/FixModelByCgal.cpp @@ -3,240 +3,115 @@ #include #include #include -#include #include -#include "libslic3r/TriangleMesh.hpp" -#include "libslic3r/BoundingBox.hpp" -#include "libslic3r/Point.hpp" -#include "libslic3r/libslic3r.h" -#include #include -#include "libslic3r/Exception.hpp" -#include +#include #include -#include "slic3r/GUI/Widgets/ProgressDialog.hpp" -#include #include #include -#include #include + #include -#include "libslic3r/MeshBoolean.hpp" -#include "libslic3r/Model.hpp" #include "libslic3r/Format/bbs_3mf.hpp" -#include "libslic3r/format.hpp" +#include "libslic3r/MeshRepair.hpp" +#include "libslic3r/Model.hpp" #include "libslic3r/Thread.hpp" -#include "../GUI/I18N.hpp" -#include "libslic3r/TriangleSelector.hpp" - -// Orca: This file provides utilities for repairing 3D model meshes using the CGAL library, handling mesh splitting, merging, and boolean operations. +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/Widgets/ProgressDialog.hpp" namespace Slic3r { namespace { -// Orca: Helper functions for analyzing mesh properties and transformations. - -bool is_not_3dimensional_part(const TriangleMesh &mesh) +class RepairCanceledException : public std::exception { - // Orca: Determines if a mesh is degenerate or represents a non-3dimensional part by checking volume and bounding box dimensions. - if (mesh.its.indices.empty()) - return true; - - indexed_triangle_set tmp = mesh.its; - its_remove_degenerate_faces(tmp, true); - if (tmp.indices.empty()) - return true; - - const BoundingBoxf3 bbox = mesh.bounding_box(); - const Vec3d size = bbox.size(); - const double min_dim = std::min(size.x(), std::min(size.y(), size.z())); - const double max_dim = std::max(size.x(), std::max(size.y(), size.z())); - if (min_dim <= EPSILON) - return true; - - const double volume = std::abs(its_volume(mesh.its)); - const double bbox_volume = size.x() * size.y() * size.z(); - if (volume <= EPSILON) - return true; - - const double min_relative_thickness = 1e-6; - const double min_volume_ratio = 1e-6; - if (min_dim / max_dim <= min_relative_thickness) - return true; - if (bbox_volume > 0.0 && volume / bbox_volume <= min_volume_ratio) - return true; - - return false; -} +public: + const char *what() const noexcept override { return "Model repair has been canceled"; } +}; } // namespace -// Orca: Exception class for handling user-initiated cancellation of model repair operations. -class RepairCanceledException : public std::exception { -public: - const char* what() const noexcept override { return "Model repair has been canceled"; } -}; - -// Orca: Main function to repair model objects using CGAL, with progress dialog and cancellation support. -// Returns false if fixing was canceled. fix_result contains error message if failed. -bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dialog, const wxString &msg_header, std::string &fix_result, bool keep_painting) +bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dialog, const wxString &msg_header, std::string &fix_result, bool keep_painting, + bool *modified) { - // Hold SaveObjectGaurd to prevent backup manager from racing concurrent mesh mutations (use-after-free). + // Keeps the backup manager from reading a mesh while the worker replaces it. SaveObjectGaurd backup_gaurd(model_object); - // Orca: Synchronization primitives for progress updates between worker thread and GUI. - std::mutex mtx; - std::condition_variable condition; - struct Progress { - std::string message; - int percent = 0; - bool updated = false; - } progress; + std::vector volumes; + if (volume_idx < 0) + volumes = model_object.volumes; + else if (std::size_t(volume_idx) < model_object.volumes.size()) + volumes.push_back(model_object.volumes[volume_idx]); + std::mutex mtx; + std::condition_variable condition; + struct Progress + { + std::string message; + int percent = 0; + bool updated = false; + } progress; std::atomic canceled = false; std::atomic finished = false; + bool any_modified = false; + std::string errors; - bool success = false; - size_t ivolume = 0; - - // Orca: Lambda for updating progress from worker thread. - auto on_progress = [&mtx, &condition, &ivolume, &model_object, &progress](const std::string &msg, unsigned prcnt) { - std::unique_lock lock(mtx); + auto on_progress = [&mtx, &condition, &progress](const std::string &msg, int percent) { + std::lock_guard lock(mtx); progress.message = msg; - const size_t total = std::max(1, model_object.volumes.size()); - progress.percent = int(std::floor((float(prcnt) + float(ivolume) * 100.f) / float(total))); + progress.percent = percent; progress.updated = true; condition.notify_all(); }; - // Orca: Worker thread that performs the actual model repair operations. - auto worker_thread = std::thread([&model_object, volume_idx, &ivolume, on_progress, &success, &canceled, &finished, &fix_result, keep_painting]() { + auto worker_thread = std::thread([&]() { + set_current_thread_name("cgal_fix_model"); try { - set_current_thread_name("cgal_fix_model"); + for (std::size_t i = 0; i < volumes.size(); ++i) { + ModelVolume *volume = volumes[i]; + const std::string name = volumes.size() > 1 ? volume->name : std::string(); + const auto report = [&](int percent) { + if (canceled) + throw RepairCanceledException(); + on_progress(name, int((100 * i + std::size_t(percent)) / volumes.size())); + }; + report(0); - size_t start_volume = volume_idx == -1 ? 0 : size_t(volume_idx); - size_t end_volume = volume_idx == -1 ? std::numeric_limits::max() : size_t(volume_idx); - - for (ivolume = start_volume; ivolume < model_object.volumes.size(); ++ivolume) { - if (volume_idx != -1 && ivolume > end_volume) - break; - if (canceled) - throw RepairCanceledException(); - - on_progress(_u8L("Repairing model object"), 10); - - ModelVolume *volume = model_object.volumes[ivolume]; - - // Orca: Split splittable volumes into parts for individual processing. - size_t parts_count = 1; - if (volume->is_splittable()) { - parts_count = volume->split(1, keep_painting); - if (parts_count > 1) { - const std::string msg = Slic3r::format(L("Split into %1% parts"), parts_count); - on_progress(msg, 10); - } - } - - size_t part_end = std::min(ivolume + parts_count - 1, model_object.volumes.size() - 1); - if (volume_idx != -1) - end_volume = part_end; - - size_t removed_parts = 0; - for (size_t idx = part_end + 1; idx > ivolume; --idx) { - const size_t part_idx = idx - 1; - const ModelVolume *part_volume = model_object.volumes[part_idx]; - if (!is_not_3dimensional_part(part_volume->mesh())) - continue; - - model_object.delete_volume(part_idx); - ++removed_parts; - if (part_end > 0) - --part_end; - else - part_end = 0; - if (volume_idx != -1) - end_volume = part_end; - } - - if (removed_parts >= parts_count) { - ivolume = part_end; - on_progress(_u8L("Repair finished"), 100); - continue; - } - - for (size_t part_idx = ivolume; part_idx <= part_end && part_idx < model_object.volumes.size(); ++part_idx) { - ModelVolume *part_volume = model_object.volumes[part_idx]; - TriangleMesh mesh = part_volume->mesh(); - if (its_num_open_edges(mesh.its) != 0) { - - // Save painting for later remap - const std::optional saved_painting = keep_painting ? - part_volume->save_painting() : - std::optional{}; - - std::string error; - if (!MeshBoolean::cgal::repair(mesh, nullptr, &error)) - throw Slic3r::RuntimeError(error.empty() ? _u8L("Repair failed") : error); - - part_volume->set_mesh(std::move(mesh)); - part_volume->calculate_convex_hull(); - part_volume->invalidate_convex_hull_2d(); - part_volume->set_new_unique_id(); - - // Remap paint back - part_volume->restore_painting(saved_painting); - } - } - - ivolume = part_end; - - on_progress(_u8L("Repair finished"), 100); + MeshRepairStats stats; + std::string error; + if (!volume->repair(keep_painting, &stats, &error, report)) + errors += (errors.empty() ? "" : "\n") + (volumes.size() > 1 ? volume->name + ": " : std::string()) + error; + any_modified = any_modified || stats.modified; } - - model_object.invalidate_bounding_box(); - - if (ivolume > 0) - --ivolume; - on_progress(_u8L("Repair finished"), 100); - success = true; - finished = true; - } catch (RepairCanceledException &) { + } catch (const RepairCanceledException &) { canceled = true; - finished = true; - on_progress(_u8L("Repair canceled"), 100); - } catch (std::exception &ex) { - success = false; - finished = true; - fix_result = ex.what(); - on_progress(ex.what(), 100); + } catch (const std::exception &ex) { + errors += (errors.empty() ? "" : "\n") + std::string(ex.what()); } + // Volumes repaired before a cancel or an error keep their new mesh. + model_object.invalidate_bounding_box(); + finished = true; + on_progress(_u8L("Repair finished"), 100); }); - // Orca: Main GUI loop to update progress dialog and handle cancellation. while (!finished) { std::unique_lock lock(mtx); - condition.wait_for(lock, std::chrono::milliseconds(250), [&progress]{ return progress.updated; }); - - // Decrease progress percent slightly to avoid auto-closing. - if (!progress_dialog.Update(progress.percent - 1, msg_header + _(progress.message))) + condition.wait_for(lock, std::chrono::milliseconds(250), [&progress] { return progress.updated; }); + // One percent short of the maximum, so that the dialog does not close itself. + if (!progress_dialog.Update(std::max(0, progress.percent - 1), msg_header + wxString::FromUTF8(progress.message))) canceled = true; else progress_dialog.Fit(); - progress.updated = false; } - if (canceled) { - // Nothing to show. - } else if (success) { - fix_result.clear(); - } - if (worker_thread.joinable()) worker_thread.join(); + fix_result = canceled ? std::string() : errors; + if (modified) + *modified = any_modified; return !canceled; } diff --git a/src/slic3r/Utils/FixModelByCgal.hpp b/src/slic3r/Utils/FixModelByCgal.hpp index b3f96169f6..e59e767223 100644 --- a/src/slic3r/Utils/FixModelByCgal.hpp +++ b/src/slic3r/Utils/FixModelByCgal.hpp @@ -12,8 +12,9 @@ class Model; class ModelObject; class Print; -// Return false if fixing was canceled. fix_result is empty on success. -extern bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dlg, const wxString &msg_header, std::string &fix_result, bool keep_painting); +// Return false if fixing was canceled. fix_result is empty on success; modified tells whether any mesh changed. +extern bool fix_model_with_cgal_gui(ModelObject &model_object, int volume_idx, GUI::ProgressDialog &progress_dlg, const wxString &msg_header, std::string &fix_result, bool keep_painting, + bool *modified = nullptr); } // namespace Slic3r diff --git a/tests/data/surface_model_geometric_set.step b/tests/data/surface_model_geometric_set.step new file mode 100644 index 0000000000..ec11fde62d --- /dev/null +++ b/tests/data/surface_model_geometric_set.step @@ -0,0 +1,35 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION((''),'2;1'); +FILE_NAME('surface_model_geometric_set','2026-10-12T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('CONFIG_CONTROL_DESIGN')); +ENDSEC; +DATA; +#1=APPLICATION_CONTEXT('configuration controlled 3d designs of mechanical parts and assemblies'); +#2=APPLICATION_PROTOCOL_DEFINITION('international standard','config_control_design',1994,#1); +#3=MECHANICAL_CONTEXT('',#1,'mechanical'); +#4=PRODUCT('surfaces','surfaces','',(#3)); +#5=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#4,.NOT_KNOWN.); +#6=DESIGN_CONTEXT('',#1,'design'); +#7=PRODUCT_DEFINITION('','',#5,#6); +#8=PRODUCT_DEFINITION_SHAPE('','',#7); +#9=SHAPE_DEFINITION_REPRESENTATION(#8,#20); +#10=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.)); +#11=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.)); +#12=(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT()); +#13=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-6),#10,'distance_accuracy_value',''); +#14=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#13))GLOBAL_UNIT_ASSIGNED_CONTEXT((#10,#11,#12))REPRESENTATION_CONTEXT('','')); +#15=CARTESIAN_POINT('',(0.,0.,0.)); +#16=DIRECTION('',(0.,0.,1.)); +#17=DIRECTION('',(1.,0.,0.)); +#18=AXIS2_PLACEMENT_3D('',#15,#16,#17); +#19=GEOMETRIC_SET('',(#30,#40)); +#20=GEOMETRICALLY_BOUNDED_SURFACE_SHAPE_REPRESENTATION('',(#18,#19),#14); +#30=RECTANGULAR_TRIMMED_SURFACE('',#31,0.,10.,0.,10.,.T.,.T.); +#31=PLANE('',#18); +#40=RECTANGULAR_TRIMMED_SURFACE('',#41,0.,10.,0.,10.,.T.,.T.); +#41=PLANE('',#42); +#42=AXIS2_PLACEMENT_3D('',#43,#16,#17); +#43=CARTESIAN_POINT('',(0.,0.,10.)); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 9e1e7c44f8..1f72f83478 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -53,6 +53,7 @@ add_executable(${_TEST_NAME}_tests test_stl.cpp test_triangle_selector.cpp test_meshboolean.cpp + test_mesh_repair.cpp test_trianglemesh_slicer.cpp test_marchingsquares.cpp test_lay_on_face.cpp diff --git a/tests/libslic3r/test_mesh_repair.cpp b/tests/libslic3r/test_mesh_repair.cpp new file mode 100644 index 0000000000..3390a65c77 --- /dev/null +++ b/tests/libslic3r/test_mesh_repair.cpp @@ -0,0 +1,255 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinRel; + +namespace { + +indexed_triangle_set cube(double size, const Vec3d &offset = Vec3d::Zero()) +{ + TriangleMesh mesh = make_cube(size, size, size); + mesh.translate(offset.cast()); + return mesh.its; +} + +indexed_triangle_set merged(const std::vector &parts) +{ + indexed_triangle_set out; + for (const indexed_triangle_set &part : parts) + its_merge(out, part); + return out; +} + +// Every triangle with its own three vertices, as some exporters write meshes. +indexed_triangle_set soup(const indexed_triangle_set &its) +{ + indexed_triangle_set out; + for (const stl_triangle_vertex_indices &f : its.indices) { + const int first = int(out.vertices.size()); + for (int k = 0; k < 3; ++k) + out.vertices.push_back(its.vertices[f[k]]); + out.indices.emplace_back(first, first + 1, first + 2); + } + return out; +} + +double regular_polygon_area(int sides, double radius) { return 0.5 * sides * radius * radius * std::sin(2. * PI / sides); } + +// Closed by vertex indices, outward oriented and free of self-intersections. +void check_solid(const TriangleMesh &mesh, double volume) +{ + CHECK(its_num_open_edges(mesh.its) == 0); + CHECK_THAT(its_volume(mesh.its), WithinRel(volume, 1e-4)); + CHECK_FALSE(MeshBoolean::cgal::does_self_intersect(mesh)); +} + +} // namespace + +TEST_CASE("A triangle soup repairs into the solid it describes", "[MeshRepair]") +{ + TriangleMesh mesh(soup(cube(20.))); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.modified); + check_solid(mesh, 20. * 20. * 20.); + CHECK(mesh.its.vertices.size() == 8); +} + +TEST_CASE("Flipped triangles take the orientation of their neighbours", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + std::swap(its.indices[1][1], its.indices[1][2]); + std::swap(its.indices[5][1], its.indices[5][2]); + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("An inside-out mesh is turned outward", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its_flip_triangles(its); + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Holes are filled", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its.indices.erase(its.indices.begin(), its.indices.begin() + 3); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.filled_holes > 0); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Cracks between triangles that should share corners are closed", "[MeshRepair]") +{ + indexed_triangle_set its = soup(cube(20.)); + // Shift the corners of two triangles by far less than a micron, as float rounding in an exporter would. + for (std::size_t v = 0; v < 6; ++v) + its.vertices[v] += Vec3f(1e-5f, -1e-5f, 1e-5f); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.welded_vertices > 0); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("A T-junction is closed", "[MeshRepair]") +{ + // The top of the cube is split at the middle of its front edge, which the front side does not share. + indexed_triangle_set its; + its.vertices = {{0, 0, 0}, {20, 0, 0}, {20, 20, 0}, {0, 20, 0}, {0, 0, 20}, {20, 0, 20}, {20, 20, 20}, {0, 20, 20}, {10, 0, 20}}; + its.indices = {{0, 2, 1}, {0, 3, 2}, {4, 8, 7}, {8, 6, 7}, {8, 5, 6}, {0, 1, 5}, {0, 5, 4}, + {1, 2, 6}, {1, 6, 5}, {2, 3, 7}, {2, 7, 6}, {3, 0, 4}, {3, 4, 7}}; + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 20. * 20. * 20.); +} + +TEST_CASE("Crossing shells stay separate parts and are made solid", "[MeshRepair]") +{ + indexed_triangle_set inside_out = cube(20., {10., 10., 10.}); + its_flip_triangles(inside_out); + TriangleMesh mesh(merged({cube(20.), inside_out})); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.reversed_shells == 1); + CHECK(its_num_open_edges(mesh.its) == 0); + CHECK_THAT(its_volume(mesh.its), WithinRel(2 * 8000., 1e-4)); + CHECK(mesh.split().size() == 2); +} + +TEST_CASE("Solids touching face to face in a soup lose the wall between them", "[MeshRepair]") +{ + // The mirrored copy shares the wall at x = 10 with exactly the same triangles, reversed. + TriangleMesh right = make_cube(10., 10., 10.); + right.mirror(X); + right.translate(20.f, 0.f, 0.f); + TriangleMesh mesh(soup(merged({cube(10.), right.its}))); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 2 * 1000.); + CHECK(mesh.split().size() == 1); +} + +TEST_CASE("A shell inside a solid follows the nonzero winding rule of slicing", "[MeshRepair]") +{ + SECTION("Oriented like the outer shell, it adds nothing and is removed") + { + TriangleMesh mesh(merged({cube(20.), cube(10., {5., 5., 5.})})); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 8000.); + CHECK(mesh.its.indices.size() == 12); + } + SECTION("Oriented inward, it is a cavity and stays") + { + indexed_triangle_set cavity = cube(10., {5., 5., 5.}); + its_flip_triangles(cavity); + TriangleMesh mesh(merged({cube(20.), cavity})); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK_FALSE(stats.modified); + CHECK_THAT(its_volume(mesh.its), WithinRel(8000. - 1000., 1e-4)); + } +} + +TEST_CASE("A pipe missing its end caps gets annular caps", "[MeshRepair]") +{ + const int n = 64; + const double h = 20., r_out = 10., r_in = 7.; + indexed_triangle_set its; + for (const double r : {r_out, r_in}) + for (int k = 0; k < n; ++k) { + const double a = 2. * PI * k / n; + its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), 0.f); + its.vertices.emplace_back(float(r * std::cos(a)), float(r * std::sin(a)), float(h)); + } + for (int k = 0; k < n; ++k) { + const int a0 = 2 * k, b0 = a0 + 1, a1 = 2 * ((k + 1) % n), b1 = a1 + 1; + its.indices.emplace_back(a0, a1, b1); + its.indices.emplace_back(a0, b1, b0); + its.indices.emplace_back(2 * n + a0, 2 * n + b1, 2 * n + a1); + its.indices.emplace_back(2 * n + a0, 2 * n + b0, 2 * n + b1); + } + TriangleMesh mesh(std::move(its)); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, h * (regular_polygon_area(n, r_out) - regular_polygon_area(n, r_in))); +} + +TEST_CASE("A stray flat triangle is dropped", "[MeshRepair]") +{ + indexed_triangle_set its = cube(20.); + its.vertices.insert(its.vertices.end(), {{30, 0, 0}, {40, 0, 0}, {30, 10, 0}}); + its.indices.emplace_back(8, 9, 10); + TriangleMesh mesh(std::move(its)); + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK(stats.removed_shells == 1); + check_solid(mesh, 8000.); +} + +TEST_CASE("A two-sided export keeps one side of each triangle", "[MeshRepair]") +{ + indexed_triangle_set back = cube(20.); + its_flip_triangles(back); + TriangleMesh mesh(soup(merged({cube(20.), back}))); + REQUIRE(repair_mesh(mesh)); + check_solid(mesh, 8000.); +} + +TEST_CASE("A valid mesh is left untouched", "[MeshRepair]") +{ + TriangleMesh mesh = make_sphere(10., 2. * PI / 36.); + const TriangleMesh original = mesh; + MeshRepairStats stats; + REQUIRE(repair_mesh(mesh, &stats)); + CHECK_FALSE(stats.modified); + CHECK(mesh.its.vertices == original.its.vertices); + CHECK(mesh.its.indices == original.its.indices); +} + +TEST_CASE("A mesh enclosing no volume is reported and left untouched", "[MeshRepair]") +{ + indexed_triangle_set its; + its.vertices = {{0, 0, 0}, {10, 0, 0}, {10, 10, 0}, {0, 10, 0}}; + its.indices = {{0, 1, 2}, {0, 2, 3}}; + TriangleMesh mesh(its); + std::string error; + CHECK_FALSE(repair_mesh(mesh, nullptr, &error)); + CHECK_FALSE(error.empty()); + CHECK(mesh.its.indices.size() == 2); +} + +TEST_CASE("Progress is reported in order and throwing from it cancels the repair", "[MeshRepair]") +{ + TriangleMesh mesh(soup(cube(20.))); + std::vector reported; + REQUIRE(repair_mesh(mesh, nullptr, nullptr, [&reported](int percent) { reported.push_back(percent); })); + CHECK_FALSE(reported.empty()); + CHECK(std::is_sorted(reported.begin(), reported.end())); + + struct Canceled {}; + TriangleMesh canceled(soup(cube(20.))); + const TriangleMesh original = canceled; + CHECK_THROWS_AS(repair_mesh(canceled, nullptr, nullptr, [](int) { throw Canceled(); }), Canceled); + CHECK(canceled.its.vertices == original.its.vertices); + CHECK(canceled.its.indices == original.its.indices); +} diff --git a/tests/libslic3r/test_model.cpp b/tests/libslic3r/test_model.cpp index 5fc72a2b9b..abdaabd801 100644 --- a/tests/libslic3r/test_model.cpp +++ b/tests/libslic3r/test_model.cpp @@ -12,7 +12,9 @@ #include #include #include +#include "libslic3r/MeshRepair.hpp" #include "libslic3r/Model.hpp" +#include "libslic3r/ObjectID.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/libslic3r.h" @@ -97,3 +99,104 @@ TEST_CASE("An instance added from another's scale, rotation and mirror matches i original->get_rotation(), original->get_mirror()); CHECK(copy->get_matrix().linear().isApprox(original->get_matrix().linear(), 1e-9)); } + +namespace { + +// A flat triangle first, then a cube for each offset, all in one mesh. +TriangleMesh flat_triangle_then_cubes(const std::vector &cube_offsets) +{ + indexed_triangle_set its; + its.vertices = {{-20.f, 0.f, 0.f}, {-10.f, 0.f, 0.f}, {-15.f, 5.f, 0.f}}; + its.indices = {{0, 1, 2}}; + for (double offset : cube_offsets) { + TriangleMesh cube = make_cube(10, 10, 10); + cube.translate(float(offset), 0.f, 0.f); + its_merge(its, cube.its); + } + return TriangleMesh(its); +} + +} // namespace + +TEST_CASE("Splitting a part drops pieces enclosing no volume and keeps the part itself", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0., 30.}), ModelVolumeType::MODEL_PART, false); + REQUIRE(volume->is_splittable()); + + CHECK(volume->split(1, false) == 2); + REQUIRE(object->volumes.size() == 2); + CHECK(std::find(object->volumes.begin(), object->volumes.end(), volume) != object->volumes.end()); + for (const ModelVolume *part : object->volumes) { + CHECK(part->mesh().facets_count() == 12); + CHECK_FALSE(part->is_splittable()); + } +} + +TEST_CASE("Splitting a part with a single solid piece keeps just that piece", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + ModelVolume *volume = object->add_volume(flat_triangle_then_cubes({0.}), ModelVolumeType::MODEL_PART, false); + + CHECK(volume->split(1, false) == 1); + REQUIRE(object->volumes.size() == 1); + CHECK(object->volumes.front() == volume); + CHECK(volume->mesh().facets_count() == 12); +} + +TEST_CASE("Splitting a part made only of pieces enclosing no volume leaves it whole", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + indexed_triangle_set its; + its.vertices = {{0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {5.f, 5.f, 0.f}, {20.f, 0.f, 0.f}, {30.f, 0.f, 0.f}, {25.f, 5.f, 0.f}}; + its.indices = {{0, 1, 2}, {3, 4, 5}}; + ModelVolume *volume = object->add_volume(TriangleMesh(its), ModelVolumeType::MODEL_PART, false); + + CHECK(volume->split(1, false) == 1); + REQUIRE(object->volumes.size() == 1); + CHECK(object->volumes.front() == volume); + CHECK(volume->mesh().facets_count() == 2); +} + +TEST_CASE("Replacing a part's mesh updates whether it can be split", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + TriangleMesh two_cubes = make_cube(10, 10, 10); + TriangleMesh second = make_cube(10, 10, 10); + second.translate(30.f, 0.f, 0.f); + two_cubes.merge(second); + ModelVolume *volume = object->add_volume(std::move(two_cubes), ModelVolumeType::MODEL_PART, false); + REQUIRE(volume->is_splittable()); + + volume->set_mesh(make_cube(10, 10, 10)); + CHECK_FALSE(volume->is_splittable()); +} + +TEST_CASE("Repairing a part replaces its mesh only when the repair changes it", "[Model]") +{ + Model model; + ModelObject *object = model.add_object(); + TriangleMesh flipped = make_cube(10, 10, 10); + std::swap(flipped.its.indices.front()[1], flipped.its.indices.front()[2]); + ModelVolume *broken = object->add_volume(std::move(flipped), ModelVolumeType::MODEL_PART, false); + TriangleMesh cube = make_cube(10, 10, 10); + cube.translate(30.f, 0.f, 0.f); + ModelVolume *valid = object->add_volume(std::move(cube), ModelVolumeType::MODEL_PART, false); + const ObjectID broken_id = broken->id(); + const ObjectID valid_id = valid->id(); + + MeshRepairStats stats; + REQUIRE(broken->repair(false, &stats)); + CHECK(stats.modified); + CHECK(broken->id() != broken_id); + CHECK_THAT(its_volume(broken->mesh().its), Catch::Matchers::WithinRel(1000., 1e-6)); + CHECK_FALSE(broken->get_convex_hull().empty()); + + REQUIRE(valid->repair(false, &stats)); + CHECK_FALSE(stats.modified); + CHECK(valid->id() == valid_id); +} diff --git a/tests/libslic3r/test_step.cpp b/tests/libslic3r/test_step.cpp index 5d843499fa..ff8ce1dcba 100644 --- a/tests/libslic3r/test_step.cpp +++ b/tests/libslic3r/test_step.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include "libslic3r/Model.hpp" @@ -91,6 +92,26 @@ TEST_CASE("A cone with a slightly tilted seam imports as a closed mesh", "[Step] CHECK(its_num_open_edges(model.objects.front()->volumes.front()->mesh().its) == 0); } +// The fixture holds two trimmed planes in a geometric set and no shells or solids, as some CAD exporters write +// surface models, so OCCT reads it as a compound of loose faces. +TEST_CASE("A surface model imports whether or not compounds are split", "[Step]") +{ + const std::string path = std::string(TEST_DATA_DIR) + PATH_SEPARATOR "surface_model_geometric_set.step"; + const bool split = GENERATE(false, true); + CAPTURE(split); + + Model model; + bool cancel = false; + Step step(path); + + REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS); + REQUIRE(step.mesh(&model, cancel, split) == Step::Step_Status::MESH_SUCCESS); + + REQUIRE(model.objects.size() == 1); + REQUIRE(model.objects.front()->volumes.size() == 1); + CHECK(model.objects.front()->volumes.front()->mesh().facets_count() > 0); +} + TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]") { CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9 From 237586675f31378fc0b3e7144715b1c79280231a Mon Sep 17 00:00:00 2001 From: Downtime Date: Mon, 12 Oct 2026 20:01:57 -0500 Subject: [PATCH 40/45] fix(gui): remove legacy fixed-function OpenGL texture calls (#16270) Debug builds asserted on startup because these calls raise GL errors on a core-profile context. Both draws already use shaders, so removing the calls changes nothing on compatibility contexts. --- src/slic3r/GUI/GLTexture.cpp | 4 ---- src/slic3r/GUI/PartPlate.cpp | 7 ------- 2 files changed, 11 deletions(-) diff --git a/src/slic3r/GUI/GLTexture.cpp b/src/slic3r/GUI/GLTexture.cpp index f83dd1f854..90cae850c9 100644 --- a/src/slic3r/GUI/GLTexture.cpp +++ b/src/slic3r/GUI/GLTexture.cpp @@ -719,9 +719,6 @@ void GLTexture::render_sub_texture(unsigned int tex_id, float left, float right, // does not treat anti-aliased icon edges as window transparency. glsafe(::glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA)); - glsafe(::glEnable(GL_TEXTURE_2D)); - glsafe(::glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE)); - glsafe(::glBindTexture(GL_TEXTURE_2D, (GLuint)tex_id)); GLModel::Geometry init_data; @@ -753,7 +750,6 @@ void GLTexture::render_sub_texture(unsigned int tex_id, float left, float right, glsafe(::glBindTexture(GL_TEXTURE_2D, 0)); - glsafe(::glDisable(GL_TEXTURE_2D)); glsafe(::glDisable(GL_BLEND)); } diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 3bf53ecb73..d5d9fb5b8a 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1880,13 +1880,6 @@ void PartPlate::render_exclude_area(bool force_default_color) { // draw exclude area glsafe(::glDepthMask(GL_FALSE)); - if (m_selected) { - glsafe(::glColor4fv(select_color.data())); - } - else { - glsafe(::glColor4fv(unselect_color.data())); - } - m_exclude_triangles.set_color(m_selected ? select_color : unselect_color); m_exclude_triangles.render(); glsafe(::glDepthMask(GL_TRUE)); From b920116fbd68781652a868fccc1e84718409c1f8 Mon Sep 17 00:00:00 2001 From: hikaru0602 <72114035+hikaru0602@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:03:04 +0900 Subject: [PATCH 41/45] Fix manual tool change handling when loading G-code files (#15379) Opened or drag-dropped G-code files ignored `; MANUAL_TOOL_CHANGE` comments, so the preview showed every move on the first filament. Only `apply_config(const PrintConfig&)` read `manual_filament_change`, and loading a file goes through `apply_config(const DynamicPrintConfig&)`. Read the option there too, initialize the flag to false, and add a regression test. --- src/libslic3r/GCode/GCodeProcessor.cpp | 4 ++ src/libslic3r/GCode/GCodeProcessor.hpp | 2 +- tests/fff_print/test_gcodeprocessor.cpp | 55 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 8e7d5e7557..f41a4443fb 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -3598,6 +3598,10 @@ void GCodeProcessor::apply_config(const DynamicPrintConfig& config) if (has_scarf_joint_seam != nullptr) m_detect_layer_based_on_tag = m_detect_layer_based_on_tag || has_scarf_joint_seam->value; + const ConfigOptionBool* manual_filament_change = config.option("manual_filament_change"); + if (manual_filament_change != nullptr) + m_manual_filament_change = manual_filament_change->value; + const ConfigOptionEnumGeneric *bed_type = config.option("curr_bed_type"); if (bed_type != nullptr) m_result.bed_type = (BedType)bed_type->value; diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index f5b9985f0e..270aceb1c3 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -1228,7 +1228,7 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f; // m_result.nozzle_group_result, which is handed over only after the stream for the // pre-heat injector's second pass and gates the richer change-time model. std::shared_ptr m_nozzle_group_result; - bool m_manual_filament_change; + bool m_manual_filament_change{ false }; //BBS: x, y offset for gcode generated double m_x_offset{ 0 }; diff --git a/tests/fff_print/test_gcodeprocessor.cpp b/tests/fff_print/test_gcodeprocessor.cpp index 0be8cc1c12..48cf3e8ae9 100644 --- a/tests/fff_print/test_gcodeprocessor.cpp +++ b/tests/fff_print/test_gcodeprocessor.cpp @@ -178,6 +178,61 @@ bool is_block_move(const GCodeProcessorResult::MoveVertex &move) } // namespace +TEST_CASE("Loaded manual tool change comments switch subsequent moves when enabled", "[GCodeProcessor][Regression]") +{ + const bool manual_filament_change = GENERATE(false, true); + CAPTURE(manual_filament_change); + const DynamicPrintConfig config = Test::multifilament_config(2, { + { "gcode_flavor", "marlin" }, + { "filament_type", "PLA;PETG" }, + { "manual_filament_change", manual_filament_change }, + }); + const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter; + const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; }); + GCodeProcessor::s_IsBBLPrinter = false; + + ScopedTemporaryFile temp(".gcode"); + { + std::ofstream out(temp.string()); + REQUIRE(out.is_open()); + out << "; generated by OrcaSlicer\n" + << "G90\nM83\nT0\n" + << "G1 X10 Y10 Z0.2 F3000\n" + << "G1 X20 E1\n" + << "; MANUAL_TOOL_CHANGE T1\n" + << "G1 X30\nG1 X40 E1\nG1 X50\nG1 X60 E1\n"; + // Let process_file load the embedded DynamicPrintConfig, as the standalone viewer does. + out << "; CONFIG_BLOCK_START\n"; + for (const std::string &key : config.keys()) + out << "; " << key << " = " << config.opt_serialize(key) << '\n'; + out << "; CONFIG_BLOCK_END\n"; + REQUIRE(out.good()); + } + + GCodeProcessor processor; + processor.process_file(temp.string()); + const auto &result = processor.get_result(); + REQUIRE(result.filaments_count == 2); + + constexpr unsigned int tool_change_line = 7; + size_t before = 0; + size_t after = 0; + for (const auto &move : result.moves) { + if (!is_block_move(move)) + continue; + CAPTURE(move.gcode_id, move.type); + if (move.gcode_id < tool_change_line) { + ++before; + CHECK(move.extruder_id == 0); + } else if (move.gcode_id > tool_change_line) { + ++after; + CHECK(move.extruder_id == (manual_filament_change ? 1 : 0)); + } + } + REQUIRE(before > 0); + REQUIRE(after > 0); +} + TEST_CASE("Actual speed moves are inserted on their block's segment just before its move", "[GCodeProcessor]") { // 60 squares take several planner passes, which remap the blocks kept between passes. From 869e450981bb8bece1622ef3fd55381962af11b3 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:59:48 +0800 Subject: [PATCH 42/45] Keep Per-Nozzle Values When a User Preset Syncs Through the Cloud (#16355) The cloud upload sent only the keys that differ from the parent, so a variant layout equal to the parent's was left out. On download the loader sized every per-variant value by the missing layout, cut it to one value and kept the parent's values for the other nozzles and nozzle volume types. On dual-nozzle printers the right nozzle's settings silently reverted to the parent's after a sync. Upload the variant layout always, as the local save already does, and read copies stored without it against the parent's layout when they carry one value per parent variant. --- src/libslic3r/Preset.cpp | 41 +++++-- .../libslic3r/test_preset_bundle_loading.cpp | 100 ++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 78fa0785ef..19110cd2d2 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -2164,6 +2164,16 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map dirty_options = preset.config.diff(parent_preset->config); + // Send the variant layout even when it matches the parent's, as the local save does: the loader + // sizes the per-variant values by it and would otherwise keep only the first one. + std::string extruder_id_name, extruder_variant_name; + std::set *key_set1 = nullptr, *key_set2 = nullptr; + Preset::get_extruder_names_and_keysets(m_type, extruder_id_name, extruder_variant_name, &key_set1, &key_set2); + if (!extruder_id_name.empty()) + dirty_options.emplace_back(extruder_id_name); + if (!extruder_variant_name.empty()) + dirty_options.emplace_back(extruder_variant_name); + for (auto option: dirty_options) { ConfigOption *opt_src = preset.config.option(option); @@ -2628,15 +2638,34 @@ bool PresetCollection::load_user_preset(std::string name, std::map *key_set1 = nullptr, *key_set2 = nullptr; + Preset::get_extruder_names_and_keysets(m_type, extruder_id_name, extruder_variant_name, &key_set1, &key_set2); + + // Older uploads left out a variant layout equal to the parent's but still sent one value per + // parent variant. Read those values against the parent's layout instead of cutting them to one. + if (inherit_preset && !cloud_config.has(extruder_variant_name)) { + const auto *parent_variants = inherit_preset->config.option(extruder_variant_name); + const size_t variant_count = parent_variants ? parent_variants->size() : 0; + bool values_per_variant = false; + for (const std::string &key : cloud_config.keys()) { + const size_t stride = key_set2->count(key) ? 2 : key_set1->count(key) ? 1 : 0; + const auto *values = dynamic_cast(cloud_config.option(key)); + if (stride > 0 && key != extruder_id_name && values && variant_count > 1 && values->size() == variant_count * stride) { + values_per_variant = true; + break; + } + } + if (values_per_variant) + for (const std::string &key : {extruder_id_name, extruder_variant_name}) + if (!key.empty() && !cloud_config.has(key) && inherit_preset->config.has(key)) + cloud_config.set_key_value(key, inherit_preset->config.option(key)->clone()); + } + extend_default_config_length(cloud_config, false, {}); - if (inherit_preset) { - std::string extruder_id_name, extruder_variant_name; - std::set *key_set1 = nullptr, *key_set2 = nullptr; - Preset::get_extruder_names_and_keysets(m_type, extruder_id_name, extruder_variant_name, &key_set1, &key_set2); - + if (inherit_preset) new_config.update_diff_values_to_child_config(cloud_config, extruder_id_name, extruder_variant_name, *key_set1, *key_set2); - } else{ new_config.apply(std::move(cloud_config)); extend_default_config_length(new_config, true, default_preset.config); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 02d607650f..342b5343e4 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -37,6 +37,7 @@ #include #include "libslic3r/Config.hpp" #include +#include #include "libslic3r/libslic3r.h" #include "libslic3r/PublishSettings.hpp" #include @@ -333,6 +334,105 @@ TEST_CASE("A directory of user presets loads with each one resolved against its } } +TEST_CASE("A user preset synced to the cloud and back keeps its values on every nozzle variant", "[Preset][Bundle]") +{ + // Two extruders with a Standard and a High Flow variant each, as on a dual-nozzle printer. + auto add_parent = [](PresetCollection &coll) { + Preset &parent = add_inmemory_preset(coll, "Parent Process"); + parent.config.set_key_value("print_extruder_variant", + new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive Standard", "Direct Drive High Flow"})); + parent.config.set_key_value("print_extruder_id", new ConfigOptionInts({1, 1, 2, 2})); + parent.config.set_deserialize_strict("outer_wall_speed", "50,50,50,50"); + parent.is_system = true; + parent.setting_id = "parent-id"; + return parent.config; + }; + + RenameTestCollection local; + const DynamicPrintConfig parent_config = add_parent(local); + Preset &child = add_inmemory_preset(local, "User Process", "Parent Process"); + child.config.apply_only(parent_config, {"print_extruder_variant", "print_extruder_id"}); + child.config.set_deserialize_strict("outer_wall_speed", "11,12,21,22"); + child.version = Semver(1, 0, 0); + + std::map uploaded; + REQUIRE(local.get_differed_values_to_update(child, uploaded) == 0); + uploaded[BBL_JSON_KEY_SETTING_ID] = "child-id"; + uploaded[BBL_JSON_KEY_USER_ID] = "user"; + + // A cloud copy stored before uploads carried the variant layout holds the values alone. + std::map stored_without_layout = uploaded; + stored_without_layout.erase("print_extruder_variant"); + stored_without_layout.erase("print_extruder_id"); + // One saved while the parent had a single variant covers the first extruder only. + std::map stored_single_value = stored_without_layout; + stored_single_value["outer_wall_speed"] = "11"; + + struct Case { const char *label; std::map values; const char *expected; }; + for (const Case &c : {Case{"current upload", uploaded, "11,12,21,22"}, + Case{"stored without layout", stored_without_layout, "11,12,21,22"}, + Case{"stored with a single value", stored_single_value, "11,11,50,50"}}) { + DYNAMIC_SECTION(c.label) { + RenameTestCollection downloaded; + PresetsConfigSubstitutions substitutions; + add_parent(downloaded); + REQUIRE(downloaded.load_user_preset("User Process", c.values, substitutions, ForwardCompatibilitySubstitutionRule::Disable)); + + const Preset *synced = downloaded.find_preset("User Process"); + REQUIRE(synced != nullptr); + CHECK(synced->config.opt_serialize("outer_wall_speed") == c.expected); + } + } +} + +TEST_CASE("A user filament synced to the cloud and back keeps its values and unset overrides on every variant", "[Preset][Bundle]") +{ + auto add_parent = [](PresetCollection &coll) { + Preset &parent = add_inmemory_preset(coll, "Parent Filament"); + parent.config.set_key_value("filament_extruder_variant", + new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"})); + parent.config.set_deserialize_strict("nozzle_temperature", "220,220"); + parent.config.set_deserialize_strict("filament_retraction_length", "nil,nil"); + parent.is_system = true; + parent.setting_id = "parent-id"; + return parent.config; + }; + auto make_collection = [] { + return std::make_unique(Preset::TYPE_FILAMENT, Preset::filament_options(), + static_cast(FullPrintConfig::defaults())); + }; + + auto local = make_collection(); + add_parent(*local); + Preset &child = add_inmemory_preset(*local, "User Filament", "Parent Filament"); + child.config.apply_only(add_parent(*make_collection()), {"filament_extruder_variant"}); + child.config.set_deserialize_strict("nozzle_temperature", "200,210"); + child.config.set_deserialize_strict("filament_retraction_length", "1.2,nil"); + child.version = Semver(1, 0, 0); + + std::map uploaded; + REQUIRE(local->get_differed_values_to_update(child, uploaded) == 0); + uploaded[BBL_JSON_KEY_SETTING_ID] = "child-id"; + uploaded[BBL_JSON_KEY_USER_ID] = "user"; + std::map stored_without_layout = uploaded; + stored_without_layout.erase("filament_extruder_variant"); + + for (const auto &[label, values] : {std::make_pair("current upload", uploaded), + std::make_pair("stored without layout", stored_without_layout)}) { + DYNAMIC_SECTION(label) { + auto downloaded = make_collection(); + PresetsConfigSubstitutions substitutions; + add_parent(*downloaded); + REQUIRE(downloaded->load_user_preset("User Filament", values, substitutions, ForwardCompatibilitySubstitutionRule::Disable)); + + const Preset *synced = downloaded->find_preset("User Filament"); + REQUIRE(synced != nullptr); + CHECK(synced->config.opt_serialize("nozzle_temperature") == "200,210"); + CHECK(synced->config.opt_serialize("filament_retraction_length") == "1.2,nil"); + } + } +} + TEST_CASE("Repeated loads of a user preset directory produce the same presets", "[Preset][Bundle]") { ScopedTemporaryDir temp_dir; From 01499e536ed68cad6d1eabe80bbc672bfb42201a Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 13 Oct 2026 15:00:18 +0800 Subject: [PATCH 43/45] fix(gui): stop the UV editor pane from hijacking the shared GL context The texture-displacement gizmo pushes pane state to the UV editor from the preview rebuild that runs on every frame of a slider drag. Tile size is the only one of the layer card's sliders whose value appears in that pane state, so dragging it changes the "N.N mm tile" label, the panel relays out, and the UV canvas receives a synchronous wxEVT_SIZE. Its on_size() flushes a paint with Update(), and render() then makes the app's single shared wxGLContext current on the UV drawable in the middle of the 3D canvas's frame. On X11/GLX that out-of-turn context activation is fatal: the drawable the 3D canvas later swaps no longer belongs to the current context, the server returns BadMatch, and GDK turns the unhandled X error into g_error(). EGL on Wayland reports only a status, so the same theft silently corrupts the frame instead of aborting - which is why the guard is not conditioned on the display backend. Add GLCanvas3D::is_frame_in_progress(), backed by a counter that _render_frame() maintains through its existing scope guard, and let the UV canvas skip the synchronous paint while a frame is on the stack, re-queueing it with Refresh() so nothing is dropped. Rebind the context with _set_current() before SwapBuffers() as well, so a surface that steals it by some other route - a modal GL dialog a gizmo opens from its panel - cannot turn the swap into the same fatal error. --- src/slic3r/GUI/GLCanvas3D.cpp | 25 ++++++++++++++++++++++--- src/slic3r/GUI/GLCanvas3D.hpp | 7 +++++++ src/slic3r/GUI/UVEditorCanvas.cpp | 18 +++++++++++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index c509e5d6f8..2e429dda20 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1514,6 +1514,8 @@ wxDEFINE_EVENT(EVT_GLCANVAS_PRINTABLE, SimpleEvent); const double GLCanvas3D::DefaultCameraZoomToBoxMarginFactor = 1.25; const double GLCanvas3D::DefaultCameraZoomToBedMarginFactor = 2.00; const double GLCanvas3D::DefaultCameraZoomToPlateMarginFactor = 1.25; +// Main-thread only: _render_frame never runs off it, so no atomic is needed. +int GLCanvas3D::s_frames_in_progress = 0; void GLCanvas3D::load_arrange_settings() { @@ -2468,7 +2470,8 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init) } m_in_render = true; - Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; }); + ++s_frames_in_progress; + Slic3r::ScopeGuard in_render_guard([this]() { m_in_render = false; --s_frames_in_progress; }); (void)in_render_guard; if (m_canvas == nullptr) @@ -2713,15 +2716,31 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init) // On Wayland, eglSwapBuffers blocks when the canvas is hidden or // occluded. Skip the swap to avoid stalling the render loop. - if (m_canvas->IsShownOnScreen()) { + // Code run from inside this frame - a modal dialog a gizmo opened from its ImGui panel, a + // synchronous paint of another canvas - may have made a different surface current. Bind this one + // again before presenting, so the swap is not issued against a window the context no longer + // belongs to: on Mesa/X11 that is a fatal BadMatch, not the no-op it is on other platforms. Draws + // already issued before this point cannot be redirected - only this swap is protected. + if (m_canvas->IsShownOnScreen() && _set_current()) { m_canvas->SwapBuffers(); m_frame_profiler.mark("swap"); if (!overlay_tick) m_render_stats.increment_fps_counter(); m_presented_signature = signature; } - else + else { + if (m_canvas->IsShownOnScreen()) { + // Logged once: if the rebind fails it does so every frame, and a warning per frame would + // bury everything else. + static bool rebind_failure_logged = false; + if (!rebind_failure_logged) { + rebind_failure_logged = true; + BOOST_LOG_TRIVIAL(warning) << "GLCanvas3D: could not rebind the GL context to this canvas before " + "SwapBuffers(); the frame is not presented"; + } + } m_presented_signature.reset(); + } } // Everything drawn into the 3D scene, from the clear to the post processing passes, ending in the diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index bb148234d1..2cb6bcea4f 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -643,6 +643,8 @@ private: #endif unsigned int m_last_w, m_last_h; bool m_in_render; + // How many canvases are inside _render_frame() right now - see is_frame_in_progress(). + static int s_frames_in_progress; wxTimer m_timer; wxTimer m_timer_set_color; // Armed by each frame that draws the FPS overlay; its tick requests an overlay-only frame. @@ -974,6 +976,11 @@ public: void on_change_color_mode(bool is_dark, bool reinit = true); const bool get_dark_mode_status() { return m_is_dark; } void set_as_dirty() { m_dirty = true; } + // True while any GLCanvas3D is inside _render_frame(). The other surfaces that share the app's one + // GL context (the UV editor canvas) check this before making themselves current: a frame interrupted + // by another surface finishes drawing into that surface and then presents a window the context no + // longer belongs to, which on Mesa/X11 is a fatal X error (BadMatch) rather than a no-op. + static bool is_frame_in_progress() { return s_frames_in_progress > 0; } void requires_check_outside_state() { m_requires_check_outside_state = true; } unsigned int get_volumes_count() const { return (unsigned int)m_volumes.volumes.size(); } diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index a4a9901822..87f629816f 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -36,6 +36,7 @@ #include "3DScene.hpp" #include "BitmapCache.hpp" +#include "GLCanvas3D.hpp" #include "GLShader.hpp" #include "GUI_App.hpp" #include "I18N.hpp" @@ -1324,8 +1325,13 @@ void UVEditorCanvas::on_size(wxSizeEvent &evt) // Update() flushes it immediately - still through the normal paint path (unlike calling // render() directly), which matters because this canvas shares the app's one GL context with // the 3D view and must not hijack it outside its own paint. + // Not while a 3D canvas frame is in progress, though: wxGTK delivers this size event synchronously + // when the texture displacement gizmo relays out the pane from its ImGui panel, i.e. from inside + // that frame, and flushing the paint there would take the shared context away from it. The Refresh() + // then runs the paint once the frame is over. Refresh(); - Update(); + if (!GLCanvas3D::is_frame_in_progress()) + Update(); } void UVEditorCanvas::on_leave(wxMouseEvent &evt) @@ -1346,6 +1352,16 @@ void UVEditorCanvas::render() { if (m_context == nullptr || !IsShownOnScreen()) return; + // Painted from inside a 3D canvas frame - a modal dialog the gizmo opened from its ImGui panel runs + // this paint, as does any other synchronous event raised there. Making this surface current now + // would take the shared context away from that canvas mid-frame: its remaining draws would land + // here and, on Mesa/X11, its SwapBuffers() would be a fatal BadMatch. Skip, but ask for another + // paint so the skipped one is not simply lost. The pane stays stale for as long as a nested modal + // loop keeps a frame on the stack, which is behind the dialog anyway. + if (GLCanvas3D::is_frame_in_progress()) { + Refresh(); + return; + } // wxGLCanvas::SetCurrent() genuinely fails (returns false) on a canvas that isn't shown on // screen, and can also fail on a pixel-format mismatch with the context - and every GL call From bf5a3784790d2819eca304f938f0301613e1becf Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 13 Oct 2026 18:05:51 +0800 Subject: [PATCH 44/45] Free G-code result buffers with clear_and_shrink --- src/libslic3r/GCode/GCodeProcessor.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index 243aa514cd..9d166805a5 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2602,16 +2602,13 @@ void GCodeProcessorResult::reset() { //BBS: add mutex for protection of gcode result lock(); - // release rather than clear: these two are sized by the print - one entry per move and one - // per g-code line - and a reset is where the memory is expected to go back to the allocator - // (see BackgroundSlicingProcess::apply()). The capacity would not be reused anyway: the - // result is refilled by move-assigning the processor's own result. - moves = std::vector(); + // moves and lines_ends grow with the print: free them, as no reset result reuses the capacity. + clear_and_shrink(moves); plate_mass = {}; object_masses.clear(); body_masses.clear(); support_masses.clear(); - lines_ends = std::vector(); + clear_and_shrink(lines_ends); printable_area = Pointfs(); //BBS: add bed exclude area bed_exclude_area = Pointfs(); From af29e4d553860ad82f72cf43738ef946c1adad77 Mon Sep 17 00:00:00 2001 From: ExPikaPaka <112851715+ExPikaPaka@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:45:21 +0200 Subject: [PATCH 45/45] Image adjustment panel for texture displacement layers (#16391) --- src/libslic3r/TextureDisplacement.cpp | 255 +++++++++++- src/libslic3r/TextureDisplacement.hpp | 74 ++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 373 +++++++++++++++++- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 67 ++++ src/slic3r/GUI/TextureLibrary.cpp | 107 +++-- src/slic3r/GUI/TextureLibrary.hpp | 10 + tests/libslic3r/test_texture_displacement.cpp | 289 ++++++++++++++ 7 files changed, 1141 insertions(+), 34 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index aba998bbbf..0153bb169b 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -158,12 +158,33 @@ struct SmoothedTextureCache { std::weak_ptr> source; float smoothing = 0.f; + // Part of the key, not of the result: smoothing is applied *after* the panel's adjustments, so a + // blur computed over one set of adjustments is not the blur another set would give. + TextureAdjustments adjust; DecodedHeightTexture texture; }; std::mutex mutex; std::unordered_map entries; }; SmoothedTextureCache g_smoothed_texture_cache; + +// The adjusted copy, cached between the raw decode and the smoothed one. Needed for the same reason the +// smoothed cache is: while a slider in the image-adjustment panel moves, the thumbnail, the shaded +// preview's height and colour textures, the UV editor backdrop and the displacement preview job all ask +// for the same (image, adjustments) pair within one frame, and each of them running a full-size resample +// of its own is what would make the panel unusable on a large texture. +struct AdjustedTextureCache +{ + struct Entry + { + std::weak_ptr> source; + TextureAdjustments adjust; + DecodedHeightTexture texture; + }; + std::mutex mutex; + std::unordered_map entries; +}; +AdjustedTextureCache g_adjusted_texture_cache; } // namespace namespace { @@ -339,6 +360,34 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer entries[key] = { std::weak_ptr>(layer.image_data), result }; } + // The image-adjustment panel's live edits, on top of the decode and before the smoothing. Before, + // because `preview_adjust` is an edit to the image while `smoothing` is a live low-pass over whatever + // the image is - the same order the panel presents them in, and the same order a commit bakes in. + // + // Nothing applies here once the panel has closed: it resets preview_adjust to identity, and identity + // is a no-op that costs one comparison. + if (!layer.preview_adjust.identity()) { + bool have_adjusted = false; + { + std::lock_guard lock(g_adjusted_texture_cache.mutex); + auto it = g_adjusted_texture_cache.entries.find(key); + if (it != g_adjusted_texture_cache.entries.end() && it->second.adjust == layer.preview_adjust && + it->second.source.lock() == layer.image_data) { + result = it->second.texture; + have_adjusted = true; + } + } + if (!have_adjusted) { + result = apply_texture_adjustments(result, layer.preview_adjust); + std::lock_guard lock(g_adjusted_texture_cache.mutex); + auto &entries = g_adjusted_texture_cache.entries; + for (auto it = entries.begin(); it != entries.end();) + it = it->second.source.expired() ? entries.erase(it) : std::next(it); + entries[key] = { std::weak_ptr>(layer.image_data), + layer.preview_adjust, result }; + } + } + // Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of // the image whatever resolution it came in at, and stays continuous in the slider - see // smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is @@ -349,7 +398,7 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer std::lock_guard lock(g_smoothed_texture_cache.mutex); auto it = g_smoothed_texture_cache.entries.find(key); if (it != g_smoothed_texture_cache.entries.end() && it->second.smoothing == layer.smoothing && - it->second.source.lock() == layer.image_data) + it->second.adjust == layer.preview_adjust && it->second.source.lock() == layer.image_data) return it->second.texture; } const float span = 0.05f * float(std::min(result.width, result.height)); @@ -373,12 +422,70 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer auto &entries = g_smoothed_texture_cache.entries; for (auto it = entries.begin(); it != entries.end();) it = it->second.source.expired() ? entries.erase(it) : std::next(it); - entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, result }; + entries[key] = { std::weak_ptr>(layer.image_data), layer.smoothing, + layer.preview_adjust, result }; } return result; } namespace { +// Fraction of the short side that TextureAdjustments::blur == 1 blurs over, and the cost cap on it. +// Deliberately the same pair decode_height_texture() uses for the Smoothing slider - see +// adjustment_blur_radius(). +constexpr float SOURCE_BLUR_SPAN = 0.05f; +constexpr float SOURCE_BLUR_MAX_PX = 48.f; + +// Radius of the unsharp mask's blurred reference, in texels. Small and only mildly resolution-dependent: +// a sharpen is meant to act on the finest detail the image holds, so its radius tracks the texel grid +// rather than the picture's size. 1024x1024 gives ~2 texels, the usual photo-editor default. +float unsharp_radius(int width, int height) +{ + return std::max(1.f, 0.002f * float(std::min(width, height))); +} + +// Brightness as a *lift* toward white (or toward black) rather than an additive offset: positive b maps +// v to v + b*(1 - v), negative to v*(1 + b). Both reach the endpoint exactly at |b| == 1 while staying +// monotonic and non-clipping in between, which an additive shift does not - it drives a whole region to +// a flat 255 and, because this image is also the height map, that region bakes as flat relief rather +// than as bright relief. Keeping the curve injective keeps the detail in it. +float apply_brightness(float v, float b) +{ + return b >= 0.f ? v + b * (1.f - v) : v * (1.f + b); +} + +// Contrast about the 0.5 pivot. Gain is 1/(1-c) going up (c == 1 would be an infinite-slope threshold, so +// the caller's clamp keeps it finite) and 1+c going down, reaching a flat mid-grey at c == -1. +float apply_contrast(float v, float c) +{ + const float gain = c >= 0.f ? 1.f / std::max(1e-3f, 1.f - c) : 1.f + c; + return (v - 0.5f) * gain + 0.5f; +} + +// Blur then sharpen one 8-bit channel in place. Shared by the grayscale path and by each of a colour +// image's three channels, so every channel gets the identical spatial treatment and a colour image's +// hue cannot shift just because its channels were filtered differently. +void adjust_channel_spatial(std::vector &channel, int width, int height, float blur, float sharpness) +{ + if (blur > 0.f) + smooth_height_pixels(channel, width, height, adjustment_blur_radius(blur, width, height)); + + if (sharpness <= 0.f) + return; + + // Unsharp mask: push each texel away from its local average by `amount` times the difference. The + // blurred reference is a copy, so the sharpen reads the un-sharpened neighbourhood everywhere rather + // than its own partly-written output. + std::vector base = channel; + smooth_height_pixels(base, width, height, unsharp_radius(width, height)); + const float amount = 1.5f * std::clamp(sharpness, 0.f, 1.f); + tbb::parallel_for(tbb::blocked_range(0, channel.size()), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float v = float(channel[i]) + amount * (float(channel[i]) - float(base[i])); + channel[i] = uint8_t(std::lround(std::clamp(v, 0.f, 255.f))); + } + }); +} + struct TextureDetailCache { std::mutex mutex; @@ -387,6 +494,150 @@ struct TextureDetailCache TextureDetailCache g_texture_detail_cache; } // namespace +float adjustment_blur_radius(float blur, int width, int height) +{ + if (blur <= 0.f || width <= 0 || height <= 0) + return 0.f; + const float span = SOURCE_BLUR_SPAN * float(std::min(width, height)); + return std::clamp(blur, 0.f, 1.f) * std::min(span, SOURCE_BLUR_MAX_PX); +} + +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px) +{ + if (src.empty() || max_px <= 0 || (src.width <= max_px && src.height <= max_px)) + return src; + + const int scale = std::max(1, (std::max(src.width, src.height) + max_px - 1) / max_px); + const int w = std::max(1, src.width / scale); + const int h = std::max(1, src.height / scale); + const bool color = src.has_color(); + + DecodedHeightTexture out; + out.width = w; + out.height = h; + out.pixels.resize(size_t(w) * size_t(h)); + if (color) + out.rgb.resize(size_t(w) * size_t(h) * 3); + + tbb::parallel_for(tbb::blocked_range(0, h), [&](const tbb::blocked_range &r) { + for (int y = r.begin(); y < r.end(); ++y) + for (int x = 0; x < w; ++x) { + // The source block this destination texel covers. The max() keeps the block non-empty + // when rounding would otherwise collapse it. + const int x0 = x * src.width / w, x1 = std::max(x0 + 1, (x + 1) * src.width / w); + const int y0 = y * src.height / h, y1 = std::max(y0 + 1, (y + 1) * src.height / h); + uint32_t sum_h = 0, sum_c[3] = { 0, 0, 0 }, n = 0; + for (int sy = y0; sy < y1 && sy < src.height; ++sy) + for (int sx = x0; sx < x1 && sx < src.width; ++sx, ++n) { + const size_t si = size_t(sy) * size_t(src.width) + size_t(sx); + sum_h += src.pixels[si]; + if (color) + for (int c = 0; c < 3; ++c) + sum_c[c] += src.rgb[si * 3 + size_t(c)]; + } + const size_t di = size_t(y) * size_t(w) + size_t(x); + out.pixels[di] = n > 0 ? uint8_t(sum_h / n) : 0; + if (color) + for (int c = 0; c < 3; ++c) + out.rgb[di * 3 + size_t(c)] = n > 0 ? uint8_t(sum_c[c] / n) : 0; + } + }); + return out; +} + +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj) +{ + DecodedHeightTexture out = src; + if (src.empty() || adj.identity()) + return out; + + const int w = out.width; + const int h = out.height; + const size_t n = size_t(w) * size_t(h); + + const float brightness = std::clamp(adj.brightness, -1.f, 1.f); + // Short of 1, so apply_contrast()'s gain stays finite; at this value it is already a 1000x slope, + // i.e. a threshold in all but name. + const float contrast = std::clamp(adj.contrast, -1.f, 0.999f); + const float blur = std::clamp(adj.blur, 0.f, 1.f); + const float sharpness = std::clamp(adj.sharpness, 0.f, 1.f); + + // The tonal pass: a 256-entry lookup table, because brightness and contrast are the same scalar + // curve for every texel and every channel. Building it once beats evaluating two curves a few + // million times, and it is what keeps this pass off the panel's critical path. + std::array tone{}; + for (int i = 0; i < 256; ++i) { + float v = float(i) / 255.f; + v = apply_brightness(v, brightness); + v = apply_contrast(v, contrast); + tone[size_t(i)] = uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); + } + + if (!out.has_color()) { + // Grayscale: one channel, and hue has nothing to turn. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = tone[out.pixels[i]]; + }); + adjust_channel_spatial(out.pixels, w, h, blur, sharpness); + return out; + } + + // Colour: tone each channel through the same table, then rotate the hue about the luminance axis. + // + // The rotation is feColorMatrix's hueRotate matrix (SVG filters), which holds Rec.709 luma fixed. + // The height below is taken with decode_height_texture()'s Rec.601 coefficients instead, so a hue + // turn is not perfectly height-neutral - a few levels at full saturation. Reconciling the two would + // mean a different rotation than every other tool's, which is the worse trade: the user is watching + // the colour, and the height follows the colour by construction either way. + const float theta = adj.hue_deg * float(M_PI) / 180.f; + const float c = std::cos(theta); + const float s = std::sin(theta); + const float m[9] = { + 0.213f + c * 0.787f - s * 0.213f, 0.715f - c * 0.715f - s * 0.715f, 0.072f - c * 0.072f + s * 0.928f, + 0.213f - c * 0.213f + s * 0.143f, 0.715f + c * 0.285f + s * 0.140f, 0.072f - c * 0.072f - s * 0.283f, + 0.213f - c * 0.213f - s * 0.787f, 0.715f - c * 0.715f + s * 0.715f, 0.072f + c * 0.928f + s * 0.072f, + }; + const bool turn_hue = adj.hue_deg != 0.f; + + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) { + const float rr = float(tone[out.rgb[i * 3]]); + const float gg = float(tone[out.rgb[i * 3 + 1]]); + const float bb = float(tone[out.rgb[i * 3 + 2]]); + const float nr = turn_hue ? m[0] * rr + m[1] * gg + m[2] * bb : rr; + const float ng = turn_hue ? m[3] * rr + m[4] * gg + m[5] * bb : gg; + const float nb = turn_hue ? m[6] * rr + m[7] * gg + m[8] * bb : bb; + out.rgb[i * 3] = uint8_t(std::lround(std::clamp(nr, 0.f, 255.f))); + out.rgb[i * 3 + 1] = uint8_t(std::lround(std::clamp(ng, 0.f, 255.f))); + out.rgb[i * 3 + 2] = uint8_t(std::lround(std::clamp(nb, 0.f, 255.f))); + } + }); + + // The spatial pass wants one channel at a time (smooth_height_pixels() is a single-channel blur, and + // reusing it is what keeps this consistent with Smoothing), so de-interleave, filter, re-interleave. + if (blur > 0.f || sharpness > 0.f) { + std::vector channel(n); + for (int ch = 0; ch < 3; ++ch) { + for (size_t i = 0; i < n; ++i) + channel[i] = out.rgb[i * 3 + size_t(ch)]; + adjust_channel_spatial(channel, w, h, blur, sharpness); + for (size_t i = 0; i < n; ++i) + out.rgb[i * 3 + size_t(ch)] = channel[i]; + } + } + + // Height is the luminance of the colour that came out, not the toned luminance of the colour that + // went in: the two differ once hue rotation or a per-channel clamp has moved the channels around, + // and a height that disagrees with the colour it is stored next to is the bug this avoids. + tbb::parallel_for(tbb::blocked_range(0, n), [&](const tbb::blocked_range &r) { + for (size_t i = r.begin(); i < r.end(); ++i) + out.pixels[i] = uint8_t(std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2])); + }); + return out; +} + TextureDetail analyze_texture_detail(const TextureDisplacementLayer &layer) { TextureDetail out; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index f42e3c9c4c..6a121d19a8 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -154,6 +154,38 @@ struct TextureIsland // chart to its true mm area, so a freshly unwrapped patch has uniform texel density to begin with. void average_island_scales(std::vector &islands); +// Photo-style adjustments to a layer's source image, as the gizmo's image-adjustment panel sets them. +// +// These are *not* layer parameters: nothing here is stored on TextureDisplacementLayer and nothing here is +// re-applied at bake time. The panel applies them once, to the image, and the result becomes the layer's +// image - so the adjusted pixels feed the colour and the displacement together, and a brightness change +// really does change the relief. That is the intended behaviour: one image, one source of truth, rather +// than a second set of knobs the bake would have to replay in the right order. +// +// Contrast with TextureDisplacementLayer::smoothing, which *is* a layer parameter applied on every decode: +// `blur` here is a destructive edit of the image, `smoothing` is a live low-pass over whatever the image is. +struct TextureAdjustments +{ + float brightness = 0.f; // [-1, 1]; 0 unchanged, -1 black, +1 white + float contrast = 0.f; // [-1, 1]; 0 unchanged, -1 flat mid-grey, +1 hard threshold + float sharpness = 0.f; // [0, 1]; 0 unchanged. Unsharp-mask amount + float blur = 0.f; // [0, 1]; 0 unchanged. Fraction of SOURCE_BLUR_SPAN, as `smoothing` is + float hue_deg = 0.f; // [-180, 180]; 0 unchanged. No effect on a grayscale image - grey has no hue + + bool identity() const + { + return brightness == 0.f && contrast == 0.f && sharpness == 0.f && blur == 0.f && hue_deg == 0.f; + } + // Exact equality, which is what is wanted: these come straight off sliders, and the question asked of + // them is "is this still the value the preview on screen was computed from", not "are these close". + bool operator==(const TextureAdjustments &o) const + { + return brightness == o.brightness && contrast == o.contrast && sharpness == o.sharpness && + blur == o.blur && hue_deg == o.hue_deg; + } + bool operator!=(const TextureAdjustments &o) const { return !(*this == o); } +}; + // One texture asset plus its projection/displacement parameters. Several layers may be painted // onto overlapping areas of the same volume: their displacements are combined per vertex, in slot // order, each layer folding into the total via its own TextureBlendMode (see @@ -277,6 +309,18 @@ struct TextureDisplacementLayer // - zero or positive: a mesh vertex, every unwrapped copy of it (how edits were stored before). std::vector> lscm_uv_overrides; + // Live, unsaved image adjustments from the gizmo's image-adjustment panel, applied by + // decode_height_texture() on top of the decoded image - so while the panel is open the relief, the + // colour, the thumbnails and the bake all see the adjusted image, and moving a slider really does + // change the displacement. See TextureAdjustments. + // + // Deliberately *not* serialized, by either save()/load() here or the .3mf: it is the state of an open + // editor, not a property of the layer. The panel resets it when it closes, and "Save as copy" resolves + // it by writing a new image and pointing the layer at that - which is the only way an adjustment + // becomes permanent. A project saved mid-adjustment therefore stores the unadjusted image, which is + // the honest answer: nothing was committed. + TextureAdjustments preview_adjust; + // How this layer folds into the displacement accumulated by the layers below it. Ignored for // the lowest-slot painted layer, which has nothing beneath it to combine with (the GUI shows // it as the "Base" layer and hides the control). @@ -504,6 +548,36 @@ struct DecodedHeightTexture bool texel_tap(const Vec2f &uv, bool tile_enabled, TextureTileMethod tile_method, TexelTap &out) const; }; +// Applies `adj` to `src` and returns the result; `src` is left alone. An empty `src`, or an identity +// `adj`, gives back a copy. +// +// Order is tonal, then spatial: brightness, contrast and hue per texel first, then blur, then sharpness. +// Sharpness last is what lets the two spatial knobs be used together - blur to kill speckle, then +// sharpness to put the real edges back - instead of the blur simply undoing the sharpen. +// +// Grayscale in, grayscale out (`rgb` stays empty): promoting a grey image to RGB would hand the layer's +// colour feature a row of identical greys to "colour" the model with, and triple the stored PNG for it. +// For a colour image the height is recomputed as the luminance of the *adjusted* colour, with +// decode_height_texture()'s own coefficients, so the relief and the colour can never drift apart. +DecodedHeightTexture apply_texture_adjustments(const DecodedHeightTexture &src, const TextureAdjustments &adj); + +// Blur radius, in texels, that TextureAdjustments::blur == 1 means on a `min(width, height)`-sized image: +// the same 5%-of-the-short-side span the Smoothing slider uses, under the same 48-texel cost cap, so the +// two controls bite at the same rate and a user moving between them is not surprised. +float adjustment_blur_radius(float blur, int width, int height); + +// Box-filters `src` down so neither side exceeds `max_px`, preserving aspect; never upscales, and +// returns a copy when it already fits. Colour and height are filtered the same way, and the height +// stays the height - it is not recomputed from the filtered colour, because a box filter is linear and +// luminance is linear in the channels, so the two agree already. +// +// This is what lets the adjustment panel's live preview cost depend on the size of the box it is drawn +// in rather than on the size of the image: adjusting a downscaled copy per slider frame, and the +// full-size image only once, on commit. A box filter (rather than a plain decimation) is the point - +// these textures are high-frequency by nature, and dropping texels would alias the preview into +// showing detail the adjustment is not actually producing. +DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, int max_px); + // Decode a layer's raw image bytes into sampleable height data, plus colour when the source has any. // Both 8-bit grayscale PNGs (the shipped library, and anything imported before colour was kept) and // colour PNGs are accepted; for a colour source the height is its luminance, using the same diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index c9b5a54370..14eb31b19c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +121,14 @@ constexpr int THUMBNAIL_MAX_PX = 128; // blur of it - which is what made Fast look flatter and softer than the result it was previewing. constexpr int HEIGHT_TEX_MAX_PX = 2048; +// Size the image-adjustment panel's live preview is computed and uploaded at. The panel draws the image +// in a box about 240 px wide, so there is nothing to show above this - and the preview is recomputed on +// every slider move, which is what makes its cost worth bounding. Measured on a 1024x1024 with every +// knob engaged, a full-size pass is 13-19 ms grayscale / 40-60 ms in colour (130-190 ms at 2048 in +// colour); the same pass on a 384 px copy is 3-4 ms / 9-12 ms, and it runs off the UI thread besides. +// The full-size image is only ever adjusted once, on commit. +constexpr int ADJUST_PREVIEW_MAX_PX = 384; + // How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine // band (see collect_paint_region()). Two is enough to grade the size change without the band's own // cost growing to matter: it is a ring around a perimeter, not an area. @@ -535,6 +544,10 @@ void GLGizmoTextureDisplacement::on_shutdown() // progress poll, and its completion handler then finds nothing to do. m_preview_generation->fetch_add(1); m_preview_job_pending = false; + // The adjustment panel holds a full-size decode of its layer's image, which is the largest thing + // here; a closed gizmo should not keep it alive. Discards the edit, as closing the gizmo on an open + // panel implies. + close_image_adjust(); // The palette caches hold the last volume's images; a closed gizmo should not keep them alive. m_palette_cache.clear(); m_palette_quantizer = nullptr; @@ -2471,9 +2484,12 @@ void GLGizmoTextureDisplacement::update_uv_editor() // before, and independently of, the unwrap - the texture belongs on screen before anything is unwrapped - and // only when the choice, the layer's image or its smoothing changed, because the height image is large. const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height; - const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing); + const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && + (m_uv_editor_bg_smoothing != layer->smoothing || + !(m_uv_editor_bg_adjust == layer->preview_adjust)); if (desired_bg != m_uv_editor_bg || layer->image_data.get() != m_uv_editor_bg_image || bg_smoothing_changed) { m_uv_editor_bg_smoothing = layer->smoothing; + m_uv_editor_bg_adjust = layer->preview_adjust; m_uv_editor_bg_image = layer->image_data.get(); m_uv_editor_bg = desired_bg; if (desired_bg == UVBackground::Checker) { @@ -3744,6 +3760,7 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_checker.svg", "texture_displacement_distortion.svg", "texture_displacement_wireframe.svg", "texture_displacement_cross.svg", "texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg", + "cog.svg", // the layer card's "adjust image" button "texture_displacement_uv_select_vertex.svg", // Paint / Erase, brush cursor shape, mapping, tiling, placement and the dock toggle. "texture_displacement_add.svg", "texture_displacement_add_negative.svg", "circle_paint.svg", @@ -4056,6 +4073,338 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_picker_open_request = true; } } +bool GLGizmoTextureDisplacement::load_image_adjust_source(const TextureDisplacementLayer &layer) +{ + if (layer.empty()) + return false; + + // The *raw* image: neither the layer's Smoothing nor the panel's own preview_adjust, so what the + // sliders act on is always the image as it is actually stored rather than the last preview of it. + // Without clearing preview_adjust here the adjustment would compound on itself every reload. + TextureDisplacementLayer raw = layer; + raw.smoothing = 0.f; + raw.preview_adjust = TextureAdjustments{}; + + DecodedHeightTexture decoded = decode_height_texture(raw); + if (decoded.empty()) + return false; + + m_adjust_source = std::move(decoded); + m_adjust_preview_source = downscale_height_texture(m_adjust_source, ADJUST_PREVIEW_MAX_PX); + m_adjust_source_image = layer.image_data.get(); + // The unadjusted image, so the box is filled now rather than one background pass later. Pairing it + // with an identity `shown` is what makes update_image_adjust_preview() relaunch for the sliders' + // current values against the new source instead of deciding it is already showing them. + m_adjust_preview_shown = TextureAdjustments{}; + m_adjust_preview_tex = upload_height_thumbnail(m_adjust_preview_source, ADJUST_PREVIEW_MAX_PX); + m_adjust_preview_valid = m_adjust_preview_tex != nullptr; + return true; +} + +void GLGizmoTextureDisplacement::open_image_adjust(const TextureDisplacementLayer &layer) +{ + if (layer.empty() || layer.slot < 0) + return; + + close_image_adjust(); // reverts and clears any previous panel + + if (!load_image_adjust_source(layer)) + return; + + m_adjust = TextureAdjustments{}; + m_adjust_applied = TextureAdjustments{}; + m_adjust_slot = layer.slot; + const ModelVolume *mv = texture_volume(); + m_adjust_volume_id = mv != nullptr ? mv->id().id : 0; +} + +void GLGizmoTextureDisplacement::invalidate_adjust_textures() +{ + // The GPU copies of a layer's image - the list thumbnail, the preview shader's height and colour + // textures - are keyed on the image_data pointer and the layer's Smoothing, and a live adjustment + // changes neither: the pointer still names the same PNG. Rather than widen three cache keys for a + // panel that is open a few seconds at a time, the panel drops the copies itself whenever it moves + // the adjustment. Resetting the source is enough; the next get_layer_*() rebuilds from the decode, + // which does see the adjustment. + m_thumbnail_source.fill({}); + m_color_tex_source.reset(); + m_height_tex_source.reset(); +} + +void GLGizmoTextureDisplacement::close_image_adjust() +{ + // Revert the layer's live adjustment. Every exit that reaches here is a discard: a commit resets + // preview_adjust itself, before pointing the layer at the file that already carries the edit, so this + // cannot undo a committed adjustment. + // + // Found by the id recorded when the panel opened rather than through texture_volume(), which answers + // for the *current* selection. One of the ways to get here is the selection having moved on, and + // reverting the newly selected object's layer instead would both miss the adjustment that is still + // live on the original volume and clear one the user never touched. + bool reverted = false; + if (m_adjust_slot >= 0 && m_adjust_volume_id != 0) + for (ModelObject *mo : wxGetApp().model().objects) { + for (ModelVolume *mv : mo->volumes) { + if (mv->id().id != m_adjust_volume_id) + continue; + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_adjust_slot && !l.preview_adjust.identity()) { + l.preview_adjust = TextureAdjustments{}; + reverted = true; + } + } + } + + if (reverted) + invalidate_adjust_textures(); + + // std::async's future blocks here until the pass finishes. That is a few milliseconds on an image + // this small, and the alternative - a detached thread writing into the members of a closed panel - + // is worse. + if (m_adjust_preview_job.valid()) + m_adjust_preview_job.wait(); + m_adjust_preview_job = {}; + + m_adjust_slot = -1; + m_adjust = TextureAdjustments{}; + m_adjust_applied = TextureAdjustments{}; + m_adjust_source = DecodedHeightTexture{}; + m_adjust_preview_source = DecodedHeightTexture{}; + m_adjust_source_image = nullptr; + m_adjust_volume_id = 0; + m_adjust_preview_tex.reset(); + m_adjust_preview_valid = false; + + // Only while the gizmo is up: on_shutdown() also comes through here, and rebuilding a preview for a + // gizmo that is closing would be work for a model nobody is looking at. The reverted layer data is + // what matters there, and that is already done above. + if (reverted && m_state == On) { + rebuild_preview(); + m_parent.set_as_dirty(); + } +} + +void GLGizmoTextureDisplacement::update_image_adjust_preview() +{ + if (m_adjust_preview_source.empty()) + return; + + // Collect a finished pass. wait_for(0) rather than get() outright: this runs inside the frame, and + // blocking on the worker here would hand back to the UI thread the very stall the worker exists to + // spare it. + if (m_adjust_preview_job.valid() && + m_adjust_preview_job.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + const DecodedHeightTexture adjusted = m_adjust_preview_job.get(); + m_adjust_preview_job = {}; + if (std::unique_ptr tex = upload_height_thumbnail(adjusted, ADJUST_PREVIEW_MAX_PX)) { + m_adjust_preview_tex = std::move(tex); + m_adjust_preview_shown = m_adjust_preview_running; + m_adjust_preview_valid = true; + } + } + + // One pass at a time: whatever the sliders did while it ran is picked up by the next launch, so a + // drag collapses to the value the sliders ended on instead of queuing one pass per frame. + if (!m_adjust_preview_job.valid() && m_adjust != m_adjust_preview_shown) { + m_adjust_preview_running = m_adjust; + // Captured by value: the pass must not read members the UI thread goes on editing while it runs. + m_adjust_preview_job = std::async(std::launch::async, + [src = m_adjust_preview_source, adj = m_adjust_preview_running] { + return apply_texture_adjustments(src, adj); + }); + } + + // A pass in flight has no other way to get its result on screen: without a redraw request the canvas + // can sit idle, and the preview would only catch up on the next unrelated frame. + if (m_adjust_preview_job.valid()) + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::commit_image_adjust(TextureDisplacementLayer &layer) +{ + if (m_adjust_source.empty() || m_adjust.identity()) { + // Nothing was changed, so there is nothing to save. + close_image_adjust(); + return; + } + + // The full-size pass, once: 13-19 ms grayscale / 40-60 ms colour on a 1024x1024 with every knob + // engaged. Run on the UI thread deliberately, unlike the live preview: this is a single click, and the + // rebuild that follows it - a CPU mesh displacement of the painted patch - is already an order of + // magnitude more work, synchronously, so moving only this off the thread would buy nothing a user + // could perceive. + const DecodedHeightTexture adjusted = apply_texture_adjustments(m_adjust_source, m_adjust); + + std::string error; + const std::string stem = layer.name.empty() ? std::string("texture") : layer.name; + const std::optional entry = save_texture_to_library(adjusted, stem, error); + if (!entry) { + show_error(nullptr, error); + return; // the panel stays open, and the layer keeps the live adjustment: the edit is not lost + } + + // Cleared *before* the layer is pointed at the new file, and this ordering is the whole correctness of + // the commit: the saved image already has the adjustment baked into its pixels, so leaving + // preview_adjust set would apply brightness, contrast and the rest a second time on top of itself. + layer.preview_adjust = TextureAdjustments{}; + // set_layer_texture() does the rest - the name, the recently-used ordering, a fresh image_data buffer + // (which is what invalidates every cache keyed by the old pointer), the undo snapshot and the rebuild. + set_layer_texture(layer, *entry); + + close_image_adjust(); +} + +void GLGizmoTextureDisplacement::render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max) +{ + if (m_adjust_slot < 0) + return; + + ModelVolume *mv = texture_volume(); + // A different volume is selected than the one this panel was opened on. Slots are numbered per + // volume, so looking m_adjust_slot up here would find an unrelated layer rather than nothing. + if (mv == nullptr || mv->id().id != m_adjust_volume_id) { + close_image_adjust(); + return; + } + + TextureDisplacementLayer *layer = nullptr; + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_adjust_slot) + layer = &l; + if (layer == nullptr) { + // The layer went away under the panel (removed, or merged away by an undo). + close_image_adjust(); + return; + } + + // The layer's image can be swapped from the texture picker while this panel is open - the panel is a + // window now, so the rest of the gizmo stays usable behind it. Re-read the source when that happens, + // keeping the slider positions: the user is adjusting this layer, and the 3D view has already moved to + // the new image (decode_height_texture() reads layer.image_data, which changed), so re-reading here is + // also what keeps the panel's photo and the model showing the same thing. + if (layer->image_data.get() != m_adjust_source_image && !load_image_adjust_source(*layer)) { + close_image_adjust(); // the new image cannot be decoded; nothing left to adjust + return; + } + + const float img_w = std::round(m_imgui->scaled(16.f)); + const float gap = m_imgui->scaled(0.5f); + // Placed beside the panel on first appearance only, and left wherever the user drags it after that - + // it is a window, not a popup, so its position is theirs to keep. + const float win_w = img_w + 2.f * ImGui::GetStyle().WindowPadding.x; + const bool left = panel_min.x - gap - win_w >= 0.f; + ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap - win_w : panel_max.x + gap, + panel_min.y + m_imgui->scaled(4.f)), ImGuiCond_Appearing); + + bool keep_open = true; + if (!ImGui::Begin((_u8L("Adjust image") + "##texture_adjust").c_str(), &keep_open, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + if (!keep_open) + close_image_adjust(); + return; + } + + // Collect a finished pass and start one for the current slider values before the image is drawn, so + // the newest available result is what lands on screen this frame. + update_image_adjust_preview(); + + // The photo itself, at the image's own aspect so a non-square texture is not stretched. + const float aspect = m_adjust_source.height > 0 ? float(m_adjust_source.width) / float(m_adjust_source.height) : 1.f; + const float img_h = std::round(img_w / std::max(0.05f, aspect)); + if (m_adjust_preview_valid && m_adjust_preview_tex) + ImGui::Image((ImTextureID) (intptr_t) m_adjust_preview_tex->get_id(), ImVec2(img_w, img_h)); + else + ImGui::Dummy(ImVec2(img_w, img_h)); + + // The label column is sized off the widest of the five labels, so the tracks line up whatever the + // translation does to their lengths. + float label_w = 0.f; + for (const wxString &l : { _L("Brightness"), _L("Contrast"), _L("Sharpness"), _L("Blur"), _L("Hue") }) + label_w = std::max(label_w, ImGui::CalcTextSize(into_u8(l).c_str()).x); + label_w += m_imgui->scaled(1.2f); + // An explicit track width rather than a fill-to-the-right one: this window is AlwaysAutoResize, so its + // width comes from its content, and an item asking for "whatever is left" would make the width depend + // on itself. The image above sets the content width, and the sliders match it. + const float slider_w = std::max(m_imgui->scaled(4.f), img_w - label_w); + + const auto row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, + const char *format, bool enabled, const std::string &tip) { + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + m_imgui->text(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + ImGui::SetNextItemWidth(slider_w); + m_imgui->disabled_begin(!enabled); + ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp); + m_imgui->disabled_end(); + // Only on an enabled row: a disabled item does not report hover, so a tooltip on one would never + // be shown. What has to be said about a disabled slider is written out below it instead. + if (enabled && !tip.empty() && ImGui::IsItemHovered()) + m_imgui->tooltip(tip, ImGui::GetFontSize() * 20.f); + }; + + row("##adj_brightness", _L("Brightness"), &m_adjust.brightness, -1.f, 1.f, "%.2f", true, {}); + row("##adj_contrast", _L("Contrast"), &m_adjust.contrast, -1.f, 1.f, "%.2f", true, {}); + row("##adj_sharpness", _L("Sharpness"), &m_adjust.sharpness, 0.f, 1.f, "%.2f", true, + _u8L("Brings out the finest detail in the image. Applied after Blur, so the two can be used " + "together: blur away the speckle, then sharpen the edges that remain.")); + row("##adj_blur", _L("Blur"), &m_adjust.blur, 0.f, 1.f, "%.2f", true, + _u8L("Blurs the image itself. The layer's own Smoothing slider softens the relief without altering " + "the image, and is the one to reach for first.")); + // Grey has no hue to turn, and the shipped library is grayscale - so on most textures this is a + // slider that would visibly do nothing. Disabled with the reason given, rather than hidden. + const bool has_color = m_adjust_source.has_color(); + row("##adj_hue", _L("Hue"), &m_adjust.hue_deg, -180.f, 180.f, "%.0f°", has_color, {}); + if (!has_color) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + img_w); + ImGui::TextDisabled("%s", _u8L("This texture is grayscale, so it has no hue to shift.").c_str()); + ImGui::PopTextWrapPos(); + } + + if (m_imgui->button(_L("Reset"))) + m_adjust = TextureAdjustments{}; + + // Hand the sliders to the layer, which is what puts the adjusted image in front of the displacement: + // decode_height_texture() applies preview_adjust, so the relief, the colour and the thumbnails all + // follow from here. Written every frame rather than only on a change, so an undo snapshot or a + // reselection that resets the layer cannot leave the model showing an adjustment the panel has moved + // on from. + layer->preview_adjust = m_adjust; + if (m_adjust != m_adjust_applied) { + m_adjust_applied = m_adjust; + invalidate_adjust_textures(); + // Marked dirty rather than rebuilt here: the panel's own flag is consumed by the main panel's + // "rebuild once the mouse is released" rule, so dragging a slider recomputes the displacement + // once, on release, instead of on every frame of the drag. That is what keeps the UI responsive + // while a full-size resample and a mesh displacement happen behind it. + m_preview_params_dirty = true; + // This window is drawn after the block that consumes that flag, so the rebuild happens on a later + // frame - which means there has to *be* one. Cheap insurance: a slider release that generated no + // further canvas event would otherwise leave the flag set and the model unchanged until something + // unrelated redrew. + m_parent.set_as_dirty(); + } + + ImGui::Separator(); + m_imgui->text_wrapped(_u8L("The model updates when you release a slider. Save as copy writes the " + "adjusted image to your texture library and points this layer at it."), img_w); + + const bool cancel = m_imgui->button(_L("Cancel")); + ImGui::SameLine(); + // Nothing to save until a slider has actually moved. + m_imgui->disabled_begin(m_adjust.identity()); + const bool as_copy = m_imgui->button(_L("Save as copy")); + m_imgui->disabled_end(); + + ImGui::End(); + + if (cancel || !keep_open) + close_image_adjust(); + else if (as_copy) + commit_image_adjust(*layer); +} void GLGizmoTextureDisplacement::remove_texture_layer(int slot) { @@ -6580,7 +6929,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); const std::string not_painted = _u8L("not painted"); - const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 3.f * frame_h + 2.f * gap_s; + const float right_w = (painted ? 0.f : ImGui::CalcTextSize(not_painted.c_str()).x + gap_s) + 4.f * frame_h + 3.f * gap_s; const float name_w = std::max(frame_h, ImGui::GetWindowContentRegionMax().x - card_pad - ImGui::GetCursorPosX() - gap_s - right_w); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(orca.x, orca.y, orca.z, 0.2f)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(orca.x, orca.y, orca.z, 0.35f)); @@ -6594,6 +6943,16 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::TextDisabled("%s", not_painted.c_str()); } + // Between the name and the order arrows: it acts on the image the name refers to, so it + // belongs with the name rather than with the controls that reorder the stack. + ImGui::SameLine(); + m_imgui->disabled_begin(busy || layer.empty()); + if (icon_button(613 + layer.slot, "cog.svg", frame_h, _L("Adjust image"), + _u8L("Brightness, contrast, sharpness, blur and hue, shown on the image and on the " + "model as you set them. Save as copy writes the result to your texture library " + "and points this layer at it."))) + open_image_adjust(layer); + m_imgui->disabled_end(); ImGui::SameLine(); m_imgui->disabled_begin(busy || li == 0); if (icon_button(611, "texture_displacement_move_up.svg", frame_h, _L("Move up"), _L("Move up - applied earlier"))) { @@ -7391,10 +7750,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float set_active_layer(m_picker_slot); // In this window's scope, where the popup's open request and the popup itself share one ID stack. + ImVec2 panel_rect_min, panel_rect_max; { const ImVec2 win_min = ImGui::GetWindowPos(); const ImVec2 win_sz = ImGui::GetWindowSize(); render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y)); + // Only recorded here. The image-adjustment panel is a top-level window rather than a popup, so it + // cannot be begun inside this one; it is drawn after GizmoImguiEnd() below. + panel_rect_min = win_min; + panel_rect_max = ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y); } // ImGui sliders report "changed" continuously on every frame while being dragged, not just once on @@ -7632,6 +7996,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_parent.request_extra_frame(); } GizmoImguiEnd(); + + // Outside the gizmo's window, because it is a window of its own: a floating, non-modal panel that + // leaves the 3D view live so the relief can be watched while the sliders move. No-op when closed. + render_image_adjust_window(panel_rect_min, panel_rect_max); + ImGuiWrapper::pop_toolbar_style(); // Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the 3D diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 927954dd76..6470743543 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -23,6 +23,7 @@ #include "libslic3r/Point.hpp" #include #include +#include #include #include #include "slic3r/GUI/Gizmos/GLGizmoBase.hpp" @@ -435,6 +436,71 @@ private: std::string m_slider_type_request; int m_slider_type_request_frame = 0; + // ---- The image-adjustment panel ---- + // + // Brightness/contrast/sharpness/blur/hue over one layer's image, shown on the image itself and + // updating as the sliders move. What it produces is not a set of layer parameters: it replaces the + // layer's *image*, so the adjusted pixels feed the colour and the displacement together and a + // brightness change moves the relief too (see TextureAdjustments). + // + // An ordinary floating window rather than a popup, and deliberately so. A popup dims the canvas + // behind it and closes on any click outside itself, which between them make it impossible to watch the + // relief change on the model - which is the whole point of adjusting the image here rather than in an + // image editor. A window leaves the 3D view live, clickable and orbitable while the panel is open. + void render_image_adjust_window(const ImVec2 &panel_min, const ImVec2 &panel_max); + // Opens the panel for `layer`, decoding its image into m_adjust_source. Sliders never re-decode + // the PNG. + void open_image_adjust(const TextureDisplacementLayer &layer); + // Reads `layer`'s stored image into the panel's source buffers and records which image it came from. + // Called when the panel opens and again whenever the layer's image is swapped under it, which is why + // it leaves the slider values alone. False when the image cannot be decoded. + bool load_image_adjust_source(const TextureDisplacementLayer &layer); + // Resolves the panel: writes the adjusted image into the user's texture library as a new file and + // points `layer` at it, which is the only way an adjustment becomes permanent. Closes the panel. + void commit_image_adjust(TextureDisplacementLayer &layer); + // Closes the panel and reverts the layer's live preview_adjust to identity, so every exit that is not + // a commit - Cancel, the window's close button, the gizmo closing - leaves the layer as it was found. + void close_image_adjust(); + // Collects a finished preview and starts one for the current slider values. One at a time, so a drag + // coalesces into "whatever the sliders last said" instead of a queue of superseded frames. + void update_image_adjust_preview(); + + int m_adjust_slot = -1; // the layer the panel is open for; -1 when closed + TextureAdjustments m_adjust; // what the sliders currently say + // What the layer's live preview_adjust was last set from, so a slider move can be told from a frame in + // which nothing happened and only the former asks the displacement to be recomputed. + TextureAdjustments m_adjust_applied; + + // The layer's image as it was when the panel opened, and a small copy of it. The sliders are applied + // to the small copy for the live preview and to the full-size one exactly once, on commit. Measured on + // a 1024x1024 with every knob engaged, a full-size pass costs 13-19 ms grayscale and 40-60 ms in + // colour (130-190 ms at 2048 in colour) - far too much to repeat per slider frame. The same pass on a + // 384 px copy is 3-4 ms / 9-12 ms, and that is all the preview box can show anyway. + DecodedHeightTexture m_adjust_source; + DecodedHeightTexture m_adjust_preview_source; + // Which image_data the two buffers above were decoded from, so the panel can notice the layer's image + // being swapped from the texture picker while it is open and re-read it. Compared, never dereferenced. + const void *m_adjust_source_image = nullptr; + // And which volume the panel was opened on. Slot numbers are per volume, so without this a change of + // selection would silently rebind the open panel to whichever layer happens to hold the same slot on + // the newly selected object - a different image, under a panel the user opened for this one. + size_t m_adjust_volume_id = 0; + // The preview image on screen, re-uploaded whenever a background pass finishes. + std::unique_ptr m_adjust_preview_tex; + + // The live preview runs on its own thread rather than on the UI thread or on the plater's job + // worker. Not the UI thread because even the small pass is milliseconds and this runs while a slider + // is being dragged; not the job worker because that is a single FIFO shared with Bake and the + // displacement preview (see queue_preview_job()), where a per-frame image filter would sit behind - + // and delay - multi-second mesh work. + std::future m_adjust_preview_job; + TextureAdjustments m_adjust_preview_running; // what the in-flight pass is computing + TextureAdjustments m_adjust_preview_shown; // what m_adjust_preview_tex holds + bool m_adjust_preview_valid = false; + // Drops the GPU copies of the layer's image so the next frame rebuilds them from the decode, which + // is where the live adjustment is applied. Called whenever the panel moves or reverts it. + void invalidate_adjust_textures(); + // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane // (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's @@ -915,6 +981,7 @@ private: enum class UVBackground { None, Height, Checker }; UVBackground m_uv_editor_bg = UVBackground::None; float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at + TextureAdjustments m_uv_editor_bg_adjust; // and the live image adjustment it was uploaded at const void *m_uv_editor_bg_image = nullptr; // the layer image it was uploaded from // Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is // re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index e3f73258df..69301b42d1 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -27,6 +27,7 @@ #include #include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/AppConfig.hpp" @@ -158,6 +159,28 @@ bool encode_png_bytes(const wxImage &image, std::vector &out, std : encode_gray_png_bytes(image, out, error); } +// A wxImage over decoded pixels, so the encoders above can be reached from an in-memory image as well as +// from a file. Grayscale is expanded to the three equal channels wxImage always stores, which is what +// image_has_color() then reads as "no colour" - so a grey image still encodes as a grayscale PNG. +// +// `scratch` holds the pixel buffer: the wxImage is constructed over it without taking ownership, so it +// has to outlive every use of the returned image. +wxImage image_from_decoded(const DecodedHeightTexture &image, std::vector &scratch) +{ + if (image.empty()) + return wxImage(); + + const size_t n = size_t(image.width) * size_t(image.height); + scratch.resize(n * 3); + if (image.has_color()) + scratch.assign(image.rgb.begin(), image.rgb.end()); + else + for (size_t i = 0; i < n; ++i) + scratch[i * 3] = scratch[i * 3 + 1] = scratch[i * 3 + 2] = image.pixels[i]; + + return wxImage(image.width, image.height, scratch.data(), /* static_data */ true); +} + std::vector read_file_bytes(const std::string &path) { std::ifstream ifs(path, std::ios::binary); @@ -186,6 +209,42 @@ bool is_supported_height_map(const std::vector &bytes) std::vector g_library; bool g_library_scanned = false; +// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture - +// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new +// file's library entry. Shared by import_texture_to_library() and save_texture_to_library(), which +// differ only in where their bytes came from. +std::optional write_user_texture(const std::vector &bytes, const std::string &stem, + std::string &error) +{ + const std::string user_dir = user_texture_dir(); + if (user_dir.empty()) { + error = _u8L("Could not create the folder for imported textures."); + return std::nullopt; + } + + boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); + for (int i = 2; boost::filesystem::exists(dest); ++i) + dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); + + { + boost::nowide::ofstream ofs(dest.string(), std::ios::binary); + ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); + if (!ofs.good()) { + error = _u8L("Failed to save the imported texture."); + return std::nullopt; + } + } + + texture_library(true); // pick the new file up + const std::string dest_str = dest.string(); + for (const TextureLibraryEntry &e : g_library) + if (e.path == dest_str) + return e; + + error = _u8L("Failed to save the imported texture."); + return std::nullopt; +} + // The user's own textures, most recently used first, kept in the app config. File names are stored rather // than paths so the order survives a moved data directory; '/' separates them because no platform allows // it inside a file name. Bounded so a long-lived config does not grow a line without end. @@ -317,12 +376,6 @@ bool remove_user_texture(const std::string &path, std::string &error) std::optional import_texture_to_library(const std::string &source_path, std::string &error) { - const std::string user_dir = user_texture_dir(); - if (user_dir.empty()) { - error = _u8L("Could not create the folder for imported textures."); - return std::nullopt; - } - wxImage image; if (!image.LoadFile(from_u8(source_path)) || !image.IsOk()) { error = _u8L("Could not load the selected image."); @@ -333,30 +386,7 @@ std::optional import_texture_to_library(const std::string & if (!encode_png_bytes(image, bytes, error)) return std::nullopt; - // Never overwrite an existing texture (the user's or, if they picked the same name twice, their - // own earlier import) - uniquify instead. - const std::string stem = boost::filesystem::path(source_path).stem().string(); - boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); - for (int i = 2; boost::filesystem::exists(dest); ++i) - dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); - - { - boost::nowide::ofstream ofs(dest.string(), std::ios::binary); - ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); - if (!ofs.good()) { - error = _u8L("Failed to save the imported texture."); - return std::nullopt; - } - } - - texture_library(true); // pick the new file up - const std::string dest_str = dest.string(); - for (const TextureLibraryEntry &e : g_library) - if (e.path == dest_str) - return e; - - error = _u8L("Failed to save the imported texture."); - return std::nullopt; + return write_user_texture(bytes, boost::filesystem::path(source_path).stem().string(), error); } std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error) @@ -382,4 +412,21 @@ std::shared_ptr> load_texture_image_data(const std::s return std::make_shared>(std::move(converted)); } +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error) +{ + std::vector scratch; + const wxImage wx = image_from_decoded(image, scratch); + if (!wx.IsOk()) { + error = _u8L("The selected image is empty."); + return std::nullopt; + } + + std::vector bytes; + if (!encode_png_bytes(wx, bytes, error)) + return std::nullopt; + + return write_user_texture(bytes, stem.empty() ? std::string("texture") : stem, error); +} + } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp index 65b35bcea8..38ad277a84 100644 --- a/src/slic3r/GUI/TextureLibrary.hpp +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -6,6 +6,8 @@ #include #include +#include "libslic3r/TextureDisplacement.hpp" + namespace Slic3r::GUI { // One selectable height-map texture in the texture-displacement gizmo's texture picker. @@ -57,6 +59,14 @@ std::optional import_texture_to_library(const std::string & // Returns nullptr with `error` set if the file cannot be read or decoded at all. std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error); +// Writes `image` into user_texture_dir() as a new PNG named after `stem` (uniquified if taken, so an +// existing texture is never overwritten) and returns its library entry. The in-memory counterpart of +// import_texture_to_library(), for an image that was edited rather than read off disk. +// +// Returns nullopt with `error` set on failure. +std::optional save_texture_to_library(const DecodedHeightTexture &image, const std::string &stem, + std::string &error); + } // namespace Slic3r::GUI #endif // slic3r_TextureLibrary_hpp_ diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index d0a417ccd0..f57be521ab 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -1301,6 +1302,294 @@ TEST_CASE("TextureDisplacement: the step cutter only cuts inside the region", "[ } } +// ---- Image adjustments (the gizmo's image-adjustment panel) ---------------------------------------- + +// A grayscale ramp 0..255 across the width, so every input level is present exactly once per row and a +// tonal curve can be read straight off the output. +static DecodedHeightTexture make_gray_ramp(int w = 256, int h = 4) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = uint8_t(x * 255 / std::max(1, w - 1)); + return t; +} + +// A one-texel checkerboard of 0 and 255 - the highest spatial frequency the grid can hold, which is what +// a blur has to remove and a downscale has to average rather than drop. +static DecodedHeightTexture make_gray_checkerboard(int w = 16, int h = 16) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) + t.pixels[size_t(y) * size_t(w) + size_t(x)] = ((x + y) % 2 == 0) ? 0 : 255; + return t; +} + +static DecodedHeightTexture make_rgb_flat(uint8_t r, uint8_t g, uint8_t b, int w = 8, int h = 8) +{ + DecodedHeightTexture t; + t.width = w; + t.height = h; + t.pixels.resize(size_t(w) * size_t(h)); + t.rgb.resize(size_t(w) * size_t(h) * 3); + for (size_t i = 0; i < size_t(w) * size_t(h); ++i) { + t.rgb[i * 3] = r; + t.rgb[i * 3 + 1] = g; + t.rgb[i * 3 + 2] = b; + t.pixels[i] = uint8_t(std::lround(0.299 * r + 0.587 * g + 0.114 * b)); + } + return t; +} + +TEST_CASE("an adjustment that moves no slider returns the image unchanged", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + const DecodedHeightTexture out = apply_texture_adjustments(src, TextureAdjustments{}); + CHECK(out.width == src.width); + CHECK(out.height == src.height); + CHECK(out.pixels == src.pixels); + CHECK(TextureAdjustments{}.identity()); +} + +TEST_CASE("brightness reaches pure white and pure black only at the ends of its range", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments up; + up.brightness = 1.f; + const DecodedHeightTexture white = apply_texture_adjustments(src, up); + CHECK(std::all_of(white.pixels.begin(), white.pixels.end(), [](uint8_t v) { return v == 255; })); + + TextureAdjustments down; + down.brightness = -1.f; + const DecodedHeightTexture black = apply_texture_adjustments(src, down); + CHECK(std::all_of(black.pixels.begin(), black.pixels.end(), [](uint8_t v) { return v == 0; })); +} + +TEST_CASE("brightness lifts the image without flattening it into a clipped plateau", "[TextureDisplacement]") +{ + // The reason brightness is a lift toward white rather than an additive offset: an offset would drive + // everything above mid-grey to a flat 255, and since this image is also the height map that region + // would bake as flat relief. Half a stop up must leave almost nothing at pure white. + const DecodedHeightTexture src = make_gray_ramp(); + TextureAdjustments adj; + adj.brightness = 0.5f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + + REQUIRE(out.pixels.size() == src.pixels.size()); + const size_t clipped = size_t(std::count(out.pixels.begin(), out.pixels.end(), uint8_t(255))); + CHECK(clipped * 100 < out.pixels.size()); // under 1%; an additive shift would clip half the ramp + + // Every texel got brighter, and the curve stays ordered: brighter in means brighter out. + for (size_t i = 0; i < out.pixels.size(); ++i) + REQUIRE(out.pixels[i] >= src.pixels[i]); + for (int x = 1; x < out.width; ++x) + REQUIRE(out.pixels[size_t(x)] >= out.pixels[size_t(x - 1)]); +} + +TEST_CASE("contrast at its lowest flattens the image to mid-grey and at its highest spreads it apart", + "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + + TextureAdjustments flat; + flat.contrast = -1.f; + const DecodedHeightTexture grey = apply_texture_adjustments(src, flat); + // One value everywhere - the pivot. Which exact level that rounds to is not the point. + CHECK(std::adjacent_find(grey.pixels.begin(), grey.pixels.end(), std::not_equal_to()) == + grey.pixels.end()); + + TextureAdjustments hard; + hard.contrast = 1.f; + const DecodedHeightTexture split = apply_texture_adjustments(src, hard); + // A threshold about the pivot: the dark end of the ramp goes to black, the light end to white. + CHECK(split.pixels[0] == 0); + CHECK(split.pixels[size_t(split.width - 1)] == 255); +} + +TEST_CASE("blur removes the finest detail and sharpness exaggerates an edge", "[TextureDisplacement]") +{ + // Blur: a one-texel checkerboard is the highest frequency the grid holds, so a blur has to collapse it + // toward its own mean. Measured as mean absolute deviation, which needs no assumption about the mean. + const DecodedHeightTexture checker = make_gray_checkerboard(32, 32); + const auto deviation = [](const DecodedHeightTexture &t) { + double mean = 0.; + for (uint8_t v : t.pixels) + mean += double(v); + mean /= double(t.pixels.size()); + double dev = 0.; + for (uint8_t v : t.pixels) + dev += std::abs(double(v) - mean); + return dev / double(t.pixels.size()); + }; + TextureAdjustments blur; + blur.blur = 1.f; + CHECK(deviation(apply_texture_adjustments(checker, blur)) < 0.25 * deviation(checker)); + + // Sharpness: an unsharp mask pushes each side of a step further from the other, so the step + // overshoots - darker than the dark side just inside it, brighter than the bright side just inside. + DecodedHeightTexture step; + step.width = 64; + step.height = 8; + step.pixels.assign(size_t(step.width) * size_t(step.height), 100); + for (int y = 0; y < step.height; ++y) + for (int x = step.width / 2; x < step.width; ++x) + step.pixels[size_t(y) * size_t(step.width) + size_t(x)] = 150; + + TextureAdjustments sharp; + sharp.sharpness = 1.f; + const DecodedHeightTexture sharpened = apply_texture_adjustments(step, sharp); + const size_t row = size_t(step.height / 2) * size_t(step.width); + const size_t mid = size_t(step.width / 2); + CHECK(sharpened.pixels[row + mid - 1] < 100); // undershoot on the dark side of the step + CHECK(sharpened.pixels[row + mid] > 150); // overshoot on the light side +} + +TEST_CASE("a grayscale image stays grayscale and its hue slider does nothing", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_gray_ramp(); + REQUIRE_FALSE(src.has_color()); + + TextureAdjustments hue; + hue.hue_deg = 120.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, hue); + // Not promoted to RGB: a grey image has no colour to offer the layer's colour feature, and storing it + // as RGB would triple the PNG for a row of identical greys. + CHECK_FALSE(out.has_color()); + CHECK(out.rgb.empty()); + CHECK(out.pixels == src.pixels); +} + +TEST_CASE("a colour image's height stays the luminance of its adjusted colour", "[TextureDisplacement]") +{ + // The invariant that keeps the relief and the colour from drifting apart: whatever the adjustment did + // to the channels, the height is recomputed from the channels that came out. + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + REQUIRE(src.has_color()); + + TextureAdjustments adj; + adj.brightness = 0.2f; + adj.contrast = 0.3f; + adj.hue_deg = 75.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + + for (size_t i = 0; i < out.pixels.size(); ++i) { + const long expect = std::lround(0.299 * out.rgb[i * 3] + 0.587 * out.rgb[i * 3 + 1] + + 0.114 * out.rgb[i * 3 + 2]); + REQUIRE(long(out.pixels[i]) == expect); + } + // The hue turn actually moved the colour, so the check above is not passing on an unchanged image. + CHECK(out.rgb != src.rgb); +} + +TEST_CASE("a full turn of the hue leaves a colour where it started", "[TextureDisplacement]") +{ + const DecodedHeightTexture src = make_rgb_flat(200, 60, 30); + TextureAdjustments adj; + adj.hue_deg = 360.f; + const DecodedHeightTexture out = apply_texture_adjustments(src, adj); + REQUIRE(out.has_color()); + // Rounding through 8-bit channels, so a level or two of slack rather than exact equality. + for (size_t i = 0; i < out.rgb.size(); ++i) + REQUIRE(std::abs(int(out.rgb[i]) - int(src.rgb[i])) <= 2); +} + +TEST_CASE("the adjustment blur radius ramps from zero and stays under its cost cap", "[TextureDisplacement]") +{ + CHECK_THAT(adjustment_blur_radius(0.f, 512, 512), Catch::Matchers::WithinAbs(0.f, 1e-6f)); + // 5% of the short side, as the Smoothing slider uses. + CHECK_THAT(adjustment_blur_radius(1.f, 512, 512), Catch::Matchers::WithinAbs(25.6f, 1e-3f)); + CHECK_THAT(adjustment_blur_radius(0.5f, 512, 512), Catch::Matchers::WithinAbs(12.8f, 1e-3f)); + // Capped, so a large image at the top of the slider cannot cost without bound. + CHECK_THAT(adjustment_blur_radius(1.f, 8192, 8192), Catch::Matchers::WithinAbs(48.f, 1e-3f)); + // Sized off the short side, not the long one. + CHECK_THAT(adjustment_blur_radius(1.f, 4096, 200), Catch::Matchers::WithinAbs(10.f, 1e-3f)); +} + +TEST_CASE("downscaling a height texture averages the texels it drops and never upscales", + "[TextureDisplacement]") +{ + // Averaging, not decimation: a one-texel checkerboard halved must come out uniformly mid-grey. Picking + // one texel per block would instead come out all black or all white - detail the adjustment never + // produced, shown in the preview. + const DecodedHeightTexture small = downscale_height_texture(make_gray_checkerboard(16, 16), 8); + CHECK(small.width == 8); + CHECK(small.height == 8); + CHECK(std::all_of(small.pixels.begin(), small.pixels.end(), [](uint8_t v) { return v == 127; })); + + // Already within the limit: handed back as it is. + const DecodedHeightTexture ramp = make_gray_ramp(64, 4); + const DecodedHeightTexture same = downscale_height_texture(ramp, 256); + CHECK(same.width == ramp.width); + CHECK(same.height == ramp.height); + CHECK(same.pixels == ramp.pixels); + + // Aspect preserved, and a colour source keeps its colour. + const DecodedHeightTexture wide = downscale_height_texture(make_rgb_flat(200, 60, 30, 400, 100), 100); + CHECK(wide.width == 100); + CHECK(wide.height == 25); + CHECK(wide.has_color()); +} + +TEST_CASE("a layer's live image adjustment reaches its decode", "[TextureDisplacement]") +{ + // This is what makes the image-adjustment panel's sliders move the relief: decode_height_texture() + // applies the layer's preview_adjust, so everything downstream of the decode - the displacement, the + // colour, the thumbnails, the bake - sees the adjusted image without being told about the panel. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + // An untouched panel, and a closed one, cost nothing and change nothing. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); + + layer.preview_adjust.brightness = 0.5f; + const DecodedHeightTexture bright = decode_height_texture(layer); + CHECK(bright.pixels != raw.pixels); + CHECK(bright.pixels == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // Back to identity gives the stored image back, so closing the panel really does revert the layer. + layer.preview_adjust = TextureAdjustments{}; + CHECK(decode_height_texture(layer).pixels == raw.pixels); +} + +TEST_CASE("changing a live adjustment is not served the previous one from cache", "[TextureDisplacement]") +{ + // The decode caches by the image_data pointer, which does not change while a slider moves - so the + // adjustment has to be part of the key or every slider move past the first would redraw the same + // pixels. Same for the smoothed copy, which is computed on top of the adjusted one. + TextureDisplacementLayer layer; + layer.image_data = make_checkerboard_png(32, 32); + const DecodedHeightTexture raw = decode_height_texture(layer); + REQUIRE_FALSE(raw.empty()); + + layer.preview_adjust.brightness = 0.5f; + const std::vector bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector dark = decode_height_texture(layer).pixels; + CHECK(bright != dark); + CHECK(dark == apply_texture_adjustments(raw, layer.preview_adjust).pixels); + + // The same question again with smoothing on, which is cached separately and keyed by both. + layer.smoothing = 0.5f; + layer.preview_adjust.brightness = 0.5f; + const std::vector smooth_bright = decode_height_texture(layer).pixels; + layer.preview_adjust.brightness = -0.5f; + const std::vector smooth_dark = decode_height_texture(layer).pixels; + CHECK(smooth_bright != smooth_dark); +} + // --------------------------------------------------------------------------------------------- // Texture smoothing // ---------------------------------------------------------------------------------------------