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