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); +}