From 86ab5988997c906e41eab93d15db562ae7cc6268 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 14 Oct 2026 17:15:39 +0800 Subject: [PATCH] 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); +}