mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-14 11:21:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf16febf6 | ||
|
|
70e52a105f | ||
|
|
b525cb79e1 | ||
|
|
86ab598899 |
@@ -685,6 +685,8 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
plugin/host/PluginHostUi.hpp
|
plugin/host/PluginHostUi.hpp
|
||||||
plugin/host/PluginPages.cpp
|
plugin/host/PluginPages.cpp
|
||||||
plugin/host/PluginPages.hpp
|
plugin/host/PluginPages.hpp
|
||||||
|
plugin/host/PluginResourceTextures.cpp
|
||||||
|
plugin/host/PluginResourceTextures.hpp
|
||||||
plugin/CloudPluginService.cpp
|
plugin/CloudPluginService.cpp
|
||||||
plugin/CloudPluginService.hpp
|
plugin/CloudPluginService.hpp
|
||||||
plugin/PluginFsUtils.cpp
|
plugin/PluginFsUtils.cpp
|
||||||
@@ -708,6 +710,9 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
plugin/pluginTypes/pages/PagesPluginCapability.hpp
|
plugin/pluginTypes/pages/PagesPluginCapability.hpp
|
||||||
plugin/pluginTypes/pages/PagesPluginCapability.cpp
|
plugin/pluginTypes/pages/PagesPluginCapability.cpp
|
||||||
plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp
|
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.hpp
|
||||||
plugin/pluginTypes/script/ScriptPluginCapability.cpp
|
plugin/pluginTypes/script/ScriptPluginCapability.cpp
|
||||||
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
|
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
|
||||||
|
|||||||
@@ -251,9 +251,11 @@
|
|||||||
#include "slic3r/GUI/ParamsDialog.hpp"
|
#include "slic3r/GUI/ParamsDialog.hpp"
|
||||||
#include "slic3r/GUI/ParamsPanel.hpp"
|
#include "slic3r/GUI/ParamsPanel.hpp"
|
||||||
#include "slic3r/GUI/PartPlate.hpp"
|
#include "slic3r/GUI/PartPlate.hpp"
|
||||||
|
#include "slic3r/GUI/TextureLibrary.hpp"
|
||||||
#include "slic3r/GUI/Widgets/Button.hpp"
|
#include "slic3r/GUI/Widgets/Button.hpp"
|
||||||
#include "slic3r/GUI/wxExtensions.hpp"
|
#include "slic3r/GUI/wxExtensions.hpp"
|
||||||
#include "slic3r/plugin/host/PluginPages.hpp"
|
#include "slic3r/plugin/host/PluginPages.hpp"
|
||||||
|
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
|
||||||
#include <wx/defs.h>
|
#include <wx/defs.h>
|
||||||
#include "slic3r/GUI/Widgets/WebView.hpp"
|
#include "slic3r/GUI/Widgets/WebView.hpp"
|
||||||
#include <cwchar>
|
#include <cwchar>
|
||||||
@@ -3053,6 +3055,37 @@ void GUI_App::init_plugin_gui_wiring()
|
|||||||
refresh_plugins_dialog();
|
refresh_plugins_dialog();
|
||||||
switch_printer_agent_after_unload(capability.plugin_key);
|
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()
|
bool GUI_App::on_init_inner()
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <queue>
|
#include <queue>
|
||||||
#include <set>
|
#include <set>
|
||||||
@@ -3917,7 +3918,7 @@ void GLGizmoTextureDisplacement::set_layer_texture(TextureDisplacementLayer &lay
|
|||||||
// the PNG the first time the layer is previewed or baked.
|
// the PNG the first time the layer is previewed or baked.
|
||||||
layer.image_data = tex->image_data;
|
layer.image_data = tex->image_data;
|
||||||
// The user's own textures are listed most recently used first.
|
// 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);
|
touch_user_texture(entry.path);
|
||||||
|
|
||||||
rebuild_preview();
|
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 spacing = std::round(m_imgui->scaled(0.4f));
|
||||||
const float grid_w = 4.f * tile + 3.f * spacing;
|
const float grid_w = 4.f * tile + 3.f * spacing;
|
||||||
const float gap = m_imgui->scaled(0.5f);
|
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
|
// 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.
|
// 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 float width = viewport->Size.x > 0.f ? std::min(fixed_w, viewport->Size.x) : fixed_w;
|
||||||
const bool left = panel_min.x - gap - popup_w >= 0.f;
|
const bool left = panel_min.x - gap - width >= 0.f;
|
||||||
ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap : panel_max.x + gap, panel_min.y + m_imgui->scaled(4.f)),
|
const float popup_x = left ? panel_min.x - gap - width : panel_max.x + gap;
|
||||||
ImGuiCond_Appearing, ImVec2(left ? 1.f : 0.f, 0.f));
|
// 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))
|
if (!ImGui::BeginPopup(popup_id))
|
||||||
return;
|
return;
|
||||||
if (layer == nullptr) {
|
if (layer == nullptr) {
|
||||||
@@ -3983,6 +4013,12 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
|||||||
m_imgui->pop_bold_font();
|
m_imgui->pop_bold_font();
|
||||||
|
|
||||||
const std::vector<TextureLibraryEntry> &library = texture_library();
|
const std::vector<TextureLibraryEntry> &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();
|
ImDrawList *dl = ImGui::GetWindowDrawList();
|
||||||
const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
|
const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
|
||||||
std::optional<TextureLibraryEntry> chosen;
|
std::optional<TextureLibraryEntry> 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));
|
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
|
// One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by
|
||||||
// order texture_library() keeps), those with a remove button on hover. Returns how many it drew.
|
// name, the user's own most recently used first - the order texture_library() produced); a user tile
|
||||||
const auto grid = [&](const ImVec2 &origin, bool user) {
|
// carries a remove button on hover. Returns how many it drew.
|
||||||
|
const auto grid = [&](const ImVec2 &origin, const std::vector<const TextureLibraryEntry *> &entries, bool removable) {
|
||||||
int n = 0;
|
int n = 0;
|
||||||
for (const TextureLibraryEntry &entry : library) {
|
for (const TextureLibraryEntry *entry : entries) {
|
||||||
if (entry.is_user != user)
|
|
||||||
continue;
|
|
||||||
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
|
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
|
||||||
ImGui::PushID(entry.path.c_str());
|
ImGui::PushID(entry->path.c_str());
|
||||||
const LibraryTexture *tex = get_library_texture(entry.path);
|
const LibraryTexture *tex = get_library_texture(entry->path);
|
||||||
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
|
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
|
||||||
bool clicked = false;
|
bool clicked = false;
|
||||||
if (thumb != nullptr) {
|
if (thumb != nullptr) {
|
||||||
@@ -4030,15 +4065,17 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
|||||||
} else {
|
} else {
|
||||||
clicked = ImGui::Button("?", ImVec2(tile, tile));
|
clicked = ImGui::Button("?", ImVec2(tile, tile));
|
||||||
}
|
}
|
||||||
if (user)
|
if (removable)
|
||||||
ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks
|
ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks
|
||||||
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
|
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);
|
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())
|
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 float r = std::round(tile * 0.17f);
|
||||||
const ImVec2 c(b.x - r - 2.f, a.y + r + 2.f);
|
const ImVec2 c(b.x - r - 2.f, a.y + r + 2.f);
|
||||||
ImGui::SetCursorScreenPos(ImVec2(c.x - r, c.y - r));
|
ImGui::SetCursorScreenPos(ImVec2(c.x - r, c.y - r));
|
||||||
@@ -4051,27 +4088,85 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
|||||||
if (hot)
|
if (hot)
|
||||||
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
|
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
|
||||||
if (remove_clicked)
|
if (remove_clicked)
|
||||||
to_remove = entry;
|
to_remove = *entry;
|
||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
if (clicked)
|
if (clicked)
|
||||||
chosen = entry;
|
chosen = *entry;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
|
|
||||||
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
|
// 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<std::string, bool> s_section_collapsed; // true = collapsed; absent = expanded
|
||||||
|
|
||||||
|
// A section heading with its collapse control at the right edge of the grid: the triangle points
|
||||||
|
// down while the section is open and right while it is collapsed, and toggles it when clicked.
|
||||||
|
// 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:<name>`, so a pack named "builtin" cannot collide with
|
||||||
|
// the fixed Built-in section.
|
||||||
|
struct Section
|
||||||
{
|
{
|
||||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
std::string key;
|
||||||
const int count = grid(origin, false);
|
std::string title;
|
||||||
end_grid(origin, count);
|
TextureSource source;
|
||||||
if (count == 0)
|
std::vector<const TextureLibraryEntry *> entries;
|
||||||
ImGui::TextDisabled("%s", _u8L("No textures found.").c_str());
|
};
|
||||||
|
std::vector<Section> sections;
|
||||||
|
std::map<std::string, size_t> 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;
|
||||||
}
|
}
|
||||||
ImGui::TextDisabled("%s", _u8L("My textures").c_str());
|
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 ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||||
const int count = grid(origin, true);
|
const int count = grid(origin, section.entries, section.source == TextureSource::User);
|
||||||
|
if (section.source == TextureSource::User) {
|
||||||
ImGui::SetCursorScreenPos(tile_pos(origin, count));
|
ImGui::SetCursorScreenPos(tile_pos(origin, count));
|
||||||
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
|
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
|
||||||
import_clicked = true;
|
import_clicked = true;
|
||||||
@@ -4081,11 +4176,17 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
|||||||
"textures shipped with OrcaSlicer."),
|
"textures shipped with OrcaSlicer."),
|
||||||
m_imgui->scaled(20.f));
|
m_imgui->scaled(20.f));
|
||||||
end_grid(origin, count + 1);
|
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::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::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();
|
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)
|
if (chosen || import_clicked)
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
|
|||||||
@@ -1061,6 +1061,8 @@ private:
|
|||||||
|
|
||||||
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
|
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
|
||||||
std::map<std::string, LibraryTexture> m_library_textures;
|
std::map<std::string, LibraryTexture> 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
|
// 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
|
// every stroke end and every slider release - but depth/tiling/rotation/offset/blend change
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "TextureLibrary.hpp"
|
#include "TextureLibrary.hpp"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <boost/filesystem/operations.hpp>
|
#include <boost/filesystem/operations.hpp>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <boost/filesystem/directory.hpp>
|
#include <boost/filesystem/directory.hpp>
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
#include <boost/system/error_code.hpp>
|
#include <boost/system/error_code.hpp>
|
||||||
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <wx/app.h>
|
||||||
#include <wx/gdicmn.h>
|
#include <wx/gdicmn.h>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <wx/image.h>
|
#include <wx/image.h>
|
||||||
@@ -36,6 +38,9 @@
|
|||||||
#include "slic3r/GUI/GUI_App.hpp"
|
#include "slic3r/GUI/GUI_App.hpp"
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
|
|
||||||
|
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||||
|
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
|
||||||
|
|
||||||
namespace Slic3r::GUI {
|
namespace Slic3r::GUI {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -50,7 +55,7 @@ bool is_image_file(const boost::filesystem::path &path)
|
|||||||
return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp";
|
return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp";
|
||||||
}
|
}
|
||||||
|
|
||||||
void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<TextureLibraryEntry> &out)
|
void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vector<TextureLibraryEntry> &out)
|
||||||
{
|
{
|
||||||
boost::system::error_code ec;
|
boost::system::error_code ec;
|
||||||
if (!boost::filesystem::is_directory(dir, ec))
|
if (!boost::filesystem::is_directory(dir, ec))
|
||||||
@@ -60,12 +65,43 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<Text
|
|||||||
for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) {
|
for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) {
|
||||||
if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path()))
|
if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path()))
|
||||||
continue;
|
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(),
|
std::sort(out.begin() + first, out.end(),
|
||||||
[](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; });
|
[](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<TextureLibraryEntry> &out)
|
||||||
|
{
|
||||||
|
std::vector<TextureLibraryEntry> found;
|
||||||
|
for (const ResourceTextureEntry &pack : PluginResourceTextures::instance().entries()) {
|
||||||
|
std::string error;
|
||||||
|
const std::vector<std::string> 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
|
// 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
|
// 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
|
// 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<unsigned char> &bytes)
|
|||||||
|
|
||||||
std::vector<TextureLibraryEntry> g_library;
|
std::vector<TextureLibraryEntry> g_library;
|
||||||
bool g_library_scanned = false;
|
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<uint64_t> g_library_generation{ 0 };
|
||||||
|
|
||||||
// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture -
|
// 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
|
// 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<std::string> recent_texture_names()
|
std::vector<std::string> recent_texture_names()
|
||||||
{
|
{
|
||||||
std::vector<std::string> names;
|
std::vector<std::string> names;
|
||||||
|
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to read
|
||||||
|
return names;
|
||||||
if (wxGetApp().app_config == nullptr)
|
if (wxGetApp().app_config == nullptr)
|
||||||
return names;
|
return names;
|
||||||
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
|
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
|
||||||
@@ -269,6 +312,8 @@ std::vector<std::string> recent_texture_names()
|
|||||||
|
|
||||||
void store_recent_texture_names(std::vector<std::string> names)
|
void store_recent_texture_names(std::vector<std::string> names)
|
||||||
{
|
{
|
||||||
|
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to write
|
||||||
|
return;
|
||||||
if (wxGetApp().app_config == nullptr)
|
if (wxGetApp().app_config == nullptr)
|
||||||
return;
|
return;
|
||||||
if (names.size() > RECENT_MAX)
|
if (names.size() > RECENT_MAX)
|
||||||
@@ -279,8 +324,9 @@ void store_recent_texture_names(std::vector<std::string> names)
|
|||||||
wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value);
|
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
|
// Reorders the user's entries by recent use. Stable, so textures never used keep the name order
|
||||||
// the name order scan_dir() gave them, after every used one.
|
// 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<TextureLibraryEntry> &library)
|
void sort_user_textures(std::vector<TextureLibraryEntry> &library)
|
||||||
{
|
{
|
||||||
const std::vector<std::string> names = recent_texture_names();
|
const std::vector<std::string> names = recent_texture_names();
|
||||||
@@ -291,8 +337,11 @@ void sort_user_textures(std::vector<TextureLibraryEntry> &library)
|
|||||||
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
|
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
|
||||||
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
|
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
|
||||||
};
|
};
|
||||||
const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; });
|
const auto first_user = std::find_if(library.begin(), library.end(),
|
||||||
std::stable_sort(first_user, 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); });
|
[&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
|
} // 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()
|
std::string user_texture_dir()
|
||||||
{
|
{
|
||||||
const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement";
|
const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement";
|
||||||
@@ -325,16 +384,19 @@ std::string user_texture_dir()
|
|||||||
|
|
||||||
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
|
const std::vector<TextureLibraryEntry> &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;
|
return g_library;
|
||||||
|
|
||||||
g_library.clear();
|
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();
|
const std::string user_dir = user_texture_dir();
|
||||||
if (!user_dir.empty())
|
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);
|
sort_user_textures(g_library);
|
||||||
|
scan_plugin_textures(g_library);
|
||||||
|
|
||||||
|
g_library_scan_generation = generation;
|
||||||
g_library_scanned = true;
|
g_library_scanned = true;
|
||||||
return g_library;
|
return g_library;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#ifndef slic3r_TextureLibrary_hpp_
|
#ifndef slic3r_TextureLibrary_hpp_
|
||||||
#define slic3r_TextureLibrary_hpp_
|
#define slic3r_TextureLibrary_hpp_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -10,26 +11,45 @@
|
|||||||
|
|
||||||
namespace Slic3r::GUI {
|
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
|
struct TextureLibraryEntry
|
||||||
{
|
{
|
||||||
std::string name; // display name (the file's stem, e.g. "Wood Grain")
|
std::string name; // display name (the file's stem, e.g. "Wood Grain")
|
||||||
std::string path; // absolute path on disk
|
std::string path; // absolute path on disk
|
||||||
bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer
|
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
|
// Every available height-map texture, in the order the picker lists them: the ones shipped in
|
||||||
// by name, then the user's own from <data_dir>/textures/displacement - most recently used first (see
|
// resources/textures/displacement first, by name; then the user's own from <data_dir>/textures/displacement,
|
||||||
// touch_user_texture()), then any never used, by name.
|
// 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
|
// The shipped and user textures live in separate directories deliberately: an app update replaces the
|
||||||
// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or
|
// resources tree wholesale, so anything the user imported has to sit somewhere that update can never
|
||||||
// delete. `is_user` is what the picker uses to show them under separate headings.
|
// 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
|
// 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<TextureLibraryEntry> &texture_library(bool force_rescan = false);
|
const std::vector<TextureLibraryEntry> &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
|
// 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
|
// 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().
|
// not in user_texture_dir().
|
||||||
|
|||||||
@@ -529,6 +529,7 @@ std::string PluginConfig::capability_type_label(PluginCapabilityType type)
|
|||||||
case PluginCapabilityType::Visualization: return _u8L("Visualization");
|
case PluginCapabilityType::Visualization: return _u8L("Visualization");
|
||||||
case PluginCapabilityType::Script: return _u8L("Script");
|
case PluginCapabilityType::Script: return _u8L("Script");
|
||||||
case PluginCapabilityType::SlicingPipeline: return _u8L("Slicing Pipeline");
|
case PluginCapabilityType::SlicingPipeline: return _u8L("Slicing Pipeline");
|
||||||
|
case PluginCapabilityType::ResourceTexture: return _u8L("Resource Texture");
|
||||||
default: return _u8L("Unknown");
|
default: return _u8L("Unknown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <boost/filesystem/directory.hpp>
|
#include <boost/filesystem/directory.hpp>
|
||||||
#include <boost/filesystem/file_status.hpp>
|
#include <boost/filesystem/file_status.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
#include <boost/algorithm/string/case_conv.hpp>
|
||||||
#include <boost/nowide/fstream.hpp>
|
#include <boost/nowide/fstream.hpp>
|
||||||
|
|
||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
@@ -1255,4 +1256,76 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> 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<std::string> 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
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -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.
|
// 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);
|
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<std::string> 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.
|
// 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);
|
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.
|
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "PluginFsUtils.hpp"
|
#include "PluginFsUtils.hpp"
|
||||||
#include "PythonInterpreter.hpp"
|
#include "PythonInterpreter.hpp"
|
||||||
#include "PythonPluginBridge.hpp"
|
#include "PythonPluginBridge.hpp"
|
||||||
|
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
#include "slic3r/GUI/I18N.hpp"
|
#include "slic3r/GUI/I18N.hpp"
|
||||||
#include "slic3r/GUI/format.hpp"
|
#include "slic3r/GUI/format.hpp"
|
||||||
@@ -387,6 +388,37 @@ bool load(const PluginDescriptor& descriptor,
|
|||||||
instance->set_config_ui_available(false);
|
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<ResourceTexturePluginCapability*>(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));
|
capabilities.push_back(std::move(instance));
|
||||||
}
|
}
|
||||||
} catch (const std::exception& ex) {
|
} catch (const std::exception& ex) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include "PyPluginTrampoline.hpp"
|
#include "PyPluginTrampoline.hpp"
|
||||||
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
|
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
|
||||||
#include "pluginTypes/pages/PagesPluginCapability.hpp"
|
#include "pluginTypes/pages/PagesPluginCapability.hpp"
|
||||||
|
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||||
#include "pluginTypes/script/ScriptPluginCapability.hpp"
|
#include "pluginTypes/script/ScriptPluginCapability.hpp"
|
||||||
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
||||||
#include <pybind11/pytypes.h>
|
#include <pybind11/pytypes.h>
|
||||||
@@ -374,6 +375,7 @@ void bind_python_api(pybind11::module_& m)
|
|||||||
.value("Visualization", PluginCapabilityType::Visualization)
|
.value("Visualization", PluginCapabilityType::Visualization)
|
||||||
.value("Script", PluginCapabilityType::Script)
|
.value("Script", PluginCapabilityType::Script)
|
||||||
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
|
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
|
||||||
|
.value("ResourceTexture", PluginCapabilityType::ResourceTexture)
|
||||||
.value("Unknown", PluginCapabilityType::Unknown)
|
.value("Unknown", PluginCapabilityType::Unknown)
|
||||||
.export_values();
|
.export_values();
|
||||||
|
|
||||||
@@ -527,6 +529,7 @@ void bind_python_api(pybind11::module_& m)
|
|||||||
PagesPluginCapability::RegisterBindings(m);
|
PagesPluginCapability::RegisterBindings(m);
|
||||||
ScriptPluginCapability::RegisterBindings(m);
|
ScriptPluginCapability::RegisterBindings(m);
|
||||||
SlicingPipelinePluginCapability::RegisterBindings(m);
|
SlicingPipelinePluginCapability::RegisterBindings(m);
|
||||||
|
ResourceTexturePluginCapability::RegisterBindings(m);
|
||||||
PluginHost::RegisterBindings(m);
|
PluginHost::RegisterBindings(m);
|
||||||
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
|
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
namespace Slic3r {
|
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
|
struct PluginCapabilityId
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
|
|||||||
case PluginCapabilityType::Visualization: return "visualization";
|
case PluginCapabilityType::Visualization: return "visualization";
|
||||||
case PluginCapabilityType::Script: return "script";
|
case PluginCapabilityType::Script: return "script";
|
||||||
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
|
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
|
||||||
|
case PluginCapabilityType::ResourceTexture: return "resource-texture";
|
||||||
default: return "unknown";
|
default: return "unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
|
|||||||
case PluginCapabilityType::Visualization: return "Visualization";
|
case PluginCapabilityType::Visualization: return "Visualization";
|
||||||
case PluginCapabilityType::Script: return "Script";
|
case PluginCapabilityType::Script: return "Script";
|
||||||
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
|
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
|
||||||
|
case PluginCapabilityType::ResourceTexture: return "Resource Texture";
|
||||||
default: return "Unknown";
|
default: return "Unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
|
|||||||
return PluginCapabilityType::Script;
|
return PluginCapabilityType::Script;
|
||||||
if (lowered == "slicing-pipeline")
|
if (lowered == "slicing-pipeline")
|
||||||
return PluginCapabilityType::SlicingPipeline;
|
return PluginCapabilityType::SlicingPipeline;
|
||||||
|
if (lowered == "resource-texture")
|
||||||
|
return PluginCapabilityType::ResourceTexture;
|
||||||
return PluginCapabilityType::Unknown;
|
return PluginCapabilityType::Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "PluginResourceTextures.hpp"
|
||||||
|
|
||||||
|
#include "slic3r/plugin/PluginDescriptor.hpp"
|
||||||
|
#include "slic3r/plugin/PluginManager.hpp"
|
||||||
|
#include "slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
PluginResourceTextures &PluginResourceTextures::instance()
|
||||||
|
{
|
||||||
|
static PluginResourceTextures registry;
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ResourceTextureEntry> 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<ResourceTexturePluginCapability>(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<std::mutex> 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<ResourceTextureEntry> entry = build_entry(id);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
if (entry)
|
||||||
|
m_entries[entry->id] = std::move(*entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginResourceTextures::on_cap_deregister(const PluginCapabilityId &id)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_entries.erase(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginResourceTextures::initial_sweep()
|
||||||
|
{
|
||||||
|
std::map<PluginCapabilityId, ResourceTextureEntry> entries;
|
||||||
|
for (const auto &capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::ResourceTexture)) {
|
||||||
|
if (!capability)
|
||||||
|
continue;
|
||||||
|
std::optional<ResourceTextureEntry> entry = build_entry(capability->identity());
|
||||||
|
if (entry)
|
||||||
|
entries[entry->id] = std::move(*entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
m_entries = std::move(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ResourceTextureEntry> PluginResourceTextures::entries() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
std::vector<ResourceTextureEntry> result;
|
||||||
|
result.reserve(m_entries.size());
|
||||||
|
for (const auto &entry : m_entries)
|
||||||
|
result.push_back(entry.second);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<ResourceTextureEntry> entries() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
PluginResourceTextures() = default;
|
||||||
|
|
||||||
|
std::optional<ResourceTextureEntry> build_entry(const PluginCapabilityId &id) const;
|
||||||
|
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
std::map<PluginCapabilityId, ResourceTextureEntry> m_entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#include "ResourceTexturePluginCapability.hpp"
|
||||||
|
#include "ResourceTexturePluginCapabilityTrampoline.hpp"
|
||||||
|
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/pytypes.h>
|
||||||
|
|
||||||
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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_<ResourceTexturePluginCapability, PluginCapabilityInterface,
|
||||||
|
PyResourceTexturePluginCapabilityTrampoline,
|
||||||
|
std::shared_ptr<ResourceTexturePluginCapability>>(resource_texture,
|
||||||
|
"ResourceTexturePluginCapabilityBase")
|
||||||
|
.def(py::init<>())
|
||||||
|
.def("get_type", &ResourceTexturePluginCapability::get_type)
|
||||||
|
.def("get_texture_dir", &ResourceTexturePluginCapability::get_texture_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../../PythonPluginInterface.hpp"
|
||||||
|
#include "pybind11/pybind11.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
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
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ResourceTexturePluginCapability.hpp"
|
||||||
|
#include "../../PyPluginTrampoline.hpp"
|
||||||
|
#include <pybind11/pybind11.h>
|
||||||
|
#include <pybind11/pytypes.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
class PyResourceTexturePluginCapabilityTrampoline :
|
||||||
|
public PyPluginCommonTrampoline<ResourceTexturePluginCapability>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
using PyPluginCommonTrampoline<ResourceTexturePluginCapability>::PyPluginCommonTrampoline;
|
||||||
|
|
||||||
|
std::string get_texture_dir() const override
|
||||||
|
{
|
||||||
|
ORCA_PY_OVERRIDE_AUDITED(
|
||||||
|
[] {},
|
||||||
|
PYBIND11_OVERRIDE_PURE,
|
||||||
|
std::string,
|
||||||
|
ResourceTexturePluginCapability,
|
||||||
|
get_texture_dir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_printer_agent.cpp
|
test_printer_agent.cpp
|
||||||
test_plugin_install.cpp
|
test_plugin_install.cpp
|
||||||
test_plugin_lifecycle.cpp
|
test_plugin_lifecycle.cpp
|
||||||
|
test_plugin_resource_texture.cpp
|
||||||
test_plugin_printer_agent.cpp
|
test_plugin_printer_agent.cpp
|
||||||
test_printhost.cpp
|
test_printhost.cpp
|
||||||
test_slicing_pipeline_bindings.cpp
|
test_slicing_pipeline_bindings.cpp
|
||||||
|
|||||||
@@ -0,0 +1,941 @@
|
|||||||
|
// 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 <windows.h>, so keep
|
||||||
|
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers.
|
||||||
|
#include "test_utils.hpp"
|
||||||
|
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||||
|
#ifdef WIN32
|
||||||
|
#ifndef WIN32_LEAN_AND_MEAN
|
||||||
|
#define WIN32_LEAN_AND_MEAN
|
||||||
|
#endif
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <Windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <boost/filesystem.hpp>
|
||||||
|
#include <boost/filesystem/operations.hpp>
|
||||||
|
#include <boost/filesystem/path.hpp>
|
||||||
|
#include <boost/system/error_code.hpp>
|
||||||
|
#include <catch2/catch_all.hpp>
|
||||||
|
#include <catch2/catch_message.hpp>
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <initializer_list>
|
||||||
|
#include <ios>
|
||||||
|
#include <iterator>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <wx/image.h>
|
||||||
|
|
||||||
|
#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/<stem>/<stem>.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<GUI::TextureLibraryEntry> plugin_textures(bool force)
|
||||||
|
{
|
||||||
|
std::vector<GUI::TextureLibraryEntry> 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<unsigned char> read_file_bytes(const fs::path& path)
|
||||||
|
{
|
||||||
|
std::ifstream ifs(path.string(), std::ios::binary);
|
||||||
|
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_bytes(const fs::path& path, const std::vector<unsigned char>& bytes)
|
||||||
|
{
|
||||||
|
fs::create_directories(path.parent_path());
|
||||||
|
std::ofstream out(path.string(), std::ios::binary);
|
||||||
|
out.write(reinterpret_cast<const char*>(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<uint8_t> 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<unsigned char> 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<unsigned char> 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 and, before that, wxEntryStart() runs the
|
||||||
|
// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and
|
||||||
|
// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or
|
||||||
|
// a .bmp texture cannot be loaded here at all.
|
||||||
|
void ensure_wx_image_handlers()
|
||||||
|
{
|
||||||
|
static bool initialized = false;
|
||||||
|
if (initialized)
|
||||||
|
return;
|
||||||
|
initialized = true;
|
||||||
|
wxImage::InitStandardHandlers();
|
||||||
|
wxInitAllImageHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Preset*> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||||
|
|
||||||
|
CHECK(error.empty());
|
||||||
|
CHECK(files.size() == 8);
|
||||||
|
|
||||||
|
std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> 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<GUI::TextureLibraryEntry> 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<std::string> runs;
|
||||||
|
for (const GUI::TextureLibraryEntry& entry : pack_entries)
|
||||||
|
if (runs.empty() || runs.back() != entry.pack_name)
|
||||||
|
runs.push_back(entry.pack_name);
|
||||||
|
|
||||||
|
std::vector<std::string> 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<std::string>({ "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<GUI::TextureLibraryEntry> 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<GUI::TextureLibraryEntry> 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<unsigned char> 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<unsigned char> 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<std::string> 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<TextureDisplacementLayer>& 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<TextureDisplacementLayer>& 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user