From 86ab5988997c906e41eab93d15db562ae7cc6268 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 14 Oct 2026 17:15:39 +0800 Subject: [PATCH 01/21] Add a ResourceTexture plugin capability for height-map texture packs A fifth Python plugin capability type, ResourceTexture, contributes height-map textures to the Texture Displacement picker. A pack returns a directory of images; the host validates it once at load and lists the images under a section of their own. - New capability type ResourceTexture, type string "resource-texture", exposed to plugin authors as orca.resource_texture.ResourceTexturePluginCapabilityBase with one required method, get_texture_dir(). - The directory is resolved once on the load worker, under the GIL, and cached as a plain string: the picker runs inside the ImGui frame, where a trampoline call would block on the GIL behind a load/unload worker, or throw past EndPopup. - Validation resolves the directory and requires containment in the plugin root, re-checked per file so a symlink inside the pack cannot resolve outside it. Containment is the only security control here and it guards exfiltration: layer bytes are embedded in every saved .3mf, so a pack that could return ~/.ssh/id_rsa would leak it through any shared project. - New host registry PluginResourceTextures, filled from both the plugin and the capability lifecycle callbacks plus a startup sweep. It holds strings only and never a capability reference, so nothing outlives Py_Finalize. - The picker lists pack textures read-only, one collapsible section per pack, in a fixed-size scrolling popup whose height follows the window. A generation counter invalidates the cached listing, and each gizmo's cached thumbnails, when the set of packs changes. - Tests in tests/slic3rutils/test_plugin_resource_texture.cpp cover the validator (parent-directory and absolute escapes, an escaping symlink, the extension whitelist, the 64-file cap), the listing and its load/disable/re-enable/unload lifecycle, hostile get_texture_dir() overrides, decode parity with hand-picked files, and a .3mf round trip with the pack uninstalled. No persistence work is needed: a layer carries its own image bytes and 3MF writes them into the archive, so uninstalling a pack leaves existing projects intact. Behaviour change worth noting for review: the picker popup is now a fixed size that scrolls, rather than sizing itself to its content, and it gains one collapsible section per pack. That sizing applies to every user, not only those with packs installed. --- src/slic3r/CMakeLists.txt | 5 + src/slic3r/GUI/GUI_App.cpp | 33 + .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 189 +++- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 2 + src/slic3r/GUI/TextureLibrary.cpp | 82 +- src/slic3r/GUI/TextureLibrary.hpp | 42 +- src/slic3r/plugin/PluginConfig.cpp | 1 + src/slic3r/plugin/PluginFsUtils.cpp | 73 ++ src/slic3r/plugin/PluginFsUtils.hpp | 6 + src/slic3r/plugin/PluginLoader.cpp | 32 + src/slic3r/plugin/PythonPluginBridge.cpp | 3 + src/slic3r/plugin/PythonPluginInterface.hpp | 6 +- .../plugin/host/PluginResourceTextures.cpp | 114 +++ .../plugin/host/PluginResourceTextures.hpp | 50 + .../ResourceTexturePluginCapability.cpp | 31 + .../ResourceTexturePluginCapability.hpp | 28 + ...ourceTexturePluginCapabilityTrampoline.hpp | 30 + tests/slic3rutils/CMakeLists.txt | 1 + .../test_plugin_resource_texture.cpp | 935 ++++++++++++++++++ 19 files changed, 1597 insertions(+), 66 deletions(-) create mode 100644 src/slic3r/plugin/host/PluginResourceTextures.cpp create mode 100644 src/slic3r/plugin/host/PluginResourceTextures.hpp create mode 100644 src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp create mode 100644 src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp create mode 100644 src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp create mode 100644 tests/slic3rutils/test_plugin_resource_texture.cpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index dd13fd5353..52651e9b06 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -683,6 +683,8 @@ set(SLIC3R_GUI_SOURCES plugin/host/PluginHostUi.hpp plugin/host/PluginPages.cpp plugin/host/PluginPages.hpp + plugin/host/PluginResourceTextures.cpp + plugin/host/PluginResourceTextures.hpp plugin/CloudPluginService.cpp plugin/CloudPluginService.hpp plugin/PluginFsUtils.cpp @@ -706,6 +708,9 @@ set(SLIC3R_GUI_SOURCES plugin/pluginTypes/pages/PagesPluginCapability.hpp plugin/pluginTypes/pages/PagesPluginCapability.cpp plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp + plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp + plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp + plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp plugin/pluginTypes/script/ScriptPluginCapability.hpp plugin/pluginTypes/script/ScriptPluginCapability.cpp plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index fa8bc811c3..2acc53f954 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -251,9 +251,11 @@ #include "slic3r/GUI/ParamsDialog.hpp" #include "slic3r/GUI/ParamsPanel.hpp" #include "slic3r/GUI/PartPlate.hpp" +#include "slic3r/GUI/TextureLibrary.hpp" #include "slic3r/GUI/Widgets/Button.hpp" #include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/plugin/host/PluginPages.hpp" +#include "slic3r/plugin/host/PluginResourceTextures.hpp" #include #include "slic3r/GUI/Widgets/WebView.hpp" #include @@ -3053,6 +3055,37 @@ void GUI_App::init_plugin_gui_wiring() refresh_plugins_dialog(); switch_printer_agent_after_unload(capability.plugin_key); }); + plugin_mgr.subscribe_on_load_callback([](const std::string& plugin_key) { + if (wxTheApp == nullptr || wxGetApp().is_closing()) + return; + PluginResourceTextures::instance().on_plugin_register(plugin_key); + Slic3r::GUI::bump_texture_library_generation(); + }); + plugin_mgr.subscribe_on_unload_callback([](const std::string& plugin_key) { + if (wxTheApp == nullptr || wxGetApp().is_closing()) + return; + PluginResourceTextures::instance().on_plugin_deregister(plugin_key); + Slic3r::GUI::bump_texture_library_generation(); + }); + plugin_mgr.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) { + if (capability.type != PluginCapabilityType::ResourceTexture) + return; + if (wxTheApp == nullptr || wxGetApp().is_closing()) + return; + PluginResourceTextures::instance().on_cap_register(capability); + Slic3r::GUI::bump_texture_library_generation(); + }); + plugin_mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) { + if (capability.type != PluginCapabilityType::ResourceTexture) + return; + if (wxTheApp == nullptr || wxGetApp().is_closing()) + return; + PluginResourceTextures::instance().on_cap_deregister(capability); + Slic3r::GUI::bump_texture_library_generation(); + }); + // Packs already loaded before this wiring, or in tests where no callback fires. + PluginResourceTextures::instance().initial_sweep(); + Slic3r::GUI::bump_texture_library_generation(); } bool GUI_App::on_init_inner() diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 7d7d0e7434..30756eac49 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -71,6 +71,7 @@ #include #include #include +#include #include #include #include @@ -3917,7 +3918,7 @@ void GLGizmoTextureDisplacement::set_layer_texture(TextureDisplacementLayer &lay // the PNG the first time the layer is previewed or baked. layer.image_data = tex->image_data; // The user's own textures are listed most recently used first. - if (entry.is_user) + if (entry.source == TextureSource::User) touch_user_texture(entry.path); rebuild_preview(); @@ -3964,12 +3965,41 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane const float spacing = std::round(m_imgui->scaled(0.4f)); const float grid_w = 4.f * tile + 3.f * spacing; const float gap = m_imgui->scaled(0.5f); + const ImGuiStyle &style = ImGui::GetStyle(); + + // A fixed size rather than a fit-to-content one. The width is the four-column grid plus the window + // padding and the scrollbar gutter, so the columns always fit and no horizontal scrollbar is needed. + // The height is a share of the window, not of the content, so it does not jump as packs load or + // sections collapse; the width derives from scaled values, so it tracks the DPI like the rest. + const float fixed_w = grid_w + 2.f * style.WindowPadding.x + style.ScrollbarSize; + + // BeginPopup() ORs in ImGuiWindowFlags_AlwaysAutoResize (deps_src/imgui/imgui.cpp:8371) and no flag + // turns it back off. A SetNextWindowSize() overrides it for each axis it sets (imgui.cpp:5983-5996), + // so an explicit size is the only way to fix the popup's size; the window then scrolls its content. + // + // About 70% of the window, fixed for a given window, shortened only enough to keep a gap above and + // below, and floored at two frame heights so a very short window still shows something usable. + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + const float height = std::max(std::min(0.70f * viewport->Size.y, viewport->Size.y - 2.f * gap), + 2.f * ImGui::GetFrameHeight()); + + // A 70%-tall popup cannot hang off the panel's top: anchored there, the side it grows to holds only + // about half the window. It uses the whole column beside the panel instead, filling from the top or + // the bottom of the viewport depending on which side of the panel's top has more room. + const float popup_top = panel_min.y + m_imgui->scaled(4.f); + const float space_below = viewport->Pos.y + viewport->Size.y - popup_top - gap; + const float space_above = popup_top - viewport->Pos.y - gap; + const bool grow_down = space_below >= space_above; + const float popup_y = grow_down ? viewport->Pos.y + gap + : viewport->Pos.y + viewport->Size.y - gap - height; // Beside the panel rather than over it, so the layer being retextured stays in view: to its left when // there is room (the docked panel sits against the right edge of the canvas), otherwise to its right. - const float popup_w = grid_w + 2.f * ImGui::GetStyle().WindowPadding.x; - const bool left = panel_min.x - gap - popup_w >= 0.f; - ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap : panel_max.x + gap, panel_min.y + m_imgui->scaled(4.f)), - ImGuiCond_Appearing, ImVec2(left ? 1.f : 0.f, 0.f)); + const float width = viewport->Size.x > 0.f ? std::min(fixed_w, viewport->Size.x) : fixed_w; + const bool left = panel_min.x - gap - width >= 0.f; + const float popup_x = left ? panel_min.x - gap - width : panel_max.x + gap; + // Cond_Always, not Appearing: a viewport resize while the popup is open must move and resize it too. + ImGui::SetNextWindowPos(ImVec2(popup_x, popup_y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always); if (!ImGui::BeginPopup(popup_id)) return; if (layer == nullptr) { @@ -3983,6 +4013,12 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_imgui->pop_bold_font(); const std::vector &library = texture_library(); + // A reinstalled pack can drop new bytes at a path already cached on this gizmo, and get_library_texture() + // caches a failure as well as a success, so a moved generation makes every cached entry suspect. + if (const uint64_t generation = texture_library_generation(); generation != m_library_generation) { + m_library_textures.clear(); + m_library_generation = generation; + } ImDrawList *dl = ImGui::GetWindowDrawList(); const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA); std::optional chosen; @@ -4003,16 +4039,15 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane ImGui::Dummy(ImVec2(grid_w, float(rows) * tile + float(rows - 1) * spacing)); } }; - // One group's tiles: shipped textures alphabetically, or the user's own most recently used first (the - // order texture_library() keeps), those with a remove button on hover. Returns how many it drew. - const auto grid = [&](const ImVec2 &origin, bool user) { + // One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by + // name, the user's own most recently used first - the order texture_library() produced); a user tile + // carries a remove button on hover. Returns how many it drew. + const auto grid = [&](const ImVec2 &origin, const std::vector &entries, bool removable) { int n = 0; - for (const TextureLibraryEntry &entry : library) { - if (entry.is_user != user) - continue; + for (const TextureLibraryEntry *entry : entries) { ImGui::SetCursorScreenPos(tile_pos(origin, n++)); - ImGui::PushID(entry.path.c_str()); - const LibraryTexture *tex = get_library_texture(entry.path); + ImGui::PushID(entry->path.c_str()); + const LibraryTexture *tex = get_library_texture(entry->path); GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr; bool clicked = false; if (thumb != nullptr) { @@ -4030,15 +4065,17 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane } else { clicked = ImGui::Button("?", ImVec2(tile, tile)); } - if (user) + if (removable) ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); - if (entry.path == layer->path) + if (entry->path == layer->path) dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f); if (ImGui::IsItemHovered()) - m_imgui->tooltip(entry.name, m_imgui->scaled(18.f)); + m_imgui->tooltip(entry->name, m_imgui->scaled(18.f)); - if (user && ImGui::IsMouseHoveringRect(a, b)) { + // Clipped by the window's own clip rect, so a tile scrolled under the heading or the scrollbar + // does not offer its remove button. IsMouseHoveringRect() clips by default. + if (removable && ImGui::IsMouseHoveringRect(a, b)) { const float r = std::round(tile * 0.17f); const ImVec2 c(b.x - r - 2.f, a.y + r + 2.f); ImGui::SetCursorScreenPos(ImVec2(c.x - r, c.y - r)); @@ -4051,41 +4088,105 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane if (hot) m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f)); if (remove_clicked) - to_remove = entry; + to_remove = *entry; } ImGui::PopID(); if (clicked) - chosen = entry; + chosen = *entry; } return n; }; - ImGui::TextDisabled("%s", _u8L("Built-in").c_str()); - { - const ImVec2 origin = ImGui::GetCursorScreenPos(); - const int count = grid(origin, false); - end_grid(origin, count); - if (count == 0) - ImGui::TextDisabled("%s", _u8L("No textures found.").c_str()); - } - ImGui::TextDisabled("%s", _u8L("My textures").c_str()); - { - const ImVec2 origin = ImGui::GetCursorScreenPos(); - const int count = grid(origin, true); - ImGui::SetCursorScreenPos(tile_pos(origin, count)); - if (ImGui::Button("+##import_texture", ImVec2(tile, tile))) - import_clicked = true; - if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("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."), - m_imgui->scaled(20.f)); - end_grid(origin, count + 1); - } + // Every section collapses; the state is remembered for the app run. Looked up by section name - the + // fixed Built-in / My textures keys, or a pack's display name. A capability name is unique per type + // across loaded packages (PluginManager::check_registry_locked, PluginManager.cpp:211-225), and it is + // stable across reinstalls, unlike the plugin_key, which is the wheel filename stem and so carries the + // version: keying on that would silently reset the user's collapse choice on every pack upgrade. A + // function-local static lives exactly as long as the app and never touches app_config: remembered + // while the popup closes and reopens, reset on restart. + static std::map s_section_collapsed; // true = collapsed; absent = expanded - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w); - ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str()); - ImGui::PopTextWrapPos(); + // A section heading with its collapse control at the right edge of the grid: the triangle points + // down while the section is open and right while it is collapsed, and toggles it when clicked. + // Returns true when the section is open, so the caller knows whether to draw its grid. + const auto section_header = [&](const std::string &key, const std::string &title) { + bool &collapsed = s_section_collapsed[key]; // default-inserts false -> expanded + const ImVec2 row = ImGui::GetCursorScreenPos(); + const float row_h = ImGui::GetFrameHeight(); + ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + (row_h - ImGui::GetTextLineHeight()) * 0.5f)); + ImGui::TextDisabled("%s", title.c_str()); + const bool clipped = ImGui::CalcTextSize(title.c_str()).x > grid_w - row_h - spacing; + if (clipped && ImGui::IsItemHovered()) + m_imgui->tooltip(title, m_imgui->scaled(18.f)); // a long plugin-supplied name, cut by the fixed width + ImGui::SetCursorScreenPos(ImVec2(row.x + grid_w - row_h, row.y)); + ImGui::PushID(key.c_str()); + if (ImGui::ArrowButton("##collapse", collapsed ? ImGuiDir_Right : ImGuiDir_Down)) + collapsed = !collapsed; + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + row_h + ImGui::GetStyle().ItemSpacing.y)); + return !collapsed; + }; + + // One section per source: Built-in first, then one per pack, then My textures last (so the import tile + // and the help line keep describing the group they sit in). Pack entries are grouped by pack_name, + // which is the capability's get_name(): unique per capability type across loaded packages + // (PluginManager::check_registry_locked, PluginManager.cpp:211-225), so it identifies a pack both for + // grouping and for the remembered collapse state, and stands in for the section title. A section is + // identified by `builtin:`, `user:` or `pack:`, so a pack named "builtin" cannot collide with + // the fixed Built-in section. + struct Section + { + std::string key; + std::string title; + TextureSource source; + std::vector entries; + }; + std::vector
sections; + std::map pack_section; // pack_name -> index in `sections` + sections.push_back({ "builtin:", _u8L("Built-in"), TextureSource::Shipped, {} }); + for (const TextureLibraryEntry &entry : library) { + if (entry.source == TextureSource::Shipped) { + sections.front().entries.push_back(&entry); // the section exists even when the library is empty + continue; + } + if (entry.source == TextureSource::User) + continue; // added below, so My textures stays last + const auto [it, inserted] = pack_section.emplace(entry.pack_name, sections.size()); + if (inserted) + sections.push_back({ "pack:" + entry.pack_name, entry.pack_name, TextureSource::Plugin, {} }); + sections[it->second].entries.push_back(&entry); + } + sections.push_back({ "user:", _u8L("My textures"), TextureSource::User, {} }); + for (const TextureLibraryEntry &entry : library) + if (entry.source == TextureSource::User) + sections.back().entries.push_back(&entry); + + for (const Section §ion : sections) { + if (!section_header(section.key, section.title)) + continue; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const int count = grid(origin, section.entries, section.source == TextureSource::User); + if (section.source == TextureSource::User) { + ImGui::SetCursorScreenPos(tile_pos(origin, count)); + if (ImGui::Button("+##import_texture", ImVec2(tile, tile))) + import_clicked = true; + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("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."), + m_imgui->scaled(20.f)); + end_grid(origin, count + 1); + // Belongs to this section: it describes the import tile and the remove-on-hover tiles, both of + // which live here, so it is hidden with them rather than left describing controls off-screen. + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w); + ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str()); + ImGui::PopTextWrapPos(); + } else { + end_grid(origin, count); + if (section.source == TextureSource::Shipped && count == 0) + ImGui::TextDisabled("%s", _u8L("No textures found.").c_str()); + } + } if (chosen || import_clicked) ImGui::CloseCurrentPopup(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 232f1c1729..0cda80f427 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -1061,6 +1061,8 @@ private: // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures; + // Generation of the library the above were cached against; a change clears them. + uint64_t m_library_generation = 0; // Everything the *unwrap* depends on. update_uv_editor() runs from rebuild_preview(), i.e. on // every stroke end and every slider release - but depth/tiling/rotation/offset/blend change diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index 69301b42d1..0388ed4f21 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -1,6 +1,7 @@ #include "TextureLibrary.hpp" #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include +#include #include #include #include @@ -36,6 +38,9 @@ #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/I18N.hpp" +#include "slic3r/plugin/PluginFsUtils.hpp" +#include "slic3r/plugin/host/PluginResourceTextures.hpp" + namespace Slic3r::GUI { namespace { @@ -50,7 +55,7 @@ bool is_image_file(const boost::filesystem::path &path) return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp"; } -void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector &out) +void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vector &out) { boost::system::error_code ec; if (!boost::filesystem::is_directory(dir, ec)) @@ -60,12 +65,43 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vectorpath(), ec) || !is_image_file(it->path())) continue; - out.push_back({ it->path().stem().string(), it->path().string(), is_user }); + out.push_back({ it->path().stem().string(), it->path().string(), source }); } std::sort(out.begin() + first, out.end(), [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; }); } +// Appends every loaded pack's textures, ordered by the pack's display name and then by file name. Each +// entry carries the contributing pack's display name, which the picker uses to group a pack's textures +// into one section; a pack name is unique among loaded packs of this capability type, so the name both +// groups the section and keeps its entries contiguous. A pack whose directory fails validation is +// skipped, so one bad pack never hides the others. +void scan_plugin_textures(std::vector &out) +{ + std::vector found; + for (const ResourceTextureEntry &pack : PluginResourceTextures::instance().entries()) { + std::string error; + const std::vector files = + collect_resource_texture_files(pack.texture_dir, pack.plugin_root, error); + if (!error.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Texture pack '" << pack.display_name << "' was skipped: " << error; + continue; + } + for (const std::string &file : files) { + const boost::filesystem::path path(file); + found.push_back({ path.stem().string(), file, TextureSource::Plugin, pack.display_name }); + } + } + + std::stable_sort(found.begin(), found.end(), [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { + if (a.pack_name != b.pack_name) + return a.pack_name < b.pack_name; + return a.name < b.name; + }); + for (TextureLibraryEntry &entry : found) + out.push_back(std::move(entry)); +} + // True if the image carries any colour at all, i.e. some pixel's channels are not all equal. A // photo saved as RGB but actually grey should still be stored as a grayscale PNG - it is a third of // the bytes, and the colour feature keys off has_color(), so storing a grey image as RGB would offer @@ -208,6 +244,11 @@ bool is_supported_height_map(const std::vector &bytes) std::vector g_library; bool g_library_scanned = false; +// The generation g_library was last built from, so texture_library() can rescan after a pack change. +uint64_t g_library_scan_generation = 0; + +// Bumped by plugin callbacks, read by the GUI frame. Atomic so a read never races a write. +std::atomic g_library_generation{ 0 }; // 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 @@ -255,6 +296,8 @@ const size_t RECENT_MAX = 200; std::vector recent_texture_names() { std::vector names; + if (wxTheApp == nullptr) // headless or shutting down: there is no app config to read + return names; if (wxGetApp().app_config == nullptr) return names; const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY); @@ -269,6 +312,8 @@ std::vector recent_texture_names() void store_recent_texture_names(std::vector names) { + if (wxTheApp == nullptr) // headless or shutting down: there is no app config to write + return; if (wxGetApp().app_config == nullptr) return; if (names.size() > RECENT_MAX) @@ -279,8 +324,9 @@ void store_recent_texture_names(std::vector names) wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value); } -// Reorders the user's entries (the tail of `library`) by recent use. Stable, so textures never used keep -// the name order scan_dir() gave them, after every used one. +// Reorders the user's entries by recent use. Stable, so textures never used keep the name order +// scan_dir() gave them, after every used one. Only the user's own range is touched: shipped and pack +// entries must keep the order their scans produced. void sort_user_textures(std::vector &library) { const std::vector names = recent_texture_names(); @@ -291,8 +337,11 @@ void sort_user_textures(std::vector &library) const auto it = rank.find(boost::filesystem::path(e.path).filename().string()); return it == rank.end() ? std::numeric_limits::max() : it->second; }; - const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; }); - std::stable_sort(first_user, library.end(), + const auto first_user = std::find_if(library.begin(), library.end(), + [](const TextureLibraryEntry &e) { return e.source == TextureSource::User; }); + const auto user_end = std::find_if(first_user, library.end(), + [](const TextureLibraryEntry &e) { return e.source != TextureSource::User; }); + std::stable_sort(first_user, user_end, [&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); }); } @@ -311,6 +360,16 @@ bool in_user_texture_dir(const std::string &path) } // namespace +uint64_t texture_library_generation() +{ + return g_library_generation.load(); +} + +void bump_texture_library_generation() +{ + g_library_generation.fetch_add(1); +} + std::string user_texture_dir() { const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement"; @@ -325,17 +384,20 @@ std::string user_texture_dir() const std::vector &texture_library(bool force_rescan) { - if (g_library_scanned && !force_rescan) + const uint64_t generation = texture_library_generation(); + if (g_library_scanned && !force_rescan && generation == g_library_scan_generation) return g_library; g_library.clear(); - scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", false, g_library); + scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", TextureSource::Shipped, g_library); const std::string user_dir = user_texture_dir(); if (!user_dir.empty()) - scan_dir(boost::filesystem::path(user_dir), true, g_library); + scan_dir(boost::filesystem::path(user_dir), TextureSource::User, g_library); sort_user_textures(g_library); + scan_plugin_textures(g_library); - g_library_scanned = true; + g_library_scan_generation = generation; + g_library_scanned = true; return g_library; } diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp index 38ad277a84..ac4f50b671 100644 --- a/src/slic3r/GUI/TextureLibrary.hpp +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_TextureLibrary_hpp_ #define slic3r_TextureLibrary_hpp_ +#include #include #include #include @@ -10,26 +11,45 @@ namespace Slic3r::GUI { -// One selectable height-map texture in the texture-displacement gizmo's texture picker. +// Where a library texture came from. The picker lists each source under its own heading. +enum class TextureSource { Shipped, User, Plugin }; + +// One selectable height-map texture in the texture-displacement gizmo's texture picker. A texture that +// came from a plugin pack also carries the provenance the picker groups its sections by. struct TextureLibraryEntry { - std::string name; // display name (the file's stem, e.g. "Wood Grain") - std::string path; // absolute path on disk - bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer + std::string name; // display name (the file's stem, e.g. "Wood Grain") + std::string path; // absolute path on disk + TextureSource source; // which of the three sources it came from + + std::string pack_name; // contributing pack's display name; empty for Shipped and User }; -// Every available height-map texture: the ones shipped in resources/textures/displacement first, sorted -// by name, then the user's own from /textures/displacement - most recently used first (see -// touch_user_texture()), then any never used, by name. +// Every available height-map texture, in the order the picker lists them: the ones shipped in +// resources/textures/displacement first, by name; then the user's own from /textures/displacement, +// most recently used first (see touch_user_texture()) and the rest by name; then the loaded texture +// packs, ordered by pack display name and then by file name. // -// The two live in separate directories deliberately: an app update replaces the resources tree -// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or -// delete. `is_user` is what the picker uses to show them under separate headings. +// The shipped and user textures live in separate directories deliberately: an app update replaces the +// resources tree wholesale, so anything the user imported has to sit somewhere that update can never +// overwrite or delete. Pack textures are read-only like the shipped ones. Every texture carries its +// `source`; a pack texture also carries `pack_name`, and the picker groups pack textures into one +// collapsible section per pack name. A pack's name is unique among loaded packs of this capability type - +// the loader refuses a collision with "Capability collision for type ... and name ...", see +// PluginManager::check_registry_locked (PluginManager.cpp:211-225) - so the name on its own identifies a +// section and keeps that pack's entries contiguous. // // Scanned once and cached. Pass force_rescan after an import, or to pick up a file the user dropped -// into either folder by hand while the app was running. +// into either folder by hand while the app was running. A rescan also happens on its own when the pack +// registry changes, which is what texture_library_generation() tracks. const std::vector &texture_library(bool force_rescan = false); +// Monotonic counter, bumped whenever the set of loaded texture packs changes. The GUI reads it to +// decide whether texture_library() must rescan, and plugin callbacks bump it. Safe to read from any +// thread at any time. +uint64_t texture_library_generation(); +void bump_texture_library_generation(); + // Moves one of the user's own textures to the front of the recently-used order texture_library() lists // them in, and stores that order in the app config so it survives a restart. No-op for anything that is // not in user_texture_dir(). diff --git a/src/slic3r/plugin/PluginConfig.cpp b/src/slic3r/plugin/PluginConfig.cpp index 8dd377e7a9..349e70b166 100644 --- a/src/slic3r/plugin/PluginConfig.cpp +++ b/src/slic3r/plugin/PluginConfig.cpp @@ -529,6 +529,7 @@ std::string PluginConfig::capability_type_label(PluginCapabilityType type) case PluginCapabilityType::Visualization: return _u8L("Visualization"); case PluginCapabilityType::Script: return _u8L("Script"); case PluginCapabilityType::SlicingPipeline: return _u8L("Slicing Pipeline"); + case PluginCapabilityType::ResourceTexture: return _u8L("Resource Texture"); default: return _u8L("Unknown"); } } diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index e40ddd28e2..c04b15c26f 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "PluginAuditManager.hpp" @@ -1255,4 +1256,76 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat return {}; } +std::vector collect_resource_texture_files(const std::string& dir, + const std::string& plugin_root, + std::string& error) +{ + namespace fs = boost::filesystem; + + // Picker sanity bound, not a security control: the popup draws every entry in its grid. + constexpr size_t kMaxResourceTextureFiles = 64; + + std::vector files; + error.clear(); + + if (dir.empty()) { + error = _u8L("Resource texture directory is not set"); + return files; + } + if (plugin_root.empty()) { + error = _u8L("Plugin folder could not be determined."); + return files; + } + + boost::system::error_code ec; + const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec); + if (ec) { + error = _u8L("Resource texture directory could not be resolved"); + return files; + } + + if (!fs::is_directory(resolved_dir, ec) || ec) { + error = _u8L("Resource texture path is not a directory"); + return files; + } + + const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec); + if (ec) { + error = _u8L("Plugin folder could not be resolved"); + return files; + } + + if (!is_inside_allowed_root(resolved_dir, resolved_root)) { + error = _u8L("Resource texture directory is outside the plugin folder"); + return files; + } + + for (fs::directory_iterator it(resolved_dir, ec), end; it != end && !ec; it.increment(ec)) { + const fs::path& path = it->path(); + + if (!fs::is_regular_file(path, ec) || ec) + continue; + + std::string ext = path.extension().string(); + boost::algorithm::to_lower(ext); + if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".bmp") + continue; + + // Re-check per file: a symlink inside the pack must not resolve outside it. + if (!is_inside_allowed_root(path, resolved_root)) + continue; + + files.push_back(path.string()); + if (files.size() >= kMaxResourceTextureFiles) + break; + } + + if (ec) { + error = _u8L("Resource texture directory could not be read"); + return {}; + } + + return files; +} + } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index d485ac5efe..9c864ed73a 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -176,6 +176,12 @@ bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginD // Returns the path to the entry file, or an empty path with error set if zero or multiple candidates. boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error); +// Validated non-recursive .png/.jpg/.jpeg/.bmp files directly inside `dir`, every one contained in +// `plugin_root`. Empty with `error` set on failure. +std::vector collect_resource_texture_files(const std::string& dir, + const std::string& plugin_root, + std::string& error); + // Canonical writer: emits the .install_state.json schema for the given state. bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); // Builds a PluginInstallState from the descriptor and delegates to the canonical writer. diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 51f6807615..70601b3a5b 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -9,6 +9,7 @@ #include "PluginFsUtils.hpp" #include "PythonInterpreter.hpp" #include "PythonPluginBridge.hpp" +#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp" #include "libslic3r/Utils.hpp" #include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/format.hpp" @@ -387,6 +388,37 @@ bool load(const PluginDescriptor& descriptor, instance->set_config_ui_available(false); } + // ResourceTexture capabilities resolve their texture directory here, under the + // GIL this block already holds, and cache the validated result. A resolution + // fault only costs this capability its textures, so it is caught locally like + // has_config_ui() above rather than failing the load. + if (auto* resource_texture = dynamic_cast(instance.get())) { + try { + const std::string texture_dir = resource_texture->get_texture_dir(); + if (texture_dir.empty()) { + resource_texture->set_texture_dir({}); + } else { + std::string texture_error; + collect_resource_texture_files(texture_dir, descriptor.plugin_root, texture_error); + if (texture_error.empty()) { + resource_texture->set_texture_dir(texture_dir); + } else { + BOOST_LOG_TRIVIAL(warning) + << "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key + << "': texture directory rejected (" << texture_error + << "); the capability will provide no textures"; + resource_texture->set_texture_dir({}); + } + } + } catch (const std::exception& ex) { + BOOST_LOG_TRIVIAL(warning) + << "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key + << "': get_texture_dir() failed (" << ex.what() + << "); the capability will provide no textures"; + resource_texture->set_texture_dir({}); + } + } + capabilities.push_back(std::move(instance)); } } catch (const std::exception& ex) { diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 8d1a5860dd..103de8e362 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -25,6 +25,7 @@ #include "PyPluginTrampoline.hpp" #include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp" #include "pluginTypes/pages/PagesPluginCapability.hpp" +#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp" #include "pluginTypes/script/ScriptPluginCapability.hpp" #include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include @@ -374,6 +375,7 @@ void bind_python_api(pybind11::module_& m) .value("Visualization", PluginCapabilityType::Visualization) .value("Script", PluginCapabilityType::Script) .value("SlicingPipeline", PluginCapabilityType::SlicingPipeline) + .value("ResourceTexture", PluginCapabilityType::ResourceTexture) .value("Unknown", PluginCapabilityType::Unknown) .export_values(); @@ -527,6 +529,7 @@ void bind_python_api(pybind11::module_& m) PagesPluginCapability::RegisterBindings(m); ScriptPluginCapability::RegisterBindings(m); SlicingPipelinePluginCapability::RegisterBindings(m); + ResourceTexturePluginCapability::RegisterBindings(m); PluginHost::RegisterBindings(m); BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings"; diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index 2702275166..c57c9ce6b6 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -14,7 +14,7 @@ namespace Slic3r { -enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown }; +enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, ResourceTexture, Unknown }; struct PluginCapabilityId { @@ -48,6 +48,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type) case PluginCapabilityType::Visualization: return "visualization"; case PluginCapabilityType::Script: return "script"; case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline"; + case PluginCapabilityType::ResourceTexture: return "resource-texture"; default: return "unknown"; } } @@ -63,6 +64,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type case PluginCapabilityType::Visualization: return "Visualization"; case PluginCapabilityType::Script: return "Script"; case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline"; + case PluginCapabilityType::ResourceTexture: return "Resource Texture"; default: return "Unknown"; } } @@ -92,6 +94,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view return PluginCapabilityType::Script; if (lowered == "slicing-pipeline") return PluginCapabilityType::SlicingPipeline; + if (lowered == "resource-texture") + return PluginCapabilityType::ResourceTexture; return PluginCapabilityType::Unknown; } diff --git a/src/slic3r/plugin/host/PluginResourceTextures.cpp b/src/slic3r/plugin/host/PluginResourceTextures.cpp new file mode 100644 index 0000000000..07ac432cd7 --- /dev/null +++ b/src/slic3r/plugin/host/PluginResourceTextures.cpp @@ -0,0 +1,114 @@ +#include "PluginResourceTextures.hpp" + +#include "slic3r/plugin/PluginDescriptor.hpp" +#include "slic3r/plugin/PluginManager.hpp" +#include "slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +PluginResourceTextures &PluginResourceTextures::instance() +{ + static PluginResourceTextures registry; + return registry; +} + +std::optional PluginResourceTextures::build_entry(const PluginCapabilityId &id) const +{ + // Query by id and re-check state; the manager copies the shared_ptr and releases its lock. + auto capability = PluginManager::instance().get_plugin_capability(id, /*only_enabled=*/false); + if (!capability || !capability->is_enabled() || capability->type() != PluginCapabilityType::ResourceTexture) + return std::nullopt; + + auto texture_capability = std::dynamic_pointer_cast(capability); + if (!texture_capability) + return std::nullopt; + + // An empty directory means resolution failed at load, so the pack contributes nothing. + const std::string &texture_dir = texture_capability->texture_dir(); + if (texture_dir.empty()) + return std::nullopt; + + PluginDescriptor descriptor; + if (!PluginManager::instance().try_get_plugin_descriptor_for_capability(capability->name(), + PluginCapabilityType::ResourceTexture, + descriptor)) + return std::nullopt; + + ResourceTextureEntry entry; + entry.id = capability->identity(); + entry.plugin_key = entry.id.plugin_key; + entry.display_name = capability->name(); + entry.texture_dir = texture_dir; + entry.plugin_root = descriptor.plugin_root; + return entry; +} + +void PluginResourceTextures::on_plugin_register(const std::string &plugin_key) +{ + for (const auto &capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::ResourceTexture)) { + if (capability) + on_cap_register(capability->identity()); + } +} + +void PluginResourceTextures::on_plugin_deregister(const std::string &plugin_key) +{ + std::lock_guard lock(m_mutex); + for (auto it = m_entries.begin(); it != m_entries.end();) { + if (it->first.plugin_key == plugin_key) + it = m_entries.erase(it); + else + ++it; + } +} + +void PluginResourceTextures::on_cap_register(const PluginCapabilityId &id) +{ + std::optional entry = build_entry(id); + + std::lock_guard lock(m_mutex); + if (entry) + m_entries[entry->id] = std::move(*entry); +} + +void PluginResourceTextures::on_cap_deregister(const PluginCapabilityId &id) +{ + std::lock_guard lock(m_mutex); + m_entries.erase(id); +} + +void PluginResourceTextures::initial_sweep() +{ + std::map entries; + for (const auto &capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::ResourceTexture)) { + if (!capability) + continue; + std::optional entry = build_entry(capability->identity()); + if (entry) + entries[entry->id] = std::move(*entry); + } + + std::lock_guard lock(m_mutex); + m_entries = std::move(entries); +} + +std::vector PluginResourceTextures::entries() const +{ + std::lock_guard lock(m_mutex); + + std::vector result; + result.reserve(m_entries.size()); + for (const auto &entry : m_entries) + result.push_back(entry.second); + return result; +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/host/PluginResourceTextures.hpp b/src/slic3r/plugin/host/PluginResourceTextures.hpp new file mode 100644 index 0000000000..4de61e8d1d --- /dev/null +++ b/src/slic3r/plugin/host/PluginResourceTextures.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace Slic3r { + +// One ResourceTexture capability contributing a texture directory to the texture picker. +struct ResourceTextureEntry +{ + PluginCapabilityId id; + std::string plugin_key; + std::string display_name; + std::string texture_dir; + std::string plugin_root; // so a scan can re-check containment +}; + +// Tracks every loaded and enabled ResourceTexture capability by id. Holds strings only: a +// shared_ptr here would keep a Python object alive past Py_Finalize. +class PluginResourceTextures +{ +public: + static PluginResourceTextures &instance(); + + PluginResourceTextures(const PluginResourceTextures &) = delete; + PluginResourceTextures &operator=(const PluginResourceTextures &) = delete; + + void on_plugin_register(const std::string &plugin_key); + void on_plugin_deregister(const std::string &plugin_key); + void on_cap_register(const PluginCapabilityId &id); + void on_cap_deregister(const PluginCapabilityId &id); + void initial_sweep(); + + std::vector entries() const; + +private: + PluginResourceTextures() = default; + + std::optional build_entry(const PluginCapabilityId &id) const; + + mutable std::mutex m_mutex; + std::map m_entries; +}; + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp new file mode 100644 index 0000000000..155722d791 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp @@ -0,0 +1,31 @@ +#include "ResourceTexturePluginCapability.hpp" +#include "ResourceTexturePluginCapabilityTrampoline.hpp" + +#include +#include + +#include + +#include +#include + +namespace py = pybind11; + +namespace Slic3r { + +void ResourceTexturePluginCapability::RegisterBindings(pybind11::module_& module) +{ + BOOST_LOG_TRIVIAL(debug) << "Registering orca.resource_texture bindings"; + + auto resource_texture = module.def_submodule("resource_texture", "Plugin resource texture API"); + + py::class_>(resource_texture, + "ResourceTexturePluginCapabilityBase") + .def(py::init<>()) + .def("get_type", &ResourceTexturePluginCapability::get_type) + .def("get_texture_dir", &ResourceTexturePluginCapability::get_texture_dir); +} + +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp new file mode 100644 index 0000000000..2f4e2d061b --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "../../PythonPluginInterface.hpp" +#include "pybind11/pybind11.h" + +#include +#include + +namespace Slic3r { +class ResourceTexturePluginCapability : public PluginCapabilityInterface +{ +public: + static void RegisterBindings(pybind11::module_& module); + + PluginCapabilityType get_type() const override { return PluginCapabilityType::ResourceTexture; } + + // Python contract: the directory the host scans for textures. Resolved once at materialization + // under the GIL; never called from the GUI. Overridden in Python through the trampoline. + virtual std::string get_texture_dir() const = 0; + + // C++-only read of the validated directory cached by the loader; empty if resolution failed. + const std::string& texture_dir() const { return m_texture_dir; } + void set_texture_dir(std::string dir) { m_texture_dir = std::move(dir); } + +private: + std::string m_texture_dir; +}; +} // namespace Slic3r diff --git a/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp new file mode 100644 index 0000000000..fa7330ce57 --- /dev/null +++ b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "ResourceTexturePluginCapability.hpp" +#include "../../PyPluginTrampoline.hpp" +#include +#include + +#include +#include "slic3r/plugin/PythonPluginInterface.hpp" + +namespace Slic3r { + +class PyResourceTexturePluginCapabilityTrampoline : + public PyPluginCommonTrampoline +{ +public: + using PyPluginCommonTrampoline::PyPluginCommonTrampoline; + + std::string get_texture_dir() const override + { + ORCA_PY_OVERRIDE_AUDITED( + [] {}, + PYBIND11_OVERRIDE_PURE, + std::string, + ResourceTexturePluginCapability, + get_texture_dir); + } +}; + +} // namespace Slic3r diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 298610644b..ee556f8d86 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests test_printer_agent.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp + test_plugin_resource_texture.cpp test_plugin_printer_agent.cpp test_printhost.cpp test_slicing_pipeline_bindings.cpp diff --git a/tests/slic3rutils/test_plugin_resource_texture.cpp b/tests/slic3rutils/test_plugin_resource_texture.cpp new file mode 100644 index 0000000000..c0c11e4820 --- /dev/null +++ b/tests/slic3rutils/test_plugin_resource_texture.cpp @@ -0,0 +1,935 @@ +// ResourceTexture plugin type: directory validation, the host registry's lifecycle, the texture +// listing's plugin section, hostile get_texture_dir() overrides, decode parity and the .3mf round +// trip without the pack installed. +// +// The GUI wiring lives in GUI_App::init_plugin_gui_wiring; the test binary has no app, so the same +// registry callbacks are installed here. As in test_plugin_lifecycle.cpp, each Catch2 test case runs +// in its own process (catch_discover_tests), so the PluginManager and interpreter come up at most once +// per test. +// +// Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in , so keep +// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers. +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/Format/bbs_3mf.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Semver.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Utils.hpp" +#include "libslic3r/Zipper.hpp" + +#include "slic3r/GUI/TextureLibrary.hpp" +#include "slic3r/plugin/PluginFsUtils.hpp" +#include "slic3r/plugin/PluginManager.hpp" +#include "slic3r/plugin/PythonInterpreter.hpp" +#include "slic3r/plugin/PythonPluginInterface.hpp" +#include "slic3r/plugin/host/PluginResourceTextures.hpp" + +#include "plugin_test_utils.hpp" + +using namespace Slic3r; +namespace fs = boost::filesystem; + +namespace { + +// Brings the plugin system up, and tears it down explicitly at the end of the test, as +// test_plugin_lifecycle.cpp does. Declare this FIRST in a test so it is destroyed last. +struct ScopedPluginManager +{ + // Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log. + ScopedDataDir python_data_dir{"texture-python"}; + bool initialized = false; + + ScopedPluginManager() { initialized = PluginManager::instance().initialize(); } + ~ScopedPluginManager() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +// The app installs these in GUI_App::init_plugin_gui_wiring. Without an app the callbacks run inline +// on the load worker, which is what lets the tests observe the registry right after the wait. +void install_resource_texture_wiring() +{ + static bool installed = false; + if (installed) + return; + installed = true; + + PluginManager& manager = PluginManager::instance(); + manager.subscribe_on_load_callback([](const std::string& plugin_key) { + PluginResourceTextures::instance().on_plugin_register(plugin_key); + GUI::bump_texture_library_generation(); + }); + manager.subscribe_on_unload_callback([](const std::string& plugin_key) { + PluginResourceTextures::instance().on_plugin_deregister(plugin_key); + GUI::bump_texture_library_generation(); + }); + manager.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) { + if (capability.type != PluginCapabilityType::ResourceTexture) + return; + PluginResourceTextures::instance().on_cap_register(capability); + GUI::bump_texture_library_generation(); + }); + manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) { + if (capability.type != PluginCapabilityType::ResourceTexture) + return; + PluginResourceTextures::instance().on_cap_deregister(capability); + GUI::bump_texture_library_generation(); + }); +} + +// A minimal resource-texture plugin. `display_name` is the PEP723 package name and, unless +// `capability_name` is given, the capability's get_name() too; pass `capability_name` to keep the +// package name and the capability name independent. +std::string make_resource_texture_source(const std::string& display_name, + const std::string& get_texture_dir_body, + const std::string& capability_name = "") +{ + const std::string cap_name = capability_name.empty() ? display_name : capability_name; + return std::string{} + + "# /// script\n" + + "# requires-python = \">=3.12\"\n" + + "# dependencies = []\n" + + "#\n" + + "# [tool.orcaslicer.plugin]\n" + + "# name = \"" + display_name + "\"\n" + + "# description = \"Resource texture pack fixture.\"\n" + + "# author = \"tests\"\n" + + "# version = \"1.0.0\"\n" + + "# type = \"resource-texture\"\n" + + "# ///\n" + + "import os\n" + + "import orca\n" + + "\n\n" + + "class PackTextures(orca.resource_texture.ResourceTexturePluginCapabilityBase):\n" + + " def get_name(self):\n" + + " return \"" + cap_name + "\"\n" + + "\n" + + " def get_texture_dir(self):\n" + + " " + get_texture_dir_body + "\n" + + "\n\n" + + "@orca.plugin\n" + + "class TexturePack(orca.base):\n" + + " def register_capabilities(self):\n" + + " orca.register_capability(PackTextures)\n"; +} + +std::string default_texture_dir_body() +{ + return "return os.path.join(os.path.dirname(__file__), \"textures\")"; +} + +// Writes {data_dir}/orca_plugins//.py and returns the plugin directory. +fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source) +{ + const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem; + fs::create_directories(plugin_dir); + + std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary); + out << source; + out.close(); + + return plugin_dir; +} + +bool load_and_wait(PluginManager& manager, const std::string& plugin_key, std::string& error) +{ + manager.load_plugin(plugin_key, /*skip_deps=*/true); + return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error); +} + +// The Plugin-sourced slice of the texture listing, which is exactly what the picker's third heading +// draws. Pass force to call texture_library(true); without it the listing is reused unless a pack +// callback moved the generation. +std::vector plugin_textures(bool force) +{ + std::vector found; + for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(force)) + if (entry.source == GUI::TextureSource::Plugin) + found.push_back(entry); + return found; +} + +void write_text_file(const fs::path& path, const std::string& content = "x") +{ + fs::create_directories(path.parent_path()); + std::ofstream out(path.string(), std::ios::binary); + out << content; +} + +std::vector read_file_bytes(const fs::path& path) +{ + std::ifstream ifs(path.string(), std::ios::binary); + return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); +} + +void write_bytes(const fs::path& path, const std::vector& bytes) +{ + fs::create_directories(path.parent_path()); + std::ofstream out(path.string(), std::ios::binary); + out.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); +} + +void write_gray_png(const fs::path& path, uint8_t value, size_t w = 4, size_t h = 4) +{ + fs::create_directories(path.parent_path()); + const std::vector pixels(w * h, value); + REQUIRE(Slic3r::png::write_gray_to_file(path.string(), w, h, pixels)); +} + +// A 24-bit gray BMP (R == G == B), synthesised without an image toolkit. Rows are bottom-up and +// padded to four bytes; the image is uniform, so the orientation does not matter. +std::vector make_gray_bmp(int width, int height, uint8_t value) +{ + const int row_bytes = ((width * 3 + 3) / 4) * 4; + const uint32_t pixel_bytes = uint32_t(row_bytes) * uint32_t(height); + const uint32_t file_size = 14 + 40 + pixel_bytes; + + std::vector bmp(file_size, 0); + const auto put16 = [&bmp](size_t at, uint16_t v) { + bmp[at] = uint8_t(v); + bmp[at + 1] = uint8_t(v >> 8); + }; + const auto put32 = [&bmp](size_t at, uint32_t v) { + bmp[at] = uint8_t(v); + bmp[at + 1] = uint8_t(v >> 8); + bmp[at + 2] = uint8_t(v >> 16); + bmp[at + 3] = uint8_t(v >> 24); + }; + + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, file_size); + put32(10, 54); // pixel data offset + put32(14, 40); // BITMAPINFOHEADER size + put32(18, uint32_t(width)); + put32(22, uint32_t(height)); + put16(26, 1); // planes + put16(28, 24); // bits per pixel + put32(34, pixel_bytes); + + for (int y = 0; y < height; ++y) { + unsigned char* row = bmp.data() + 54 + size_t(y) * size_t(row_bytes); + for (int x = 0; x < width; ++x) { + row[x * 3] = value; + row[x * 3 + 1] = value; + row[x * 3 + 2] = value; + } + } + return bmp; +} + +// The app calls wxInitAllImageHandlers() during startup; the test binary has no app. +void ensure_wx_image_handlers() +{ + static bool initialized = false; + if (initialized) + return; + initialized = true; + wxInitAllImageHandlers(); +} + +// Stores `model` as a one-plate project, mirroring tests/libslic3r/test_3mf.cpp. +void store_project(Model& model, const std::string& path) +{ + ScopedTemporaryDir backup_dir("orca_tex_pack_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)); +} + +bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir) +{ + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs plates; + std::vector project_presets; + bool is_bbl_3mf = false; + bool is_orca_3mf = false; + Semver file_version; + const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets, + &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + release_PlateData_list(plates); + return loaded; +} + +} // namespace + +// ── Validation (collect_resource_texture_files, no Python) ────────────────────────────────────── + +TEST_CASE("A texture file directly inside the plugin folder is listed", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-root"); + write_text_file(root.path() / "textures" / "grain.png"); + + std::string error; + const std::vector files = + collect_resource_texture_files((root.path() / "textures").string(), root.string(), error); + + CHECK(error.empty()); + REQUIRE(files.size() == 1); + CHECK(fs::path(files.front()).filename() == "grain.png"); +} + +TEST_CASE("A directory reached with a parent-directory escape is refused", "[PluginResourceTexture]") +{ + ScopedTemporaryDir parent("tex-escape"); + const fs::path root = parent.path() / "pack"; + const fs::path outside = parent.path() / "outside"; + fs::create_directories(root); + write_text_file(outside / "leak.png"); + + std::string error; + const std::vector files = + collect_resource_texture_files((root / ".." / "outside").string(), root.string(), error); + + CHECK(files.empty()); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("An absolute directory outside the plugin folder is refused", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-abs-root"); + ScopedTemporaryDir elsewhere("tex-abs-elsewhere"); + write_text_file(elsewhere.path() / "leak.png"); + + std::string error; + const std::vector files = collect_resource_texture_files(elsewhere.string(), root.string(), error); + + CHECK(files.empty()); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("A symlink pointing outside the plugin folder is skipped, not reported as an error", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-symlink-root"); + ScopedTemporaryDir target("tex-symlink-target"); + write_text_file(target.path() / "secret.png"); + + const fs::path link = root.path() / "textures" / "link.png"; + fs::create_directories(link.parent_path()); + boost::system::error_code ec; + fs::create_symlink(target.path() / "secret.png", link, ec); + if (ec) + SKIP("symlinks unavailable on this filesystem: " + ec.message()); + + std::string error; + const std::vector files = + collect_resource_texture_files(link.parent_path().string(), root.string(), error); + + // The directory itself is legitimate; the escaping link is dropped, not an error. + CHECK(error.empty()); + CHECK(files.empty()); +} + +TEST_CASE("Files in a subdirectory of the texture directory are not listed", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-recursive"); + write_text_file(root.path() / "top.png"); + write_text_file(root.path() / "nested" / "deep.png"); + + std::string error; + const std::vector files = collect_resource_texture_files(root.string(), root.string(), error); + + CHECK(error.empty()); + REQUIRE(files.size() == 1); + CHECK(fs::path(files.front()).filename() == "top.png"); +} + +TEST_CASE("An entry that is not a regular file is skipped", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-nonregular"); + write_text_file(root.path() / "real.png"); + fs::create_directories(root.path() / "fake.png"); // a directory named like an image + + std::string error; + const std::vector files = collect_resource_texture_files(root.string(), root.string(), error); + + CHECK(error.empty()); + REQUIRE(files.size() == 1); + CHECK(fs::path(files.front()).filename() == "real.png"); +} + +TEST_CASE("Image extensions are matched case-insensitively and other extensions are ignored", + "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-extensions"); + for (const char* name : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" }) + write_text_file(root.path() / name); + for (const char* name : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" }) + write_text_file(root.path() / name); + + std::string error; + const std::vector files = collect_resource_texture_files(root.string(), root.string(), error); + + CHECK(error.empty()); + CHECK(files.size() == 8); + + std::vector names; + for (const std::string& file : files) + names.push_back(fs::path(file).filename().string()); + for (const char* accepted : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" }) + CHECK(std::find(names.begin(), names.end(), accepted) != names.end()); + for (const char* rejected : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" }) + CHECK(std::find(names.begin(), names.end(), rejected) == names.end()); +} + +TEST_CASE("The listing stops at 64 image files", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-cap"); + for (int i = 0; i < 70; ++i) + write_text_file(root.path() / ("file" + std::to_string(i) + ".png")); + + std::string error; + const std::vector files = collect_resource_texture_files(root.string(), root.string(), error); + + CHECK(error.empty()); + CHECK(files.size() == 64); +} + +TEST_CASE("A missing texture directory yields no files and an error", "[PluginResourceTexture]") +{ + ScopedTemporaryDir root("tex-missing"); + + std::string error; + const std::vector files = + collect_resource_texture_files((root.path() / "textures").string(), root.string(), error); + + CHECK(files.empty()); + CHECK_FALSE(error.empty()); +} + +// ── Lifecycle through the real listing ────────────────────────────────────────────────────────── + +TEST_CASE("A resource-texture pack's images appear in the listing and clear on disable and unload", + "[PluginResourceTexture][PluginLifecycle][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-listing"); + + const std::string plugin_key = "Texture_Pack"; + const fs::path plugin_dir = + write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body())); + + const fs::path textures = plugin_dir / "textures"; + write_gray_png(textures / "alpha.png", 0x20); + write_gray_png(textures / "beta.png", 0x80); + write_text_file(textures / "gamma.jpg"); // the listing only checks the extension; decode is covered below + write_text_file(textures / "notes.txt"); + + // A user-owned texture, so the listing has a non-pack entry whose provenance must be empty. + write_gray_png(fs::path(GUI::user_texture_dir()) / "user_made.png", 0x40); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, plugin_key, error)); + INFO("load error: " << error); + CHECK(error.empty()); + + CHECK(PluginResourceTextures::instance().entries().size() == 1); + + const uint64_t loaded_generation = GUI::texture_library_generation(); + const auto loaded = plugin_textures(true); + REQUIRE(loaded.size() == 3); + std::vector names; + for (const GUI::TextureLibraryEntry& entry : loaded) + names.push_back(entry.name); + CHECK(std::find(names.begin(), names.end(), "alpha") != names.end()); + CHECK(std::find(names.begin(), names.end(), "beta") != names.end()); + CHECK(std::find(names.begin(), names.end(), "gamma") != names.end()); + CHECK(std::find(names.begin(), names.end(), "notes") == names.end()); + + // Every pack entry names its contributing pack; shipped and user entries carry no pack. + for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) { + if (entry.source == GUI::TextureSource::Plugin) { + CHECK_FALSE(entry.pack_name.empty()); + CHECK(entry.pack_name == "Texture Pack"); + } else { + CHECK(entry.pack_name.empty()); + } + } + + // A no-argument call reuses the cached listing until a pack callback moves the generation. + CHECK(plugin_textures(false).size() == 3); + + manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, false); + CHECK(GUI::texture_library_generation() > loaded_generation); + CHECK(PluginResourceTextures::instance().entries().empty()); + CHECK(plugin_textures(false).empty()); + + manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, true); + CHECK(plugin_textures(false).size() == 3); + + REQUIRE(manager.unload_plugin(plugin_key)); + CHECK(PluginResourceTextures::instance().entries().empty()); + CHECK(plugin_textures(false).empty()); +} + +TEST_CASE("Two distinctly named packs each list as one contiguous run", + "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-contiguous"); + + // Loaded out of name order, so it is the listing's pack-name sort that orders the runs. + const std::string key_zeta = "Pack_Zeta"; + const std::string key_alpha = "Pack_Alpha"; + + const fs::path dir_zeta = write_plugin( + data_dir_guard, key_zeta, make_resource_texture_source("Zeta Pack", default_texture_dir_body())); + const fs::path dir_alpha = write_plugin( + data_dir_guard, key_alpha, make_resource_texture_source("Alpha Pack", default_texture_dir_body())); + + write_gray_png(dir_zeta / "textures" / "zeta_one.png", 0x20); + write_gray_png(dir_zeta / "textures" / "zeta_two.png", 0x40); + write_gray_png(dir_alpha / "textures" / "alpha_one.png", 0x60); + write_gray_png(dir_alpha / "textures" / "alpha_two.png", 0x80); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, key_zeta, error)); + REQUIRE(load_and_wait(manager, key_alpha, error)); + + std::vector pack_entries; + for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) + if (entry.source == GUI::TextureSource::Plugin) + pack_entries.push_back(entry); + REQUIRE(pack_entries.size() == 4); + + const auto count_pack = [&pack_entries](const std::string& name) { + return std::count_if(pack_entries.begin(), pack_entries.end(), + [&name](const GUI::TextureLibraryEntry& e) { return e.pack_name == name; }); + }; + CHECK(count_pack("Zeta Pack") == 2); + CHECK(count_pack("Alpha Pack") == 2); + + // Collapsing consecutive entries by pack name must leave one run per pack: a name never reappears + // after another name intervenes, which is what lets the picker draw one section per pack. + std::vector runs; + for (const GUI::TextureLibraryEntry& entry : pack_entries) + if (runs.empty() || runs.back() != entry.pack_name) + runs.push_back(entry.pack_name); + + std::vector sorted_runs = runs; + std::sort(sorted_runs.begin(), sorted_runs.end()); + CHECK(std::unique(sorted_runs.begin(), sorted_runs.end()) == sorted_runs.end()); + CHECK(runs == std::vector({ "Alpha Pack", "Zeta Pack" })); + + manager.unload_plugin(key_zeta); + manager.unload_plugin(key_alpha); +} + +TEST_CASE("A second pack declaring a loaded capability name is refused", + "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-capability-clash"); + + // Distinct package names, one capability name: the loader allows only the first. + const std::string key_first = "Clash_First"; + const std::string key_second = "Clash_Second"; + const std::string capability = "Shared Textures"; + + const fs::path dir_first = write_plugin( + data_dir_guard, key_first, + make_resource_texture_source("Clash Package One", default_texture_dir_body(), capability)); + write_plugin(data_dir_guard, key_second, + make_resource_texture_source("Clash Package Two", default_texture_dir_body(), capability)); + + write_gray_png(dir_first / "textures" / "one.png", 0x20); + write_gray_png(dir_first / "textures" / "two.png", 0x40); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, key_first, error)); + REQUIRE(manager.is_plugin_loaded(key_first)); + + const std::vector before = plugin_textures(true); + REQUIRE(before.size() == 2); + for (const GUI::TextureLibraryEntry& entry : before) + CHECK(entry.pack_name == capability); + + // The second package claims the capability name the first already owns, so the load is refused. + std::string second_error; + CHECK_FALSE(load_and_wait(manager, key_second, second_error)); + CHECK_FALSE(manager.is_plugin_loaded(key_second)); + + // The first pack's textures are untouched by the refused load. + const std::vector after = plugin_textures(true); + REQUIRE(after.size() == before.size()); + for (size_t i = 0; i < before.size(); ++i) { + CHECK(after[i].name == before[i].name); + CHECK(after[i].path == before[i].path); + CHECK(after[i].pack_name == before[i].pack_name); + } + + manager.unload_plugin(key_first); +} + +TEST_CASE("A resource-texture pack with no images contributes no textures", "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-empty"); + + const std::string plugin_key = "Empty_Pack"; + const fs::path plugin_dir = + write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Empty Pack", default_texture_dir_body())); + fs::create_directories(plugin_dir / "textures"); // valid, but empty + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, plugin_key, error)); + CHECK(manager.is_plugin_loaded(plugin_key)); + CHECK(plugin_textures(true).empty()); + + manager.unload_plugin(plugin_key); +} + +// ── Hostile get_texture_dir() overrides ───────────────────────────────────────────────────────── + +TEST_CASE("A texture directory outside the plugin root contributes no textures", "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-hostile"); + ScopedResourcesDir resources_dir_guard("texture-hostile-res"); + write_text_file(fs::path(resources_dir_guard.dir) / "sentinel.png"); // would list if it were scanned + + const std::string data_pack = "Data_Dir_Pack"; + write_plugin(data_dir_guard, data_pack, + make_resource_texture_source("Data Dir Pack", "return r\"" + data_dir_guard.dir.string() + "\"")); + const std::string resources_pack = "Resources_Dir_Pack"; + write_plugin(data_dir_guard, resources_pack, + make_resource_texture_source("Resources Dir Pack", "return r\"" + resources_dir_guard.dir.string() + "\"")); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, data_pack, error)); + REQUIRE(load_and_wait(manager, resources_pack, error)); + + // The packs load; their out-of-root directories are simply refused. + CHECK(manager.is_plugin_loaded(data_pack)); + CHECK(manager.is_plugin_loaded(resources_pack)); + CHECK(PluginResourceTextures::instance().entries().empty()); + CHECK(plugin_textures(true).empty()); + + manager.unload_plugin(data_pack); + manager.unload_plugin(resources_pack); +} + +TEST_CASE("A raising get_texture_dir leaves the pack loaded with no textures", "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-raising"); + + const std::string plugin_key = "Raising_Pack"; + write_plugin(data_dir_guard, plugin_key, + make_resource_texture_source("Raising Pack", "raise RuntimeError(\"no textures today\")")); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, plugin_key, error)); + CHECK(error.empty()); + CHECK(manager.is_plugin_loaded(plugin_key)); + CHECK(PluginResourceTextures::instance().entries().empty()); + CHECK(plugin_textures(true).empty()); + + manager.unload_plugin(plugin_key); +} + +// ── Decoding parity ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("Pack images decode through the same path as hand-picked files", "[PluginResourceTexture]") +{ + ensure_wx_image_handlers(); + + ScopedTemporaryDir pack_root("tex-pack"); + ScopedTemporaryDir hand_root("tex-hand"); + const fs::path pack_dir = pack_root.path() / "textures"; + fs::create_directories(pack_dir); + + // PNG: grayscale, so load_texture_image_data passes it through verbatim. + const fs::path pack_png = pack_dir / "grain.png"; + write_gray_png(pack_png, 0x40); + const std::vector png_bytes = read_file_bytes(pack_png); + REQUIRE_FALSE(png_bytes.empty()); + write_bytes(hand_root.path() / "grain.png", png_bytes); + + // BMP: not a PNG, so it goes through wxImage and is re-encoded; the same file must give the same + // bytes wherever it sits. + const std::vector bmp_bytes = make_gray_bmp(4, 4, 0x40); + const fs::path pack_bmp = pack_dir / "bump.bmp"; + write_bytes(pack_bmp, bmp_bytes); + const fs::path hand_bmp = hand_root.path() / "bump.bmp"; + write_bytes(hand_bmp, bmp_bytes); + + // The collector lists both pack files, so these are the paths a pack would serve. + std::string collect_error; + const std::vector pack_files = + collect_resource_texture_files(pack_dir.string(), pack_root.string(), collect_error); + REQUIRE(collect_error.empty()); + CHECK(pack_files.size() == 2); + + std::string pack_error; + std::string hand_error; + + const auto pack_png_decoded = GUI::load_texture_image_data(pack_png.string(), pack_error); + const auto hand_png_decoded = GUI::load_texture_image_data((hand_root.path() / "grain.png").string(), hand_error); + REQUIRE(pack_png_decoded != nullptr); + REQUIRE(hand_png_decoded != nullptr); + CHECK(*pack_png_decoded == png_bytes); // supported PNG is passed through unchanged + CHECK(*pack_png_decoded == *hand_png_decoded); + + const auto pack_bmp_decoded = GUI::load_texture_image_data(pack_bmp.string(), pack_error); + const auto hand_bmp_decoded = GUI::load_texture_image_data(hand_bmp.string(), hand_error); + REQUIRE(pack_bmp_decoded != nullptr); + REQUIRE(hand_bmp_decoded != nullptr); + REQUIRE_FALSE(pack_bmp_decoded->empty()); + CHECK(*pack_bmp_decoded == *hand_bmp_decoded); +} + +TEST_CASE("Reinstalling a pack with different bytes at the same path changes the decoded content", + "[PluginResourceTexture][Python]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-reinstall"); + + const std::string plugin_key = "Reinstall_Pack"; + const fs::path plugin_dir = + write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Reinstall Pack", default_texture_dir_body())); + const fs::path texture = plugin_dir / "textures" / "grain.png"; + write_gray_png(texture, 0x10); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, plugin_key, error)); + + auto loaded = plugin_textures(true); + REQUIRE(loaded.size() == 1); + const std::string path = loaded.front().path; + + std::string decode_error; + const auto first = GUI::load_texture_image_data(path, decode_error); + REQUIRE(first != nullptr); + + REQUIRE(manager.unload_plugin(plugin_key)); + + // The reinstalled pack writes different bytes to the same file path. + write_gray_png(texture, 0xE0); + REQUIRE(load_and_wait(manager, plugin_key, error)); + + loaded = plugin_textures(true); + REQUIRE(loaded.size() == 1); + CHECK(loaded.front().path == path); + + const auto second = GUI::load_texture_image_data(path, decode_error); + REQUIRE(second != nullptr); + CHECK(*first != *second); +} + +// ── Wheel metadata ────────────────────────────────────────────────────────────────────────────── + +TEST_CASE("A minimal resource-texture wheel is accepted", "[PluginResourceTexture][Wheel]") +{ + ScopedTemporaryDir dir("texture-wheel"); + const fs::path wheel = dir.path() / "texture_pack-1.0-py3-none-any.whl"; + + const std::string dist_info = "texture_pack-1.0.dist-info"; + const std::string metadata = + "Metadata-Version: 2.1\n" + "Name: texture_pack\n" + "Version: 1.0\n" + "Summary: Resource texture pack fixture\n" + "Author: tests\n"; + const std::string wheel_metadata = + "Wheel-Version: 1.0\n" + "Generator: manual\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n"; + const std::string record = dist_info + "/METADATA,,\n" + dist_info + "/WHEEL,,\n" + dist_info + "/RECORD,,\n"; + const std::string top_level = "texture_pack\n"; + + { + Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION); + zipper.add_entry(dist_info + "/METADATA", metadata.data(), metadata.size()); + zipper.add_entry(dist_info + "/WHEEL", wheel_metadata.data(), wheel_metadata.size()); + zipper.add_entry(dist_info + "/RECORD", record.data(), record.size()); + // read_wheel_plugin_metadata leaves `error` set when an optional top_level.txt is missing. + zipper.add_entry(dist_info + "/top_level.txt", top_level.data(), top_level.size()); + zipper.finalize(); + } + + PluginDescriptor descriptor; + std::string error; + REQUIRE(read_wheel_plugin_metadata(wheel, descriptor, error)); + INFO("wheel metadata error: " << error); + CHECK(error.empty()); + CHECK(descriptor.name == "texture_pack"); + CHECK(descriptor.version == "1.0"); + CHECK(descriptor.entry_package == "texture_pack"); +} + +// ── .3mf round trip ───────────────────────────────────────────────────────────────────────────── + +TEST_CASE("A project keeps a pack texture after the pack is uninstalled", + "[PluginResourceTexture][PluginLifecycle][Python][3mf]") +{ + ScopedPluginManager plugin_system; + if (!plugin_system.initialized) + SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); + + ScopedDataDir data_dir_guard("texture-3mf"); + + const std::string plugin_key = "Texture_Pack_3mf"; + const fs::path plugin_dir = + write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body())); + write_gray_png(plugin_dir / "textures" / "grain.png", 0x60); + + PluginManager& manager = PluginManager::instance(); + install_resource_texture_wiring(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + std::string error; + REQUIRE(load_and_wait(manager, plugin_key, error)); + + const auto loaded = plugin_textures(true); + REQUIRE(loaded.size() == 1); + + std::string decode_error; + const auto pack_bytes = GUI::load_texture_image_data(loaded.front().path, decode_error); + REQUIRE(pack_bytes != nullptr); + REQUIRE_FALSE(pack_bytes->empty()); + + // Build a project the way the gizmo would: the layer carries the pack texture's bytes. + Model model; + ModelObject* object = model.add_object(); + ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.)); + object->add_instance(); + TextureDisplacementLayer layer; + layer.slot = 0; + layer.name = loaded.front().name; + layer.path = loaded.front().path; + layer.image_data = pack_bytes; + volume->texture_displacement_layers.push_back(layer); + + ScopedTemporaryFile first(".3mf"); + store_project(model, first.string()); + + // Uninstall the pack; the project still has to open and re-save with the same image bytes. + REQUIRE(manager.unload_plugin(plugin_key)); + CHECK(plugin_textures(true).empty()); + + ScopedTemporaryDir backup_dir("orca_tex_pack_dst"); + Model reloaded; + REQUIRE(load_project(first.string(), reloaded, backup_dir)); + REQUIRE(reloaded.objects.size() == 1); + REQUIRE(reloaded.objects.front()->volumes.size() == 1); + const std::vector& reloaded_layers = + reloaded.objects.front()->volumes.front()->texture_displacement_layers; + REQUIRE(reloaded_layers.size() == 1); + REQUIRE(reloaded_layers.front().image_data); + CHECK(*reloaded_layers.front().image_data == *pack_bytes); + + ScopedTemporaryFile second(".3mf"); + store_project(reloaded, second.string()); + + ScopedTemporaryDir backup_dir2("orca_tex_pack_dst2"); + Model reloaded_again; + REQUIRE(load_project(second.string(), reloaded_again, backup_dir2)); + REQUIRE(reloaded_again.objects.size() == 1); + REQUIRE(reloaded_again.objects.front()->volumes.size() == 1); + const std::vector& again_layers = + reloaded_again.objects.front()->volumes.front()->texture_displacement_layers; + REQUIRE(again_layers.size() == 1); + REQUIRE(again_layers.front().image_data); + CHECK(*again_layers.front().image_data == *pack_bytes); +} From 70e52a105f48caf49a6440a7b6358fbae3b650e3 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 14 Oct 2026 17:38:52 +0800 Subject: [PATCH 02/21] Fix clang tidy --- src/slic3r/plugin/host/PluginResourceTextures.cpp | 1 + .../resourceTexture/ResourceTexturePluginCapability.cpp | 1 + tests/slic3rutils/test_plugin_resource_texture.cpp | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/slic3r/plugin/host/PluginResourceTextures.cpp b/src/slic3r/plugin/host/PluginResourceTextures.cpp index 07ac432cd7..6a5c29d2cf 100644 --- a/src/slic3r/plugin/host/PluginResourceTextures.cpp +++ b/src/slic3r/plugin/host/PluginResourceTextures.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include diff --git a/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp index 155722d791..ce58708842 100644 --- a/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace py = pybind11; diff --git a/tests/slic3rutils/test_plugin_resource_texture.cpp b/tests/slic3rutils/test_plugin_resource_texture.cpp index c0c11e4820..efe7cfbb86 100644 --- a/tests/slic3rutils/test_plugin_resource_texture.cpp +++ b/tests/slic3rutils/test_plugin_resource_texture.cpp @@ -9,6 +9,8 @@ // // Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in , so keep // WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers. +#include "test_utils.hpp" +#include #ifdef WIN32 #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN From 4cf16febf68a24128b4a9b75a83e4930f127151b Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 14 Oct 2026 18:23:32 +0800 Subject: [PATCH 03/21] Fix plugin unit test --- tests/slic3rutils/test_plugin_resource_texture.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/slic3rutils/test_plugin_resource_texture.cpp b/tests/slic3rutils/test_plugin_resource_texture.cpp index efe7cfbb86..c57a39b5e1 100644 --- a/tests/slic3rutils/test_plugin_resource_texture.cpp +++ b/tests/slic3rutils/test_plugin_resource_texture.cpp @@ -258,13 +258,17 @@ std::vector make_gray_bmp(int width, int height, uint8_t value) return bmp; } -// The app calls wxInitAllImageHandlers() during startup; the test binary has no app. +// The app calls wxInitAllImageHandlers() during startup and, before that, wxEntryStart() runs the +// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and +// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or +// a .bmp texture cannot be loaded here at all. void ensure_wx_image_handlers() { static bool initialized = false; if (initialized) return; initialized = true; + wxImage::InitStandardHandlers(); wxInitAllImageHandlers(); } From 92b5301bf6c430e95bd6caecc7058d567f73ea04 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 15 Oct 2026 15:34:05 +0800 Subject: [PATCH 04/21] Fix trackpad scroll bounce in ImGui panels --- src/slic3r/GUI/ImGuiWrapper.cpp | 8 ++-- tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_imgui_wrapper.cpp | 61 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/slic3rutils/test_imgui_wrapper.cpp diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index 7def86aaeb..9ce8d79e4d 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -505,10 +505,12 @@ bool ImGuiWrapper::update_mouse_data(wxMouseEvent& evt) io.MouseDown[0] = evt.LeftIsDown(); io.MouseDown[1] = evt.RightIsDown(); io.MouseDown[2] = evt.MiddleIsDown(); - float wheel_delta = static_cast(evt.GetWheelDelta()); - if (wheel_delta != 0.0f) { + // Only a vertical wheel event with a direction is a wheel step. A macOS trackpad reports sideways motion + // as separate horizontal events, and its wheel events often carry a rotation of 0: wx truncates the + // sub-pixel deltas to whole ones and passes on events with no delta at all. Read as vertical steps, + // either kind scrolls against the gesture, and scrolling up bounces back and forth. + if (evt.GetWheelAxis() == wxMOUSE_WHEEL_VERTICAL && evt.GetWheelRotation() != 0) io.MouseWheel = evt.GetWheelRotation() > 0 ? 1.f : -1.f; - } unsigned buttons = (evt.LeftIsDown() ? 1 : 0) | (evt.RightIsDown() ? 2 : 0) | (evt.MiddleIsDown() ? 4 : 0); m_mouse_buttons = buttons; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index ee556f8d86..e740ce95f0 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(${_TEST_NAME}_tests test_texture_color_palette.cpp test_texture_island_view.cpp test_file_url.cpp + test_imgui_wrapper.cpp test_user_manager.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_imgui_wrapper.cpp b/tests/slic3rutils/test_imgui_wrapper.cpp new file mode 100644 index 0000000000..f24e371cd2 --- /dev/null +++ b/tests/slic3rutils/test_imgui_wrapper.cpp @@ -0,0 +1,61 @@ +// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives 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 +#include + +#include +#include + +#include "slic3r/GUI/ImGuiWrapper.hpp" + +using Catch::Matchers::WithinAbs; +using Slic3r::GUI::ImGuiWrapper; + +namespace { + +// ImGui's wheel after one wheel event, as wx on macOS builds it: a delta of 10 and the rotation in whole +// points of trackpad travel. +float wheel_after(int rotation, wxMouseWheelAxis axis) +{ + ImGuiWrapper imgui; + imgui.set_display_size(800.f, 600.f); // update_mouse_data() ignores input until the display has a size + + wxMouseEvent evt(wxEVT_MOUSEWHEEL); + evt.m_wheelRotation = rotation; + evt.m_wheelDelta = 10; + evt.m_wheelAxis = axis; + imgui.update_mouse_data(evt); + return ImGui::GetIO().MouseWheel; +} + +} // namespace + +TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]") +{ + const int rotation = GENERATE(1, 7, 120, -1, -7, -120); + CHECK_THAT(wheel_after(rotation, wxMOUSE_WHEEL_VERTICAL), WithinAbs(rotation > 0 ? 1.0 : -1.0, 0.0)); +} + +// Both kinds arrive from a macOS trackpad while scrolling; see ImGuiWrapper::update_mouse_data(). +TEST_CASE("a wheel event with no rotation or on the horizontal axis does not step ImGui's wheel", "[ImGuiWrapper][Regression]") +{ + const auto [rotation, axis] = GENERATE(table({ + { 0, wxMOUSE_WHEEL_VERTICAL }, + { 5, wxMOUSE_WHEEL_HORIZONTAL }, + { -5, wxMOUSE_WHEEL_HORIZONTAL }, + })); + CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0)); +} From 79ed246d265eb137700627a34d05cfd37b63cbb5 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Thu, 15 Oct 2026 15:34:05 +0800 Subject: [PATCH 05/21] Refresh the texture picker's look and scrolling --- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 323 +++++++++++------- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 17 +- 2 files changed, 215 insertions(+), 125 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index c826a8b369..338ec80ffb 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -98,6 +98,43 @@ namespace { // bigger jump than the whole useful region. constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic); +// The scrollbar of the panel's scrolling areas (the panel body and the texture library). ImGui's stock +// scrollbar is a wide, square-cornered slab in a tinted track - against the flat panel it reads as a raw +// widget bolted onto the edge - so it is slimmed to a rounded thumb over an invisible track. The child +// background goes transparent with it, so the area reads as part of the panel. +void push_slim_scrollbar_style(float width, bool dark) +{ + const ImVec4 grab = dark ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, width); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * width); + ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f)); +} + +void pop_slim_scrollbar_style() +{ + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(2); +} + +// Cuts a UTF-8 string down to `max_w` pixels, ending it in "..." when it had to be cut. +std::string ellipsize(std::string s, float max_w) +{ + if (ImGui::CalcTextSize(s.c_str()).x <= max_w) + return s; + while (!s.empty() && ImGui::CalcTextSize((s + "...").c_str()).x > max_w) { + unsigned char c; + do { // a whole code point at a time + c = static_cast(s.back()); + s.pop_back(); + } while (!s.empty() && (c & 0xC0) == 0x80); + } + return s + "..."; +} + // Uploads decoded 8-bit grayscale height data as an RGBA GPU texture for display in the panel. // Shared by the per-layer thumbnail and the picker's library entries, which differ only in where // their pixels came from. @@ -2920,8 +2957,9 @@ void GLGizmoTextureDisplacement::run_uv_command(int cmd, float value) } else if (cmd == int(Command::PickTexture)) { // The same texture library the layer card opens; the panel renders it this frame. - m_picker_slot = m_active_layer_slot; - m_picker_open_request = true; + m_picker_slot = m_active_layer_slot; + m_picker_open_request = true; + m_picker_reveal_selected = true; } else if (cmd == int(Command::Unwrap)) { // The solve runs only on this - painting, the seam angle and seam marking never trigger it. m_uv_unwrap_pending = true; @@ -4130,6 +4168,40 @@ void GLGizmoTextureDisplacement::import_custom_texture(TextureDisplacementLayer set_layer_texture(layer, *entry); } +void GLGizmoTextureDisplacement::ease_wheel_scroll(float &target, float &applied) +{ + ImGuiIO &io = ImGui::GetIO(); + const float scroll_now = ImGui::GetScrollY(); + const float scroll_max = ImGui::GetScrollMaxY(); + + // Anything that moved the scroll without us - dragging the grab, a keyboard/gamepad nav step, the + // content shrinking under a clamped offset, a SetScroll*() call - has to re-seed the target, or the + // easing below would immediately drag the view back to where it last animated to. + if (applied < 0.f || std::abs(scroll_now - applied) > 0.5f) + target = scroll_now; + + if (io.MouseWheel != 0.f && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) + target -= io.MouseWheel * ImGui::GetFontSize() * 4.f; + // Whole pixels: ImGui floors whatever SetScrollY() is given, so a fractional target could never be + // reached and the "still gliding" test below would stay true forever, repainting the canvas for good. + target = std::floor(std::clamp(target, 0.f, scroll_max)); + + const float delta = target - scroll_now; + if (std::abs(delta) >= 1.f) { + // Exponential ease, formulated against the frame time so the glide takes the same wall time + // whether the canvas is running at 30 or 144 fps. The last sub-pixel step would be floored + // away, so land on the target outright once the remainder is that small. + const float t = 1.f - std::exp(-20.f * std::clamp(io.DeltaTime, 1.f / 240.f, 1.f / 15.f)); + const float step = (std::abs(delta * t) < 1.f) ? delta : delta * t; + const float next = std::floor(scroll_now + step); + ImGui::SetScrollY(next); + applied = next; + m_parent.set_as_dirty(); // nothing else would redraw mid-glide once the mouse stops + } else { + applied = scroll_now; + } +} + void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max) { ModelVolume *mv = texture_volume(); @@ -4146,37 +4218,35 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane ImGui::OpenPopup(popup_id); } - const float tile = std::round(m_imgui->scaled(3.2f)); - const float spacing = std::round(m_imgui->scaled(0.4f)); - const float grid_w = 4.f * tile + 3.f * spacing; - const float gap = m_imgui->scaled(0.5f); - const ImGuiStyle &style = ImGui::GetStyle(); + const float tile = std::round(m_imgui->scaled(3.2f)); + const float spacing = std::round(m_imgui->scaled(0.4f)); + const float grid_w = 4.f * tile + 3.f * spacing; + const float gap = m_imgui->scaled(0.5f); + const float scrollbar_w = m_imgui->scaled(0.5f); + const float ring_w = std::max(2.f, std::round(m_imgui->scaled(0.1f))); // the current texture's ring + const float frame_h = ImGui::GetFrameHeight(); + const ImVec4 orca = ImGuiWrapper::COL_ORCA; + const ImGuiStyle &style = ImGui::GetStyle(); - // A fixed size rather than a fit-to-content one. The width is the four-column grid plus the window - // padding and the scrollbar gutter, so the columns always fit and no horizontal scrollbar is needed. - // The height is a share of the window, not of the content, so it does not jump as packs load or - // sections collapse; the width derives from scaled values, so it tracks the DPI like the rest. - const float fixed_w = grid_w + 2.f * style.WindowPadding.x + style.ScrollbarSize; + // A fixed size rather than a fit-to-content one. The width is the four-column grid, a gutter and the + // slim scrollbar beside it, plus the window padding, so the columns always fit and no horizontal + // scrollbar is needed; it derives from scaled values, so it tracks the DPI like the rest. + const float fixed_w = grid_w + spacing + scrollbar_w + 2.f * style.WindowPadding.x; // BeginPopup() ORs in ImGuiWindowFlags_AlwaysAutoResize (deps_src/imgui/imgui.cpp:8371) and no flag // turns it back off. A SetNextWindowSize() overrides it for each axis it sets (imgui.cpp:5983-5996), - // so an explicit size is the only way to fix the popup's size; the window then scrolls its content. + // so an explicit size is the only way to fix the popup's size; the grid child below then scrolls. // - // About 70% of the window, fixed for a given window, shortened only enough to keep a gap above and - // below, and floored at two frame heights so a very short window still shows something usable. - const ImGuiViewport *viewport = ImGui::GetMainViewport(); - const float height = std::max(std::min(0.70f * viewport->Size.y, viewport->Size.y - 2.f * gap), - 2.f * ImGui::GetFrameHeight()); - - // A 70%-tall popup cannot hang off the panel's top: anchored there, the side it grows to holds only - // about half the window. It uses the whole column beside the panel instead, filling from the top or - // the bottom of the viewport depending on which side of the panel's top has more room. - const float popup_top = panel_min.y + m_imgui->scaled(4.f); - const float space_below = viewport->Pos.y + viewport->Size.y - popup_top - gap; - const float space_above = popup_top - viewport->Pos.y - gap; - const bool grow_down = space_below >= space_above; - const float popup_y = grow_down ? viewport->Pos.y + gap - : viewport->Pos.y + viewport->Size.y - gap - height; + // Top-aligned with the panel, which keeps it clear of the gizmo toolbar above a docked panel, and as + // tall as the panel or 70% of the window, whichever is taller, while keeping a gap inside the window. + // The height follows the window and the panel, never the content, so it does not jump as packs load or + // sections collapse. + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + const float vp_top = viewport->Pos.y + gap; + const float vp_bottom = viewport->Pos.y + viewport->Size.y - gap; + const float height = std::max(std::min(std::max(0.70f * viewport->Size.y, panel_max.y - panel_min.y), vp_bottom - vp_top), + 2.f * frame_h); + const float popup_y = std::max(vp_top, std::min(panel_min.y, vp_bottom - height)); // Beside the panel rather than over it, so the layer being retextured stays in view: to its left when // there is room (the docked panel sits against the right edge of the canvas), otherwise to its right. const float width = viewport->Size.x > 0.f ? std::min(fixed_w, viewport->Size.x) : fixed_w; @@ -4185,7 +4255,18 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane // Cond_Always, not Appearing: a viewport resize while the popup is open must move and resize it too. ImGui::SetNextWindowPos(ImVec2(popup_x, popup_y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always); - if (!ImGui::BeginPopup(popup_id)) + // Drawn as the panel's own sheet: its opaque background and rounding, no border. A popup is filled with + // PopupBg, which the panel's toolbar style leaves at ImGui's translucent near-black - a dark window + // beside a light panel, with whatever it covers showing through. Only Begin reads these, so they are + // popped at once and the tooltips shown from inside keep their own look. + ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, style.WindowRounding); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.f); + // The window itself never scrolls; the grid child below does. + const bool open = ImGui::BeginPopup(popup_id, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + if (!open) return; if (layer == nullptr) { ImGui::CloseCurrentPopup(); @@ -4201,9 +4282,29 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x, m_tooltip_anchor_min.y + ImGui::GetWindowSize().y); - m_imgui->push_bold_font(); - m_imgui->text(_L("Choose a texture")); - m_imgui->pop_bold_font(); + // The header stays put while the grid scrolls under it: the title says what the popup is for, and the + // close button is there for anyone who does not know that a click outside closes it. + bool close_clicked = false; + { + ImDrawList *hdl = ImGui::GetWindowDrawList(); + const ImVec2 row = ImGui::GetCursorScreenPos(); + ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + 0.5f * (frame_h - ImGui::GetTextLineHeight()))); + m_imgui->bold_text(_u8L("Choose a texture")); + const ImVec2 btn(ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x - frame_h, row.y); + ImGui::SetCursorScreenPos(btn); + close_clicked = ImGui::InvisibleButton("##close", ImVec2(frame_h, frame_h)); + const bool hot = ImGui::IsItemHovered(); + const ImVec2 c(btn.x + 0.5f * frame_h, btn.y + 0.5f * frame_h); + const float k = std::round(0.18f * frame_h); + const float line_w = std::max(1.f, std::round(m_imgui->scaled(0.08f))); + const ImU32 x_col = ImGui::GetColorU32(ImGuiCol_Text, hot ? 1.f : 0.6f); + if (hot) + hdl->AddCircleFilled(c, 0.5f * frame_h, ImGui::GetColorU32(ImGuiCol_Text, 0.08f)); + hdl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), x_col, line_w); + hdl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), x_col, line_w); + ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + frame_h + style.ItemSpacing.y)); + ImGui::Separator(); + } const std::vector &library = texture_library(); // A reinstalled pack can drop new bytes at a path already cached on this gizmo, and get_library_texture() @@ -4212,11 +4313,17 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane m_library_textures.clear(); m_library_generation = generation; } - ImDrawList *dl = ImGui::GetWindowDrawList(); - const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA); - std::optional chosen; - std::optional to_remove; - bool import_clicked = false; + + // The grid scrolls as the panel body does: the same slim scrollbar, and the wheel eased by + // ease_wheel_scroll() instead of ImGui's five-line jumps (hence NoScrollWithMouse). + push_slim_scrollbar_style(scrollbar_w, wxGetApp().dark_mode()); + ImGui::BeginChild("##texture_grid", ImVec2(0.f, 0.f), false, ImGuiWindowFlags_NoScrollWithMouse); + ease_wheel_scroll(m_picker_scroll_target, m_picker_scroll_applied); + + ImDrawList *dl = ImGui::GetWindowDrawList(); // the child's, so tiles clip to the grid + std::optional chosen; + std::optional to_remove; + bool import_clicked = false; // Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile // overlays a remove button on its corner, and that button would otherwise be "the previous item" the @@ -4260,13 +4367,23 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane } if (removable) ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks - const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); - if (entry->path == layer->path) - dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f); - if (ImGui::IsItemHovered()) + const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); + const bool current = entry->path == layer->path; + const bool hovered = ImGui::IsItemHovered(); + // The layer's current texture carries an accent ring, and the tile under the pointer a thinner, + // fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them. + if (current || hovered) { + const float w = current ? ring_w : std::round(0.5f * ring_w); + dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w), + ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w); + } + // Centred, as far as the scroll range allows, so the textures around it are in view too. + if (current && m_picker_reveal_selected) + ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f); + if (hovered) panel_tooltip(entry->name); - // Clipped by the window's own clip rect, so a tile scrolled under the heading or the scrollbar + // Clipped by the grid's clip rect, so a tile scrolled out of the grid or under its scrollbar // does not offer its remove button. IsMouseHoveringRect() clips by default. if (removable && ImGui::IsMouseHoveringRect(a, b)) { const float r = std::round(tile * 0.17f); @@ -4299,24 +4416,40 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane // while the popup closes and reopens, reset on restart. static std::map s_section_collapsed; // true = collapsed; absent = expanded - // A section heading with its collapse control at the right edge of the grid: the triangle points - // down while the section is open and right while it is collapsed, and toggles it when clicked. + // A section heading, one row as wide as the grid: a click anywhere on it collapses or expands the + // section. The chevron takes the accent of the panel's More settings link, and the count at the right + // says what a collapsed section holds. The grid follows closer than the next heading does, so each + // heading reads as part of its own grid. // Returns true when the section is open, so the caller knows whether to draw its grid. - const auto section_header = [&](const std::string &key, const std::string &title) { + const auto section_header = [&](const std::string &key, const std::string &title, size_t count) { bool &collapsed = s_section_collapsed[key]; // default-inserts false -> expanded const ImVec2 row = ImGui::GetCursorScreenPos(); - const float row_h = ImGui::GetFrameHeight(); - ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + (row_h - ImGui::GetTextLineHeight()) * 0.5f)); - ImGui::TextDisabled("%s", title.c_str()); - const bool clipped = ImGui::CalcTextSize(title.c_str()).x > grid_w - row_h - spacing; - if (clipped && ImGui::IsItemHovered()) - panel_tooltip(title); // a long plugin-supplied name, cut by the fixed width - ImGui::SetCursorScreenPos(ImVec2(row.x + grid_w - row_h, row.y)); - ImGui::PushID(key.c_str()); - if (ImGui::ArrowButton("##collapse", collapsed ? ImGuiDir_Right : ImGuiDir_Down)) + const ImVec2 row_max(row.x + grid_w, row.y + frame_h); + if (ImGui::InvisibleButton(key.c_str(), ImVec2(grid_w, frame_h))) collapsed = !collapsed; - ImGui::PopID(); - ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + row_h + ImGui::GetStyle().ItemSpacing.y)); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) + dl->AddRectFilled(row, row_max, ImGui::GetColorU32(ImGuiCol_Text, 0.05f), style.FrameRounding); + + const float inset = std::round(0.25f * frame_h); + const float arrow = std::round(ImGui::GetFontSize() * 0.55f); + const float cx = row.x + inset + 0.5f * arrow, cy = row.y + 0.5f * frame_h, hs = 0.3f * arrow; + const ImU32 accent = ImGui::GetColorU32(orca); + if (collapsed) + dl->AddTriangleFilled(ImVec2(cx - 0.5f * hs, cy - hs), ImVec2(cx + 0.5f * hs, cy), ImVec2(cx - 0.5f * hs, cy + hs), accent); + else + dl->AddTriangleFilled(ImVec2(cx - hs, cy - 0.5f * hs), ImVec2(cx + hs, cy - 0.5f * hs), ImVec2(cx, cy + 0.5f * hs), accent); + + const std::string n = std::to_string(count); + const float n_w = ImGui::CalcTextSize(n.c_str()).x; + const float ty = row.y + 0.5f * (frame_h - ImGui::GetTextLineHeight()); + const float tx = row.x + inset + arrow + spacing; + const std::string shown = ellipsize(title, std::max(0.f, row_max.x - inset - n_w - spacing - tx)); + dl->AddText(ImVec2(tx, ty), ImGui::GetColorU32(ImGuiCol_Text), shown.c_str()); + dl->AddText(ImVec2(row_max.x - inset - n_w, ty), ImGui::GetColorU32(ImGuiCol_TextDisabled), n.c_str()); + if (hovered && shown != title) + panel_tooltip(title); // a long plugin-supplied name, cut by the fixed width + ImGui::SetCursorScreenPos(ImVec2(row.x, row_max.y + spacing)); return !collapsed; }; @@ -4355,7 +4488,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane sections.back().entries.push_back(&entry); for (const Section §ion : sections) { - if (!section_header(section.key, section.title)) + if (!section_header(section.key, section.title, section.entries.size())) continue; const ImVec2 origin = ImGui::GetCursorScreenPos(); const int count = grid(origin, section.entries, section.source == TextureSource::User); @@ -4379,8 +4512,12 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane ImGui::TextDisabled("%s", _u8L("No textures found.").c_str()); } } + // Done whether or not the current texture was drawn: one in a collapsed section stays where it is. + m_picker_reveal_selected = false; + ImGui::EndChild(); + pop_slim_scrollbar_style(); - if (chosen || import_clicked) + if (chosen || import_clicked || close_clicked) ImGui::CloseCurrentPopup(); m_tooltip_anchor_min = prev_anchor_min; m_tooltip_anchor_max = prev_anchor_max; @@ -6687,19 +6824,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->text(text); m_imgui->pop_bold_font(); }; - // Cuts a UTF-8 string down to `max_w` pixels, ending it in "..." when it had to be cut. - const auto ellipsize = [](std::string s, float max_w) { - if (ImGui::CalcTextSize(s.c_str()).x <= max_w) - return s; - while (!s.empty() && ImGui::CalcTextSize((s + "...").c_str()).x > max_w) { - unsigned char c; - do { // a whole code point at a time - c = static_cast(s.back()); - s.pop_back(); - } while (!s.empty() && (c & 0xC0) == 0x80); - } - return s + "..."; - }; // 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) { @@ -6892,52 +7016,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float style.WindowPadding.y - style.ItemSpacing.y); const float body_h = m_panel_body_h > 0.f ? std::min(m_panel_body_h + 1.f, max_body_h) : max_body_h; - // ImGui's stock scrollbar is a wide, square-cornered slab in a tinted track - against this flat panel - // it reads as a raw widget bolted onto the edge. Slim it to a rounded thumb over an invisible track. - const float scrollbar_w = m_imgui->scaled(0.5f); - const ImVec4 grab = dark ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f); - ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_w); - ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * scrollbar_w); - ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f)); - ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab); - ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f)); - ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f)); - // NoScrollWithMouse: the wheel is handled below so the scroll can be eased instead of teleporting - // five text lines per notch, which on blocks this tall lost the reader's place. + push_slim_scrollbar_style(m_imgui->scaled(0.5f), dark); + // NoScrollWithMouse: ease_wheel_scroll() eases the scroll instead of teleporting five text lines per + // notch, which on blocks this tall lost the reader's place. ImGui::BeginChild("##td_body", ImVec2(panel_w, body_h), false, ImGuiWindowFlags_NoScrollWithMouse); - { - ImGuiIO &io = ImGui::GetIO(); - const float scroll_now = ImGui::GetScrollY(); - const float scroll_max = ImGui::GetScrollMaxY(); - - // Anything that moved the scroll without us - dragging the grab, a keyboard/gamepad nav step, the - // content shrinking under a clamped offset - has to re-seed the target, or the easing below would - // immediately drag the view back to where it last animated to. - if (m_panel_scroll_applied < 0.f || std::abs(scroll_now - m_panel_scroll_applied) > 0.5f) - m_panel_scroll_target = scroll_now; - - if (io.MouseWheel != 0.f && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) - m_panel_scroll_target -= io.MouseWheel * ImGui::GetFontSize() * 4.f; - // Whole pixels: ImGui floors whatever SetScrollY() is given, so a fractional target could never be - // reached and the "still gliding" test below would stay true forever, repainting the canvas for good. - m_panel_scroll_target = std::floor(std::clamp(m_panel_scroll_target, 0.f, scroll_max)); - - const float delta = m_panel_scroll_target - scroll_now; - if (std::abs(delta) >= 1.f) { - // Exponential ease, formulated against the frame time so the glide takes the same wall time - // whether the canvas is running at 30 or 144 fps. The last sub-pixel step would be floored - // away, so land on the target outright once the remainder is that small. - const float t = 1.f - std::exp(-20.f * std::clamp(io.DeltaTime, 1.f / 240.f, 1.f / 15.f)); - const float step = (std::abs(delta * t) < 1.f) ? delta : delta * t; - const float next = std::floor(scroll_now + step); - ImGui::SetScrollY(next); - m_panel_scroll_applied = next; - m_parent.set_as_dirty(); // nothing else would redraw mid-glide once the mouse stops - } else { - m_panel_scroll_applied = scroll_now; - } - } + ease_wheel_scroll(m_panel_scroll_target, m_panel_scroll_applied); // Everything that changes the layer list is deferred until the list is no longer being drawn: // removing or reordering shifts mv->texture_displacement_layers under the pointers the loop holds, @@ -7182,8 +7265,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Opens the texture library for a layer, making it the active one too (after the loop). const auto open_picker = [&](int slot) { - m_picker_slot = slot; - m_picker_open_request = true; + m_picker_slot = slot; + m_picker_open_request = true; + m_picker_reveal_selected = true; }; // The six-dot drag icon, drawn into `list` over [x, x + w] x [y, y + h]. const auto draw_drag_icon = [&](ImDrawList *list, float x, float y, float w, float h, ImU32 tint) { @@ -8178,8 +8262,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // height of everything above, whether or not it is scrolled. m_panel_body_h = ImGui::GetCursorPosY() - style.ItemSpacing.y; ImGui::EndChild(); - ImGui::PopStyleColor(5); - ImGui::PopStyleVar(2); + pop_slim_scrollbar_style(); // The layer-list changes deferred from the loop above. if (slot_to_remove >= 0) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 7bdb4fbeff..22d8b417c7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -428,6 +428,10 @@ private: void render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max); int m_picker_slot = -1; bool m_picker_open_request = false; + // Set when the popup opens, so the next layout scrolls the layer's current texture into view. + bool m_picker_reveal_selected = false; + float m_picker_scroll_target = 0.f; // see ease_wheel_scroll() + float m_picker_scroll_applied = -1.f; void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry); void import_custom_texture(TextureDisplacementLayer &layer); @@ -1105,12 +1109,15 @@ private: // 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 - // jolt rather than a scroll. The wheel is intercepted (ImGuiWindowFlags_NoScrollWithMouse) and - // moves a *target* offset instead; the real scroll is eased toward it over the following frames. + // Smooth scrolling for a child window, called inside it: the panel body (everything between the header + // and the pinned Bake footer) and the texture library's grid. ImGui jumps a fixed number of lines per + // wheel notch, which on tall layer cards reads as a hard jolt rather than a scroll. The child sets + // ImGuiWindowFlags_NoScrollWithMouse, and the wheel moves `target` instead; the real scroll is eased + // toward it over the following frames. `applied` is what the easing wrote last frame, <0 until the + // first one. + void ease_wheel_scroll(float &target, float &applied); float m_panel_scroll_target = 0.f; - float m_panel_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one + float m_panel_scroll_applied = -1.f; // Last frame's body content height and footer height. The body is a child window that has to be // given its height before its content is laid out, so it is sized from the previous frame: as tall // as its content, capped so the footer still fits above the bottom of the canvas. From 54e6df02353d9ff032f23a281c752d3781f8d11a Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 15 Oct 2026 18:10:33 +0800 Subject: [PATCH 06/21] Group texture packs under one picker section and add a Download from OrcaCloud shortcut The texture picker lists every plugin pack as an indented, collapsible child of one "Resource Texture" section, which now always renders so the new button is reachable even before any pack is installed. A pack whose name carries a " by ..." suffix shows that part on a smaller, dimmed second line, keeping the name line short. The button switches to the Home tab and opens the OrcaCloud pane on the plugin hub filtered to resource textures. A native open now also drives the homepage shell (nav highlight and the cloud-mode layout), and the requested page is remembered so a sign-in re-seed keeps it. Also fixes the PluginsDialog fuzzy-search script path, which pointed at a directory the file is not in. --- resources/web/dialog/PluginsDialog/index.html | 2 +- resources/web/homepage/js/home.js | 8 +- src/slic3r/GUI/GUI_App.cpp | 29 ++- src/slic3r/GUI/GUI_App.hpp | 7 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 182 ++++++++++++------ src/slic3r/GUI/WebViewDialog.cpp | 4 + 6 files changed, 161 insertions(+), 71 deletions(-) diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index ae4ffc8b9e..8981a7d62d 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -18,7 +18,7 @@ - + diff --git a/resources/web/homepage/js/home.js b/resources/web/homepage/js/home.js index 188a9dded2..75d1af232f 100644 --- a/resources/web/homepage/js/home.js +++ b/resources/web/homepage/js/home.js @@ -156,12 +156,16 @@ function SetCloudMode( bOn ) m_CloudMode=bOn; $("body").toggleClass("cloud-mode", bOn); + + if( bOn ) + { + $(".BtnItem").removeClass("BtnItemSelected"); + $(".BtnShortcut").addClass("BtnItemSelected"); + } } function GotoCloud() { - $(".BtnItem").removeClass("BtnItemSelected"); - $(".BtnShortcut").addClass("BtnItemSelected"); SetCloudMode(true); SendSimpleCommand("homepage_open_cloud"); } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index e764a9ea7e..5e14d587a5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -5467,12 +5467,7 @@ std::string GUI_App::handle_web_request(std::string cmd) this->request_open_project({}); } else if (command_str.compare("homepage_open_cloud") == 0) { - // Deferred: set_cloud_mode() may create a second native webview, which must - // not run inside this webview's script-message callback. - CallAfter([this]() { - if (WebViewPanel* home = WebViewPanel::if_built()) - home->set_cloud_mode(true, cloud_webview_handoff()); - }); + this->open_orca_cloud(); } else if (command_str.compare("homepage_close_cloud") == 0) { CallAfter([]() { @@ -5621,6 +5616,22 @@ std::string GUI_App::handle_web_request(std::string cmd) return ""; } +void GUI_App::open_orca_cloud(const std::string& next) +{ + // Remembered so a login while the pane is open re-seeds the same page rather than the default. + m_cloud_open_path = next; + // Deferred: set_cloud_mode() may create the second native webview, so this must not run inside + // the caller's frame (a webview script-message callback or an ImGui frame). + CallAfter([this]() { + if (mainframe != nullptr) + mainframe->select_tab(TAB_ID_HOME); + // The Home page is built lazily, so the tab switch may not have created it yet: ensure() builds + // it if needed, matching what showing the tab does, rather than dropping the open request. + if (WebViewPanel* home = WebViewPanel::ensure()) + home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path)); + }); +} + void GUI_App::handle_script_message(std::string msg, const std::string& provider) { try { @@ -5898,7 +5909,7 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt) }*/ } -wxString GUI_App::cloud_webview_handoff() +wxString GUI_App::cloud_webview_handoff(const std::string& next) { // The cloud pane loads the Start URL, not a token: the page checks the webview's own session // and asks for a handoff itself (guide §4). Signed out, user_id is omitted and the page will @@ -5906,7 +5917,7 @@ wxString GUI_App::cloud_webview_handoff() auto orca = get_orca_cloud_agent(m_agent); if (!orca) return {}; - return from_u8(orca->build_web_session_start_url()); + return from_u8(orca->build_web_session_start_url(next)); } void GUI_App::request_cloud_web_session(const std::string& state) @@ -6049,7 +6060,7 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt) m_cloud_handoff_state.clear(); if (WebViewPanel* home = WebViewPanel::if_built()) if (home->cloud_mode_active()) - home->set_cloud_mode(true, cloud_webview_handoff()); + home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path)); } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 475bec7e26..1b60a98cf1 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -365,6 +365,8 @@ private: // and a generation counter so a superseded mint/login result is dropped (guide §5, "only the // newest request counts"). std::string m_cloud_handoff_state; + // The OrcaCloud path the open requests target, remembered so a login re-seed keeps the same page. + std::string m_cloud_open_path; uint64_t m_cloud_handoff_gen { 0 }; bool m_show_error_msgdlg{false}; wxString m_info_dialog_content; @@ -466,6 +468,9 @@ public: #if wxUSE_WEBVIEW_EDGE void init_webview_runtime(); #endif + // Switches to the Home tab and opens the OrcaCloud pane beside the homepage; work is deferred + // internally. `next` is a same-site OrcaCloud path (empty = the page's default). + void open_orca_cloud(const std::string& next = std::string()); static unsigned get_colour_approx_luma(const wxColour& colour); static bool dark_mode(); const wxColour get_label_default_clr_system(); @@ -572,7 +577,7 @@ public: // called from the pane when the page asks for a session; it opens the desktop login dialog if // signed out, otherwise mints a one-time token on a worker thread and loads the Complete URL // in the same webview. - wxString cloud_webview_handoff(); + wxString cloud_webview_handoff(const std::string& next = std::string()); void request_cloud_web_session(const std::string& state); void mint_cloud_web_session(const std::string& state, uint64_t gen); void on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 338ec80ffb..89e1653a37 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -40,6 +40,7 @@ #include "slic3r/Utils/UndoRedo.hpp" #include "GLGizmoUtils.hpp" +#include #include #include #include @@ -121,11 +122,16 @@ void pop_slim_scrollbar_style() } // Cuts a UTF-8 string down to `max_w` pixels, ending it in "..." when it had to be cut. -std::string ellipsize(std::string s, float max_w) +std::string ellipsize(std::string s, float max_w, float font_size = 0.f) { - if (ImGui::CalcTextSize(s.c_str()).x <= max_w) + const auto text_width = [&](const std::string &text) { + return font_size > 0.f + ? ImGui::GetFont()->CalcTextSizeA(font_size, std::numeric_limits::max(), 0.f, text.c_str()).x + : ImGui::CalcTextSize(text.c_str()).x; + }; + if (text_width(s) <= max_w) return s; - while (!s.empty() && ImGui::CalcTextSize((s + "...").c_str()).x > max_w) { + while (!s.empty() && text_width(s + "...") > max_w) { unsigned char c; do { // a whole code point at a time c = static_cast(s.back()); @@ -4324,6 +4330,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane std::optional chosen; std::optional to_remove; bool import_clicked = false; + bool download_clicked = false; // Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile // overlays a remove button on its corner, and that button would otherwise be "the previous item" the @@ -4407,25 +4414,48 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane return n; }; - // Every section collapses; the state is remembered for the app run. Looked up by section name - the - // fixed Built-in / My textures keys, or a pack's display name. A capability name is unique per type - // across loaded packages (PluginManager::check_registry_locked, PluginManager.cpp:211-225), and it is - // stable across reinstalls, unlike the plugin_key, which is the wheel filename stem and so carries the - // version: keying on that would silently reset the user's collapse choice on every pack upgrade. A - // function-local static lives exactly as long as the app and never touches app_config: remembered - // while the popup closes and reopens, reset on restart. + // Every section collapses; the state is remembered for the app run. Pack keys use pack_name, a + // capability name unique per type across loaded packages and stable across reinstalls. This function-local + // static survives popup closes, never touches app_config, and resets on restart. static std::map s_section_collapsed; // true = collapsed; absent = expanded + const auto split_pack_title = [](const std::string &title) -> std::pair { + size_t split = std::string::npos; + for (size_t i = 0; i + 3 < title.size(); ++i) + if (title[i] == ' ' && (title[i + 1] == 'b' || title[i + 1] == 'B') && + (title[i + 2] == 'y' || title[i + 2] == 'Y') && title[i + 3] == ' ') + split = i; + if (split == std::string::npos) + return { title, {} }; + + std::string name = title.substr(0, split); + while (!name.empty() && std::isspace(static_cast(name.back()))) + name.pop_back(); + std::string attribution = title.substr(split); + size_t first = 0; + while (first < attribution.size() && std::isspace(static_cast(attribution[first]))) + ++first; + attribution.erase(0, first); + if (name.empty() || attribution.empty()) + return { title, {} }; + return { name, attribution }; + }; + // A section heading, one row as wide as the grid: a click anywhere on it collapses or expands the - // section. The chevron takes the accent of the panel's More settings link, and the count at the right - // says what a collapsed section holds. The grid follows closer than the next heading does, so each - // heading reads as part of its own grid. - // Returns true when the section is open, so the caller knows whether to draw its grid. - const auto section_header = [&](const std::string &key, const std::string &title, size_t count) { + // section. Child headings indent their contents while keeping their counts right-aligned. Attribution + // lines use a smaller disabled style; the grid follows closer than the next heading does. + // Returns true when the section is open, so the caller knows whether to draw its contents. + const auto section_header = [&](const std::string &key, const std::string &title, size_t count, float indent, + const std::string &attribution, const std::string &full_title) { bool &collapsed = s_section_collapsed[key]; // default-inserts false -> expanded const ImVec2 row = ImGui::GetCursorScreenPos(); - const ImVec2 row_max(row.x + grid_w, row.y + frame_h); - if (ImGui::InvisibleButton(key.c_str(), ImVec2(grid_w, frame_h))) + const bool two_lines = !attribution.empty(); + const float line_h = ImGui::GetTextLineHeight(); + const float small_font_size = ImGui::GetFontSize() * 0.8f; + const float small_line_h = line_h * 0.8f; + const float row_h = two_lines ? std::max(frame_h, line_h + small_line_h + spacing) : frame_h; + const ImVec2 row_max(row.x + grid_w, row.y + row_h); + if (ImGui::InvisibleButton(key.c_str(), ImVec2(grid_w, row_h))) collapsed = !collapsed; const bool hovered = ImGui::IsItemHovered(); if (hovered) @@ -4433,7 +4463,9 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane const float inset = std::round(0.25f * frame_h); const float arrow = std::round(ImGui::GetFontSize() * 0.55f); - const float cx = row.x + inset + 0.5f * arrow, cy = row.y + 0.5f * frame_h, hs = 0.3f * arrow; + const float cx = row.x + inset + indent + 0.5f * arrow; + const float cy = row.y + 0.5f * row_h; + const float hs = 0.3f * arrow; const ImU32 accent = ImGui::GetColorU32(orca); if (collapsed) dl->AddTriangleFilled(ImVec2(cx - 0.5f * hs, cy - hs), ImVec2(cx + 0.5f * hs, cy), ImVec2(cx - 0.5f * hs, cy + hs), accent); @@ -4442,34 +4474,36 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane const std::string n = std::to_string(count); const float n_w = ImGui::CalcTextSize(n.c_str()).x; - const float ty = row.y + 0.5f * (frame_h - ImGui::GetTextLineHeight()); - const float tx = row.x + inset + arrow + spacing; - const std::string shown = ellipsize(title, std::max(0.f, row_max.x - inset - n_w - spacing - tx)); - dl->AddText(ImVec2(tx, ty), ImGui::GetColorU32(ImGuiCol_Text), shown.c_str()); - dl->AddText(ImVec2(row_max.x - inset - n_w, ty), ImGui::GetColorU32(ImGuiCol_TextDisabled), n.c_str()); - if (hovered && shown != title) - panel_tooltip(title); // a long plugin-supplied name, cut by the fixed width + const float tx = row.x + inset + indent + arrow + spacing; + const float max_text_w = std::max(0.f, row_max.x - inset - n_w - spacing - tx); + const std::string shown = ellipsize(title, max_text_w); + const std::string shown_attribution = two_lines ? ellipsize(attribution, max_text_w, small_font_size) : std::string{}; + const float title_y = two_lines ? row.y + 0.5f * (row_h - line_h - small_line_h) + : row.y + 0.5f * (frame_h - line_h); + const float count_y = row.y + 0.5f * (row_h - line_h); + dl->AddText(ImVec2(tx, title_y), ImGui::GetColorU32(ImGuiCol_Text), shown.c_str()); + if (two_lines) + dl->AddText(ImGui::GetFont(), small_font_size, ImVec2(tx, title_y + line_h), + ImGui::GetColorU32(ImGuiCol_TextDisabled), shown_attribution.c_str()); + dl->AddText(ImVec2(row_max.x - inset - n_w, count_y), ImGui::GetColorU32(ImGuiCol_TextDisabled), n.c_str()); + if (hovered && (shown != title || shown_attribution != attribution)) + panel_tooltip(full_title); // a long plugin-supplied name, cut by the fixed width ImGui::SetCursorScreenPos(ImVec2(row.x, row_max.y + spacing)); return !collapsed; }; - // One section per source: Built-in first, then one per pack, then My textures last (so the import tile - // and the help line keep describing the group they sit in). Pack entries are grouped by pack_name, - // which is the capability's get_name(): unique per capability type across loaded packages - // (PluginManager::check_registry_locked, PluginManager.cpp:211-225), so it identifies a pack both for - // grouping and for the remembered collapse state, and stands in for the section title. A section is - // identified by `builtin:`, `user:` or `pack:`, so a pack named "builtin" cannot collide with - // the fixed Built-in section. + // Built-in is first, plugin packs are grouped under Resource Texture, and My + // textures stays last so its import tile and help line describe that group. Pack names identify both + // their child section and state. struct Section { std::string key; std::string title; - TextureSource source; std::vector entries; }; std::vector
sections; std::map pack_section; // pack_name -> index in `sections` - sections.push_back({ "builtin:", _u8L("Built-in"), TextureSource::Shipped, {} }); + sections.push_back({ "builtin:", _u8L("Built-in"), {} }); for (const TextureLibraryEntry &entry : library) { if (entry.source == TextureSource::Shipped) { sections.front().entries.push_back(&entry); // the section exists even when the library is empty @@ -4477,51 +4511,83 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane } if (entry.source == TextureSource::User) continue; // added below, so My textures stays last + if (entry.source != TextureSource::Plugin) + continue; const auto [it, inserted] = pack_section.emplace(entry.pack_name, sections.size()); if (inserted) - sections.push_back({ "pack:" + entry.pack_name, entry.pack_name, TextureSource::Plugin, {} }); + sections.push_back({ "pack:" + entry.pack_name, entry.pack_name, {} }); sections[it->second].entries.push_back(&entry); } - sections.push_back({ "user:", _u8L("My textures"), TextureSource::User, {} }); + sections.push_back({ "user:", _u8L("My textures"), {} }); for (const TextureLibraryEntry &entry : library) if (entry.source == TextureSource::User) sections.back().entries.push_back(&entry); - for (const Section §ion : sections) { - if (!section_header(section.key, section.title, section.entries.size())) - continue; + const float child_indent = std::round(m_imgui->scaled(0.8f)); + const Section &built_in = sections.front(); + if (section_header(built_in.key, built_in.title, built_in.entries.size(), 0.f, {}, built_in.title)) { const ImVec2 origin = ImGui::GetCursorScreenPos(); - const int count = grid(origin, section.entries, section.source == TextureSource::User); - if (section.source == TextureSource::User) { - ImGui::SetCursorScreenPos(tile_pos(origin, count)); - if (ImGui::Button("+##import_texture", ImVec2(tile, tile))) - import_clicked = true; - if (ImGui::IsItemHovered()) - panel_tooltip(_u8L("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.")); - end_grid(origin, count + 1); - // Belongs to this section: it describes the import tile and the remove-on-hover tiles, both of - // which live here, so it is hidden with them rather than left describing controls off-screen. - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w); - ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str()); - ImGui::PopTextWrapPos(); - } else { + const int count = grid(origin, built_in.entries, false); + end_grid(origin, count); + if (count == 0) + ImGui::TextDisabled("%s", _u8L("No textures found.").c_str()); + } + + size_t subscribed_count = 0; + for (size_t i = 1; i + 1 < sections.size(); ++i) + subscribed_count += sections[i].entries.size(); + + const std::string subscribed_title = _u8L("Resource Texture"); + if (section_header("subscribed:", subscribed_title, subscribed_count, 0.f, {}, subscribed_title)) { + for (size_t i = 1; i + 1 < sections.size(); ++i) { + const Section &pack = sections[i]; + const auto [name, attribution] = split_pack_title(pack.title); + if (!section_header(pack.key, name, pack.entries.size(), child_indent, attribution, pack.title)) + continue; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const int count = grid(origin, pack.entries, false); end_grid(origin, count); - if (section.source == TextureSource::Shipped && count == 0) - ImGui::TextDisabled("%s", _u8L("No textures found.").c_str()); } + + ImGui::Dummy(ImVec2(0.f, spacing)); + const std::string download_label = _u8L("Download from OrcaCloud"); + if (ImGui::Button(download_label.c_str(), ImVec2(grid_w, frame_h))) + download_clicked = true; + if (ImGui::IsItemHovered()) + panel_tooltip(_u8L("Opens OrcaCloud in the Home page to browse for plugins.")); + } + + const Section &user = sections.back(); + if (section_header(user.key, user.title, user.entries.size(), 0.f, {}, user.title)) { + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const int count = grid(origin, user.entries, true); + ImGui::SetCursorScreenPos(tile_pos(origin, count)); + if (ImGui::Button("+##import_texture", ImVec2(tile, tile))) + import_clicked = true; + if (ImGui::IsItemHovered()) + panel_tooltip(_u8L("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.")); + end_grid(origin, count + 1); + // Belongs to this section: it describes the import tile and the remove-on-hover tiles, both of + // which live here, so it is hidden with them rather than left describing controls off-screen. + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w); + ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str()); + ImGui::PopTextWrapPos(); } // Done whether or not the current texture was drawn: one in a collapsed section stays where it is. m_picker_reveal_selected = false; ImGui::EndChild(); pop_slim_scrollbar_style(); - if (chosen || import_clicked || close_clicked) + if (chosen || import_clicked || close_clicked || download_clicked) ImGui::CloseCurrentPopup(); m_tooltip_anchor_min = prev_anchor_min; m_tooltip_anchor_max = prev_anchor_max; ImGui::EndPopup(); + // The OrcaCloud plugin hub filtered to resource textures; the pane's session handoff redirects here. + if (download_clicked) + wxGetApp().open_orca_cloud("/app/plugins/plugin-hub?types=resource-textures"); // Only once the loop over the library is done: importing, picking (which reorders the recently used) // and removing all change the library the loop was iterating. diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp index fe7bd0fdfb..2b2797e88a 100644 --- a/src/slic3r/GUI/WebViewDialog.cpp +++ b/src/slic3r/GUI/WebViewDialog.cpp @@ -556,6 +556,10 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url) load_cloud_after_signout(url); } m_cloud_browser->Show(); + // The homepage shell owns the nav highlight and the cloud-mode layout class. A native open + // (not the page's own GotoCloud) must ask it to switch; the page's SetCloudMode no-ops if it + // already did. OnDocumentLoaded re-applies this when the homepage was still loading. + RunScript("SetCloudMode(true)"); } else if (m_cloud_browser) { // Closing keeps the pane alive with its session, so re-opening does not have to sign in // again (guide §10). Nothing is cleared here. From b8d40ed61f30015afe02a90c23f1a02d15c306f4 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 15 Oct 2026 18:46:30 +0800 Subject: [PATCH 07/21] Load the OrcaCloud plugin page directly when the pane is already open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The texture picker's Download from OrcaCloud shortcut asks for a specific page. Re-running the session handoff to reach it flashed the sign-in interstitial every time; instead an already-open pane is navigated straight to the page URL, keeping its session. The handoff is still used on the first open, where a session has to be established. set_cloud_mode() gains an optional page_url for this. The homepage nav passes none, so its ordinary re-open keeps the page the pane already holds (guide §8/§10). --- src/slic3r/GUI/GUI_App.cpp | 19 ++++++++++++++++++- src/slic3r/GUI/GUI_App.hpp | 4 ++-- src/slic3r/GUI/WebViewDialog.cpp | 6 +++++- src/slic3r/GUI/WebViewDialog.hpp | 5 ++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 5e14d587a5..dfed529360 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -335,6 +335,23 @@ static std::shared_ptr get_orca_cloud_agent(NetworkAgent* return agent ? std::dynamic_pointer_cast(agent->get_cloud_agent()) : nullptr; } +// Full {web} page URL for a same-site OrcaCloud path, or empty when it cannot be built. An already +// open cloud pane is navigated straight to it, without re-running the session handoff. +static wxString cloud_page_url(NetworkAgent* agent, const std::string& path) +{ + if (path.empty()) + return {}; + auto orca = get_orca_cloud_agent(agent); + if (!orca) + return {}; + std::string base = orca->get_cloud_base_url(); + while (!base.empty() && base.back() == '/') + base.pop_back(); + if (base.empty()) + return {}; + return from_u8(base + path); +} + void start_ping_test() { return; @@ -5628,7 +5645,7 @@ void GUI_App::open_orca_cloud(const std::string& next) // The Home page is built lazily, so the tab switch may not have created it yet: ensure() builds // it if needed, matching what showing the tab does, rather than dropping the open request. if (WebViewPanel* home = WebViewPanel::ensure()) - home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path)); + home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path), cloud_page_url(m_agent, m_cloud_open_path)); }); } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 1b60a98cf1..afd7a78f6d 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -468,8 +468,8 @@ public: #if wxUSE_WEBVIEW_EDGE void init_webview_runtime(); #endif - // Switches to the Home tab and opens the OrcaCloud pane beside the homepage; work is deferred - // internally. `next` is a same-site OrcaCloud path (empty = the page's default). + // Switches to the Home tab and opens the OrcaCloud pane beside the homepage on `next`, a + // same-site OrcaCloud path (empty = the page's default). Work is deferred internally. void open_orca_cloud(const std::string& next = std::string()); static unsigned get_colour_approx_luma(const wxColour& colour); static bool dark_mode(); diff --git a/src/slic3r/GUI/WebViewDialog.cpp b/src/slic3r/GUI/WebViewDialog.cpp index 2b2797e88a..d6fc4a7170 100644 --- a/src/slic3r/GUI/WebViewDialog.cpp +++ b/src/slic3r/GUI/WebViewDialog.cpp @@ -537,7 +537,7 @@ void WebViewPanel::cloud_signed_out(const wxString& signout_url) } } -void WebViewPanel::set_cloud_mode(bool on, const wxString& url) +void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString& page_url) { if (on) { // load_cloud_url records the origin from whatever it loads, including the plain cloud @@ -546,6 +546,10 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url) create_cloud_browser(); m_cloud_seed_url = url; schedule_cloud_load(url.empty() ? wxString(kCloudUrl) : url); + } else if (!page_url.empty()) { + // The pane already holds a session, so an explicit page loads straight in; the handoff is + // only needed to establish one, not to reach an ordinary page. + load_cloud_url(page_url); } else if (!url.empty() && url != m_cloud_seed_url) { // Re-seed only when the Start URL actually changed — a login, an account switch or a // sign-out changes it; an ordinary re-open does not (guide §8). Reloading an unchanged diff --git a/src/slic3r/GUI/WebViewDialog.hpp b/src/slic3r/GUI/WebViewDialog.hpp index 07f719331a..4191360002 100644 --- a/src/slic3r/GUI/WebViewDialog.hpp +++ b/src/slic3r/GUI/WebViewDialog.hpp @@ -118,7 +118,10 @@ public: // Shows/hides the native OrcaCloud pane beside this homepage nav column. `url` is the // cloud Start URL ({web}/auth/handoff?next=…&user_id=…), which the page uses to decide // whether it already has a session; empty falls back to the plain cloud page. - void set_cloud_mode(bool on, const wxString& url = wxString()); + // `page_url` is a full {web} page URL: an already-open pane is navigated straight there, + // without re-running the handoff, which is what an explicit "open this page" wants. Empty + // keeps whatever page the pane holds. + void set_cloud_mode(bool on, const wxString& url = wxString(), const wxString& page_url = wxString()); // True while the OrcaCloud pane exists and is visible. bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); } From 99de46b9d00d2e3b089c3754ada0142b34a3e365 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 15 Oct 2026 19:19:28 +0800 Subject: [PATCH 08/21] Rename the texture picker's pack section and OrcaCloud shortcut Use "Texture libraries" for the plugin pack group and "Explore more texture libraries" for the button that opens the OrcaCloud plugin hub. --- src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 89e1653a37..4597e98fd2 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -4492,7 +4492,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane return !collapsed; }; - // Built-in is first, plugin packs are grouped under Resource Texture, and My + // Built-in is first, plugin packs are grouped under Texture libraries, and My // textures stays last so its import tile and help line describe that group. Pack names identify both // their child section and state. struct Section @@ -4537,7 +4537,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane for (size_t i = 1; i + 1 < sections.size(); ++i) subscribed_count += sections[i].entries.size(); - const std::string subscribed_title = _u8L("Resource Texture"); + const std::string subscribed_title = _u8L("Texture libraries"); if (section_header("subscribed:", subscribed_title, subscribed_count, 0.f, {}, subscribed_title)) { for (size_t i = 1; i + 1 < sections.size(); ++i) { const Section &pack = sections[i]; @@ -4550,7 +4550,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane } ImGui::Dummy(ImVec2(0.f, spacing)); - const std::string download_label = _u8L("Download from OrcaCloud"); + const std::string download_label = _u8L("Explore more texture libraries"); if (ImGui::Button(download_label.c_str(), ImVec2(grid_w, frame_h))) download_clicked = true; if (ImGui::IsItemHovered()) From 0caefcf4aaa684079e32bc4ad50a9bbacfcf6192 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Thu, 15 Oct 2026 22:35:56 +0800 Subject: [PATCH 09/21] Linux: only disable WebKit compositing for WebKitGTK older than 2.42 (#16385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Linux: only disable WebKit compositing for WebKitGTK older than 2.42 The default Wayland path set WEBKIT_DISABLE_COMPOSITING_MODE=1 whenever the session had both DISPLAY and WAYLAND_DISPLAY, i.e. any Wayland session with XWayland. That forces WebKitGTK accelerated compositing off and also disables threaded scrolling, so the Device tab web view (Mainsail/Fluidd) lagged unless the app was launched with GDK_BACKEND=x11, the only path that skipped the variable. Disabling compositing also turns off threaded scrolling. The XWayland compositing bug the fallback worked around is fixed in WebKitGTK >= 2.42, so apply it only for older WebKit. WebView::NeedsCompositingWorkaround() reads the runtime WebKitGTK version; the X11 opt-in path is unchanged. Behavior is unchanged on WebKit < 2.42, on pure X11, and on native Wayland without XWayland. The wxWidgets skill reference is updated to match. * Linux: use WebKit's shared-memory transport for the NVIDIA DMABUF renderer The Device tab web view is slow on NVIDIA proprietary drivers even with accelerated compositing left on: since the DMABUF renderer became the default in WebKitGTK 2.42, the driver cannot back it, and web views fall back to a slow path. Gating the old WEBKIT_DISABLE_COMPOSITING_MODE fallback on WebKit < 2.42 is the wrong axis, because the failure is version-independent from 2.42 on. Force the shared-memory transport with WEBKIT_DMABUF_RENDERER_FORCE_SHM=1. That keeps compositing and threaded scrolling and skips only the hardware path. Also set WEBKIT_FORCE_DMABUF_RENDERER=1, which clears the isNVIDIA() early return that Debian/Ubuntu/openSUSE patch into rendererBufferTransportMode() and which otherwise returns before the FORCE_SHM check is reached. Both are set before the X11-vs-Wayland split so the workaround also covers XWayland, and only when WebKitGTK is new enough to honour FORCE_SHM (>= 2.44, WebView::SupportsForceSharedMemory()). The AppImage launcher exported the superseded WEBKIT_DISABLE_DMABUF_RENDERER=1, which is read first, leaves the transport set empty and so disables compositing outright; replace it with the same pair. The wxWidgets skill reference is updated to match. * Linux: drop the AppImage WebKit DMABUF exports; correct the skill notes The launcher cannot gate on the runtime WebKitGTK version, so exporting WEBKIT_DMABUF_RENDERER_FORCE_SHM/WEBKIT_FORCE_DMABUF_RENDERER there would clear a distro isNVIDIA() guard on hosts older than 2.44, where FORCE_SHM is ignored, re-exposing the broken hardware path. The binary already sets both with the correct gate, so remove them and say who owns the workaround. Also correct two claims in the skill reference. An empty transport set only means "no accelerated compositing" from 2.44 on; 2.42/2.43 still fall back to the Wayland/X11 renderers, removed in the same release that added FORCE_SHM. And threaded scrolling is off by default in 2.44/2.45 (on from 2.46), so FORCE_SHM keeps compositing rather than scrolling. Document the module-level detection cost and the WEBKIT_DMABUF_RENDERER_FORCE_SHM=0 opt-out. * Flatpak: force WebKit's shared-memory transport instead of disabling DMABUF The entrypoint exported WEBKIT_DISABLE_DMABUF_RENDERER=1 for NVIDIA. WebKit reads that variable first and leaves the transport set empty, which turns accelerated compositing off, so the Device tab still lagged for NVIDIA Flatpak users, and the binary could not override it because its setenv is non-replacing. The runtime's WebKitGTK is vanilla, so there is no distro isNVIDIA() guard to clear and WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 alone is the right switch: compositing stays on and only the hardware DMABUF path is skipped. * Flatpak: drop the WebKit NVIDIA export; the binary owns the workaround /proc/driver/nvidia/version is readable inside the Flatpak sandbox, so the binary's NVIDIA branch already sets WEBKIT_DMABUF_RENDERER_FORCE_SHM with the correct runtime gate and the entrypoint's own export is redundant. Verified with: flatpak run --command=cat com.orcaslicer.OrcaSlicer /proc/driver/nvidia/version which prints the NVRM version (driver 615.71.09) from inside the sandbox. * Linux: say what the XWayland compositing gate actually tests DISPLAY and WAYLAND_DISPLAY are both set in a native Wayland session as well, because GNOME and KDE run Xwayland as part of the session and export DISPLAY for it whether or not this process is an X client. The pair means XWayland is available in the session, not that GTK picked X11 for this process, so "(an XWayland session)" reads as more than the condition establishes. Say what the pair means, and name the case the fallback actually covers: the X11 backend inside a Wayland session, which is what the forced GDK_BACKEND=x11 above produces in a build without EGL. Comment only; behaviour is unchanged. * Linux: apply the XWayland compositing fallback only where GTK picks X11 The default path set WEBKIT_DISABLE_COMPOSITING_MODE whenever DISPLAY and WAYLAND_DISPLAY were both set, but a native Wayland app inherits both, so a build with EGL disabled accelerated compositing — and with it threaded scrolling — while running on Wayland, where the XWayland bug does not apply. The only default-path case that really runs through XWayland is the forced GDK_BACKEND=x11 used when wx lacks EGL, so set the variable there. The backend cannot be re-checked later: that branch exports GDK_BACKEND itself, so the force and a user's GDK_BACKEND=x11 opt-in, which deliberately keeps compositing, would be indistinguishable. Behaviour is unchanged for WebKitGTK >= 2.42, where the gate already skipped the variable, and for builds without EGL. A build with EGL on WebKitGTK < 2.42 in a Wayland session no longer loses compositing and threaded scrolling. The wxWidgets skill reference is updated to match. --------- Co-authored-by: SoftFever <103989404+SoftFever@users.noreply.github.com> --- .../orca-wxwidgets/references/platforms.md | 16 +++-- .../references/webview-gl-aui-media.md | 69 ++++++++++++++----- scripts/flatpak/entrypoint | 4 +- src/OrcaSlicer.cpp | 36 ++++++---- .../platform/unix/build_linux_image.sh.in | 3 +- src/slic3r/GUI/Widgets/WebView.cpp | 27 ++++++++ src/slic3r/GUI/Widgets/WebView.hpp | 10 +++ 7 files changed, 126 insertions(+), 39 deletions(-) diff --git a/.claude/skills/orca-wxwidgets/references/platforms.md b/.claude/skills/orca-wxwidgets/references/platforms.md index f391aecf27..0ec78aaa43 100644 --- a/.claude/skills/orca-wxwidgets/references/platforms.md +++ b/.claude/skills/orca-wxwidgets/references/platforms.md @@ -569,12 +569,18 @@ on the **first** call. So: #endif ``` -**Before GTK starts**, only the environment exists. `CLI::run` (`src/OrcaSlicer.cpp`) branches on -`GDK_BACKEND` (an `x11` prefix means the X11 opt-in). On the default path it forces `GDK_BACKEND=x11` -when wx lacks EGL and `WAYLAND_DISPLAY` is set. It sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` -(non-replacing) only when both `DISPLAY` and `WAYLAND_DISPLAY` are set, and calls `XInitThreads()` +**Before GTK starts**, only the environment exists. `CLI::run` (`src/OrcaSlicer.cpp`) sets the NVIDIA +DMABUF workaround and then branches on `GDK_BACKEND` (an `x11` prefix means the X11 opt-in). On both +paths, when `/proc/driver/nvidia/version` exists and the linked WebKitGTK honours the switch (>= 2.44, +`WebView::SupportsForceSharedMemory()`), it sets `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and +`WEBKIT_FORCE_DMABUF_RENDERER=1` (non-replacing): the proprietary driver cannot back WebKit's hardware +DMABUF renderer. On the default path it forces `GDK_BACKEND=x11` when wx lacks EGL and `WAYLAND_DISPLAY` +is set, and sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` (non-replacing) inside that branch when the linked +WebKitGTK is older than 2.42: a forced X11 backend in a Wayland session is the only default-path case +that runs through XWayland, which `DISPLAY` and `WAYLAND_DISPLAY` alone do not tell you (a native +Wayland app inherits both). It calls `XInitThreads()` only when `DISPLAY` is set. With neither variable set, the GUI refuses to start ("Neither DISPLAY nor -WAYLAND_DISPLAY set"). The details and the WebKit rule (c12912e0df) are in +WAYLAND_DISPLAY set"). The details and the WebKit rules (c12912e0df) are in `references/webview-gl-aui-media.md` §WebKitGTK on Linux sessions. **wx's own detectors**, for reference: diff --git a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md index b8856b21ca..e7ff4fadde 100644 --- a/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md +++ b/.claude/skills/orca-wxwidgets/references/webview-gl-aui-media.md @@ -61,7 +61,8 @@ available; GTK2/WebKit1 is an opt-out configuration the GUI does not support. 11. A web host that re-themes in place handles `EVT_WEBVIEW_RECREATED` without `Skip()`; one that needs a reload lets it through. §[Orca wrapper](#orca-webview-wrapper-widgetswebview) 12. Keep `WebViewWebKit`'s destructor removing the `"wx"` handler. §[Orca wrapper](#orca-webview-wrapper-widgetswebview) -13. Do not widen `WEBKIT_DISABLE_COMPOSITING_MODE` beyond Orca's default-path XWayland case, and never +13. Do not widen `WEBKIT_DISABLE_COMPOSITING_MODE` beyond the no-EGL default path where Orca forces + `GDK_BACKEND=x11`, for WebKitGTK older than 2.42, and never make a page's progress depend only on a C++→JS callback. §[WebKitGTK on Linux](#webkitgtk-on-linux-sessions) 14. GL attribute lists: legacy `int[]` lists end with `0` and spell out `WX_GL_RGBA` and `WX_GL_DOUBLEBUFFER`; `wxGLAttributes`/`wxGLContextAttrs` end with `EndList()`; MSAA is requested @@ -567,33 +568,69 @@ vue-resize workaround script and sets a persistent SQLite cookie store on the vi ## WebKitGTK on Linux sessions **Mechanism.** `CLI::run` (`src/OrcaSlicer.cpp`, under `#ifdef __WXGTK__`, before GTK initialises) -branches on `GDK_BACKEND`: +sets the NVIDIA DMABUF workaround and then branches on `GDK_BACKEND`: +- **Both paths**, before the split: when `/proc/driver/nvidia/version` exists and the linked WebKitGTK + honours the switch (>= 2.44, `WebView::SupportsForceSharedMemory()`), it sets + `WEBKIT_DMABUF_RENDERER_FORCE_SHM=1` and `WEBKIT_FORCE_DMABUF_RENDERER=1` non-replacing. The + proprietary driver cannot back WebKit's hardware DMABUF renderer, so the shared-memory transport keeps + accelerated compositing instead of dropping it; `WEBKIT_FORCE_DMABUF_RENDERER` clears the + Debian/Ubuntu/openSUSE `isNVIDIA()` early return, which otherwise returns before the FORCE_SHM check. - **Default path** (`GDK_BACKEND` unset or not starting with `x11`): if wx lacks EGL (`!wxHAS_EGL`) and - `WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`; it sets `WEBKIT_DISABLE_COMPOSITING_MODE=1` - non-replacing **only** when both `DISPLAY` and `WAYLAND_DISPLAY` are non-empty (a Wayland session with - XWayland available — the session, not the backend GTK will pick); `XInitThreads()` runs only when - `DISPLAY` is set. + `WAYLAND_DISPLAY` is set, it forces `GDK_BACKEND=x11`, and in that same branch it sets + `WEBKIT_DISABLE_COMPOSITING_MODE=1` non-replacing when the linked WebKitGTK predates 2.42 + (`WebView::NeedsCompositingWorkaround()`). That forced X11 backend in a Wayland session is the only + default-path case that really runs through XWayland; `DISPLAY` and `WAYLAND_DISPLAY` alone do not + identify it, because a native Wayland app inherits both. `XInitThreads()` runs only when `DISPLAY` is + set. - **`GDK_BACKEND=x11…` opt-in path**: sets `DRI_PRIME=1` (and the NVIDIA PRIME variables when `/proc/driver/nvidia/version` exists), calls `XInitThreads()`, and deliberately does **not** disable - compositing (the comment: the WebKit2GTK XWayland bug appears fixed in WebKit2GTK ≥ 2.42, and leaving - compositing on keeps hardware acceleration for Device / Setup Wizard / login pages). + compositing, which keeps hardware acceleration for Device / Setup Wizard / login pages. Runtime backend detection elsewhere uses Orca's `is_running_on_wayland()` / `is_running_on_x11()` (see `references/platforms.md`), not these environment variables. **Pitfall.** -- **Rule:** Set `WEBKIT_DISABLE_COMPOSITING_MODE` only on the default path in Wayland sessions, - non-replacing (`setenv(..., false)`, so a value the user already exported wins), before GTK - initialises; never unconditionally. Keep JS-side fallbacks so a page never +- **Rule:** On NVIDIA reach for `WEBKIT_DMABUF_RENDERER_FORCE_SHM` before + `WEBKIT_DISABLE_COMPOSITING_MODE`. **Why:** the proprietary driver cannot back WebKit's hardware + DMABUF renderer, leaving web views blank or slow; FORCE_SHM keeps accelerated compositing and only + skips the hardware path, while disabling compositing drops it. Threaded scrolling is a separate, + release-dependent default (off in 2.44/2.45, on from 2.46). `WEBKIT_DISABLE_DMABUF_RENDERER` is not a + substitute — it is read first, leaves the transport set empty (no compositing from 2.44 on, where the + X11/Wayland fallback renderers were removed) and has a reported crash path. On Debian/Ubuntu and + openSUSE the `isNVIDIA()` patch returns before the FORCE_SHM check, so `WEBKIT_FORCE_DMABUF_RENDERER=1` + must clear it first; that variable exists only in those patches. WebKit reads all of these once per + process, before the first WebView. + The test is `/proc/driver/nvidia/version`, so a hybrid laptop that renders on its iGPU also takes the + shared-memory path, which is a per-frame GPU readback rather than zero-copy; exporting + `WEBKIT_DMABUF_RENDERER_FORCE_SHM=0` in the environment restores the hardware path. + ```cpp + // Wrong: kills compositing and threaded scrolling (and can crash on modern WebKit) + ::setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", false); + // Right: keep compositing, skip only the hardware DMABUF path + if (::access("/proc/driver/nvidia/version", F_OK) == 0 && WebView::SupportsForceSharedMemory()) { + ::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /*replace*/ false); + ::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /*replace*/ false); + } + ``` + Cite: WebKit 2.52.6 `Source/WebKit/UIProcess/gtk/AcceleratedBackingStore.cpp` + (`rendererBufferTransportMode()`), Debian `disable-nvidia-dmabuf.patch`, WebKit bug 262607. +- **Rule:** Set `WEBKIT_DISABLE_COMPOSITING_MODE` only on the default path, in the branch where wx + lacks EGL and Orca forces `GDK_BACKEND=x11`, non-replacing (`setenv(..., false)`, so a value the user + already exported wins), before GTK + initialises; never unconditionally, and only for the WebKitGTK releases that need it (older than + 2.42, `WebView::NeedsCompositingWorkaround()`). Keep JS-side fallbacks so a page never waits forever for a C++→JS call. - **Why:** WebKit2GTK accelerated compositing could fail silently under XWayland, leaving web views - blank (Setup Wizard stuck on "Loading..."); disabling it everywhere needlessly drops hardware - acceleration; without the JS timeout the user is stuck when the signal is lost. + **Why:** WebKit2GTK accelerated compositing could fail silently under XWayland before 2.42, leaving + web views blank (Setup Wizard stuck on "Loading..."); the variable also turns off threaded scrolling, + so applying it on a newer WebKit needlessly drops both GPU compositing and async scrolling (the + Device tab loses its smoothness); without the JS timeout the user is stuck when the signal is lost. ```cpp // Wrong: unconditional, kills HW acceleration everywhere ::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", false); - // Right (default path only): non-replacing, before GTK init, Wayland session with XWayland - if (display && *display && wayland && *wayland) + // Right (default path only): non-replacing, before GTK init, WebKitGTK < 2.42, + // and only in the branch that forces GDK_BACKEND=x11 (wx without EGL), the + // case that runs through XWayland + if (WebView::NeedsCompositingWorkaround() && wayland && *wayland) ::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /*replace*/ false); ``` Cite: c12912e0df (`src/OrcaSlicer.cpp`; `resources/web/guide/0/load.js` `OnInit` diff --git a/scripts/flatpak/entrypoint b/scripts/flatpak/entrypoint index f8e108e85f..7137c3798d 100644 --- a/scripts/flatpak/entrypoint +++ b/scripts/flatpak/entrypoint @@ -1,7 +1,7 @@ #!/usr/bin/env sh -# Work-around https://gitlab.gnome.org/GNOME/gnome-build-meta/-/issues/754 -grep -q org.freedesktop.Platform.GL.nvidia /.flatpak-info && export WEBKIT_DISABLE_DMABUF_RENDERER=1 +# WebKit's NVIDIA DMABUF workaround (gnome-build-meta#754) is applied by the +# binary itself, gated on the runtime WebKitGTK version (src/OrcaSlicer.cpp). # Work-around https://github.com/bambulab/BambuStudio/issues/3440 # Use LC_NUMERIC instead of LC_ALL to prevent decimal separator issues diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index ef0e4bbe3a..468cfb9a30 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -163,6 +163,7 @@ using json = nlohmann::json; #include #endif #include +#include "slic3r/GUI/Widgets/WebView.hpp" #endif #ifdef SLIC3R_GUI @@ -1156,8 +1157,10 @@ int CLI::run(int argc, char **argv) // opt-in path. The ~2020-era WebKit2GTK XWayland compositor bug it // worked around appears fixed in WebKit2GTK >= 2.42; leaving it // unset preserves WebKit hardware acceleration on Device / Setup - // Wizard / login / store. The default path still applies it on - // XWayland sessions as a conservative fallback for older WebKit. + // Wizard / login / store. The default path applies it only where it + // forces GDK_BACKEND=x11 below (a build without EGL, so the app really + // runs through XWayland), and only when the linked WebKitGTK predates + // the fix (< 2.42), as a conservative fallback for older WebKit. // ------------------------------------------------------------------ { const char* gdk_backend = ::getenv("GDK_BACKEND"); @@ -1165,6 +1168,14 @@ int CLI::run(int argc, char **argv) // honours the first backend in the comma-separated list). const bool x11_opt_in = gdk_backend && boost::starts_with(gdk_backend, "x11"); + // NVIDIA's proprietary driver cannot back WebKitGTK's DMABUF renderer. + // Force the shared-memory transport, which keeps compositing and threaded + // scrolling; the second variable clears Debian/Ubuntu/openSUSE's isNVIDIA(). + if (::access("/proc/driver/nvidia/version", F_OK) == 0 && WebView::SupportsForceSharedMemory()) { + ::setenv("WEBKIT_DMABUF_RENDERER_FORCE_SHM", "1", /* replace */ false); + ::setenv("WEBKIT_FORCE_DMABUF_RENDERER", "1", /* replace */ false); + } + if (x11_opt_in) { // ===== X11 / XWayland (opted in via GDK_BACKEND=x11) ===== BOOST_LOG_TRIVIAL(info) << "GDK_BACKEND=x11 detected; applying v2.3.2 X11/XWayland workarounds."; @@ -1204,23 +1215,18 @@ int CLI::run(int argc, char **argv) if (wayland_env && *wayland_env) { BOOST_LOG_TRIVIAL(warning) << "Wayland detected but wxWidgets has no EGL support (wxHAS_EGL is OFF). Forcing X11 backend."; ::setenv("GDK_BACKEND", "x11", true); + + // GTK will use X11 in a Wayland session here, which is the + // one default-path case that really runs through XWayland. + // WebKit2GTK accelerated compositing could fail silently + // there on releases before 2.42, so force it off. Disabling + // it also turns off threaded scrolling, hence the gate. + if (WebView::NeedsCompositingWorkaround()) + ::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /* replace */ false); } } #endif - // WebKit2GTK compositing can fail under XWayland on older - // WebKit releases. Disable it only when both DISPLAY and - // WAYLAND_DISPLAY are set (i.e. an XWayland session is in - // use). On pure X11 or native Wayland, compositing is left - // enabled so WebKit retains HW acceleration. - { - const char* display_env_wk = ::getenv("DISPLAY"); - const char* wayland_env_wk = ::getenv("WAYLAND_DISPLAY"); - if (display_env_wk && *display_env_wk && wayland_env_wk && *wayland_env_wk) { - ::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /* replace */ false); - } - } - // XInitThreads is needed before GStreamer may use Xlib. On // native Wayland without DISPLAY, GStreamer uses waylandsink // (no Xlib involved), so the call is skipped. diff --git a/src/dev-utils/platform/unix/build_linux_image.sh.in b/src/dev-utils/platform/unix/build_linux_image.sh.in index 7ccf5a5928..a7f99ba775 100755 --- a/src/dev-utils/platform/unix/build_linux_image.sh.in +++ b/src/dev-utils/platform/unix/build_linux_image.sh.in @@ -429,7 +429,8 @@ if [ "\$XDG_SESSION_TYPE" = "wayland" ] && [ "\$ZINK_DISABLE_OVERRIDE" != "1" ]; export __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json export MESA_LOADER_DRIVER_OVERRIDE=zink export GALLIUM_DRIVER=zink - export WEBKIT_DISABLE_DMABUF_RENDERER=1 + # WebKit's NVIDIA DMABUF workaround is applied by the binary itself, + # gated on the runtime WebKitGTK version (src/OrcaSlicer.cpp). fi fi fi diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp index 7af3ba4f1d..dde4df385a 100644 --- a/src/slic3r/GUI/Widgets/WebView.cpp +++ b/src/slic3r/GUI/Widgets/WebView.cpp @@ -74,6 +74,10 @@ webkit_web_view_run_javascript_finish (WebKitWebView GError **error); WEBKIT_API void webkit_javascript_result_unref (WebKitJavascriptResult *js_result); +WEBKIT_API guint +webkit_get_major_version (void); +WEBKIT_API guint +webkit_get_minor_version (void); } #endif @@ -427,6 +431,29 @@ bool WebView::NeedsRecreateOnShow() return false; #endif } + +bool WebView::NeedsCompositingWorkaround() +{ +#ifdef __linux__ + const guint major = webkit_get_major_version(); + const guint minor = webkit_get_minor_version(); + return major < 2 || (major == 2 && minor < 42); +#else + return false; +#endif +} + +bool WebView::SupportsForceSharedMemory() +{ +#ifdef __linux__ + const guint major = webkit_get_major_version(); + const guint minor = webkit_get_minor_version(); + return major > 2 || (major == 2 && minor >= 44); +#else + return false; +#endif +} + #if wxUSE_WEBVIEW_EDGE bool WebView::CheckWebViewRuntime() { diff --git a/src/slic3r/GUI/Widgets/WebView.hpp b/src/slic3r/GUI/Widgets/WebView.hpp index bef8a4a101..0692400719 100644 --- a/src/slic3r/GUI/Widgets/WebView.hpp +++ b/src/slic3r/GUI/Widgets/WebView.hpp @@ -34,6 +34,16 @@ public: // ignoring every navigation. A panel that gets true here recreates its view on first Show(). static bool NeedsRecreateOnShow(); + // True when the linked WebKitGTK predates 2.42, where accelerated compositing + // could fail silently on XWayland sessions, so it is forced off as a fallback. + // False on every other platform. Safe to call before GTK initialises. + static bool NeedsCompositingWorkaround(); + + // True when the linked WebKitGTK is new enough (>= 2.44) to honour + // WEBKIT_DMABUF_RENDERER_FORCE_SHM, the shared-memory transport switch. + // False on every other platform. Safe to call before GTK initialises. + static bool SupportsForceSharedMemory(); + static void RecreateAll(); }; From e0651ec31dd4ab4dcadce0c493936ab1d64bf1fd Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:09:08 +0800 Subject: [PATCH 10/21] Let ccache cache precompiled-header compiles in Linux builds (#16519) --- build_linux.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/build_linux.sh b/build_linux.sh index 816f1809fe..cf07d7e9df 100755 --- a/build_linux.sh +++ b/build_linux.sh @@ -22,7 +22,7 @@ function usage() { echo " -g: run the requested build steps inside a Docker/Podman Ubuntu 24.04 container similar to the GitHub Actions Linux runner" echo " -h: prints this help text" echo " -i: build the Orca Slicer AppImage (optional)" - echo " -p: boost ccache hit rate by disabling precompiled headers (default: ON)" + echo " -p: disable precompiled headers (on by default): more ccache hits after header changes, slower misses" echo " -r: skip RAM and disk checks (low RAM compiling)" echo " -s: build the Orca Slicer (optional)" echo " -t: build tests (optional), requires -s flag" @@ -549,6 +549,20 @@ fi if [ -n "${CMAKE_CCACHE}" ] ; then echo "${CMAKE_CCACHE} found, enabling compiler caching..." export CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}") + # The same ccache settings as the CI build. Without this sloppiness ccache refuses every + # compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers + # a checkout has just written. It is added to the sloppiness already configured, and + # CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win. + if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then + if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then + CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true) + export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime" + fi + # Hash the includes the compiler reports instead of preprocessing every miss. + if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then + export CCACHE_DEPEND=1 + fi + fi else echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds." fi From bc8d0a78581f0f918222ba935d0061292da42294 Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:27:33 +0800 Subject: [PATCH 11/21] Wait for the cloud preset sync before offering to migrate filament presets (#16523) --- src/slic3r/GUI/GUI_App.cpp | 44 ++++++++++++++++++++++++++++++++------ src/slic3r/GUI/GUI_App.hpp | 9 ++++++-- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 5351ae4904..020cd2a50f 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -6863,7 +6863,7 @@ void GUI_App::push_notification(const MachineObject* obj, wxString msg, wxStrin } } -void GUI_App::reload_settings() +bool GUI_App::reload_settings() { if (preset_bundle && m_agent) { // Load user's personal presets @@ -6871,7 +6871,7 @@ void GUI_App::reload_settings() int result = m_agent->get_user_presets(&user_presets); if (result != 0) { BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": get_user_presets failed with code " << result << ", skipping sync"; - return; + return false; } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " cloud user preset number is: " << user_presets.size(); auto refresh_synced_ui = [this, user_presets = std::move(user_presets)]() mutable { @@ -6946,13 +6946,30 @@ void GUI_App::reload_settings() refresh_synced_ui(); else CallAfter(refresh_synced_ui); + return true; } + return false; } void GUI_App::check_legacy_user_filament_presets(bool by_user) { + // One check at a time: the startup check can come due while the manual one is open, and on macOS the menu bar + // stays usable while a modal dialog is up. + if (m_legacy_filament_check_running) + return; if (!by_user && app_config->get_bool("legacy_user_filament_presets_dont_remind")) return; + // The local copies may be older than the cloud ones another machine changed: migrating them would push changes + // made against stale copies and conflict with the cloud. The startup check waits for the presets to be pulled. + if (!by_user && m_user_preset_pull_pending) { + m_legacy_filament_check_due = true; + return; + } + // This check also answers a startup check that is still waiting. + m_legacy_filament_check_due = false; + m_legacy_filament_check_running = true; + ScopeGuard running_guard([this] { m_legacy_filament_check_running = false; }); + const std::vector legacy_presets = preset_bundle->legacy_user_filament_presets(); if (legacy_presets.empty()) { if (by_user) @@ -6997,6 +7014,9 @@ void GUI_App::check_legacy_user_filament_presets(bool by_user) show_error(mainframe, format_wxstr(_L("Your filament presets could not be migrated. A backup of your presets was saved to:\n%1%"), backup)); return; } + // Ask the sync loop to push on its next pass instead of its next 60 s round, which shortens the time the migrated + // presets stay unpushed. + m_sync_user_presets_now.store(true, std::memory_order_release); preset_bundle->update_compatible(PresetSelectCompatibleType::Never); mainframe->update_side_preset_ui(); for (auto tab : tabs_list) { @@ -7763,6 +7783,7 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) BOOST_LOG_TRIVIAL(info) << "start_sync_service..."; // BBS m_user_sync_token.reset(new int(0)); + m_user_preset_pull_pending = true; if (with_progress_dlg) { // Mark a manual progress dialog as active so restart_sync_user_preset() ignores // repeat triggers while it is on screen (prevents stacking modal dialogs). @@ -7836,8 +7857,16 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) finishFn(ret == 0); - if (ret == 0 && m_agent && !t.expired()) - reload_settings(); + // Queued after the refresh reload_settings() queues, so it runs once the pulled presets are loaded. + if (ret == 0 && m_agent && !t.expired() && reload_settings()) + CallAfter([this, t] { + // A stopped or restarted sync: the next sync, if one starts, pulls the presets again. + if (t.expired() || is_closing()) + return; + m_user_preset_pull_pending = false; + if (m_legacy_filament_check_due) + check_legacy_user_filament_presets(false); + }); // For orca specific syncing auto orca_agent = std::dynamic_pointer_cast(m_agent->get_cloud_agent()); @@ -7852,8 +7881,11 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg) // Sync once immediately, then every 60 seconds. while (!t.expired()) { ++tick_tock; - // Sync once immediately, then every 60s, or right away when a force-push asked for it. - if (tick_tock % 120 == 0 || m_sync_user_presets_now.exchange(false, std::memory_order_acq_rel)) { + // Sync once immediately, then every 60s, or right away when a force-push or a preset migration asked + // for it. The request is consumed on every tick, so one made before a scheduled pass is served by it + // instead of running a second pass straight after. + const bool push_now = m_sync_user_presets_now.exchange(false, std::memory_order_acq_rel); + if (tick_tock % 120 == 0 || push_now) { tick_tock = 0; if (m_agent) { if (!m_agent->is_user_login()) { diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 475bec7e26..076855471e 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -355,6 +355,9 @@ private: std::atomic m_restart_sync_pending {false}; std::atomic m_sync_user_preset_dlg_active {false}; // a manual "Sync Presets" progress dialog is on screen (see restart_sync_user_preset) std::atomic m_sync_user_presets_now {false}; // request the sync loop to push user presets on its next tick + bool m_user_preset_pull_pending {false}; // set when a sync thread starts, cleared once its pull is applied; a failed pull or a stopped sync leaves it set + bool m_legacy_filament_check_due {false}; // the startup check waits for that pull + bool m_legacy_filament_check_running {false}; // a check is in progress, its dialog may be open std::atomic m_migration_retry_pending {false}; bool m_is_dark_mode{ false }; bool m_adding_script_handler { false }; @@ -589,7 +592,8 @@ public: void check_new_version_sf(bool show_tips = false, int by_user = 0); // Offers to migrate user filament presets made against an older release that lost printers their parent // listed then. At startup it asks on every start that finds such presets, unless the user chose not to be - // reminded; by_user also reports when there is nothing to migrate. + // reminded; while the user presets sync with the cloud, it waits until they have been pulled. by_user also + // reports when there is nothing to migrate. void check_legacy_user_filament_presets(bool by_user); bool process_network_msg(std::string dev_id, std::string msg); void request_new_version(int by_user); @@ -600,7 +604,8 @@ public: std::string format_IP(const std::string& ip); void show_dialog(wxString msg); void push_notification(const MachineObject* obj, wxString msg, wxString title = wxEmptyString, UserNotificationStyle style = UserNotificationStyle::UNS_NORMAL); - void reload_settings(); + // Returns false when the cloud presets could not be fetched, so nothing was reloaded. + bool reload_settings(); void remove_user_presets(); bool maybe_migrate_user_presets_on_login(); From f6cf18fb96d28c36ab1a539af3279f91d4b6a21b Mon Sep 17 00:00:00 2001 From: mosfet80 <10235105+mosfet80@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:47:21 +0200 Subject: [PATCH 12/21] Update libjpeg-turbo to 3.2.0 (#16098) Also removes the Linux-only JPEG_VERSION block, so Linux builds libjpeg with the default 6.2 ABI like Windows and macOS. --- deps/JPEG/JPEG.cmake | 19 ++----------------- scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 6 +++--- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/deps/JPEG/JPEG.cmake b/deps/JPEG/JPEG.cmake index 82b0ca5565..b3e5b06acb 100644 --- a/deps/JPEG/JPEG.cmake +++ b/deps/JPEG/JPEG.cmake @@ -1,22 +1,7 @@ -if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - if (JPEG_VERSION STREQUAL "6") - message("Using Jpeg Lib 62") - set(jpeg_flag "") - elseif (JPEG_VERSION STREQUAL "7") - message("Using Jpeg Lib 70") - set(jpeg_flag "-DWITH_JPEG7=ON") - else () - message("Using Jpeg Lib 80") - set(jpeg_flag "-DWITH_JPEG8=ON") - endif () -endif() - orcaslicer_add_cmake_project(JPEG - URL https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.0.1.zip - URL_HASH SHA256=d6d99e693366bc03897677650e8b2dfa76b5d6c54e2c9e70c03f0af821b0a52f - DEPENDS ${ZLIB_PKG} + URL https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.2.0.zip + URL_HASH SHA256=1b4c2cb6cba1ca5e53b9e85eeeab02154cab4b70894c96e97a9c747a26d19d63 CMAKE_ARGS -DENABLE_SHARED=OFF -DENABLE_STATIC=ON - ${jpeg_flag} ) diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index a8cbf1d868..cca417f735 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -336,10 +336,10 @@ modules: sha256: 3d22d46c566b1761a0e15ea397589b3a5f36ac09b7c785382e6470156c04247f dest: external-packages/PNG - # libjpeg-turbo 3.0.1 + # libjpeg-turbo 3.2.0 - type: file - url: https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.0.1.zip - sha256: d6d99e693366bc03897677650e8b2dfa76b5d6c54e2c9e70c03f0af821b0a52f + url: https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/3.2.0.zip + sha256: 1b4c2cb6cba1ca5e53b9e85eeeab02154cab4b70894c96e97a9c747a26d19d63 dest: external-packages/JPEG # Freetype 2.12.1 From 4149401c535ba6aa3da0df79f5bf9a52faa5de7f Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 16 Oct 2026 02:41:12 +0800 Subject: [PATCH 13/21] Keep the texture picker from stalling on open Opening the texture picker decoded every texture in the expanded sections on the GUI thread in one frame, each one twice at full size. Only the visible tiles now load, at most a couple per frame, and the rest fill in over the following frames. The library validation no longer decodes the pixels: a header-only check, is_decodable_8bit_png(), accepts exactly what the two decoders take. --- src/libslic3r/PNGReadWrite.cpp | 29 +++++++++++++++ src/libslic3r/PNGReadWrite.hpp | 3 ++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 35 +++++++++++++++---- src/slic3r/GUI/TextureLibrary.cpp | 18 ++-------- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/libslic3r/PNGReadWrite.cpp b/src/libslic3r/PNGReadWrite.cpp index 1f29de646b..40f88a9447 100644 --- a/src/libslic3r/PNGReadWrite.cpp +++ b/src/libslic3r/PNGReadWrite.cpp @@ -211,6 +211,35 @@ bool decode_colored_png(const ReadBuf &in_buf, ImageColorscale &out_img) return decode_colored_png(stream, out_img); } +bool is_decodable_8bit_png(const ReadBuf &pngbuf) +{ + static const constexpr int PNG_SIG_BYTES = 8; + + struct ReadBufStream stream{pngbuf}; + std::vector sig(PNG_SIG_BYTES, 0); + stream.read(sig.data(), PNG_SIG_BYTES); + if (!png_check_sig(sig.data(), PNG_SIG_BYTES)) + return false; + + PNGDescr dsc; + dsc.png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr); + if (!dsc.png) + return false; + dsc.info = png_create_info_struct(dsc.png); + if (!dsc.info) + return false; + + if (!png_read_header_guarded(dsc.png, dsc.info, &stream, PNG_SIG_BYTES)) + return false; + + const size_t color_type = png_get_color_type(dsc.png, dsc.info); + const size_t bit_depth = png_get_bit_depth(dsc.png, dsc.info); + const bool grey = color_type == PNG_COLOR_TYPE_GRAY; + const bool colour = color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_RGB_ALPHA; + return png_get_image_width(dsc.png, dsc.info) > 0 && png_get_image_height(dsc.png, dsc.info) > 0 && + bit_depth == 8 && (grey || colour); +} + // Down to earth function to store a packed RGB image to file. Mostly useful for debugging purposes. // Based on https://www.lemoda.net/c/write-png/ diff --git a/src/libslic3r/PNGReadWrite.hpp b/src/libslic3r/PNGReadWrite.hpp index c2d2149e1f..6b8ed75cae 100644 --- a/src/libslic3r/PNGReadWrite.hpp +++ b/src/libslic3r/PNGReadWrite.hpp @@ -50,6 +50,9 @@ struct ReadBuf { const void *buf = nullptr; const size_t sz = 0; }; bool is_png(const ReadBuf &pngbuf); +// True if decode_png() or decode_colored_png() would accept these bytes, judged from the header alone. +bool is_decodable_8bit_png(const ReadBuf &pngbuf); + struct ReadBufStream: public IStream { const ReadBuf &rbuf_ref; size_t pos = 0; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 4597e98fd2..be1f72e520 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -174,6 +174,10 @@ constexpr int HEIGHT_TEX_MAX_PX = 2048; // The full-size image is only ever adjusted once, on commit. constexpr int ADJUST_PREVIEW_MAX_PX = 384; +// Library textures the texture picker decodes per frame. Each decode runs on the GUI thread, so the +// rest of the visible tiles fill in over the following frames instead of in one stall. +constexpr int PICKER_LOADS_PER_FRAME = 2; + // 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. @@ -4331,6 +4335,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane std::optional to_remove; bool import_clicked = false; bool download_clicked = false; + int loads_left = PICKER_LOADS_PER_FRAME; + bool loads_deferred = false; // Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile // overlays a remove button on its corner, and that button would otherwise be "the previous item" the @@ -4348,11 +4354,30 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane }; // One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by // name, the user's own most recently used first - the order texture_library() produced); a user tile - // carries a remove button on hover. Returns how many it drew. + // carries a remove button on hover. Returns how many tiles it laid out, drawn or not. const auto grid = [&](const ImVec2 &origin, const std::vector &entries, bool removable) { int n = 0; for (const TextureLibraryEntry *entry : entries) { - ImGui::SetCursorScreenPos(tile_pos(origin, n++)); + const ImVec2 rect_min = tile_pos(origin, n++); + const ImVec2 rect_max = ImVec2(rect_min.x + tile, rect_min.y + tile); + const bool current = entry->path == layer->path; + // The selected tile is centred even when it is off screen, so it is drawn and loaded next frame. + if (current && m_picker_reveal_selected) { + ImGui::SetScrollFromPosY(0.5f * (rect_min.y + rect_max.y) - ImGui::GetWindowPos().y, 0.5f); + m_parent.set_as_dirty(); + } + if (!ImGui::IsRectVisible(rect_min, rect_max)) + continue; + // Thumbnails decode on this thread, so only a few per frame; the rest fill in on later frames. + const bool cached = m_library_textures.count(entry->path) != 0; + if (!cached && loads_left == 0) { + loads_deferred = true; + dl->AddRectFilled(rect_min, rect_max, ImGui::GetColorU32(ImGuiCol_FrameBg), style.FrameRounding); + continue; + } + if (!cached) + --loads_left; + ImGui::SetCursorScreenPos(rect_min); ImGui::PushID(entry->path.c_str()); const LibraryTexture *tex = get_library_texture(entry->path); GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr; @@ -4375,7 +4400,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane if (removable) ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax(); - const bool current = entry->path == layer->path; const bool hovered = ImGui::IsItemHovered(); // The layer's current texture carries an accent ring, and the tile under the pointer a thinner, // fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them. @@ -4384,9 +4408,6 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w), ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w); } - // Centred, as far as the scroll range allows, so the textures around it are in view too. - if (current && m_picker_reveal_selected) - ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f); if (hovered) panel_tooltip(entry->name); @@ -4575,6 +4596,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str()); ImGui::PopTextWrapPos(); } + if (loads_deferred) + m_parent.set_as_dirty(); // Done whether or not the current texture was drawn: one in a collapsed section stays where it is. m_picker_reveal_selected = false; ImGui::EndChild(); diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp index 0388ed4f21..3d76791027 100644 --- a/src/slic3r/GUI/TextureLibrary.cpp +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -223,23 +223,11 @@ std::vector read_file_bytes(const std::string &path) return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); } -// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors -// exactly what decode_height_texture() accepts: 8-bit grayscale, or 8-bit colour (whose luminance is -// the height and whose RGB is available to colour the model). Anything else is converted on load. +// True if decode_height_texture() can read these bytes, so they can be stored on a layer as-is. +// Anything else is converted on load. bool is_supported_height_map(const std::vector &bytes) { - if (bytes.empty()) - return false; - const png::ReadBuf rbuf{ bytes.data(), bytes.size() }; - if (!png::is_png(rbuf)) - return false; - png::ImageGreyscale gray; - if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0) - return true; - png::ImageColorscale color; - return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 && - color.bytes_per_pixel >= 3 && - color.buf.size() == color.cols * color.rows * size_t(color.bytes_per_pixel); + return !bytes.empty() && png::is_decodable_8bit_png(png::ReadBuf{ bytes.data(), bytes.size() }); } std::vector g_library; From b00237807dc76bec2c967d5330497c934e95e2ce Mon Sep 17 00:00:00 2001 From: Matias Alejandro Yocca Date: Thu, 15 Oct 2026 18:13:28 -0300 Subject: [PATCH 14/21] Fix crash when dragging objects in sequential printing mode (#16029) The clearance preview shown while dragging sorted instances with a comparator that is not a strict weak ordering, which aborts with libc++ debug hardening. The preview now uses object-list order, with each object's copies in their last validated print order, which is the order print validation uses. The height check is now in SequentialPrintClearance.hpp, with unit tests. --- src/slic3r/CMakeLists.txt | 1 + src/slic3r/GUI/GLCanvas3D.cpp | 106 ++---------- src/slic3r/GUI/SequentialPrintClearance.hpp | 63 +++++++ tests/slic3rutils/CMakeLists.txt | 1 + .../slic3rutils/test_sequential_clearance.cpp | 162 ++++++++++++++++++ 5 files changed, 242 insertions(+), 91 deletions(-) create mode 100644 src/slic3r/GUI/SequentialPrintClearance.hpp create mode 100644 tests/slic3rutils/test_sequential_clearance.cpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 05650d2275..367aadaa27 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -222,6 +222,7 @@ set(SLIC3R_GUI_SOURCES GUI/Gizmos/GLGizmoUtils.hpp GUI/GLCanvas3D.cpp GUI/GLCanvas3D.hpp + GUI/SequentialPrintClearance.hpp GUI/GLModel.cpp GUI/GLModel.hpp GUI/GLSelectionRectangle.cpp diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index bf87241fb6..95944a8da5 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -65,6 +65,7 @@ #include #include "slic3r/GUI/Selection.hpp" #include "GLCanvas3D.hpp" +#include "SequentialPrintClearance.hpp" #ifdef SLIC3R_CAD #include "slic3r/GUI/CAD/DesignSketchTool.hpp" // Design tab: interactive 2D sketch tool #endif @@ -6397,17 +6398,16 @@ void GLCanvas3D::update_sequential_clearance() //BBS: add the height logic PartPlate* plate = wxGetApp().plater()->get_partplate_list().get_curr_plate(); Polygons polygons; - std::vector> height_polygons; polygons.reserve(instances_count); - height_polygons.reserve(instances_count); - std::vector convex_and_bounding_boxes; - struct height_info - { - double instance_height; - BoundingBox bounding_box; - Polygon hull_polygon; - }; + std::vector convex_and_bounding_boxes; + convex_and_bounding_boxes.reserve(instances_count); + std::map print_order; + for (const PrintObject* print_object : fff_print()->objects()) + for (const PrintInstance& instance : print_object->instances()) + print_order.emplace(instance.model_instance->id(), instance.model_instance->arrange_order); + for (size_t i = 0; i < instance_transforms.size(); ++i) { + const size_t first_instance = convex_and_bounding_boxes.size(); const auto& instances = instance_transforms[i]; double rotation_z0 = instances.front()->get_rotation().z(); int index = 0; @@ -6427,96 +6427,20 @@ void GLCanvas3D::update_sequential_clearance() Polygon convex_hull(std::move(inst_pts)); BoundingBox bouding_box = convex_hull.bounding_box(); BoundingBox plate_bb = plate->get_bounding_box_crd(); + const ObjectID instance_id = m_model->objects[i]->instances[index]->id(); double instance_height = m_model->objects[i]->get_instance_max_z(index++); //skip the object for not current plate if (!plate_bb.overlap(bouding_box)) continue; - convex_and_bounding_boxes.push_back({instance_height, bouding_box, convex_hull}); + convex_and_bounding_boxes.push_back({instance_height, bouding_box, convex_hull, instance_id}); polygons.emplace_back(std::move(convex_hull)); } + sort_sequential_clearance_instances(convex_and_bounding_boxes.begin() + first_instance, convex_and_bounding_boxes.end(), print_order); } - //sort the print instance - std::sort(convex_and_bounding_boxes.begin(), convex_and_bounding_boxes.end(), - [](auto &l, auto &r) { - auto ly1 = l.bounding_box.min.y(); - auto ly2 = l.bounding_box.max.y(); - auto ry1 = r.bounding_box.min.y(); - auto ry2 = r.bounding_box.max.y(); - auto inter_min = std::max(ly1, ry1); - auto inter_max = std::min(ly2, ry2); - auto lx = l.bounding_box.min.x(); - auto rx = r.bounding_box.min.x(); - if (inter_max - inter_min > 0) - return (lx < rx) || ((lx == rx)&&(ly1 < ry1)); - else - return (ly1 < ry1); - }); - - /*bool has_interlaced_objects = false; - for (int k = 0; k < bounding_box_count; k++) - { - Polygon& convex = convex_and_bounding_boxes[k].hull_polygon; - BoundingBox& bbox = convex_and_bounding_boxes[k].bounding_box; - auto iy1 = bbox.min.y(); - auto iy2 = bbox.max.y(); - - for (int i = k+1; i < bounding_box_count; i++) - { - Polygon& next_convex = convex_and_bounding_boxes[i].hull_polygon; - BoundingBox& next_bbox = convex_and_bounding_boxes[i].bounding_box; - auto py1 = next_bbox.min.y(); - auto py2 = next_bbox.max.y(); - auto inter_min = std::max(iy1, py1); // min y of intersection - auto inter_max = std::min(iy2, py2); // max y of intersection. length=max_y-min_y>0 means intersection exists - if (inter_max - inter_min > 0) { - has_interlaced_objects = true; - break; - } - } - if (has_interlaced_objects) - break; - }*/ - - int bounding_box_count = convex_and_bounding_boxes.size(); - double printable_height = fff_print()->config().printable_height; - double hc1 = fff_print()->config().extruder_clearance_height_to_lid; - double hc2 = fff_print()->config().extruder_clearance_height_to_rod; - for (int k = 0; k < bounding_box_count; k++) - { - Polygon& convex = convex_and_bounding_boxes[k].hull_polygon; - BoundingBox& bbox = convex_and_bounding_boxes[k].bounding_box; - auto iy1 = bbox.min.y(); - auto iy2 = bbox.max.y(); - double height = (k == (bounding_box_count - 1))?printable_height:hc1; - - /*if (has_interlaced_objects) { - if ((k < (bounding_box_count - 1)) && (convex_and_bounding_boxes[k].instance_height > hc2)) { - height_polygons.emplace_back(std::make_pair(convex, hc2)); - } - } - else { - if ((k < (bounding_box_count - 1)) && (convex_and_bounding_boxes[k].instance_height > hc1)) { - height_polygons.emplace_back(std::make_pair(convex, hc1)); - } - }*/ - - for (int i = k+1; i < bounding_box_count; i++) - { - Polygon& next_convex = convex_and_bounding_boxes[i].hull_polygon; - BoundingBox& next_bbox = convex_and_bounding_boxes[i].bounding_box; - auto py1 = next_bbox.min.y(); - auto py2 = next_bbox.max.y(); - auto inter_min = std::max(iy1, py1); // min y of intersection - auto inter_max = std::min(iy2, py2); // max y of intersection. length=max_y-min_y>0 means intersection exists - if (inter_max - inter_min > 0) { - height = hc2; - break; - } - } - if (height < convex_and_bounding_boxes[k].instance_height) - height_polygons.emplace_back(std::make_pair(convex, height)); - } + const auto& config = fff_print()->config(); + const auto height_polygons = sequential_clearance_height_polygons(convex_and_bounding_boxes, config.printable_height, + config.extruder_clearance_height_to_lid, config.extruder_clearance_height_to_rod); // sends instances 2d hulls to be rendered set_sequential_print_clearance_visible(true); diff --git a/src/slic3r/GUI/SequentialPrintClearance.hpp b/src/slic3r/GUI/SequentialPrintClearance.hpp new file mode 100644 index 0000000000..0f5d79be9c --- /dev/null +++ b/src/slic3r/GUI/SequentialPrintClearance.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ObjectID.hpp" +#include "libslic3r/Polygon.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r::GUI { + +struct SequentialClearanceInstance +{ + double instance_height; + BoundingBox bounding_box; + Polygon hull_polygon; + ObjectID instance_id; +}; + +// PrintApply may group rotated copies in a different order from ModelObject::instances. +// Reorder one object's instances only when validation has assigned all of them an order. +inline void sort_sequential_clearance_instances( + std::vector::iterator first, std::vector::iterator last, + const std::map& print_order) +{ + if (!std::all_of(first, last, [&print_order](const SequentialClearanceInstance& instance) { + const auto order = print_order.find(instance.instance_id); + return order != print_order.end() && order->second > 0; + })) + return; + + std::stable_sort(first, last, [&print_order](const SequentialClearanceInstance& lhs, const SequentialClearanceInstance& rhs) { + return print_order.at(lhs.instance_id) < print_order.at(rhs.instance_id); + }); +} + +// Input follows object-list order with validated print order within each object. +inline std::vector> sequential_clearance_height_polygons( + const std::vector& instances, double printable_height, double height_to_lid, double height_to_rod) +{ + std::vector> height_polygons; + height_polygons.reserve(instances.size()); + for (size_t k = 0; k < instances.size(); ++k) { + const auto& instance = instances[k]; + double height = k + 1 == instances.size() ? printable_height : height_to_lid; + for (size_t i = k + 1; i < instances.size(); ++i) { + const auto& next_bbox = instances[i].bounding_box; + if (std::min(instance.bounding_box.max.y(), next_bbox.max.y()) > + std::max(instance.bounding_box.min.y(), next_bbox.min.y())) { + height = height_to_rod; + break; + } + } + if (height < instance.instance_height) + height_polygons.emplace_back(instance.hull_polygon, float(height)); + } + return height_polygons; +} + +} // namespace Slic3r::GUI diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index ac774899e7..83632601f7 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -47,6 +47,7 @@ add_executable(${_TEST_NAME}_tests test_file_url.cpp test_imgui_wrapper.cpp test_user_manager.cpp + test_sequential_clearance.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_sequential_clearance.cpp b/tests/slic3rutils/test_sequential_clearance.cpp new file mode 100644 index 0000000000..8068212a32 --- /dev/null +++ b/tests/slic3rutils/test_sequential_clearance.cpp @@ -0,0 +1,162 @@ +#include +#include +#include + +#include "catch2/matchers/catch_matchers.hpp" +#include "fff_print/test_helpers.hpp" +#include "slic3r/GUI/SequentialPrintClearance.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +using namespace Slic3r::GUI; +using Catch::Matchers::WithinAbs; + +namespace { + +SequentialClearanceInstance instance(coord_t x, coord_t y, double height) +{ + return {height, BoundingBox(Point(x, y), Point(x + 5, y + 10)), + Polygon{Point(x, y), Point(x + 5, y), Point(x + 5, y + 10), Point(x, y + 10)}}; +} + +} // namespace + +TEST_CASE("Sequential clearance preserves model print order when objects move", "[SequentialClearance][Regression]") +{ + const coord_t last_y = GENERATE(-20, 20); + std::vector instances{instance(20, 0, 50.), instance(0, last_y, 50.)}; + const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.); + + REQUIRE(warnings.size() == 1); + CHECK(warnings.front().first == instance(20, 0, 50.).hull_polygon); + CHECK_THAT(warnings.front().second, WithinAbs(30., 1e-6)); +} + +TEST_CASE("Sequential clearance handles chains of overlapping Y ranges", "[SequentialClearance][Regression]") +{ + // A overlaps B, B overlaps C, but A does not overlap C. Combining X order + // for overlaps with Y order otherwise formed the cycle B < A < C < B. + std::vector instances{ + instance(20, 0, 50.), instance(10, 8, 50.), instance(0, 16, 50.)}; + const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.); + + REQUIRE(warnings.size() == 2); + CHECK(warnings[0].first == instance(20, 0, 50.).hull_polygon); + CHECK(warnings[1].first == instance(10, 8, 50.).hull_polygon); + CHECK_THAT(warnings[0].second, WithinAbs(10., 1e-6)); + CHECK_THAT(warnings[1].second, WithinAbs(10., 1e-6)); +} + +TEST_CASE("Sequential clearance applies rod height only to positive Y overlap", "[SequentialClearance]") +{ + const coord_t next_y = GENERATE(9, 10, 11); + std::vector instances{instance(0, 0, 50.), instance(20, next_y, 50.)}; + const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.); + + REQUIRE(warnings.size() == 1); + CHECK(warnings.front().first == instances.front().hull_polygon); + CHECK_THAT(warnings.front().second, WithinAbs(next_y < 10 ? 10. : 30., 1e-6)); +} + +TEST_CASE("Sequential clearance allows the last object up to printable height", "[SequentialClearance]") +{ + std::vector instances; + CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty()); + + instances.push_back(instance(0, 0, 200.)); + CHECK(sequential_clearance_height_polygons(instances, 200., 30., 10.).empty()); + + instances.front().instance_height = 201.; + const auto warnings = sequential_clearance_height_polygons(instances, 200., 30., 10.); + REQUIRE(warnings.size() == 1); + CHECK_THAT(warnings.front().second, WithinAbs(200., 1e-6)); +} + +TEST_CASE("Sequential clearance warns the same rotated copy as print validation", "[SequentialClearance][Regression]") +{ + constexpr double rod_height = 10.; + constexpr double lid_height = 30.; + constexpr double printable_height = 200.; + Model model = Test::model("rotated copies", make_cube(10., 10., 50.)); + ModelObject* object = model.objects.front(); + object->instances.front()->set_offset(Vec3d(150., 80., 0.)); + ModelInstance* rotated = object->add_instance(); + rotated->set_offset(Vec3d(50., 80., 0.)); + rotated->set_rotation(Z, PI / 2.); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"print_sequence", "by object"}, {"extruder_clearance_radius", 20.}, + {"extruder_clearance_height_to_lid", lid_height}, {"extruder_clearance_height_to_rod", rod_height}, + {"printable_height", printable_height}}); + Print print; + print.auto_assign_extruders(object); + print.apply(model, config); + Polygons polygons; + std::vector> validation_warnings; + Print::sequential_print_clearance_valid(print, &polygons, &validation_warnings); + + std::map print_order; + for (const PrintObject* print_object : print.objects()) + for (const PrintInstance& print_instance : print_object->instances()) + print_order.emplace(print_instance.model_instance->id(), print_instance.model_instance->arrange_order); + + std::vector instances; + for (size_t i = 0; i < object->instances.size(); ++i) { + Polygon hull = object->convex_hull_2d(object->instances[i]->get_matrix()); + instances.push_back({object->get_instance_max_z(i), hull.bounding_box(), hull, object->instances[i]->id()}); + } + const Polygon expected_warning = instances[1].hull_polygon; + REQUIRE(validation_warnings.size() == 1); + CHECK(validation_warnings.front().first.bounding_box().contains(instances[1].bounding_box.center())); + CHECK_FALSE(validation_warnings.front().first.bounding_box().contains(instances[0].bounding_box.center())); + + sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order); + const auto warnings = sequential_clearance_height_polygons(instances, printable_height, lid_height, rod_height); + REQUIRE(warnings.size() == 1); + CHECK(warnings.front().first == expected_warning); + CHECK_THAT(warnings.front().second, WithinAbs(rod_height, 1e-6)); +} + +TEST_CASE("Sequential clearance keeps copy order when print order is unavailable", "[SequentialClearance]") +{ + std::vector instances{instance(20, 0, 50.), instance(0, 0, 50.)}; + instances[0].instance_id = ObjectID(1); + instances[1].instance_id = ObjectID(2); + std::map print_order{{ObjectID(1), 2}, {ObjectID(2), 1}}; + + SECTION("No validation order") { print_order.clear(); } + SECTION("New copy missing from print") { print_order.erase(ObjectID(2)); } + SECTION("Copy has not been ordered") { print_order[ObjectID(2)] = 0; } + SECTION("Copy has an invalid order") { print_order[ObjectID(2)] = -1; } + + sort_sequential_clearance_instances(instances.begin(), instances.end(), print_order); + CHECK(instances[0].instance_id == ObjectID(1)); + CHECK(instances[1].instance_id == ObjectID(2)); +} + +TEST_CASE("Sequential clearance keeps object order when sorting copies", "[SequentialClearance]") +{ + std::vector instances(4, instance(0, 0, 50.)); + for (size_t i = 0; i < instances.size(); ++i) + instances[i].instance_id = ObjectID(instances.size() - i); + const std::map print_order{{ObjectID(1), 1}, {ObjectID(2), 2}, {ObjectID(3), 3}, {ObjectID(4), 4}}; + + // The two objects were reordered in the model since the last validation. + sort_sequential_clearance_instances(instances.begin(), instances.begin() + 2, print_order); + sort_sequential_clearance_instances(instances.begin() + 2, instances.end(), print_order); + CHECK(instances[0].instance_id == ObjectID(3)); + CHECK(instances[1].instance_id == ObjectID(4)); + CHECK(instances[2].instance_id == ObjectID(1)); + CHECK(instances[3].instance_id == ObjectID(2)); +} From 109d09e0b8a25490ee0394f55769b1d735c36bba Mon Sep 17 00:00:00 2001 From: Error404JoyNotFound Date: Fri, 16 Oct 2026 03:05:53 +0530 Subject: [PATCH 15/21] Fix multi-tool standby heaters by turning off unused extruders (#15849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ooze prevention on, an extruder that has no layers left is set to 0 °C when it is parked, instead of its standby temperature. On IMEX printers where several filaments share one heater, the heater is only turned off once every filament on it is done. --- src/libslic3r/GCode.cpp | 45 +++++--- src/libslic3r/GCode.hpp | 1 + src/libslic3r/GCode/ToolOrdering.cpp | 15 ++- src/libslic3r/GCode/ToolOrdering.hpp | 3 + .../wipe_tower_temperature_trace_main.txt | 2 +- tests/fff_print/test_multifilament.cpp | 109 ++++++++++++++++++ 6 files changed, 155 insertions(+), 20 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index ce2be2db58..508ab5b421 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -432,27 +432,36 @@ static std::vector get_path_of_change_filament(const Print& print) std::string OozePrevention::pre_toolchange(GCode& gcodegen) { - std::string gcode; - unsigned int extruder_id = gcodegen.writer().filament()->id(); - const auto& filament_idle_temp = gcodegen.config().idle_temperature; - if (filament_idle_temp.get_at(extruder_id) == 0) { - // There is no idle temperature defined in filament settings. - // Use the delta value from print config. - if (gcodegen.config().standby_temperature_delta.value != 0) { - // we assume that heating is always slower than cooling, so no need to block - gcode += gcodegen.writer().set_temperature - (this->_get_temp(gcodegen) + gcodegen.config().standby_temperature_delta.value, false, extruder_id); - gcode.pop_back(); - gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed - } - } else { - // Use the value from filament settings. That one is absolute, not delta. - gcode += gcodegen.writer().set_temperature(filament_idle_temp.get_at(extruder_id), false, extruder_id); - gcode.pop_back(); - gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed + + // Check if this tool will ever be used again in this print. + bool is_last_use = false; + if (gcodegen.m_print != nullptr && !gcodegen.m_print->tool_ordering().empty()) { + coordf_t current_z = gcodegen.layer() ? gcodegen.layer()->print_z : gcodegen.writer().get_position().z(); + const auto& tool_ordering = gcodegen.m_print->tool_ordering(); + const auto& cfg = gcodegen.config(); + const int heater = imex_physical_heater_for(cfg.is_imex.value, cfg.physical_extruder_map, int(extruder_id)); + is_last_use = tool_ordering.is_last_extrusion_layer(current_z, extruder_id); + for (unsigned int f : tool_ordering.all_extruders()) + if (is_last_use && imex_physical_heater_for(cfg.is_imex.value, cfg.physical_extruder_map, int(f)) == heater) + is_last_use = tool_ordering.is_last_extrusion_layer(current_z, f); } + int temp = 0; + if (!is_last_use) { + const auto& filament_idle_temp = gcodegen.config().idle_temperature; + if (filament_idle_temp.get_at(extruder_id) == 0) { + if (gcodegen.config().standby_temperature_delta.value == 0) + return std::string(); + temp = this->_get_temp(gcodegen) + gcodegen.config().standby_temperature_delta.value; + } else { + temp = filament_idle_temp.get_at(extruder_id); + } + } + + std::string gcode = gcodegen.writer().set_temperature(temp, false, extruder_id); + gcode.pop_back(); + gcode += " ;cooldown\n"; // this is a marker for GCodeProcessor, so it can supress the commands when needed return gcode; } diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 08894574ed..b5898b2305 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -1004,6 +1004,7 @@ protected: size_t num_objects, size_t num_islands); + friend class OozePrevention; friend class Wipe; friend class WipeTowerIntegration; friend class PressureEqualizer; diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index 19f5c686f6..2ea34aef90 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -1276,6 +1276,13 @@ void ToolOrdering::collect_extruder_statistics(bool prime_multi_material) sort_remove_duplicates(m_all_printing_extruders); } + m_last_layer_per_extruder.clear(); + for (const LayerTools& lt : m_layer_tools) { + for (unsigned int ext : lt.extruders) { + m_last_layer_per_extruder[ext] = lt.print_z; + } + } + if (prime_multi_material && ! m_all_printing_extruders.empty()) { // Reorder m_all_printing_extruders in the sequence they will be primed, the last one will be m_first_printing_extruder. // Then set m_first_printing_extruder to the 1st extruder primed. @@ -3664,5 +3671,11 @@ int WipingExtrusions::get_support_interface_extruder_overrides(const PrintObject return -1; } - +bool ToolOrdering::is_last_extrusion_layer(coordf_t print_z, unsigned int extruder_id) const +{ + auto it = m_last_layer_per_extruder.find(extruder_id); + if (it == m_last_layer_per_extruder.end()) + return true; + return print_z >= it->second - EPSILON; +} } // namespace Slic3r diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index 5c11f482cd..b7aca7dd30 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -370,6 +370,7 @@ public: std::vector & initial_filaments); bool has_non_support_filament(const PrintConfig &config); + bool is_last_extrusion_layer(coordf_t print_z, unsigned int extruder_id) const; private: void initialize_layers(std::vector &zs); @@ -413,6 +414,8 @@ private: // which keeps every legacy per-object code path bit-identical (loops over an empty map are // no-ops; downstream emission falls through to the per-object branch). std::map>> m_gradient_volume_layers; + // Map: extruder_id -> print_z (z coordinate) of the last layer it is used on + std::map m_last_layer_per_extruder; const PrintObject* m_print_object_ptr = nullptr; Print* m_print; bool m_sorted = false; diff --git a/tests/data/wipe_tower_temperature_trace_main.txt b/tests/data/wipe_tower_temperature_trace_main.txt index f755008c26..2ff2b4f0a4 100644 --- a/tests/data/wipe_tower_temperature_trace_main.txt +++ b/tests/data/wipe_tower_temperature_trace_main.txt @@ -163,7 +163,7 @@ M109 S240 T0 ; set nozzle temperature and wait for it to be reached ; CP TOOLCHANGE END M104 S240 T1 ; preheat T1 time: 30s lead 30.0s ; CP TOOLCHANGE START -M104 S200 T0 ; set nozzle temperature ;cooldown +M104 S0 T0 ; set nozzle temperature ;cooldown T1 ; change extruder M109 S240 T1 ; set nozzle temperature and wait for it to be reached ; CP TOOLCHANGE END diff --git a/tests/fff_print/test_multifilament.cpp b/tests/fff_print/test_multifilament.cpp index 54a98d2268..b7fccf2890 100644 --- a/tests/fff_print/test_multifilament.cpp +++ b/tests/fff_print/test_multifilament.cpp @@ -1742,3 +1742,112 @@ TEST_CASE("Each filament cools with the fan speeds of its extruder variant", "[M CHECK(gcode.find("; start range high " + std::to_string(range_high) + "\n") != std::string::npos); } } + +TEST_CASE("Shared heater on IMEX printer stays hot until all filaments on it are done", "[MultiFilament]") +{ + DynamicPrintConfig cfg = multifilament_config(7); + cfg.set_deserialize_strict({ + {"ooze_prevention", 1}, + {"preheat_time", 0}, + {"single_extruder_multi_material", 0}, + }); + imex_7x4_printer(cfg); + + const std::string gcode = slice_with_object_overrides({make_cube(20., 20., 10.), make_cube(20., 20., 4.), make_cube(20., 20., 10.)}, + cfg, + {{{"extruder", 1}, {"layer_height", 0.30}}, + {{"extruder", 2}, {"layer_height", 0.15}}, + {{"extruder", 5}}}); + + bool premature_s0_t0 = false; + double current_z = 0.0; + + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) { + if (line.has_z()) + current_z = line.z(); + + const std::string raw = line.raw(); + if (raw.find("M104 S0 T0") != std::string::npos && raw.find(";cooldown") != std::string::npos) { + if (current_z < 9.0) + premature_s0_t0 = true; + } + }); + + // Filaments 1 and 2 share head 0, and filament 1 prints up to 10mm. + // T0 must not shut down at ~4mm when filament 2 finishes. + CHECK_FALSE(premature_s0_t0); +} + +TEST_CASE("Unused extruder turns off after its final layer on multi-object print", "[MultiFilament]") +{ + const std::string gcode = slice_with_object_overrides( + { make_cube(20., 20., 10.), make_cube(20., 20., 4.) }, + multifilament_config(2, { + { "single_extruder_multi_material", 0 }, + { "ooze_prevention", 1 }, + { "standby_temperature_delta", -40 }, + { "nozzle_temperature", "240,240" }, + }), + { + { { "extruder", 1 }, { "layer_height", 0.20 } }, + { { "extruder", 2 }, { "layer_height", 0.15 } } + }); + + int t1_s0_cooldown_count = 0; + int t0_s0_cooldown_count = 0; + + std::istringstream stream(gcode); + for (std::string line; std::getline(stream, line);) { + if (line.find(";cooldown") != std::string::npos) { + if (line.find("M104 S0 T1") != std::string::npos) + ++t1_s0_cooldown_count; + if (line.find("M104 S0 T0") != std::string::npos) + ++t0_s0_cooldown_count; + } + } + + // T1 (extruder 2, 4mm cube) gets exactly one S0 after its last layer. + // T0 (extruder 1, 10mm cube) never gets S0 mid-print. + CHECK(t1_s0_cooldown_count == 1); + CHECK(t0_s0_cooldown_count == 0); +} + +TEST_CASE("Unused extruder turns off on non-IMEX three-tool print", "[MultiFilament]") +{ + const std::string gcode = slice_with_object_overrides( + { make_cube(20., 20., 4.), make_cube(20., 20., 10.), make_cube(20., 20., 10.) }, + multifilament_config(3, { + { "single_extruder_multi_material", 0 }, + { "ooze_prevention", 1 }, + { "standby_temperature_delta", -40 }, + { "nozzle_temperature", "240,240,240" }, + }), + { + { { "extruder", 1 } }, + { { "extruder", 2 } }, + { { "extruder", 3 } } + }); + + bool premature_s0_t1 = false; + bool premature_s0_t2 = false; + double current_z = 0.0; + + GCodeReader reader; + reader.parse_buffer(gcode, [&](GCodeReader& /*self*/, const GCodeReader::GCodeLine& line) { + if (line.has_z()) + current_z = line.z(); + + const std::string raw = line.raw(); + if (raw.find(";cooldown") != std::string::npos && current_z < 9.0) { + if (raw.find("M104 S0 T1") != std::string::npos) + premature_s0_t1 = true; + if (raw.find("M104 S0 T2") != std::string::npos) + premature_s0_t2 = true; + } + }); + + // T1 and T2 print up to 10mm — neither should get S0 before that. + CHECK_FALSE(premature_s0_t1); + CHECK_FALSE(premature_s0_t2); +} \ No newline at end of file From 8b6c074c69ca81071e06bcd4105fa325d54e6ad8 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 16 Oct 2026 15:37:17 +0800 Subject: [PATCH 16/21] Fix publish dialog tab clipping and extruder switch distortion (#16543) * Fix TabCtrl clipping titles and indicator dots Button measures a label plus icon without the icon gap that render draws, so icon tabs were ellipsized and the indicator dot was cut off. Widen the horizontal padding so the measured width covers the gap. * Re-layout publish dialog when extruder switch labels change Setting the indicator bullet on a switch option changed its width without laying out the page, leaving the switch distorted until the next layout. --- src/slic3r/GUI/PublishSettingsDialog.cpp | 4 +++- src/slic3r/GUI/Widgets/TabCtrl.cpp | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index 080182a1b6..4020eedee4 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -2225,8 +2225,10 @@ void PublishSettingsDialog::refresh_tab_indicators() if (section.variant_switch != nullptr) { const wxString& title = m_categories[section.categories[i]].title; const wxString text = on ? title + wxString(" ") + wxString(wxUniChar(0x2022)) : title; - if (section.variant_switch->GetOptionText(static_cast(i)) != text) + if (section.variant_switch->GetOptionText(static_cast(i)) != text) { section.variant_switch->SetOptionText(static_cast(i), text); + section.page->Layout(); + } } any = any || on; } diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 6b970929e5..2135a695e1 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -32,7 +32,7 @@ END_EVENT_TABLE() */ #define TAB_BUTTON_SPACE 2 -#define TAB_BUTTON_PADDING_X 2 +#define TAB_BUTTON_PADDING_X 3 #define TAB_BUTTON_PADDING_Y 2 #define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y From 29b6bbb8c9f4c45c893ee7bb3e37ff2754729f68 Mon Sep 17 00:00:00 2001 From: HanifKoh <76276251+HanifKoh@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:39:02 +0800 Subject: [PATCH 17/21] Keep Slice and Print Visible When Plugin Page Tabs Overflow (#16541) --- src/slic3r/GUI/MainFrame.cpp | 3 +++ src/slic3r/GUI/Notebook.hpp | 1 + src/slic3r/plugin/host/PluginPages.cpp | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index a36f322066..66e982ad9f 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1739,6 +1739,9 @@ void MainFrame::fit_tab_labels() for (size_t i = count - 1; i >= 1; --i) { int right = ScreenToClient(m_slice_option_btn->ClientToScreen({})).x; int left = sizer->GetSize().GetWidth(); + // The overflow button sits between the tab buttons and the side tools, so it is not free space. + if (const wxWindow* overflow = ctrl->GetOverflowButton()) + left += overflow->GetSize().GetWidth(); if (right - left - FromDIP(15) > 0) return; ctrl->SetCompact(i, true); m_tabpanel->Refresh(); diff --git a/src/slic3r/GUI/Notebook.hpp b/src/slic3r/GUI/Notebook.hpp index e85f3ad723..b7774c84e0 100644 --- a/src/slic3r/GUI/Notebook.hpp +++ b/src/slic3r/GUI/Notebook.hpp @@ -57,6 +57,7 @@ public: // ORCA: a companion widget shown right after the tab buttons (before any side_tools), e.g. // an overflow indicator. Pass nullptr to remove it; ownership stays with the caller. void SetOverflowButton(wxWindow* button); + wxWindow* GetOverflowButton() const { return m_overflow_button; } private: wxFlexGridSizer* m_buttons_sizer; diff --git a/src/slic3r/plugin/host/PluginPages.cpp b/src/slic3r/plugin/host/PluginPages.cpp index cf4ed6a6a8..93bc8e93cd 100644 --- a/src/slic3r/plugin/host/PluginPages.cpp +++ b/src/slic3r/plugin/host/PluginPages.cpp @@ -4,6 +4,7 @@ #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/Notebook.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/Widgets/Button.hpp" #include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/plugin/PluginManager.hpp" @@ -382,6 +383,7 @@ void PluginPages::relayout() m_parent->SelectPageByName(id_to_reselect); } + const bool overflow_changed = need_overflow != (m_overflow_button != nullptr); if (need_overflow) { if (m_overflow_button == nullptr) { auto* btn = new Button(m_parent->GetBtnsListCtrl(), wxString(L"\u25BE"), wxString(), wxNO_BORDER); @@ -398,6 +400,11 @@ void PluginPages::relayout() m_overflow_button->Destroy(); m_overflow_button = nullptr; } + + // The tab strip changed width; compact the labels again so the side tools stay visible. + if ((!up_to_date || overflow_changed) && !GUI::wxGetApp().is_closing()) + if (GUI::MainFrame* mainframe = GUI::wxGetApp().mainframe) + mainframe->fit_tab_labels(); } void PluginPages::select_page(const PluginCapabilityId& id) From 99714afc4c4a5729d900a3b08abd71784bd029ae Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 16 Oct 2026 17:49:35 +0800 Subject: [PATCH 18/21] Keep picker thumbnails from retaining full-size texture decodes The thumbnail is downscaled on upload, but the full decode it was made from stayed in the decode cache for as long as the library entry lived. Thumbnail decodes now skip the cache, so only the 128 px thumbnail is kept per library texture. --- src/libslic3r/TextureDisplacement.cpp | 18 ++++++++++-------- src/libslic3r/TextureDisplacement.hpp | 5 +++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ab63a75c70..26e8726a72 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -283,7 +283,7 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer) return decode_height_texture(layer).has_color(); } -DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) +DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded) { DecodedHeightTexture result; if (layer.empty()) @@ -351,13 +351,15 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer } } - std::lock_guard lock(g_decoded_texture_cache.mutex); - // Opportunistically drop entries for image_data that no longer exists anywhere, so the - // cache doesn't grow without bound across many add/remove-texture cycles in a long session. - auto &entries = g_decoded_texture_cache.entries; - for (auto it = entries.begin(); it != entries.end();) - it = it->second.first.expired() ? entries.erase(it) : std::next(it); - entries[key] = { std::weak_ptr>(layer.image_data), result }; + if (keep_decoded) { + std::lock_guard lock(g_decoded_texture_cache.mutex); + // Opportunistically drop entries for image_data that no longer exists anywhere, so the + // cache doesn't grow without bound across many add/remove-texture cycles in a long session. + auto &entries = g_decoded_texture_cache.entries; + for (auto it = entries.begin(); it != entries.end();) + it = it->second.first.expired() ? entries.erase(it) : std::next(it); + 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, diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 6bd5d1ea33..919dfad654 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -599,8 +599,9 @@ DecodedHeightTexture downscale_height_texture(const DecodedHeightTexture &src, i // colour PNGs are accepted; for a colour source the height is its luminance, using the same // coefficients wxImage::ConvertToGreyscale() uses, so a texture imported as colour displaces exactly // as it did when the importer flattened it to grey on the way in. Returns an empty -// DecodedHeightTexture if image_data is empty or is not a PNG at all. -DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer); +// DecodedHeightTexture if image_data is empty or is not a PNG at all. keep_decoded = false leaves the +// result out of the decode cache, for one-off reads such as a picker thumbnail that must not hold full-size pixels. +DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer, bool keep_decoded = true); // decode_height_texture(layer).has_color(), answered from the decode cache rather than from a copy of the // texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read. diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index be1f72e520..646a23912a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -4122,7 +4122,7 @@ const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::ge if (entry.image_data) { TextureDisplacementLayer probe; probe.image_data = entry.image_data; - entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe)); + entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe, /* keep_decoded */ false)); if (!entry.thumbnail) entry.image_data.reset(); // decoded to nothing usable - treat it as a failed load } else { From cbd8ce98d5b5c044ab4cbda2479c0f5e77490e3f Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 16 Oct 2026 17:55:37 +0800 Subject: [PATCH 19/21] Keep the texture picker redrawing while thumbnails load Marking the canvas dirty from inside the render does not schedule another idle pass, so deferred thumbnails waited for the next mouse event. Asking for an extra frame does. --- src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 646a23912a..7f0019f86b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -4364,7 +4364,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane // The selected tile is centred even when it is off screen, so it is drawn and loaded next frame. if (current && m_picker_reveal_selected) { ImGui::SetScrollFromPosY(0.5f * (rect_min.y + rect_max.y) - ImGui::GetWindowPos().y, 0.5f); - m_parent.set_as_dirty(); + m_parent.request_extra_frame(); } if (!ImGui::IsRectVisible(rect_min, rect_max)) continue; @@ -4597,7 +4597,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane ImGui::PopTextWrapPos(); } if (loads_deferred) - m_parent.set_as_dirty(); + m_parent.request_extra_frame(); // Done whether or not the current texture was drawn: one in a collapsed section stays where it is. m_picker_reveal_selected = false; ImGui::EndChild(); From cefdd9b609bb63e6dc43d2b2f5b645867cd1d862 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 16 Oct 2026 18:47:49 +0800 Subject: [PATCH 20/21] fix: max filepath length on windows (#16552) # Description Windows traditionally limits many file operations to `MAX_PATH` (260 characters). Deeply nested plugin packages can exceed this limit during `.whl` extraction, leaving files missing and causing the plugin to fail to load. This change adds a shared extended_path helper that prepares paths for Windows file operations using the extended-length prefixes `\\?\` or `\\?\UNC\`. The helper is used for ZIP archive I/O and extraction destinations, including plugin wheel extraction. This avoids relying on the Windows long-path setting, which changes machine-wide settings. Paths remain unprefixed outside file operations. Code that bypasses the shared ZIP helpers may still require separate long-path handling. See [Microsoft's documentation on path length limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry) # Screenshots/Recordings/Graphs ## Tests Tested on Windows 11 by using a portable installation of OrcaSlicer and placing it in some really long filepath length e.g. `C:/somename/<240 characters long folder path>/OrcaSlicer` and then running and trying to install any plugin locally or on OrcaCloud. [How to Download Pull Requests Artifacts for Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts) --- src/libslic3r/Utils.hpp | 7 +++ src/libslic3r/miniz_extension.cpp | 61 +++++++++++++++++--- src/libslic3r/miniz_extension.hpp | 5 ++ src/libslic3r/utils.cpp | 40 +++++++++++++ src/slic3r/plugin/PluginFsUtils.cpp | 23 ++------ tests/libslic3r/test_miniz_extension.cpp | 50 ++++++++++++++++ tests/libslic3r/test_utils.cpp | 46 +++++++++++++++ tests/slic3rutils/test_plugin_install.cpp | 69 +++++++++++++++++++++++ 8 files changed, 276 insertions(+), 25 deletions(-) diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index eb171e4ee0..c5cf29d590 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -842,6 +842,13 @@ void load_string_file(const boost::filesystem::path& p, std::string& str); bool check_layer_id_pattern(const std::string& pattern, int layer_id); +// On Windows, return an equivalent path carrying the extended-length ("\\?\" / "\\?\UNC\") +// prefix so the Win32 APIs accept it past MAX_PATH even when LongPathsEnabled is off. The path +// is made absolute and lexically normalized first, because the prefix disables normalisation. +// A path that is already extended (or a device path) is returned unchanged; on other platforms, +// or when an absolute path cannot be formed, the input is returned unchanged. +boost::filesystem::path extended_path(const boost::filesystem::path& path); + } // namespace Slic3r #if WIN32 diff --git a/src/libslic3r/miniz_extension.cpp b/src/libslic3r/miniz_extension.cpp index f4a520d0ce..1e1efbec54 100644 --- a/src/libslic3r/miniz_extension.cpp +++ b/src/libslic3r/miniz_extension.cpp @@ -17,6 +17,7 @@ #if defined(_MSC_VER) || defined(__MINGW64__) #include "boost/nowide/cstdio.hpp" +#include "boost/nowide/convert.hpp" #endif #include "I18N.hpp" @@ -62,7 +63,11 @@ bool open_zip(mz_zip_archive *zip, const char *fname, bool isread) FILE *f = nullptr; #if defined(_MSC_VER) || defined(__MINGW64__) - f = boost::nowide::fopen(fname, mode); + // fname is UTF-8 (the nowide contract). Route it through the shared extended-path helper so an + // archive stored past MAX_PATH opens even with LongPathsEnabled off. + const std::wstring wide_path = boost::nowide::widen(fname); + const std::string extended = boost::nowide::narrow(Slic3r::extended_path(boost::filesystem::path(wide_path)).wstring()); + f = boost::nowide::fopen(extended.c_str(), mode); #elif defined(__GNUC__) && defined(_LARGEFILE64_SOURCE) f = fopen64(fname, mode); #else @@ -101,6 +106,14 @@ bool close_zip(mz_zip_archive *zip, bool isread) } return ret; } + +#ifdef WIN32 +// mz_file_write_func writing an extracted entry into an already-open FILE*. +size_t mz_write_to_cfile(void *opaque, mz_uint64 /*file_ofs*/, const void *buffer, size_t size) +{ + return std::fwrite(buffer, 1, size, static_cast(opaque)); +} +#endif } bool open_zip_reader(mz_zip_archive *zip, const std::string &fname) @@ -116,6 +129,36 @@ bool open_zip_writer(mz_zip_archive *zip, const std::string &fname) bool close_zip_reader(mz_zip_archive *zip) { return close_zip(zip, true); } bool close_zip_writer(mz_zip_archive *zip) { return close_zip(zip, false); } +bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path) +{ + if (!zip) + return false; +#ifdef WIN32 + // Open the destination ourselves with a Windows extended-length path: miniz's narrow + // extract_to_file cannot be relied on past MAX_PATH with LongPathsEnabled off, and its wide + // variant takes a plain (unprefixed) path and a text-mode stream. This keeps the write binary. + FILE *file = ::_wfopen(Slic3r::extended_path(dest_path).wstring().c_str(), L"wb"); + if (!file) { + zip->m_last_error = MZ_ZIP_FILE_OPEN_FAILED; + return false; + } + + const bool written = mz_zip_reader_extract_to_callback(zip, file_index, mz_write_to_cfile, file, 0) != 0; + const bool closed = std::fclose(file) == 0; + if (!written || !closed) { + if (written && !closed) + zip->m_last_error = MZ_ZIP_FILE_CLOSE_FAILED; + // Drop the partial entry rather than leave a truncated file behind. + boost::system::error_code ec; + boost::filesystem::remove(Slic3r::extended_path(dest_path), ec); + return false; + } + return true; +#else + return mz_zip_reader_extract_to_file(zip, file_index, dest_path.string().c_str(), 0) != 0; +#endif +} + std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat) { if (stat.m_is_utf8) @@ -156,10 +199,13 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin } const std::string dest_file = dest_dir + "/" + stat.m_filename; try { + // Use the extended-length form for every filesystem call so entries under a destination + // past MAX_PATH are created even with LongPathsEnabled off. + const boost::filesystem::path dest_path(dest_file); + const boost::filesystem::path io_dest = extended_path(dest_path); if (stat.m_is_directory) { - const boost::filesystem::path dest_path(dest_file); - if (!boost::filesystem::exists(dest_path)) - boost::filesystem::create_directories(dest_path); + if (!boost::filesystem::exists(io_dest)) + boost::filesystem::create_directories(io_dest); continue; } if (stat.m_uncomp_size == 0) { @@ -167,10 +213,9 @@ bool extract_archive_confined(const std::string &zip_path_utf8, const std::strin continue; } // Replace a symlink at the destination rather than writing through it. - const boost::filesystem::path dest_path(dest_file); - if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(dest_path))) - boost::filesystem::remove(dest_path); - if (!mz_zip_reader_extract_to_file(&archive, stat.m_file_index, dest_file.c_str(), 0)) { + if (boost::filesystem::is_symlink(boost::filesystem::symlink_status(io_dest))) + boost::filesystem::remove(io_dest); + if (!extract_zip_entry_to_file(&archive, stat.m_file_index, dest_path)) { BOOST_LOG_TRIVIAL(error) << "Unzip: extract file " << stat.m_filename << " to dest " << dest_file << " failed"; close_zip_reader(&archive); return false; diff --git a/src/libslic3r/miniz_extension.hpp b/src/libslic3r/miniz_extension.hpp index 97aa91b93f..4953f61f58 100644 --- a/src/libslic3r/miniz_extension.hpp +++ b/src/libslic3r/miniz_extension.hpp @@ -1,6 +1,7 @@ #ifndef MINIZ_EXTENSION_HPP #define MINIZ_EXTENSION_HPP +#include #include #include @@ -13,6 +14,10 @@ bool close_zip_writer(mz_zip_archive *zip); std::string decode_archive_entry_path(mz_zip_archive *zip, const mz_zip_archive_file_stat &stat); // Extracts every entry of the archive under dest_dir. Nothing is written if any entry would resolve outside dest_dir. bool extract_archive_confined(const std::string &zip_path_utf8, const std::string &dest_dir); +// Extracts one entry into dest_path, binary-safe and overwriting. On Windows the destination is +// opened through an extended-length path so it works past MAX_PATH even with LongPathsEnabled off; +// a partial file is removed if the write fails. False for a directory/unsupported entry or on error. +bool extract_zip_entry_to_file(mz_zip_archive *zip, mz_uint file_index, const boost::filesystem::path &dest_path); class MZ_Archive { public: diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 260e1e7d95..12b3083d4d 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -2254,5 +2254,45 @@ bool check_layer_id_pattern(const std::string& pattern, int layer_id){ return mod >= 0 && mod < count; } +boost::filesystem::path extended_path(const boost::filesystem::path& path) { +#ifdef WIN32 + namespace fs = boost::filesystem; + + // Already extended, or an explicit device path (which is not subject to MAX_PATH and must not + // be absolutized or normalized): leave it alone. + { + fs::path prefix_candidate = path; + const std::wstring native = prefix_candidate.make_preferred().wstring(); + if (native.rfind(L"\\\\?\\", 0) == 0 || native.rfind(L"\\\\.\\", 0) == 0) + return fs::path(native); + } + + fs::path absolute = path; + if (!absolute.is_absolute()) { + boost::system::error_code ec; + absolute = fs::absolute(absolute, ec); + if (ec) + return path; // can't form a valid prefixed path from a relative one + } + + // A trailing separator is redundant to the Win32 APIs, but lexically_normal() would rewrite it + // as a "." element, which the "\\?\" prefix (normalisation off) takes literally. Drop it first. + while (absolute.has_relative_path() && absolute.filename() == ".") + absolute = absolute.parent_path(); + + // The "\\?\" prefix turns off Win32 normalisation, so do it here first. + absolute = absolute.lexically_normal(); + + // Extended paths only recognize backslash separators. + const std::wstring native = absolute.make_preferred().wstring(); + if (native.empty()) + return path; + if (native.rfind(L"\\\\", 0) == 0) + return fs::path(L"\\\\?\\UNC\\" + native.substr(2)); // \\server\share\... -> \\?\UNC\server\share\... + return fs::path(L"\\\\?\\" + native); // C:\dir\... -> \\?\C:\dir\... +#else + return path; +#endif +} }; // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index c04b15c26f..1dd8391ad7 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -34,10 +34,6 @@ #include #include -#ifdef WIN32 -#include -#endif - namespace Slic3r { const char* const INSTALL_STATE_FILE = ".install_state.json"; @@ -665,7 +661,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo namespace fs = boost::filesystem; boost::system::error_code ec; - fs::create_directories(destination, ec); + fs::create_directories(extended_path(destination), ec); if (ec) { error = "Failed to create plugin staging directory: " + ec.message(); return false; @@ -709,7 +705,7 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo const fs::path output_path = destination / relative_path; if (stat.m_is_directory || mz_zip_reader_is_file_a_directory(&reader.archive, stat.m_file_index)) { - fs::create_directories(output_path, ec); + fs::create_directories(extended_path(output_path), ec); if (ec) { error = "Failed to create plugin zip directory " + output_path.string() + ": " + ec.message(); return false; @@ -717,26 +713,19 @@ bool extract_zip_to_directory(const boost::filesystem::path& zip_path, const boo continue; } - fs::create_directories(output_path.parent_path(), ec); + fs::create_directories(extended_path(output_path.parent_path()), ec); if (ec) { error = "Failed to create plugin zip parent directory " + output_path.parent_path().string() + ": " + ec.message(); return false; } - if (fs::exists(output_path, ec) && fs::is_directory(output_path, ec)) { + if (fs::exists(extended_path(output_path), ec) && fs::is_directory(extended_path(output_path), ec)) { error = "Plugin zip file conflicts with an existing directory: " + output_path.string(); return false; } - const std::string encoded_output_path = encode_path(output_path.string().c_str()); - mz_bool extracted = mz_zip_reader_extract_to_file(&reader.archive, stat.m_file_index, encoded_output_path.c_str(), 0); -#ifdef WIN32 - if (!extracted) { - const std::wstring wide_output_path = boost::locale::conv::utf_to_utf(output_path.generic_string()); - extracted = mz_zip_reader_extract_to_file_w(&reader.archive, stat.m_file_index, wide_output_path.c_str(), 0); - } -#endif - if (!extracted) { + // Shared with the rest of the archive code: long-path capable and binary-safe. + if (!extract_zip_entry_to_file(&reader.archive, stat.m_file_index, output_path)) { error = "Failed to extract plugin zip entry " + relative_key + ": " + MZ_Archive::get_errorstr(mz_zip_get_last_error(&reader.archive)); return false; diff --git a/tests/libslic3r/test_miniz_extension.cpp b/tests/libslic3r/test_miniz_extension.cpp index 3a10a8eb8e..dfb23c22cb 100644 --- a/tests/libslic3r/test_miniz_extension.cpp +++ b/tests/libslic3r/test_miniz_extension.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -176,6 +177,55 @@ TEST_CASE("Confined extraction writes nothing outside the target for Windows-spe CHECK(list_dir(tmp.path()) == std::vector{"bundle.zip", "cache"}); } +TEST_CASE("Confined extraction writes files under a target path longer than MAX_PATH", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + const fs::path zip_file = tmp.path() / "bundle.zip"; + // Directory entries are explicit: extract_archive_confined recreates directories only for them. + write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"}, + {"vendor/machine/printer.json", "{\"b\":2}"}}); + + // One 240-char component keeps every name below the NTFS 255 limit while pushing the full + // target path past the 260-char MAX_PATH the Win32 APIs enforce when LongPathsEnabled is off. + const fs::path target = tmp.path() / std::string(240, 'd'); + REQUIRE(target.string().size() > 260); + fs::create_directories(extended_path(target)); + + REQUIRE(extract_archive_confined(zip_file.string(), target.string())); + // Verify through extended-length paths, otherwise the checks themselves are bounded by MAX_PATH. + CHECK(fs::is_directory(extended_path(target / "vendor"))); + CHECK(read_file(extended_path(target / "vendor.json")) == "{\"a\":1}"); + CHECK(read_file(extended_path(target / "vendor" / "machine" / "printer.json")) == "{\"b\":2}"); + + // The temporary dir guard cannot remove a deep tree on Windows, so drop it here. + boost::system::error_code ec; + fs::remove_all(extended_path(target), ec); + CHECK_FALSE(ec); +} + +TEST_CASE("A zip archive under a path longer than MAX_PATH can be written and read", "[MinizExtension]") +{ + ScopedTemporaryDir tmp; + + // Create the long directory with the extended-length path, then exercise the archive + // reader/writer against an archive whose own path is past MAX_PATH. + const fs::path long_dir = tmp.path() / std::string(240, 'z'); + fs::create_directories(extended_path(long_dir)); + const fs::path zip_file = long_dir / "bundle.zip"; + REQUIRE(zip_file.string().size() > 260); + + write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}}); + + const fs::path target = tmp.path() / "out"; + fs::create_directories(target); + REQUIRE(extract_archive_confined(zip_file.string(), target.string())); + CHECK(read_file(target / "vendor.json") == "{\"a\":1}"); + + boost::system::error_code ec; + fs::remove_all(extended_path(long_dir), ec); + CHECK_FALSE(ec); +} + #ifndef _WIN32 TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]") { diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index 49161d5d6c..d87b8e686e 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -521,3 +521,49 @@ TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]") CHECK_FALSE(ascii_iequals("[", "{")); CHECK_FALSE(ascii_iequals("@", "`")); } + +TEST_CASE("extended_path turns a Windows path into its long-path form", "[Utils]") { +#ifdef _WIN32 + namespace fs = boost::filesystem; + + SECTION("a normal absolute drive path is prefixed") { + CHECK(extended_path(fs::path(R"(C:\models\model.3mf)")).wstring() == LR"(\\?\C:\models\model.3mf)"); + } + SECTION("separators are made native") { + CHECK(extended_path(fs::path("C:/models/model.3mf")).wstring() == LR"(\\?\C:\models\model.3mf)"); + } + SECTION("dot segments are collapsed before prefixing") { + // The "\\?\" prefix disables Win32 normalisation, so "." and ".." must be gone beforehand. + CHECK(extended_path(fs::path(R"(C:\models\..\model.3mf)")).wstring() == LR"(\\?\C:\model.3mf)"); + } + SECTION("a trailing separator is dropped, not turned into a dot element") { + CHECK(extended_path(fs::path(R"(C:\models\)")).wstring() == LR"(\\?\C:\models)"); + } + SECTION("a UNC path becomes an extended UNC path") { + CHECK(extended_path(fs::path(R"(\\server\share\model.3mf)")).wstring() == LR"(\\?\UNC\server\share\model.3mf)"); + } + SECTION("an already extended path is returned unchanged") { + const fs::path extended(R"(\\?\C:\models\model.3mf)"); + CHECK(extended_path(extended) == extended); + } + SECTION("a device path is returned unchanged") { + const fs::path device(R"(\\.\C:\models\model.3mf)"); + CHECK(extended_path(device) == device); + } + SECTION("a bare device path without a root directory is returned unchanged") { + // Not "absolute" by Boost's rules, so it must be recognized before absolutizing. + const fs::path device(R"(\\.\PhysicalDrive0)"); + CHECK(extended_path(device) == device); + } + SECTION("a relative path is completed against the working directory") { + const fs::path result = extended_path(fs::path("model.3mf")); + CHECK(result.wstring().rfind(L"\\\\?\\", 0) == 0); + CHECK(result.filename() == fs::path("model.3mf")); + } +#else + SECTION("non-Windows returns the path unchanged") { + const boost::filesystem::path p = boost::filesystem::temp_directory_path() / "model.3mf"; + CHECK(extended_path(p) == p); + } +#endif +} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 0f3b9a7fcf..ac7f826ed8 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -10,13 +11,17 @@ #include #include #include +#include "catch2/catch_message.hpp" #include "plugin_test_utils.hpp" +#include "test_utils.hpp" #include #include #include +#include + using namespace Slic3r; namespace fs = boost::filesystem; @@ -31,6 +36,14 @@ fs::path write_py_file(const fs::path& dir, const std::string& filename, const s return p; } +// The shared production helper: makes the path absolute and lexically normalized and, on +// Windows, prefixes it with the extended-length "\\?\" form, so the test can create, verify and +// clean up a tree past MAX_PATH even with LongPathsEnabled off. Identity on other platforms. +fs::path to_extended_path(const fs::path& p) +{ + return Slic3r::extended_path(p); +} + } // namespace TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]") @@ -184,3 +197,59 @@ TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginIns REQUIRE(read_install_state(plugin_dir, scanned)); CHECK(scanned.installed_version == "1.0.0"); } + +TEST_CASE("extract_zip_to_directory writes entries under a destination longer than MAX_PATH", + "[PluginInstall][PluginFsUtils]") +{ + ScopedTemporaryDir dir("deep-extract"); + + const fs::path wheel = dir.path() / "deep_pkg-1.0-py3-none-any.whl"; + const std::string payload = "print('hello from a deep path')\n"; + { + Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION); + zipper.add_entry("pkg/", "", 0); // explicit directory entry + zipper.add_entry("pkg/module.py", payload.data(), payload.size()); + zipper.finalize(); + } + + // A single 240-char component keeps every filename below the NTFS 255 limit while pushing the + // full destination (temp dir + component) past the 260-char MAX_PATH the Win32 APIs enforce + // when LongPathsEnabled is off. + const fs::path destination = dir.path() / std::string(240, 'd'); + REQUIRE(destination.string().size() > 260); + + std::string error; + REQUIRE(extract_zip_to_directory(wheel, destination, error)); + INFO("extract error: " << error); + CHECK(error.empty()); + + // Verify through a long-path-capable path, otherwise the check itself is bounded by MAX_PATH. + const fs::path module_py = destination / "pkg" / "module.py"; + REQUIRE(fs::exists(to_extended_path(module_py))); + REQUIRE(fs::is_regular_file(to_extended_path(module_py))); + + // Remove the deep subtree here: ScopedTemporaryDir's plain remove_all cannot reach past + // MAX_PATH on Windows. + boost::system::error_code ec; + fs::remove_all(to_extended_path(destination), ec); + CHECK_FALSE(ec); +} + +TEST_CASE("extract_zip_to_directory rejects an entry that escapes the destination", + "[PluginInstall][PluginFsUtils]") +{ + ScopedTemporaryDir dir("escape-extract"); + + const fs::path wheel = dir.path() / "evil-1.0-py3-none-any.whl"; + { + Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION); + zipper.add_entry("pkg/module.py", "print('ok')\n", 12); + zipper.add_entry("../escape.txt", "escaped\n", 8); + zipper.finalize(); + } + + std::string error; + CHECK_FALSE(extract_zip_to_directory(wheel, dir.path() / "out", error)); + CHECK_THAT(error, Catch::Matchers::ContainsSubstring("escapes the plugin package")); + CHECK_FALSE(fs::exists(dir.path() / "escape.txt")); +} From 82aee1abdd5c475cff4e3adcb9d8d56078df2c10 Mon Sep 17 00:00:00 2001 From: SoftFever <103989404+SoftFever@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:36:50 +0800 Subject: [PATCH 21/21] Cache compiles in the macOS build script, including Xcode builds (#16553) --- CMakeLists.txt | 13 +++++++++++ build_release_macos.sh | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index a302bb15e2..06ea0dbd6f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -288,6 +288,19 @@ if (APPLE) # Type"); Ninja/-Wall does not, and under -Werror it fails Xcode builds on code CI accepts. set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION "NO") + # The Xcode generator ignores CMAKE__COMPILER_LAUNCHER, so hand the launcher to + # Xcode's own Compiler Launcher setting, which wraps every clang compile (C, C++, + # Objective-C++ and the precompiled header) with one program: the C++ launcher wins when + # both are set. The setting takes a single program path, not a CMake launcher list. + # Xcode versions without it ignore it and build uncached. + if (CMAKE_GENERATOR STREQUAL "Xcode") + if (CMAKE_CXX_COMPILER_LAUNCHER) + set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_CXX_COMPILER_LAUNCHER}") + elseif (CMAKE_C_COMPILER_LAUNCHER) + set(CMAKE_XCODE_ATTRIBUTE_C_COMPILER_LAUNCHER "${CMAKE_C_COMPILER_LAUNCHER}") + endif () + endif () + message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}") elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") set(CMAKE_INSTALL_RPATH "$ORIGIN") diff --git a/build_release_macos.sh b/build_release_macos.sh index 728cb8e757..cdc5dbbe3c 100755 --- a/build_release_macos.sh +++ b/build_release_macos.sh @@ -111,6 +111,54 @@ else export CMAKE_POLICY_COMPAT="" fi +# Compiler cache, as in build_linux.sh. The program comes from CMAKE_CCACHE, else from a +# launcher already set in the environment (the CI build sets ccache there), else sccache or +# ccache from PATH; one program serves both languages. Each run applies its own choice to the +# build directories, replacing a launcher an earlier run or a hand-run configure recorded. +CMAKE_CCACHE_ARGS=() +CCACHE_VAR="" +for var in CMAKE_CCACHE CMAKE_CXX_COMPILER_LAUNCHER CMAKE_C_COMPILER_LAUNCHER; do + if [ -n "${!var:-}" ]; then + CCACHE_VAR=$var + break + fi +done +if [ -n "$CCACHE_VAR" ]; then + CCACHE_PROGRAM=${!CCACHE_VAR} + echo "Checking ${CCACHE_VAR} environment variable for compiler cache program..." + CMAKE_CCACHE=$(command -v "${CCACHE_PROGRAM}") || { + echo "${CCACHE_VAR} environment variable is set to '${CCACHE_PROGRAM}' but it was not found in PATH." + CMAKE_CCACHE="" + } +elif command -v sccache >/dev/null 2>&1 ; then + CMAKE_CCACHE=$(command -v sccache) +elif command -v ccache >/dev/null 2>&1 ; then + CMAKE_CCACHE=$(command -v ccache) +fi +if [ -n "${CMAKE_CCACHE}" ] ; then + echo "${CMAKE_CCACHE} found, enabling compiler caching..." + CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER="${CMAKE_CCACHE}" -DCMAKE_CXX_COMPILER_LAUNCHER="${CMAKE_CCACHE}") + # The same ccache settings as the CI build. Without this sloppiness ccache refuses every + # compile that uses the precompiled header, which is most of OrcaSlicer, and skips headers + # a checkout has just written. It is added to the sloppiness already configured, and + # CCACHE_SLOPPINESS or CCACHE_(NO)DEPEND set in the environment win. + if [[ "$(basename "${CMAKE_CCACHE}")" == ccache ]] ; then + if [[ -z "${CCACHE_SLOPPINESS+x}" ]] ; then + CCACHE_SLOPPINESS=$("${CMAKE_CCACHE}" --get-config sloppiness 2>/dev/null || true) + export CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS:+${CCACHE_SLOPPINESS},}pch_defines,time_macros,include_file_mtime,include_file_ctime" + fi + # Hash the includes the compiler reports instead of preprocessing every miss. + if [[ -z "${CCACHE_DEPEND+x}${CCACHE_NODEPEND+x}" ]] ; then + export CCACHE_DEPEND=1 + fi + fi +else + echo "Note: ccache or sccache are not found. Install either of them for faster rebuilds." + # Clear a launcher recorded by an earlier configure. The entries must be set empty rather + # than removed: CMake fills a missing launcher entry from the environment. + CMAKE_CCACHE_ARGS=(-DCMAKE_C_COMPILER_LAUNCHER:STRING= -DCMAKE_CXX_COMPILER_LAUNCHER:STRING=) +fi + echo "Build params:" echo " - ARCH: $ARCH" echo " - BUILD_CONFIG: $BUILD_CONFIG" @@ -162,6 +210,7 @@ function build_deps() { -DCMAKE_OSX_ARCHITECTURES:STRING="${_ARCH}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ + "${CMAKE_CCACHE_ARGS[@]}" \ ${CMAKE_POLICY_COMPAT} fi cmake --build . --config "$BUILD_CONFIG" --target deps @@ -264,6 +313,7 @@ function build_slicer() { -DCMAKE_OSX_ARCHITECTURES="${_ARCH}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ + "${CMAKE_CCACHE_ARGS[@]}" \ ${CMAKE_POLICY_COMPAT} fi cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"