mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-15 20:01:31 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cf18fb96 | ||
|
|
4ee1b9c6e9 | ||
|
|
bc8d0a7858 | ||
|
|
e0651ec31d | ||
|
|
0caefcf4aa | ||
|
|
99de46b9d0 | ||
|
|
b8d40ed61f | ||
|
|
54e6df0235 | ||
|
|
79ed246d26 | ||
|
|
92b5301bf6 | ||
|
|
20c157b0f4 | ||
|
|
4cf16febf6 | ||
|
|
70e52a105f | ||
|
|
b525cb79e1 | ||
|
|
86ab598899 |
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
Vendored
+2
-17
@@ -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}
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<script src="../js/plugin-config-ui.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
<script src="./plugin-sort.js"></script>
|
||||
<script src="../js/fuzzy-search.js"></script>
|
||||
<script src="../../js/fuzzy-search.js"></script>
|
||||
<script src="./plugin-search.js"></script>
|
||||
</head>
|
||||
<body onLoad="OnInit()">
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,957 +0,0 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# id = "preset-update-demo"
|
||||
# name = "Preset Update Demo"
|
||||
# description = "Edits any setting of the currently edited process, printer, or filament preset through the preset host API."
|
||||
# author = "OrcaSlicer contributors"
|
||||
# version = "0.2.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
"""Preset Update Demo — edit any setting of the current preset from a plugin window.
|
||||
|
||||
Install this file in the local ``orca_plugins`` directory, enable the plugin, then run
|
||||
"Preset Update Demo" from the Plugins dialog. The window lists every setting of the
|
||||
preset the matching settings tab is editing right now — process, printer, or filament —
|
||||
with its current serialized value, and writes a value back through
|
||||
``orca.host.preset.<type>.update(...)``.
|
||||
|
||||
Process and printer each have one edited preset, taken from
|
||||
``current_process_preset()`` / ``current_printer_preset()``. Filament presets are
|
||||
addressed by slot instead: the window reads one preset per slot from
|
||||
``current_filament_presets()`` (slot numbers are one-based, matching Orca's labels),
|
||||
shows the selected slot's preset, and writes through
|
||||
``orca.host.preset.filament.update(slot, key, value)``.
|
||||
|
||||
Any assigned slot can be edited: the backend selects that slot's preset through Orca's
|
||||
normal preset selection before it writes, so applying can switch the filament tab and Orca
|
||||
may ask about unsaved changes first. Only an empty slot, whose mount resolves to no preset,
|
||||
is unavailable — the window says so instead of offering an edit. A slot whose preset is not
|
||||
the one the filament settings already have open gets an informational note that applying
|
||||
will switch to it; it is never presented as a refusal. Two slots that mount the same preset
|
||||
are one preset, so editing through either of them changes both; the window marks those
|
||||
slots and says which they are.
|
||||
|
||||
Every row is one ``config_keys()`` entry carrying ``config_value(key)``.
|
||||
|
||||
What ``update()`` does, and what it does not:
|
||||
|
||||
* it runs the same validation and UI update path as editing the setting by hand, so
|
||||
the change lands in the preset the tab is editing and is never written to disk here
|
||||
— the preset shows as modified in OrcaSlicer once it differs from the saved one;
|
||||
* it accepts only settings of that preset category. A key that belongs to another
|
||||
category, or a value the setting would reject, raises an error and writes nothing;
|
||||
* it is refused when the selected printer's technology does not use that tab (the
|
||||
print and filament tabs do nothing under SLA), so this demo covers the three FFF
|
||||
categories only;
|
||||
* a setting whose page has not been built yet still runs its owning settings group's
|
||||
checks and callbacks; behavior that requires an actual widget (such as widget-specific
|
||||
parsing or clamping) only runs when the field exists;
|
||||
* on a filament slot it selects that slot's preset first, so Orca may prompt about
|
||||
unsaved changes in the filament tab before the value is written.
|
||||
|
||||
Values are OrcaSlicer's serialized config syntax — the same text a preset file stores —
|
||||
so numbers are plain, percentages keep their ``%``, booleans are ``1``/``0``, choices use
|
||||
the setting's internal key, and lists are semicolon-separated.
|
||||
"""
|
||||
|
||||
import orca
|
||||
|
||||
|
||||
# The three preset categories the main tabs edit. Each key is both the value the page
|
||||
# sends and the attribute name on orca.host.preset that carries the matching editor.
|
||||
KINDS = (
|
||||
{"kind": "process", "label": "Process"},
|
||||
{"kind": "printer", "label": "Printer"},
|
||||
{"kind": "filament", "label": "Filament"},
|
||||
)
|
||||
KIND_KEYS = {item["kind"] for item in KINDS}
|
||||
|
||||
|
||||
def _editor(kind):
|
||||
"""The host.PresetEditor for a category (orca.host.preset.process / printer).
|
||||
|
||||
Filament is not reached through this helper: it is addressed by slot, so it calls
|
||||
orca.host.preset.filament.update(slot, key, value) directly.
|
||||
"""
|
||||
return getattr(orca.host.preset, kind)
|
||||
|
||||
|
||||
def _current_preset(bundle, kind):
|
||||
"""The single preset the matching settings tab edits right now, as a host.Preset.
|
||||
|
||||
Only the process and printer categories have one such preset; filament presets are
|
||||
addressed by slot instead (see _slot_presets).
|
||||
"""
|
||||
if kind == "process":
|
||||
return bundle.current_process_preset()
|
||||
return bundle.current_printer_preset()
|
||||
|
||||
|
||||
def _slot_presets(bundle):
|
||||
"""The filament preset mounted in each slot, in slot order; a slot may be None."""
|
||||
return list(bundle.current_filament_presets())
|
||||
|
||||
|
||||
def _preset_is_open(preset, edited):
|
||||
"""Whether this slot's preset is the one the filament settings already have open.
|
||||
|
||||
A slot with an assigned preset can be edited either way: applying to another slot makes
|
||||
the backend select that slot's preset first. This only distinguishes the slot that
|
||||
switches nothing from the ones that will switch the filament tab.
|
||||
"""
|
||||
return preset is not None and preset.name == edited.name
|
||||
|
||||
|
||||
def _filament_slots(presets, edited):
|
||||
"""One entry per mounted slot: its one-based number, preset name, and open flag.
|
||||
|
||||
The number is the slot label Orca shows; the name is None for a slot that mounts no
|
||||
preset, which is the only kind that cannot be edited.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"slot": index + 1,
|
||||
"name": None if preset is None else preset.name,
|
||||
"open": _preset_is_open(preset, edited),
|
||||
}
|
||||
for index, preset in enumerate(presets)
|
||||
]
|
||||
|
||||
|
||||
def _as_slot(value):
|
||||
"""A requested slot as an int, or None when it is not a whole number."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def _clamp_slot(slot, count):
|
||||
"""The slot to display: the requested one, kept inside the available range."""
|
||||
if count <= 0:
|
||||
return 0
|
||||
number = _as_slot(slot)
|
||||
if number is None or number < 1:
|
||||
return 1
|
||||
return number if number <= count else count
|
||||
|
||||
|
||||
def _parse_slot(slot, count):
|
||||
"""Validate a requested filament slot against the mounted slots (one-based)."""
|
||||
if count == 0:
|
||||
raise RuntimeError("No filament slots are mounted")
|
||||
number = _as_slot(slot)
|
||||
if number is None:
|
||||
raise RuntimeError("A filament slot number is required")
|
||||
if number < 1 or number > count:
|
||||
raise RuntimeError("Filament slot %d is out of range (1-%d)" % (number, count))
|
||||
return number
|
||||
|
||||
|
||||
def _rows(preset):
|
||||
"""Every setting of the preset, sorted by key, as [{k, v}].
|
||||
|
||||
config_value() returns None for a key the preset does not carry; the page shows
|
||||
those as empty rather than "None".
|
||||
"""
|
||||
rows = []
|
||||
for key in sorted(preset.config_keys()):
|
||||
value = preset.config_value(key)
|
||||
rows.append({"k": key, "v": "" if value is None else str(value)})
|
||||
return rows
|
||||
|
||||
|
||||
def _state(bundle, kind, slot, message="", error=""):
|
||||
"""The payload the page renders: shown preset, rows, slots, result line."""
|
||||
payload = {"command": "state", "kind": kind, "message": message, "error": error}
|
||||
if kind == "filament":
|
||||
presets = _slot_presets(bundle)
|
||||
edited = bundle.filaments.edited_preset()
|
||||
effective = _clamp_slot(slot, len(presets))
|
||||
preset = None if effective == 0 else presets[effective - 1]
|
||||
payload["slots"] = _filament_slots(presets, edited)
|
||||
payload["slot"] = effective
|
||||
# An empty slot has nothing to edit; any assigned slot is editable, and one that is
|
||||
# not already open makes the backend select it first.
|
||||
payload["assigned"] = preset is not None
|
||||
payload["switches"] = preset is not None and not _preset_is_open(preset, edited)
|
||||
payload["edited_name"] = edited.name
|
||||
else:
|
||||
preset = _current_preset(bundle, kind)
|
||||
payload["preset"] = None if preset is None else {
|
||||
"name": preset.name,
|
||||
"is_dirty": bool(preset.is_dirty),
|
||||
"is_system": bool(preset.is_system),
|
||||
}
|
||||
payload["rows"] = [] if preset is None else _rows(preset)
|
||||
return payload
|
||||
|
||||
|
||||
def _apply(kind, key, value, slot=None):
|
||||
"""Set one setting of the preset the page is showing, through the host API.
|
||||
|
||||
A filament preset is addressed by slot number; process and printer each resolve
|
||||
their single edited preset themselves, so no slot is passed to them.
|
||||
"""
|
||||
if kind == "filament":
|
||||
orca.host.preset.filament.update(slot, key, value)
|
||||
return
|
||||
_editor(kind).update(key, value)
|
||||
|
||||
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root {
|
||||
--bg: var(--orca-bg, #fafafa);
|
||||
--fg: var(--orca-fg, #1b1e23);
|
||||
--muted: var(--orca-muted, #6b7280);
|
||||
--border: var(--orca-border, #dadee3);
|
||||
--accent: var(--orca-accent, #0e7c86);
|
||||
--accent-fg: var(--orca-accent-fg, #ffffff);
|
||||
--ui: var(--orca-font, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--surface: rgba(127, 127, 127, .09);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: var(--orca-bg, #23262a);
|
||||
--fg: var(--orca-fg, #e6e8ea);
|
||||
--muted: var(--orca-muted, #9aa1a9);
|
||||
--border: var(--orca-border, #383c42);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 13px/1.45 var(--ui);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
button { font: inherit; cursor: pointer; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* header ------------------------------------------------------------- */
|
||||
header {
|
||||
flex: none; display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logo { color: var(--accent); font-size: 15px; line-height: 1; }
|
||||
h1 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: .01em; }
|
||||
|
||||
.seg {
|
||||
display: flex; gap: 2px; margin-left: auto; padding: 2px;
|
||||
border: 1px solid var(--border); border-radius: 9px; background: var(--surface);
|
||||
}
|
||||
.seg button {
|
||||
border: 0; background: transparent; color: var(--muted);
|
||||
padding: 4px 13px; border-radius: 7px;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.seg button:hover { color: var(--fg); }
|
||||
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
.iconbtn {
|
||||
width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 7px;
|
||||
background: transparent; color: var(--fg); display: grid; place-items: center;
|
||||
font-size: 13px; transition: background .15s ease;
|
||||
}
|
||||
.iconbtn:hover:enabled { background: var(--surface); }
|
||||
.iconbtn:disabled { opacity: .5; }
|
||||
|
||||
/* preset bar --------------------------------------------------------- */
|
||||
.presetbar {
|
||||
flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 14px; border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.presetbar .name {
|
||||
font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 60%;
|
||||
}
|
||||
.spacer { flex: 1; }
|
||||
.tag {
|
||||
border: 1px solid var(--border); color: var(--muted); border-radius: 999px;
|
||||
padding: 1px 8px; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.tag.on { border-color: transparent; background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
/* filament slots ----------------------------------------------------- */
|
||||
.slotbar { flex: none; padding: 7px 14px 8px; border-bottom: 1px solid var(--border); }
|
||||
.slotrow { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.slotlabel {
|
||||
flex: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.slots { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; gap: 6px;
|
||||
max-height: 84px; overflow-y: auto; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; flex: none; max-width: 190px;
|
||||
padding: 2px 10px 2px 3px; border: 1px solid var(--border); border-radius: 999px;
|
||||
background: transparent; color: var(--fg);
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.chip:hover { background: var(--surface); }
|
||||
.chip .n {
|
||||
flex: none; min-width: 19px; height: 19px; padding: 0 4px; border-radius: 999px;
|
||||
display: grid; place-items: center; background: var(--surface); color: var(--muted);
|
||||
font-size: 11px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* the slot whose preset the filament settings already have open, so applying switches nothing */
|
||||
.chip.open .n { box-shadow: inset 0 0 0 1px var(--accent); color: var(--fg); }
|
||||
.chip .s {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.chip[aria-pressed="true"] .n {
|
||||
background: rgba(255, 255, 255, .22); color: var(--accent-fg); box-shadow: none;
|
||||
}
|
||||
.chip[aria-pressed="true"] .s { color: var(--accent-fg); }
|
||||
/* a slot that mounts the preset the page is showing: editing either changes both */
|
||||
.chip.linked::after {
|
||||
content: ""; flex: none; width: 5px; height: 5px;
|
||||
border-radius: 50%; background: var(--accent);
|
||||
}
|
||||
.slotnote {
|
||||
margin-top: 7px; padding-left: 8px; border-left: 2px solid var(--accent);
|
||||
font-size: 11px; line-height: 1.5; color: var(--muted);
|
||||
}
|
||||
.slotnote:empty { display: none; }
|
||||
|
||||
/* panes -------------------------------------------------------------- */
|
||||
main { flex: 1; display: flex; min-height: 0; }
|
||||
|
||||
.list-pane {
|
||||
flex: none; width: 45%; min-width: 250px; min-height: 0;
|
||||
display: flex; flex-direction: column; border-right: 1px solid var(--border);
|
||||
}
|
||||
.search {
|
||||
flex: none; display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search input {
|
||||
flex: 1; min-width: 0; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 7px; padding: 4px 9px;
|
||||
}
|
||||
.rows { flex: 1; min-height: 0; overflow-y: auto; padding: 3px 0; }
|
||||
|
||||
.row {
|
||||
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||
padding: 5px 10px; text-align: left; border: 0; border-left: 3px solid transparent;
|
||||
background: transparent; color: var(--fg);
|
||||
}
|
||||
.row:hover { background: var(--surface); }
|
||||
.row[aria-selected="true"] { background: var(--surface); border-left-color: var(--accent); }
|
||||
.row .k {
|
||||
flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.row .v {
|
||||
flex: none; max-width: 40%; font-family: var(--mono); font-size: 11px;
|
||||
color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.row .dot {
|
||||
flex: none; align-self: center; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); visibility: hidden;
|
||||
}
|
||||
.row.changed .dot { visibility: visible; }
|
||||
@keyframes flash {
|
||||
0% { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
100% { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.row.flash { animation: flash .7s ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row.flash { animation: none; }
|
||||
}
|
||||
|
||||
.empty { color: var(--muted); padding: 14px 12px; }
|
||||
.nil { font-style: italic; opacity: .75; }
|
||||
|
||||
/* editor ------------------------------------------------------------- */
|
||||
.edit-pane {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 9px;
|
||||
padding: 12px 14px; overflow-y: auto;
|
||||
}
|
||||
.edit-key { font-family: var(--mono); font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
||||
.current {
|
||||
margin: 0; max-height: 108px; overflow: auto;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 7px;
|
||||
padding: 7px 10px; white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
textarea {
|
||||
width: 100%; min-height: 74px; resize: vertical;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 7px; padding: 7px 10px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; align-items: center; }
|
||||
button.primary {
|
||||
padding: 5px 14px; border-radius: 7px; border: 1px solid var(--accent);
|
||||
background: var(--accent); color: var(--accent-fg);
|
||||
}
|
||||
button.ghost {
|
||||
padding: 5px 12px; border-radius: 7px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--fg);
|
||||
}
|
||||
button.primary:disabled, button.ghost:disabled { opacity: .5; cursor: default; }
|
||||
button.primary:hover:enabled { filter: brightness(1.08); }
|
||||
button.ghost:hover:enabled { background: var(--surface); }
|
||||
|
||||
.notice {
|
||||
padding: 8px 11px; border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 7px; background: var(--surface);
|
||||
font-size: 12px; line-height: 1.5; color: var(--muted);
|
||||
}
|
||||
.notice b { color: var(--fg); font-weight: 600; }
|
||||
.notice:empty { display: none; }
|
||||
|
||||
.syntax {
|
||||
margin-top: 2px; padding: 9px 11px; border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent); border-radius: 7px;
|
||||
font-size: 12px; line-height: 1.55; color: var(--muted);
|
||||
}
|
||||
.syntax b { color: var(--fg); font-weight: 600; }
|
||||
.syntax code { font-family: var(--mono); font-size: 11.5px; color: var(--fg); }
|
||||
|
||||
/* status ------------------------------------------------------------- */
|
||||
footer {
|
||||
flex: none; min-height: 30px; padding: 7px 14px;
|
||||
border-top: 1px solid var(--border); font-size: 12px; color: var(--muted);
|
||||
}
|
||||
footer.ok { color: #0f8a5f; }
|
||||
footer.err { color: #c8382c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">◈</span>
|
||||
<h1>Preset update</h1>
|
||||
<div class="seg" id="seg"></div>
|
||||
<button class="iconbtn" id="refresh" title="Reload the current values">⟳</button>
|
||||
<button class="iconbtn" id="close" title="Close">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="presetbar">
|
||||
<span class="name" id="pname">Loading…</span>
|
||||
<span id="ptag"></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="tag" id="pcount">0 settings</span>
|
||||
</div>
|
||||
|
||||
<div class="slotbar" id="slotbar" hidden>
|
||||
<div class="slotrow">
|
||||
<span class="slotlabel">Filament slots</span>
|
||||
<div class="slots" id="slots"></div>
|
||||
</div>
|
||||
<div class="slotnote" id="slotnote"></div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="list-pane">
|
||||
<div class="search">
|
||||
<input id="q" type="search" placeholder="Search settings…" autocomplete="off" spellcheck="false">
|
||||
<span class="tag" id="qcount">0</span>
|
||||
</div>
|
||||
<div class="rows" id="rows"></div>
|
||||
</section>
|
||||
|
||||
<section class="edit-pane">
|
||||
<div class="edit-key" id="ekey">Select a setting</div>
|
||||
<div class="lbl">Current value</div>
|
||||
<pre class="current" id="ecur">—</pre>
|
||||
<label class="lbl" for="evalue">New value</label>
|
||||
<textarea id="evalue" spellcheck="false" autocomplete="off"
|
||||
placeholder="Serialized value" disabled></textarea>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="actions">
|
||||
<button class="primary" id="apply" disabled>Apply</button>
|
||||
<button class="ghost" id="reset" disabled>Reset to current</button>
|
||||
</div>
|
||||
<div class="syntax">
|
||||
<b>Values use OrcaSlicer's serialized config syntax</b> — the same text a preset
|
||||
file stores. Numbers are plain (<code>0.2</code>, <code>3</code>) and percentages keep
|
||||
their sign (<code>15%</code>). On/off is <code>1</code> or <code>0</code>. A choice uses
|
||||
the setting's own key, e.g. <code>grid</code>. Lists are semicolon-separated
|
||||
(<code>0.4;0.4;0.4</code>, or <code>"#FF0000";"#00FF00"</code>). Text keeps its
|
||||
escapes, so a line break stays <code>\n</code>. A key that does not belong to this
|
||||
preset, or a value the setting would reject, comes back as an error and writes
|
||||
nothing. The edit lands in the preset the tab is editing and stays unsaved.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer id="status"></footer>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
const SEG = [['process', 'Process'], ['printer', 'Printer'], ['filament', 'Filament']];
|
||||
const IDLE = 'Edits apply to the preset shown here and stay unsaved until you save '
|
||||
+ 'or export it.';
|
||||
|
||||
const S = {
|
||||
kind: 'process',
|
||||
slot: 1, // selected filament slot (one-based); ignored by the other kinds
|
||||
slots: [], // [{slot, name, open}] from current_filament_presets()
|
||||
preset: null,
|
||||
rows: [],
|
||||
selected: null,
|
||||
changed: {}, // "kind/preset/key" -> true, for edits made in this window
|
||||
pending: null, // {kind, slot, preset, key} of an apply awaiting its reply
|
||||
error: '', // the last error the plugin reported, if any
|
||||
assigned: true, // filament only: the shown slot mounts a preset, so it is editable
|
||||
switches: false, // filament only: applying will select that preset in the filament tab
|
||||
editedName: null, // filament only: the preset the filament settings have open
|
||||
busy: true,
|
||||
filter: '',
|
||||
statusText: '',
|
||||
statusCls: '',
|
||||
};
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s == null ? '' : s)
|
||||
.replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
|
||||
/* ------------------------------------------------------------------ paint */
|
||||
function paint() {
|
||||
paintSeg();
|
||||
const p = S.preset;
|
||||
const slotTag = S.kind === 'filament' && S.slot
|
||||
? '<span class="tag">slot ' + S.slot + '</span>' : '';
|
||||
$('pname').textContent = p ? p.name : (S.busy ? 'Loading…' : 'No preset');
|
||||
$('pname').title = p ? p.name : '';
|
||||
$('ptag').innerHTML = slotTag + (p
|
||||
? '<span class="tag">' + (p.is_system ? 'system' : 'user') + '</span>'
|
||||
+ (p.is_dirty ? '<span class="tag on">modified</span>' : '<span class="tag">saved</span>')
|
||||
: '');
|
||||
$('pcount').textContent = S.rows.length + (S.rows.length === 1 ? ' setting' : ' settings');
|
||||
$('refresh').disabled = S.busy;
|
||||
paintSlots();
|
||||
paintNotice();
|
||||
renderList();
|
||||
paintEditor();
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
function paintSeg() {
|
||||
// Built once (see buildSeg) so a click never loses focus; only the state is repainted.
|
||||
document.querySelectorAll('#seg button').forEach(b =>
|
||||
b.setAttribute('aria-pressed', String(b.dataset.kind === S.kind)));
|
||||
}
|
||||
|
||||
/* the filament slot strip: one chip per mounted slot, shown for filament only */
|
||||
function paintSlots() {
|
||||
const show = S.kind === 'filament' && (S.slots.length > 0 || S.busy);
|
||||
$('slotbar').hidden = !show;
|
||||
if (!show) { $('slots').innerHTML = ''; $('slotnote').innerHTML = ''; return; }
|
||||
// Rebuilding the chips drops keyboard focus; put it back on the same slot's chip.
|
||||
const focused = document.activeElement && document.activeElement.dataset
|
||||
? document.activeElement.dataset.slot : null;
|
||||
$('slots').innerHTML = S.slots.map(s => {
|
||||
const selected = s.slot === S.slot;
|
||||
const linked = !selected && sharesSelected(s.name);
|
||||
const label = s.name == null ? '<span class="nil">empty slot</span>' : esc(s.name);
|
||||
const hint = s.name ? ': ' + s.name : ': no preset';
|
||||
return '<button type="button" class="chip' + (linked ? ' linked' : '')
|
||||
+ (s.open ? ' open' : '') + '"'
|
||||
+ ' data-slot="' + s.slot + '" aria-pressed="' + selected + '"'
|
||||
+ ' title="' + esc('Filament slot ' + s.slot + hint
|
||||
+ (s.open ? ' — open in the filament settings' : '')) + '">'
|
||||
+ '<span class="n">' + s.slot + '</span><span class="s">' + label + '</span></button>';
|
||||
}).join('');
|
||||
$('slotnote').innerHTML = shareNote();
|
||||
if (focused) {
|
||||
const chip = document.querySelector('#slots .chip[data-slot="' + focused + '"]');
|
||||
if (chip) chip.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function slotName(slot) {
|
||||
const found = S.slots.find(s => s.slot === slot);
|
||||
return found && found.name ? found.name : null;
|
||||
}
|
||||
|
||||
// Applying selects the slot's preset first, so say when that switches the filament tab. An
|
||||
// empty slot is the only unavailable one, and it says so instead of offering an edit.
|
||||
function paintNotice() {
|
||||
const el = $('notice');
|
||||
if (S.kind !== 'filament' || S.busy) { el.innerHTML = ''; return; }
|
||||
// With nothing mounted the list already says so; avoid naming a slot number here.
|
||||
if (!S.slots.length) { el.innerHTML = ''; return; }
|
||||
const name = S.preset ? S.preset.name : null;
|
||||
if (!name) {
|
||||
el.innerHTML = 'Slot ' + S.slot + ' holds no filament preset. Assign one in the '
|
||||
+ 'OrcaSlicer filament settings first.';
|
||||
return;
|
||||
}
|
||||
if (!S.switches) { el.innerHTML = ''; return; }
|
||||
const open = S.editedName
|
||||
? ', while the filament settings have <b>' + esc(S.editedName) + '</b> open'
|
||||
: '';
|
||||
el.innerHTML = 'Slot ' + S.slot + ' uses <b>' + esc(name) + '</b>' + open
|
||||
+ '. Applying switches the filament tab to <b>' + esc(name) + '</b> first — Orca may '
|
||||
+ 'ask about unsaved changes there — then sets the value.';
|
||||
}
|
||||
|
||||
function sharesSelected(name) {
|
||||
const current = slotName(S.slot);
|
||||
return !!name && !!current && name === current;
|
||||
}
|
||||
|
||||
function listSlots(numbers) {
|
||||
if (numbers.length === 1) return String(numbers[0]);
|
||||
return numbers.slice(0, -1).join(', ') + ' and ' + numbers[numbers.length - 1];
|
||||
}
|
||||
|
||||
// Slots mounting the same preset are one preset, so say which slots an edit reaches.
|
||||
function shareNote() {
|
||||
const name = slotName(S.slot);
|
||||
if (!name) return '';
|
||||
const sharing = S.slots.filter(s => s.name === name).map(s => s.slot);
|
||||
if (sharing.length < 2) return '';
|
||||
return 'Slots ' + listSlots(sharing) + ' mount the same filament preset, so an edit '
|
||||
+ 'here changes every one of them.';
|
||||
}
|
||||
|
||||
function changedKey(presetName, key) {
|
||||
return S.kind + '/' + (presetName || '?') + '/' + key;
|
||||
}
|
||||
|
||||
function paintStatus() {
|
||||
const el = $('status');
|
||||
if (S.statusText) {
|
||||
el.textContent = S.statusText;
|
||||
el.className = S.statusCls;
|
||||
} else if (S.busy) {
|
||||
el.textContent = 'Working…';
|
||||
el.className = '';
|
||||
} else {
|
||||
el.textContent = IDLE;
|
||||
el.className = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
S.statusText = text || '';
|
||||
S.statusCls = cls || '';
|
||||
paintStatus();
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
const q = S.filter.trim().toLowerCase();
|
||||
if (!q) return S.rows;
|
||||
return S.rows.filter(r => r.k.toLowerCase().includes(q) || r.v.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const scroller = $('rows');
|
||||
const top = scroller.scrollTop;
|
||||
$('qcount').textContent = String(visibleRows().length);
|
||||
|
||||
if (!S.rows.length) {
|
||||
let text = 'This preset reports no settings.';
|
||||
if (S.busy) text = 'Loading settings…';
|
||||
else if (S.error) text = 'These values could not be read — see the message below.';
|
||||
else if (S.kind === 'filament' && !S.slots.length) text = 'No filament slots are mounted.';
|
||||
else if (S.kind === 'filament' && !S.preset) text = 'This filament slot has no preset.';
|
||||
scroller.innerHTML = '<div class="empty">' + text + '</div>';
|
||||
return;
|
||||
}
|
||||
const rows = visibleRows();
|
||||
if (!rows.length) {
|
||||
scroller.innerHTML = '<div class="empty">No setting matches “' + esc(S.filter) + '”.</div>';
|
||||
return;
|
||||
}
|
||||
scroller.innerHTML = rows.map(r => {
|
||||
const value = r.v === '' ? '<span class="nil">empty</span>' : esc(r.v);
|
||||
return '<button type="button" class="row'
|
||||
+ (S.changed[changedKey(S.preset && S.preset.name, r.k)] ? ' changed' : '') + '"'
|
||||
+ ' data-key="' + esc(r.k) + '"'
|
||||
+ ' aria-selected="' + (r.k === S.selected) + '"'
|
||||
+ ' title="' + esc(r.k + ' = ' + r.v) + '">'
|
||||
+ '<span class="k">' + esc(r.k) + '</span>'
|
||||
+ '<span class="v">' + value + '</span>'
|
||||
+ '<span class="dot"></span></button>';
|
||||
}).join('');
|
||||
scroller.scrollTop = top;
|
||||
}
|
||||
|
||||
function paintEditor() {
|
||||
const row = S.rows.find(r => r.k === S.selected) || null;
|
||||
// Every assigned slot is editable now that applying selects the slot's preset first, so
|
||||
// Apply is off only for an empty slot, a missing selection, or a request in flight.
|
||||
const selected = !!row && !S.busy;
|
||||
$('ekey').textContent = row ? row.k : 'Select a setting';
|
||||
$('ecur').textContent = row ? (row.v === '' ? '(empty)' : row.v) : '—';
|
||||
$('evalue').disabled = !selected;
|
||||
$('apply').disabled = !selected || !S.assigned;
|
||||
$('reset').disabled = !selected;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- actions */
|
||||
function buildSeg() {
|
||||
$('seg').innerHTML = SEG.map(([kind, label]) =>
|
||||
'<button type="button" data-kind="' + kind + '">' + label + '</button>').join('');
|
||||
}
|
||||
|
||||
function selectKey(key) {
|
||||
S.selected = key;
|
||||
const row = S.rows.find(r => r.k === key);
|
||||
$('evalue').value = row ? row.v : '';
|
||||
// Repaint the rows in place so the scroll position of a long list is kept.
|
||||
document.querySelectorAll('#rows .row').forEach(el =>
|
||||
el.setAttribute('aria-selected', String(el.dataset.key === key)));
|
||||
paintEditor();
|
||||
}
|
||||
|
||||
function switchKind(kind) {
|
||||
S.kind = kind;
|
||||
S.selected = null;
|
||||
S.pending = null;
|
||||
S.preset = null;
|
||||
S.rows = [];
|
||||
S.busy = true;
|
||||
$('evalue').value = '';
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: kind, slot: S.slot});
|
||||
}
|
||||
|
||||
// Two slots that mount the same preset show the same settings, so only a switch to a
|
||||
// different preset drops the current selection and the value being typed.
|
||||
function selectSlot(slot) {
|
||||
if (S.busy || slot === S.slot) return;
|
||||
const same = !!slotName(slot) && slotName(slot) === slotName(S.slot);
|
||||
S.slot = slot;
|
||||
S.pending = null;
|
||||
S.busy = true;
|
||||
if (!same) {
|
||||
S.preset = null;
|
||||
S.rows = [];
|
||||
S.selected = null;
|
||||
$('evalue').value = '';
|
||||
}
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: slot});
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (S.busy) return;
|
||||
S.busy = true;
|
||||
S.pending = null;
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: S.slot});
|
||||
}
|
||||
|
||||
function applyValue() {
|
||||
const row = S.rows.find(r => r.k === S.selected);
|
||||
// Mirror the Apply button: only a missing selection, a request in flight, or an empty
|
||||
// slot blocks an edit, so Enter cannot send one the button itself would refuse.
|
||||
if (!row || S.busy || !S.assigned) return;
|
||||
S.pending = {
|
||||
kind: S.kind, slot: S.slot,
|
||||
preset: S.preset ? S.preset.name : null, key: row.k,
|
||||
};
|
||||
S.busy = true;
|
||||
setStatus('', '');
|
||||
paint();
|
||||
orca.postMessage({command: 'apply', kind: S.kind, slot: S.slot, key: row.k,
|
||||
value: $('evalue').value});
|
||||
}
|
||||
|
||||
function flashKey(key) {
|
||||
document.querySelectorAll('#rows .row').forEach(el => {
|
||||
if (el.dataset.key !== key) return;
|
||||
el.classList.remove('flash');
|
||||
void el.offsetWidth; // restart the animation if it ran on this row already
|
||||
el.classList.add('flash');
|
||||
el.addEventListener('animationend', () => el.classList.remove('flash'), {once: true});
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- wiring */
|
||||
buildSeg();
|
||||
|
||||
$('seg').addEventListener('click', e => {
|
||||
const button = e.target.closest('[data-kind]');
|
||||
if (!button || S.busy || button.dataset.kind === S.kind) return;
|
||||
switchKind(button.dataset.kind);
|
||||
});
|
||||
|
||||
$('rows').addEventListener('click', e => {
|
||||
const row = e.target.closest('.row');
|
||||
if (row && !S.busy) selectKey(row.dataset.key);
|
||||
});
|
||||
|
||||
$('slots').addEventListener('click', e => {
|
||||
const chip = e.target.closest('[data-slot]');
|
||||
if (chip && !S.busy) selectSlot(Number(chip.dataset.slot));
|
||||
});
|
||||
|
||||
$('q').addEventListener('input', e => {
|
||||
S.filter = e.target.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
$('apply').addEventListener('click', applyValue);
|
||||
|
||||
$('reset').addEventListener('click', () => {
|
||||
const row = S.rows.find(r => r.k === S.selected);
|
||||
if (row) $('evalue').value = row.v;
|
||||
});
|
||||
|
||||
$('evalue').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); applyValue(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); $('reset').click(); }
|
||||
});
|
||||
|
||||
$('refresh').addEventListener('click', reload);
|
||||
$('close').addEventListener('click', () => orca.close());
|
||||
|
||||
/* -------------------------------------------------------------- messages */
|
||||
orca.onMessage(msg => {
|
||||
if (!msg || msg.command !== 'state') return;
|
||||
if (msg.kind !== S.kind) return; // a reply for a category we already left
|
||||
|
||||
const pending = S.pending;
|
||||
S.busy = false;
|
||||
S.pending = null;
|
||||
S.slots = msg.slots || [];
|
||||
// Adopt the slot the plugin actually used (it clamps a slot that no longer exists).
|
||||
if (S.kind === 'filament' && typeof msg.slot === 'number') S.slot = msg.slot;
|
||||
S.preset = msg.preset || null;
|
||||
S.rows = msg.rows || [];
|
||||
S.error = msg.error || '';
|
||||
// Filament editability is a slot concern; process and printer have a single edited preset.
|
||||
S.assigned = S.kind === 'filament' ? !!msg.assigned : true;
|
||||
S.switches = S.kind === 'filament' ? !!msg.switches : false;
|
||||
S.editedName = S.kind === 'filament' ? (msg.edited_name || null) : null;
|
||||
if (S.selected && !S.rows.some(r => r.k === S.selected)) S.selected = null;
|
||||
|
||||
if (msg.error) {
|
||||
// Keep the typed value so it can be corrected, and keep the current selection.
|
||||
setStatus('⚠ ' + msg.error, 'err');
|
||||
} else {
|
||||
if (pending) S.changed[changedKey(pending.preset, pending.key)] = true;
|
||||
setStatus(msg.message || '', msg.message ? 'ok' : '');
|
||||
}
|
||||
|
||||
paint();
|
||||
|
||||
if (!msg.error && pending) {
|
||||
const row = S.rows.find(r => r.k === pending.key);
|
||||
if (row) { // show the value as the preset now stores it
|
||||
$('evalue').value = row.v;
|
||||
flashKey(row.k);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
paint();
|
||||
orca.postMessage({command: 'state', kind: S.kind, slot: S.slot});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class PresetUpdateDemo(orca.script.ScriptPluginCapabilityBase):
|
||||
window = None
|
||||
kind = "process" # the category the open window is showing, for a re-run refresh
|
||||
slot = 1 # the filament slot the open window is showing
|
||||
|
||||
def get_name(self):
|
||||
return "Preset update demo"
|
||||
|
||||
def execute(self):
|
||||
try:
|
||||
if self.window is not None and self.window.is_open():
|
||||
self._post_state(self.kind, self.slot)
|
||||
return orca.ExecutionResult.success("Preset update demo refreshed.")
|
||||
self.window = orca.host.ui.create_window(
|
||||
title="Preset update demo", html=PAGE, width=820, height=560,
|
||||
on_message=self.on_message, on_close=self.on_close,
|
||||
)
|
||||
return orca.ExecutionResult.success("Preset update demo opened.")
|
||||
except Exception as error:
|
||||
message = "Preset update demo failed: %s" % error
|
||||
orca.host.ui.message(message, title="Preset update demo", buttons="ok", icon="error")
|
||||
return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, message)
|
||||
|
||||
def on_close(self):
|
||||
self.window = None
|
||||
|
||||
def _post_state(self, kind="process", slot=1, message="", error=""):
|
||||
try:
|
||||
payload = _state(orca.host.preset_bundle(), kind, slot, message, error)
|
||||
except Exception as state_error:
|
||||
payload = {"command": "state", "kind": kind, "slot": slot, "slots": [],
|
||||
"assigned": False, "switches": False, "edited_name": None,
|
||||
"preset": None, "rows": [], "message": message,
|
||||
"error": str(state_error)}
|
||||
if self.window is not None and self.window.is_open():
|
||||
self.window.post(payload)
|
||||
|
||||
def on_message(self, message):
|
||||
kind = "process"
|
||||
slot = 1
|
||||
try:
|
||||
message = message or {}
|
||||
kind = message.get("kind", "process")
|
||||
if not isinstance(kind, str) or kind not in KIND_KEYS:
|
||||
raise RuntimeError("Unknown preset category %r" % (kind,))
|
||||
self.kind = kind
|
||||
slot = message.get("slot", 1)
|
||||
|
||||
command = message.get("command")
|
||||
if command == "state":
|
||||
self.slot = slot
|
||||
self._post_state(kind, slot)
|
||||
return
|
||||
if command == "apply":
|
||||
key = message.get("key")
|
||||
value = message.get("value")
|
||||
if not isinstance(key, str) or not key:
|
||||
raise RuntimeError("A setting key is required")
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("A value string is required")
|
||||
where = ""
|
||||
if kind == "filament":
|
||||
presets = _slot_presets(orca.host.preset_bundle())
|
||||
# Validate the slot strictly: an out-of-range slot must fail loudly
|
||||
# rather than edit a different filament. A slot with no preset has
|
||||
# nothing to write, which is the only slot shape the backend refuses.
|
||||
slot = _parse_slot(slot, len(presets))
|
||||
if presets[slot - 1] is None:
|
||||
raise RuntimeError("Filament slot %d has no preset to edit" % slot)
|
||||
_apply(kind, key, value, slot)
|
||||
where = " on slot %d" % slot
|
||||
else:
|
||||
_apply(kind, key, value)
|
||||
shown = value if value else "(empty)"
|
||||
self.slot = slot
|
||||
self._post_state(
|
||||
kind, slot,
|
||||
message='Set %s = "%s"%s. The edit stays in the preset tab until you save it.'
|
||||
% (key, shown, where),
|
||||
)
|
||||
return
|
||||
raise RuntimeError("Unknown command %r" % (command,))
|
||||
except Exception as error:
|
||||
safe_kind = kind if isinstance(kind, str) and kind in KIND_KEYS else "process"
|
||||
self._post_state(safe_kind, slot, error=str(error))
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class PresetUpdateDemoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(PresetUpdateDemo)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-15
@@ -163,6 +163,7 @@ using json = nlohmann::json;
|
||||
#include <X11/Xlib.h>
|
||||
#endif
|
||||
#include <unistd.h>
|
||||
#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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -689,6 +689,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 +714,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
|
||||
|
||||
+108
-15
@@ -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 <wx/defs.h>
|
||||
#include "slic3r/GUI/Widgets/WebView.hpp"
|
||||
#include <cwchar>
|
||||
@@ -333,6 +335,23 @@ static std::shared_ptr<OrcaCloudServiceAgent> get_orca_cloud_agent(NetworkAgent*
|
||||
return agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(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<LegacyUserFilamentPreset> 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<OrcaCloudServiceAgent>(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()) {
|
||||
|
||||
@@ -355,6 +355,9 @@ private:
|
||||
std::atomic<bool> m_restart_sync_pending {false};
|
||||
std::atomic<bool> m_sync_user_preset_dlg_active {false}; // a manual "Sync Presets" progress dialog is on screen (see restart_sync_user_preset)
|
||||
std::atomic<bool> 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<bool> 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();
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
#include "slic3r/Utils/UndoRedo.hpp"
|
||||
#include "GLGizmoUtils.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
@@ -72,6 +73,7 @@
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <numeric>
|
||||
#include <queue>
|
||||
#include <set>
|
||||
@@ -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<float>::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<unsigned char>(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.
|
||||
@@ -2919,8 +2963,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;
|
||||
@@ -4102,7 +4147,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 +4174,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 +4224,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 +4288,49 @@ 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<TextureLibraryEntry> &library = texture_library();
|
||||
ImDrawList *dl = ImGui::GetWindowDrawList();
|
||||
const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
|
||||
std::optional<TextureLibraryEntry> chosen;
|
||||
std::optional<TextureLibraryEntry> 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<TextureLibraryEntry> chosen;
|
||||
std::optional<TextureLibraryEntry> to_remove;
|
||||
bool import_clicked = false;
|
||||
bool download_clicked = false;
|
||||
|
||||
// Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile
|
||||
// overlays a remove button on its corner, and that button would otherwise be "the previous item" the
|
||||
@@ -4196,16 +4346,15 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
|
||||
ImGui::Dummy(ImVec2(grid_w, float(rows) * tile + float(rows - 1) * spacing));
|
||||
}
|
||||
};
|
||||
// One group's tiles: shipped textures alphabetically, or the user's own most recently used first (the
|
||||
// order texture_library() keeps), those with a remove button on hover. Returns how many it drew.
|
||||
const auto grid = [&](const ImVec2 &origin, bool user) {
|
||||
// One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by
|
||||
// name, the user's own most recently used first - the order texture_library() produced); a user tile
|
||||
// carries a remove button on hover. Returns how many it drew.
|
||||
const auto grid = [&](const ImVec2 &origin, const std::vector<const TextureLibraryEntry *> &entries, bool removable) {
|
||||
int n = 0;
|
||||
for (const TextureLibraryEntry &entry : library) {
|
||||
if (entry.is_user != user)
|
||||
continue;
|
||||
for (const TextureLibraryEntry *entry : entries) {
|
||||
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
|
||||
ImGui::PushID(entry.path.c_str());
|
||||
const LibraryTexture *tex = get_library_texture(entry.path);
|
||||
ImGui::PushID(entry->path.c_str());
|
||||
const LibraryTexture *tex = get_library_texture(entry->path);
|
||||
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
|
||||
bool clicked = false;
|
||||
if (thumb != nullptr) {
|
||||
@@ -4223,15 +4372,27 @@ 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 current = entry->path == layer->path;
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
// The layer's current texture carries an accent ring, and the tile under the pointer a thinner,
|
||||
// fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them.
|
||||
if (current || hovered) {
|
||||
const float w = current ? ring_w : std::round(0.5f * ring_w);
|
||||
dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w),
|
||||
ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w);
|
||||
}
|
||||
// Centred, as far as the scroll range allows, so the textures around it are in view too.
|
||||
if (current && m_picker_reveal_selected)
|
||||
ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f);
|
||||
if (hovered)
|
||||
panel_tooltip(entry->name);
|
||||
|
||||
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 +4405,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<std::string, bool> s_section_collapsed; // true = collapsed; absent = expanded
|
||||
|
||||
const auto split_pack_title = [](const std::string &title) -> std::pair<std::string, std::string> {
|
||||
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<unsigned char>(name.back())))
|
||||
name.pop_back();
|
||||
std::string attribution = title.substr(split);
|
||||
size_t first = 0;
|
||||
while (first < attribution.size() && std::isspace(static_cast<unsigned char>(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<const TextureLibraryEntry *> entries;
|
||||
};
|
||||
std::vector<Section> sections;
|
||||
std::map<std::string, size_t> 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 +4569,25 @@ 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();
|
||||
}
|
||||
// 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 +6890,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<unsigned char>(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 +7082,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 +7331,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 +8328,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)
|
||||
|
||||
@@ -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<std::string, LibraryTexture> m_library_textures;
|
||||
// Generation of the library the above were cached against; a change clears them.
|
||||
uint64_t m_library_generation = 0;
|
||||
|
||||
// Everything the *unwrap* depends on. update_uv_editor() runs from rebuild_preview(), i.e. on
|
||||
// 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.
|
||||
|
||||
@@ -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<float>(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;
|
||||
|
||||
|
||||
@@ -1332,21 +1332,6 @@ boost::any ConfigOptionsGroup::get_config_value2(const DynamicPrintConfig& confi
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool ConfigOptionsGroup::commit_config_value(const t_config_option_key& opt_key, int opt_index)
|
||||
{
|
||||
if (m_config == nullptr)
|
||||
return false;
|
||||
|
||||
// m_opt_map is filled while the option lines are created, so it still resolves the option when
|
||||
// the group's controls (and thus its fields) have not been built yet.
|
||||
const std::string opt_id = option_id_in_map(m_opt_map, opt_key, opt_index);
|
||||
if (opt_id.empty())
|
||||
return false;
|
||||
|
||||
on_change_OG(opt_id, get_config_value2(*m_config, opt_key, opt_index));
|
||||
return true;
|
||||
}
|
||||
|
||||
Field* ConfigOptionsGroup::get_fieldc(const t_config_option_key& opt_key, int opt_index)
|
||||
{
|
||||
Field* field = get_field(opt_key);
|
||||
|
||||
@@ -119,37 +119,6 @@ using column_t = std::function<wxWindow*(wxWindow* parent, const Line&)>;
|
||||
using t_optionfield_map = std::map<t_config_option_key, t_field>;
|
||||
using t_opt_map = std::map< std::string, std::pair<std::string, int> >;
|
||||
|
||||
// The option id a group's option map holds for opt_key, and the index it carries. `opt_index` is
|
||||
// an in/out parameter: a negative value asks for the option's single entry, an explicit one selects
|
||||
// that index. Returns an empty string when the map does not own the option, or when a negative index
|
||||
// matches several entries -- a vector option the GUI edits one index at a time, which has no single
|
||||
// value to commit. A group fills the map while its option lines are created (OptionsGroup::append_line
|
||||
// / ConfigOptionsGroup::get_option), so this resolves an option even before its controls (and its
|
||||
// field) have been built.
|
||||
inline std::string option_id_in_map(const t_opt_map& options, const t_config_option_key& opt_key, int& opt_index)
|
||||
{
|
||||
if (opt_index >= 0) {
|
||||
for (const auto& opt : options) {
|
||||
if (opt.second.first == opt_key && opt.second.second == opt_index) {
|
||||
opt_index = opt.second.second;
|
||||
return opt.first;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string opt_id;
|
||||
for (const auto& opt : options) {
|
||||
if (opt.second.first != opt_key)
|
||||
continue;
|
||||
if (!opt_id.empty())
|
||||
return {}; // several entries: a vector option, not one value
|
||||
opt_id = opt.first;
|
||||
opt_index = opt.second.second;
|
||||
}
|
||||
return opt_id;
|
||||
}
|
||||
|
||||
class OptionsGroup {
|
||||
public:
|
||||
const bool staticbox{true};
|
||||
@@ -363,15 +332,6 @@ public:
|
||||
boost::any get_config_value(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||
// BBS: restore all pages in preset
|
||||
boost::any get_config_value2(const DynamicPrintConfig& config, const std::string& opt_key, int opt_index = -1);
|
||||
// Re-run this group's committed-change path for one of its options without a widget event:
|
||||
// build the option's value from the config this group edits (get_config_value2, the same form
|
||||
// the option's field reports) and dispatch on_change_OG(), so the group's option-specific
|
||||
// conversion (e.g. the printer_agent canonicalization) and its registered change handler run
|
||||
// exactly as for a hand edit. The option map is filled while the option lines are created, so
|
||||
// this resolves an option whose field is not built yet (a lazy page, or an option the GUI has
|
||||
// no field for). Returns false when this group does not own the option, or when opt_index < 0
|
||||
// matches several entries (a vector option the GUI edits index by index has no single value).
|
||||
bool commit_config_value(const t_config_option_key& opt_key, int opt_index = -1);
|
||||
Field* get_fieldc(const t_config_option_key& opt_key, int opt_index);
|
||||
std::pair<OG_CustomCtrl*, bool*> get_custom_ctrl_with_blinking_ptr(const t_config_option_key& opt_key, int opt_index/* = -1*/);
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
#include "slic3r/GUI/DeviceCore/DevConfigUtil.h"
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <stdexcept>
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "slic3r/GUI/SettingsIndex.hpp"
|
||||
@@ -1646,101 +1645,6 @@ void Tab::load_config(const DynamicPrintConfig& config)
|
||||
}
|
||||
}
|
||||
|
||||
// The option keys a preset of `type` may hold. The tab's config shares the global
|
||||
// print_config_def with every other preset type, so membership has to be checked against the
|
||||
// preset type's own key list instead of the config definition, or a printer setting could be
|
||||
// written into the process preset.
|
||||
static const std::vector<std::string>& preset_type_options(Preset::Type type)
|
||||
{
|
||||
static const std::vector<std::string> none;
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return Preset::print_options();
|
||||
case Preset::TYPE_FILAMENT: return Preset::filament_options();
|
||||
case Preset::TYPE_PRINTER: return Preset::printer_options();
|
||||
default: return none;
|
||||
}
|
||||
}
|
||||
|
||||
static const char* preset_type_name(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return "process";
|
||||
case Preset::TYPE_FILAMENT: return "filament";
|
||||
case Preset::TYPE_PRINTER: return "printer";
|
||||
default: return "preset";
|
||||
}
|
||||
}
|
||||
|
||||
// Set one option of the currently edited preset from its serialized string form, then run the
|
||||
// same update path a hand edit runs. The edited preset is not saved: it only becomes dirty,
|
||||
// exactly as if the field had been edited by hand.
|
||||
void Tab::apply_preset_config_value(const std::string& opt_key, const std::string& value)
|
||||
{
|
||||
if (m_config == nullptr)
|
||||
throw std::runtime_error("The preset configuration is not available");
|
||||
if (wxGetApp().plater() == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
||||
|
||||
const std::vector<std::string>& options = preset_type_options(m_type);
|
||||
if (std::find(options.begin(), options.end(), opt_key) == options.end())
|
||||
throw std::invalid_argument(format("Unknown %1% preset setting \"%2%\"", preset_type_name(m_type), opt_key));
|
||||
|
||||
// Parse strictly, keeping a backup: the strict deserializer can already have mutated the option
|
||||
// when it rejects a bad value, so a failed update has to restore the preset it was given.
|
||||
const ConfigOption* previous_option = m_config->option(opt_key);
|
||||
std::unique_ptr<ConfigOption> previous(previous_option != nullptr ? previous_option->clone() : nullptr);
|
||||
try {
|
||||
m_config->set_deserialize_strict(opt_key, value);
|
||||
} catch (const ConfigurationError& err) {
|
||||
if (previous)
|
||||
m_config->set_key_value(opt_key, previous.release());
|
||||
else
|
||||
m_config->erase(opt_key);
|
||||
throw std::invalid_argument(err.what());
|
||||
}
|
||||
|
||||
// Run the same commit path the GUI runs for this setting, so its key-specific behaviour
|
||||
// (ConfigOptionsGroup::on_change_OG, including the printer_agent canonicalization, the group's
|
||||
// pre-checks and Tab::on_value_change()) runs exactly as for a hand edit. A built field supplies
|
||||
// the field's typed/clamped value; otherwise the owning ConfigOptionsGroup builds the value from
|
||||
// the config it edits and dispatches the same on_change_OG(). The field is refreshed from the
|
||||
// config just committed first, so it reports the committed value rather than a stale one.
|
||||
bool committed = false;
|
||||
if (Page* field_page = nullptr; Field* field = get_field(opt_key, &field_page)) {
|
||||
field_page->reload_config();
|
||||
field->field_changed();
|
||||
committed = true;
|
||||
} else {
|
||||
for (const PageShp& page : m_pages) {
|
||||
for (const ConfigOptionsGroupShp& group : page->m_optgroups) {
|
||||
if (group->commit_config_value(opt_key)) {
|
||||
committed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (committed)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (committed) {
|
||||
// The commit path already ran update_dirty() (dirty flag, preset combo and, on the printer
|
||||
// tab, the printer-agent switch); refresh the visible page and push the config to the plater.
|
||||
reload_config();
|
||||
update();
|
||||
} else {
|
||||
// The GUI edits this setting through a custom widget rather than a group (e.g.
|
||||
// compatible_printers), so take the same shared no-field commit path the widget uses. Saved
|
||||
// mode skips the value write (the strict parse above already did it) and keeps the path's
|
||||
// compatibility recompute, dirty flag, preset-combo and plater updates.
|
||||
load_key_value(opt_key, boost::any(), /*saved_value=*/true);
|
||||
// The widget itself normally re-reads its value after load_key_value(); with no widget to do
|
||||
// that, sync the visible page so a custom widget (the compatible-printers row) reflects the
|
||||
// new value.
|
||||
reload_config();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload current $self->{config} (aka $self->{presets}->edited_preset->config) into the UI fields.
|
||||
void Tab::reload_config()
|
||||
{
|
||||
|
||||
@@ -424,17 +424,6 @@ public:
|
||||
//BBS update plater presets if update_plater_presets = true
|
||||
void update_tab_ui(bool update_plater_presets = false);
|
||||
void load_config(const DynamicPrintConfig& config);
|
||||
// Host/plugin API: set one option of this tab's currently edited preset from its serialized
|
||||
// string value, then run the same committed-change path a field edit runs. `opt_key` must be a
|
||||
// setting of this tab's preset type, so a key of another preset type (e.g. a printer setting
|
||||
// passed to the process tab) is rejected instead of written silently. `value` is parsed by the
|
||||
// strict config deserializer, which raises std::invalid_argument for an unknown key or a value
|
||||
// the option cannot parse, and leaves the preset unchanged. The key-specific behaviour runs
|
||||
// through its ConfigOptionsGroup::on_change_OG(): with the option's built field it uses the
|
||||
// field's typed value, otherwise the owning group builds the value and dispatches the same call,
|
||||
// so a not-yet-built field still gets it. A key the GUI edits through a custom widget instead
|
||||
// takes the shared no-field commit path, Tab::load_key_value() in saved mode.
|
||||
void apply_preset_config_value(const std::string& opt_key, const std::string& value);
|
||||
virtual void reload_config();
|
||||
void update_mode();
|
||||
void update_visibility();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "TextureLibrary.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <cstddef>
|
||||
#include <boost/filesystem/directory.hpp>
|
||||
@@ -21,6 +22,7 @@
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/gdicmn.h>
|
||||
#include <utility>
|
||||
#include <wx/image.h>
|
||||
@@ -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<TextureLibraryEntry> &out)
|
||||
void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vector<TextureLibraryEntry> &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::vector<Text
|
||||
for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) {
|
||||
if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path()))
|
||||
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<TextureLibraryEntry> &out)
|
||||
{
|
||||
std::vector<TextureLibraryEntry> found;
|
||||
for (const ResourceTextureEntry &pack : PluginResourceTextures::instance().entries()) {
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files(pack.texture_dir, pack.plugin_root, error);
|
||||
if (!error.empty()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Texture pack '" << pack.display_name << "' was skipped: " << error;
|
||||
continue;
|
||||
}
|
||||
for (const std::string &file : files) {
|
||||
const boost::filesystem::path path(file);
|
||||
found.push_back({ path.stem().string(), file, TextureSource::Plugin, pack.display_name });
|
||||
}
|
||||
}
|
||||
|
||||
std::stable_sort(found.begin(), found.end(), [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) {
|
||||
if (a.pack_name != b.pack_name)
|
||||
return a.pack_name < b.pack_name;
|
||||
return a.name < b.name;
|
||||
});
|
||||
for (TextureLibraryEntry &entry : found)
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
// True if the image carries any colour at all, i.e. some pixel's channels are not all equal. A
|
||||
// photo saved as RGB but actually grey should still be stored as a grayscale PNG - it is a third of
|
||||
// the bytes, and the colour feature keys off has_color(), so storing a grey image as RGB would offer
|
||||
@@ -208,6 +244,11 @@ bool is_supported_height_map(const std::vector<unsigned char> &bytes)
|
||||
|
||||
std::vector<TextureLibraryEntry> 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<uint64_t> g_library_generation{ 0 };
|
||||
|
||||
// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture -
|
||||
// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new
|
||||
@@ -255,6 +296,8 @@ const size_t RECENT_MAX = 200;
|
||||
std::vector<std::string> recent_texture_names()
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to read
|
||||
return names;
|
||||
if (wxGetApp().app_config == nullptr)
|
||||
return names;
|
||||
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
|
||||
@@ -269,6 +312,8 @@ std::vector<std::string> recent_texture_names()
|
||||
|
||||
void store_recent_texture_names(std::vector<std::string> names)
|
||||
{
|
||||
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to write
|
||||
return;
|
||||
if (wxGetApp().app_config == nullptr)
|
||||
return;
|
||||
if (names.size() > RECENT_MAX)
|
||||
@@ -279,8 +324,9 @@ void store_recent_texture_names(std::vector<std::string> 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<TextureLibraryEntry> &library)
|
||||
{
|
||||
const std::vector<std::string> names = recent_texture_names();
|
||||
@@ -291,8 +337,11 @@ void sort_user_textures(std::vector<TextureLibraryEntry> &library)
|
||||
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
|
||||
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
|
||||
};
|
||||
const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; });
|
||||
std::stable_sort(first_user, library.end(),
|
||||
const auto first_user = std::find_if(library.begin(), library.end(),
|
||||
[](const TextureLibraryEntry &e) { return e.source == TextureSource::User; });
|
||||
const auto user_end = std::find_if(first_user, library.end(),
|
||||
[](const TextureLibraryEntry &e) { return e.source != TextureSource::User; });
|
||||
std::stable_sort(first_user, user_end,
|
||||
[&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); });
|
||||
}
|
||||
|
||||
@@ -311,6 +360,16 @@ bool in_user_texture_dir(const std::string &path)
|
||||
|
||||
} // namespace
|
||||
|
||||
uint64_t texture_library_generation()
|
||||
{
|
||||
return g_library_generation.load();
|
||||
}
|
||||
|
||||
void bump_texture_library_generation()
|
||||
{
|
||||
g_library_generation.fetch_add(1);
|
||||
}
|
||||
|
||||
std::string user_texture_dir()
|
||||
{
|
||||
const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement";
|
||||
@@ -325,17 +384,20 @@ std::string user_texture_dir()
|
||||
|
||||
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
|
||||
{
|
||||
if (g_library_scanned && !force_rescan)
|
||||
const uint64_t generation = texture_library_generation();
|
||||
if (g_library_scanned && !force_rescan && generation == g_library_scan_generation)
|
||||
return g_library;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef slic3r_TextureLibrary_hpp_
|
||||
#define slic3r_TextureLibrary_hpp_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -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 <data_dir>/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 <data_dir>/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<TextureLibraryEntry> &texture_library(bool force_rescan = false);
|
||||
|
||||
// Monotonic counter, bumped whenever the set of loaded texture packs changes. The GUI reads it to
|
||||
// decide whether texture_library() must rescan, and plugin callbacks bump it. Safe to read from any
|
||||
// thread at any time.
|
||||
uint64_t texture_library_generation();
|
||||
void bump_texture_library_generation();
|
||||
|
||||
// Moves one of the user's own textures to the front of the recently-used order texture_library() lists
|
||||
// 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().
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <boost/filesystem/directory.hpp>
|
||||
#include <boost/filesystem/file_status.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
|
||||
#include "PluginAuditManager.hpp"
|
||||
@@ -1255,4 +1256,76 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> collect_resource_texture_files(const std::string& dir,
|
||||
const std::string& plugin_root,
|
||||
std::string& error)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
// Picker sanity bound, not a security control: the popup draws every entry in its grid.
|
||||
constexpr size_t kMaxResourceTextureFiles = 64;
|
||||
|
||||
std::vector<std::string> files;
|
||||
error.clear();
|
||||
|
||||
if (dir.empty()) {
|
||||
error = _u8L("Resource texture directory is not set");
|
||||
return files;
|
||||
}
|
||||
if (plugin_root.empty()) {
|
||||
error = _u8L("Plugin folder could not be determined.");
|
||||
return files;
|
||||
}
|
||||
|
||||
boost::system::error_code ec;
|
||||
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
|
||||
if (ec) {
|
||||
error = _u8L("Resource texture directory could not be resolved");
|
||||
return files;
|
||||
}
|
||||
|
||||
if (!fs::is_directory(resolved_dir, ec) || ec) {
|
||||
error = _u8L("Resource texture path is not a directory");
|
||||
return files;
|
||||
}
|
||||
|
||||
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
|
||||
if (ec) {
|
||||
error = _u8L("Plugin folder could not be resolved");
|
||||
return files;
|
||||
}
|
||||
|
||||
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
|
||||
error = _u8L("Resource texture directory is outside the plugin folder");
|
||||
return files;
|
||||
}
|
||||
|
||||
for (fs::directory_iterator it(resolved_dir, ec), end; it != end && !ec; it.increment(ec)) {
|
||||
const fs::path& path = it->path();
|
||||
|
||||
if (!fs::is_regular_file(path, ec) || ec)
|
||||
continue;
|
||||
|
||||
std::string ext = path.extension().string();
|
||||
boost::algorithm::to_lower(ext);
|
||||
if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".bmp")
|
||||
continue;
|
||||
|
||||
// Re-check per file: a symlink inside the pack must not resolve outside it.
|
||||
if (!is_inside_allowed_root(path, resolved_root))
|
||||
continue;
|
||||
|
||||
files.push_back(path.string());
|
||||
if (files.size() >= kMaxResourceTextureFiles)
|
||||
break;
|
||||
}
|
||||
|
||||
if (ec) {
|
||||
error = _u8L("Resource texture directory could not be read");
|
||||
return {};
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -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<std::string> collect_resource_texture_files(const std::string& dir,
|
||||
const std::string& plugin_root,
|
||||
std::string& error);
|
||||
|
||||
// Canonical writer: emits the .install_state.json schema for the given state.
|
||||
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.
|
||||
|
||||
@@ -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<ResourceTexturePluginCapability*>(instance.get())) {
|
||||
try {
|
||||
const std::string texture_dir = resource_texture->get_texture_dir();
|
||||
if (texture_dir.empty()) {
|
||||
resource_texture->set_texture_dir({});
|
||||
} else {
|
||||
std::string texture_error;
|
||||
collect_resource_texture_files(texture_dir, descriptor.plugin_root, texture_error);
|
||||
if (texture_error.empty()) {
|
||||
resource_texture->set_texture_dir(texture_dir);
|
||||
} else {
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
|
||||
<< "': texture directory rejected (" << texture_error
|
||||
<< "); the capability will provide no textures";
|
||||
resource_texture->set_texture_dir({});
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
|
||||
<< "': get_texture_dir() failed (" << ex.what()
|
||||
<< "); the capability will provide no textures";
|
||||
resource_texture->set_texture_dir({});
|
||||
}
|
||||
}
|
||||
|
||||
capabilities.push_back(std::move(instance));
|
||||
}
|
||||
} catch (const std::exception& ex) {
|
||||
|
||||
@@ -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 <pybind11/pytypes.h>
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include <slic3r/GUI/GUI_App.hpp>
|
||||
#include <slic3r/GUI/MainFrame.hpp>
|
||||
#include <slic3r/GUI/Plater.hpp>
|
||||
#include <slic3r/GUI/PresetComboBoxes.hpp> // TabPresetComboBox::set_filament_idx
|
||||
#include <slic3r/GUI/Tab.hpp>
|
||||
|
||||
#include <pybind11/stl.h>
|
||||
@@ -49,110 +48,6 @@ PresetCollection& printer_presets(PresetBundle& bundle)
|
||||
return static_cast<PresetCollection&>(bundle.printers);
|
||||
}
|
||||
|
||||
// Handle to one category of currently edited preset. It is a category, not a snapshot: it always
|
||||
// resolves the preset the matching tab edits now, so it stays valid across preset switches.
|
||||
struct PresetEditor
|
||||
{
|
||||
Preset::Type type = Preset::TYPE_INVALID;
|
||||
};
|
||||
|
||||
const char* preset_tab_name(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_PRINT: return "process";
|
||||
case Preset::TYPE_FILAMENT: return "filament";
|
||||
case Preset::TYPE_PRINTER: return "printer";
|
||||
default: return "preset";
|
||||
}
|
||||
}
|
||||
|
||||
void update_edited_preset(Preset::Type type, const std::string& key, const std::string& value)
|
||||
{
|
||||
PluginHostUi::run_on_ui_thread([&] {
|
||||
GUI::GUI_App& app = GUI::wxGetApp();
|
||||
if (app.is_closing())
|
||||
throw std::runtime_error("OrcaSlicer is closing");
|
||||
if (app.preset_bundle == nullptr)
|
||||
throw std::runtime_error("Preset bundle is not available");
|
||||
if (app.plater() == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
||||
|
||||
GUI::Tab* tab = app.get_tab(type);
|
||||
if (tab == nullptr)
|
||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type) + " preset tab is not available");
|
||||
// The tab for this preset type exists, but the selected printer's technology may not use it
|
||||
// (the print tab no-ops under SLA); refuse instead of writing a value the UI cannot apply.
|
||||
if (!tab->supports_printer_technology(app.preset_bundle->printers.get_selected_preset().printer_technology()))
|
||||
throw std::runtime_error(std::string("The ") + preset_tab_name(type)
|
||||
+ " preset is not available for the selected printer technology");
|
||||
|
||||
tab->apply_preset_config_value(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Slot-addressed filament update. `slot_number` is 1-based, matching the GUI's filament slots and
|
||||
// the values PresetBundle::filament_presets holds. The slot's assigned preset is resolved to its
|
||||
// internal name; when that is not the preset the filament tab edits, the tab is switched through
|
||||
// Tab::select_preset() - the normal UI preselection flow, so a dirty edited preset still prompts
|
||||
// the user and a declined prompt cancels the call with a clear error and no change.
|
||||
void update_filament_slot(int slot_number, const std::string& key, const std::string& value)
|
||||
{
|
||||
PluginHostUi::run_on_ui_thread([&] {
|
||||
GUI::GUI_App& app = GUI::wxGetApp();
|
||||
if (app.is_closing())
|
||||
throw std::runtime_error("OrcaSlicer is closing");
|
||||
if (app.preset_bundle == nullptr)
|
||||
throw std::runtime_error("Preset bundle is not available");
|
||||
if (app.plater() == nullptr)
|
||||
throw std::runtime_error("OrcaSlicer main window is not available");
|
||||
|
||||
PresetBundle& bundle = *app.preset_bundle;
|
||||
if (slot_number < 1 || static_cast<size_t>(slot_number) > bundle.filament_presets.size())
|
||||
throw std::out_of_range("Filament slot " + std::to_string(slot_number) + " is out of range; slot numbers are 1-based and this printer has "
|
||||
+ std::to_string(bundle.filament_presets.size()) + " filament slot(s)");
|
||||
|
||||
const std::string& assigned_name = bundle.filament_presets[static_cast<size_t>(slot_number) - 1];
|
||||
if (assigned_name.empty())
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " has no assigned filament preset");
|
||||
|
||||
// Resolve the assigned name the way the app does, so a renamed/aliased entry maps to the
|
||||
// internal preset it now identifies. Copy the name out before selecting anything: the
|
||||
// collection can be reallocated while switching.
|
||||
Preset* slot_preset = bundle.filaments.find_preset(assigned_name);
|
||||
if (slot_preset == nullptr)
|
||||
throw std::runtime_error("Filament slot " + std::to_string(slot_number) + " is assigned \"" + assigned_name
|
||||
+ "\", which does not resolve to a filament preset");
|
||||
const std::string target_name = slot_preset->name;
|
||||
|
||||
GUI::Tab* tab = app.get_tab(Preset::TYPE_FILAMENT);
|
||||
if (tab == nullptr)
|
||||
throw std::runtime_error("The filament preset tab is not available");
|
||||
if (!tab->supports_printer_technology(bundle.printers.get_selected_preset().printer_technology()))
|
||||
throw std::runtime_error("The filament preset is not available for the selected printer technology");
|
||||
|
||||
// Switch to the slot's preset through the normal preset selection, never a forced one, so the
|
||||
// user's unsaved-changes prompt is honoured. A declined prompt returns false here.
|
||||
if (bundle.filaments.get_edited_preset().name != target_name) {
|
||||
if (!tab->select_preset(target_name))
|
||||
throw std::runtime_error("Switching the edited filament preset to \"" + target_name + "\" for filament slot "
|
||||
+ std::to_string(slot_number) + " was canceled; the setting was not changed");
|
||||
}
|
||||
|
||||
// select_preset() can still leave a different preset edited (e.g. the target disappeared), so
|
||||
// re-check before writing anything.
|
||||
if (bundle.filaments.get_edited_preset().name != target_name)
|
||||
throw std::runtime_error("The edited filament preset is \"" + bundle.filaments.get_edited_preset().name
|
||||
+ "\", not \"" + target_name + "\"; the setting was not changed");
|
||||
|
||||
// Match PlaterPresetComboBox::switch_to_tab(): record which 0-based filament slot this tab
|
||||
// combobox now edits, so the GUI shows the slot the value was written for.
|
||||
if (GUI::TabPresetComboBox* combo = tab->get_combo_box())
|
||||
combo->set_filament_idx(slot_number - 1);
|
||||
|
||||
tab->apply_preset_config_value(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
void reload_local_bundle(const std::string& bundle_id)
|
||||
{
|
||||
bool reloaded = false;
|
||||
@@ -224,47 +119,6 @@ void host_bindings::register_presets(py::module_& host)
|
||||
.value("Plate", Preset::TYPE_PLATE)
|
||||
.value("Model", Preset::TYPE_MODEL);
|
||||
|
||||
py::class_<PresetEditor>(preset, "PresetEditor",
|
||||
"Handle to a category of currently edited preset. It is not a snapshot: it always "
|
||||
"resolves the preset the matching tab edits now, so it stays usable across preset "
|
||||
"switches. update() edits the preset in place; the change is not saved to disk.")
|
||||
.def_readonly("type", &PresetEditor::type)
|
||||
.def(
|
||||
"update",
|
||||
[](const PresetEditor& editor, const std::string& key, const std::string& value) {
|
||||
update_edited_preset(editor.type, key, value);
|
||||
},
|
||||
py::arg("key"), py::arg("value"),
|
||||
"Set one setting of the currently edited preset and run the same committed-change path as "
|
||||
"editing the setting in the GUI; the edited preset becomes dirty until saved/exported. "
|
||||
"key must be a setting of this preset category and value its serialized string form, "
|
||||
"otherwise ValueError is raised and nothing is written. A setting edited through a field "
|
||||
"runs that field's validation even before its page has been opened; one the GUI edits "
|
||||
"outside a field (such as compatible printers) also recomputes preset compatibility.")
|
||||
.def(
|
||||
"update",
|
||||
[](const PresetEditor& editor, int slot_number, const std::string& key, const std::string& value) {
|
||||
if (editor.type != Preset::TYPE_FILAMENT)
|
||||
throw std::invalid_argument("The slot_number form of update() is only available on "
|
||||
"orca.host.preset.filament; call update(key, value) on this editor");
|
||||
update_filament_slot(slot_number, key, value);
|
||||
},
|
||||
py::arg("slot_number"), py::arg("key"), py::arg("value"),
|
||||
"Set one setting of the filament preset assigned to a 1-based filament slot. When that preset "
|
||||
"is not the one currently edited the call switches to it through the normal preset-selection "
|
||||
"flow, so an unsaved edited preset still prompts the user and a declined prompt cancels the "
|
||||
"call with an error and no change. An out-of-range, empty or unresolved slot is rejected with "
|
||||
"a clear error. Other preset editors accept only update(key, value); this form raises "
|
||||
"ValueError on them.");
|
||||
|
||||
// The three preset categories edited by the main tabs. `process` is the print/process preset,
|
||||
// `printer` the machine preset and `filament` the currently edited filament preset (its
|
||||
// 3-argument form addresses a 1-based filament slot); each only accepts its own settings, so a
|
||||
// printer setting passed to `process` is rejected instead of written silently.
|
||||
preset.attr("process") = py::cast(PresetEditor{Preset::TYPE_PRINT});
|
||||
preset.attr("printer") = py::cast(PresetEditor{Preset::TYPE_PRINTER});
|
||||
preset.attr("filament") = py::cast(PresetEditor{Preset::TYPE_FILAMENT});
|
||||
|
||||
py::class_<Preset, std::unique_ptr<Preset, py::nodelete>>(host, "Preset")
|
||||
.def_readonly("type", &Preset::type)
|
||||
.def_readonly("name", &Preset::name)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "PluginResourceTextures.hpp"
|
||||
|
||||
#include "slic3r/plugin/PluginDescriptor.hpp"
|
||||
#include "slic3r/plugin/PluginManager.hpp"
|
||||
#include "slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
PluginResourceTextures &PluginResourceTextures::instance()
|
||||
{
|
||||
static PluginResourceTextures registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
std::optional<ResourceTextureEntry> PluginResourceTextures::build_entry(const PluginCapabilityId &id) const
|
||||
{
|
||||
// Query by id and re-check state; the manager copies the shared_ptr and releases its lock.
|
||||
auto capability = PluginManager::instance().get_plugin_capability(id, /*only_enabled=*/false);
|
||||
if (!capability || !capability->is_enabled() || capability->type() != PluginCapabilityType::ResourceTexture)
|
||||
return std::nullopt;
|
||||
|
||||
auto texture_capability = std::dynamic_pointer_cast<ResourceTexturePluginCapability>(capability);
|
||||
if (!texture_capability)
|
||||
return std::nullopt;
|
||||
|
||||
// An empty directory means resolution failed at load, so the pack contributes nothing.
|
||||
const std::string &texture_dir = texture_capability->texture_dir();
|
||||
if (texture_dir.empty())
|
||||
return std::nullopt;
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor_for_capability(capability->name(),
|
||||
PluginCapabilityType::ResourceTexture,
|
||||
descriptor))
|
||||
return std::nullopt;
|
||||
|
||||
ResourceTextureEntry entry;
|
||||
entry.id = capability->identity();
|
||||
entry.plugin_key = entry.id.plugin_key;
|
||||
entry.display_name = capability->name();
|
||||
entry.texture_dir = texture_dir;
|
||||
entry.plugin_root = descriptor.plugin_root;
|
||||
return entry;
|
||||
}
|
||||
|
||||
void PluginResourceTextures::on_plugin_register(const std::string &plugin_key)
|
||||
{
|
||||
for (const auto &capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::ResourceTexture)) {
|
||||
if (capability)
|
||||
on_cap_register(capability->identity());
|
||||
}
|
||||
}
|
||||
|
||||
void PluginResourceTextures::on_plugin_deregister(const std::string &plugin_key)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
for (auto it = m_entries.begin(); it != m_entries.end();) {
|
||||
if (it->first.plugin_key == plugin_key)
|
||||
it = m_entries.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
void PluginResourceTextures::on_cap_register(const PluginCapabilityId &id)
|
||||
{
|
||||
std::optional<ResourceTextureEntry> entry = build_entry(id);
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (entry)
|
||||
m_entries[entry->id] = std::move(*entry);
|
||||
}
|
||||
|
||||
void PluginResourceTextures::on_cap_deregister(const PluginCapabilityId &id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_entries.erase(id);
|
||||
}
|
||||
|
||||
void PluginResourceTextures::initial_sweep()
|
||||
{
|
||||
std::map<PluginCapabilityId, ResourceTextureEntry> entries;
|
||||
for (const auto &capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::ResourceTexture)) {
|
||||
if (!capability)
|
||||
continue;
|
||||
std::optional<ResourceTextureEntry> entry = build_entry(capability->identity());
|
||||
if (entry)
|
||||
entries[entry->id] = std::move(*entry);
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_entries = std::move(entries);
|
||||
}
|
||||
|
||||
std::vector<ResourceTextureEntry> PluginResourceTextures::entries() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
|
||||
std::vector<ResourceTextureEntry> result;
|
||||
result.reserve(m_entries.size());
|
||||
for (const auto &entry : m_entries)
|
||||
result.push_back(entry.second);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// One ResourceTexture capability contributing a texture directory to the texture picker.
|
||||
struct ResourceTextureEntry
|
||||
{
|
||||
PluginCapabilityId id;
|
||||
std::string plugin_key;
|
||||
std::string display_name;
|
||||
std::string texture_dir;
|
||||
std::string plugin_root; // so a scan can re-check containment
|
||||
};
|
||||
|
||||
// Tracks every loaded and enabled ResourceTexture capability by id. Holds strings only: a
|
||||
// shared_ptr here would keep a Python object alive past Py_Finalize.
|
||||
class PluginResourceTextures
|
||||
{
|
||||
public:
|
||||
static PluginResourceTextures &instance();
|
||||
|
||||
PluginResourceTextures(const PluginResourceTextures &) = delete;
|
||||
PluginResourceTextures &operator=(const PluginResourceTextures &) = delete;
|
||||
|
||||
void on_plugin_register(const std::string &plugin_key);
|
||||
void on_plugin_deregister(const std::string &plugin_key);
|
||||
void on_cap_register(const PluginCapabilityId &id);
|
||||
void on_cap_deregister(const PluginCapabilityId &id);
|
||||
void initial_sweep();
|
||||
|
||||
std::vector<ResourceTextureEntry> entries() const;
|
||||
|
||||
private:
|
||||
PluginResourceTextures() = default;
|
||||
|
||||
std::optional<ResourceTextureEntry> build_entry(const PluginCapabilityId &id) const;
|
||||
|
||||
mutable std::mutex m_mutex;
|
||||
std::map<PluginCapabilityId, ResourceTextureEntry> m_entries;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -0,0 +1,32 @@
|
||||
#include "ResourceTexturePluginCapability.hpp"
|
||||
#include "ResourceTexturePluginCapabilityTrampoline.hpp"
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
void ResourceTexturePluginCapability::RegisterBindings(pybind11::module_& module)
|
||||
{
|
||||
BOOST_LOG_TRIVIAL(debug) << "Registering orca.resource_texture bindings";
|
||||
|
||||
auto resource_texture = module.def_submodule("resource_texture", "Plugin resource texture API");
|
||||
|
||||
py::class_<ResourceTexturePluginCapability, PluginCapabilityInterface,
|
||||
PyResourceTexturePluginCapabilityTrampoline,
|
||||
std::shared_ptr<ResourceTexturePluginCapability>>(resource_texture,
|
||||
"ResourceTexturePluginCapabilityBase")
|
||||
.def(py::init<>())
|
||||
.def("get_type", &ResourceTexturePluginCapability::get_type)
|
||||
.def("get_texture_dir", &ResourceTexturePluginCapability::get_texture_dir);
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../PythonPluginInterface.hpp"
|
||||
#include "pybind11/pybind11.h"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace Slic3r {
|
||||
class ResourceTexturePluginCapability : public PluginCapabilityInterface
|
||||
{
|
||||
public:
|
||||
static void RegisterBindings(pybind11::module_& module);
|
||||
|
||||
PluginCapabilityType get_type() const override { return PluginCapabilityType::ResourceTexture; }
|
||||
|
||||
// Python contract: the directory the host scans for textures. Resolved once at materialization
|
||||
// under the GIL; never called from the GUI. Overridden in Python through the trampoline.
|
||||
virtual std::string get_texture_dir() const = 0;
|
||||
|
||||
// C++-only read of the validated directory cached by the loader; empty if resolution failed.
|
||||
const std::string& texture_dir() const { return m_texture_dir; }
|
||||
void set_texture_dir(std::string dir) { m_texture_dir = std::move(dir); }
|
||||
|
||||
private:
|
||||
std::string m_texture_dir;
|
||||
};
|
||||
} // namespace Slic3r
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include "ResourceTexturePluginCapability.hpp"
|
||||
#include "../../PyPluginTrampoline.hpp"
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
#include <string>
|
||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
class PyResourceTexturePluginCapabilityTrampoline :
|
||||
public PyPluginCommonTrampoline<ResourceTexturePluginCapability>
|
||||
{
|
||||
public:
|
||||
using PyPluginCommonTrampoline<ResourceTexturePluginCapability>::PyPluginCommonTrampoline;
|
||||
|
||||
std::string get_texture_dir() const override
|
||||
{
|
||||
ORCA_PY_OVERRIDE_AUDITED(
|
||||
[] {},
|
||||
PYBIND11_OVERRIDE_PURE,
|
||||
std::string,
|
||||
ResourceTexturePluginCapability,
|
||||
get_texture_dir);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -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,6 +45,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_texture_color_palette.cpp
|
||||
test_texture_island_view.cpp
|
||||
test_file_url.cpp
|
||||
test_imgui_wrapper.cpp
|
||||
test_user_manager.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// <windows.h>; 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 <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include <imgui/imgui.h>
|
||||
#include <wx/event.h>
|
||||
|
||||
#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<int, wxMouseWheelAxis>({
|
||||
{ 0, wxMOUSE_WHEEL_VERTICAL },
|
||||
{ 5, wxMOUSE_WHEEL_HORIZONTAL },
|
||||
{ -5, wxMOUSE_WHEEL_HORIZONTAL },
|
||||
}));
|
||||
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
|
||||
}
|
||||
@@ -1,19 +1,3 @@
|
||||
// why: keep the Windows headers ahead of libslic3r. Model.hpp pulls in Format/STEP.hpp, and
|
||||
// OCCT's Standard_Macro.hxx defines NOUSER and NOGDI on Windows so that windows.h does not
|
||||
// collide with OCCT's identifiers. Defined that early they also hide the USER and GDI
|
||||
// declarations, and the wx MSW headers Field.hpp reaches (wx/wxprec.h -> wx/msw/private.h)
|
||||
// need WNDPROC, RGB and the ::GetWindowRect family. Including windows.h first, as
|
||||
// libslic3r_gui does through pchheader.hpp or win_platform.hpp, keeps them.
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
@@ -22,12 +6,8 @@
|
||||
#include <libslic3r/TriangleMesh.hpp>
|
||||
#include <slic3r/GUI/DockPanel.hpp>
|
||||
#include <slic3r/GUI/AuiPaneLayout.hpp>
|
||||
#include <slic3r/GUI/OptionsGroup.hpp>
|
||||
#include <slic3r/GUI/Widgets/WebHosting.hpp>
|
||||
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
@@ -157,106 +137,6 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho
|
||||
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes the editable preset category handles and guards them before Orca app initialization",
|
||||
"[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
py::object preset = host.attr("preset");
|
||||
|
||||
REQUIRE(has_attr(preset, "PresetEditor"));
|
||||
REQUIRE(has_attr(preset, "process"));
|
||||
REQUIRE(has_attr(preset, "printer"));
|
||||
REQUIRE(has_attr(preset, "filament"));
|
||||
|
||||
py::object process = preset.attr("process");
|
||||
py::object printer = preset.attr("printer");
|
||||
py::object filament = preset.attr("filament");
|
||||
|
||||
CHECK(process.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_PRINT);
|
||||
CHECK(printer.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_PRINTER);
|
||||
CHECK(filament.attr("type").cast<Slic3r::Preset::Type>() == Slic3r::Preset::TYPE_FILAMENT);
|
||||
for (const py::object& editor : { process, printer, filament })
|
||||
CHECK(has_attr(editor, "update"));
|
||||
|
||||
// With no wx application the call must fail cleanly, like the rest of host.preset, and must not
|
||||
// be mistaken for a missing attribute or an argument error.
|
||||
for (const py::object& editor : { process, printer, filament }) {
|
||||
try {
|
||||
editor.attr("update")("layer_height", "0.25");
|
||||
FAIL("preset editor update unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes the filament slot form of preset update and guards it", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
py::object preset = host.attr("preset");
|
||||
|
||||
// The filament editor accepts update(slot_number, key, value) as well as update(key, value); with
|
||||
// no wx application it reaches the same clean failure as the rest of host.preset. The slot
|
||||
// validation and the switch/cancel path need a running app, so they are not exercised here.
|
||||
try {
|
||||
preset.attr("filament").attr("update")(1, "layer_height", "0.25");
|
||||
FAIL("slot-addressed filament update unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
|
||||
// The process and printer editors accept only update(key, value); the slot form is rejected
|
||||
// before any application access, naming the editor that does accept it.
|
||||
for (const char* name : { "process", "printer" }) {
|
||||
CAPTURE(name);
|
||||
try {
|
||||
preset.attr(name).attr("update")(1, "layer_height", "0.25");
|
||||
FAIL("slot-addressed update on a non-filament editor unexpectedly succeeded");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_ValueError));
|
||||
CHECK(std::string(error.what()).find("only available on") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A settings group resolves an option's id from its option map", "[PluginHost]")
|
||||
{
|
||||
using Slic3r::GUI::option_id_in_map;
|
||||
using Slic3r::GUI::t_opt_map;
|
||||
|
||||
// The map ConfigOptionsGroup::get_option leaves: a scalar option, and a per-extruder option
|
||||
// whose entries carry their index.
|
||||
t_opt_map options;
|
||||
options.emplace("layer_height", std::make_pair(std::string("layer_height"), -1));
|
||||
options.emplace("nozzle_diameter#0", std::make_pair(std::string("nozzle_diameter"), 0));
|
||||
options.emplace("nozzle_diameter#1", std::make_pair(std::string("nozzle_diameter"), 1));
|
||||
|
||||
// A negative index resolves a scalar option and yields its own (negative) index.
|
||||
int index = -1;
|
||||
CHECK(option_id_in_map(options, "layer_height", index) == "layer_height");
|
||||
CHECK(index == -1);
|
||||
|
||||
// A negative index on a vector option matches several entries, so it resolves to nothing.
|
||||
index = -1;
|
||||
CHECK(option_id_in_map(options, "nozzle_diameter", index).empty());
|
||||
|
||||
// An explicit index selects that entry.
|
||||
index = 1;
|
||||
CHECK(option_id_in_map(options, "nozzle_diameter", index) == "nozzle_diameter#1");
|
||||
CHECK(index == 1);
|
||||
|
||||
// An index no entry carries, and a key the map does not own, resolve to nothing.
|
||||
index = 7;
|
||||
CHECK(option_id_in_map(options, "nozzle_diameter", index).empty());
|
||||
index = -1;
|
||||
CHECK(option_id_in_map(options, "wall_loops", index).empty());
|
||||
|
||||
const t_opt_map empty;
|
||||
CHECK(option_id_in_map(empty, "layer_height", index).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
// ResourceTexture plugin type: directory validation, the host registry's lifecycle, the texture
|
||||
// listing's plugin section, hostile get_texture_dir() overrides, decode parity and the .3mf round
|
||||
// trip without the pack installed.
|
||||
//
|
||||
// The GUI wiring lives in GUI_App::init_plugin_gui_wiring; the test binary has no app, so the same
|
||||
// registry callbacks are installed here. As in test_plugin_lifecycle.cpp, each Catch2 test case runs
|
||||
// in its own process (catch_discover_tests), so the PluginManager and interpreter come up at most once
|
||||
// per test.
|
||||
//
|
||||
// Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in <windows.h>, so keep
|
||||
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers.
|
||||
#include "test_utils.hpp"
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <initializer_list>
|
||||
#include <ios>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <wx/image.h>
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include "libslic3r/Format/bbs_3mf.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/PNGReadWrite.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "libslic3r/Semver.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/Utils.hpp"
|
||||
#include "libslic3r/Zipper.hpp"
|
||||
|
||||
#include "slic3r/GUI/TextureLibrary.hpp"
|
||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||
#include "slic3r/plugin/PluginManager.hpp"
|
||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Brings the plugin system up, and tears it down explicitly at the end of the test, as
|
||||
// test_plugin_lifecycle.cpp does. Declare this FIRST in a test so it is destroyed last.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
|
||||
ScopedDataDir python_data_dir{"texture-python"};
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// The app installs these in GUI_App::init_plugin_gui_wiring. Without an app the callbacks run inline
|
||||
// on the load worker, which is what lets the tests observe the registry right after the wait.
|
||||
void install_resource_texture_wiring()
|
||||
{
|
||||
static bool installed = false;
|
||||
if (installed)
|
||||
return;
|
||||
installed = true;
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.subscribe_on_load_callback([](const std::string& plugin_key) {
|
||||
PluginResourceTextures::instance().on_plugin_register(plugin_key);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_unload_callback([](const std::string& plugin_key) {
|
||||
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
|
||||
if (capability.type != PluginCapabilityType::ResourceTexture)
|
||||
return;
|
||||
PluginResourceTextures::instance().on_cap_register(capability);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
|
||||
if (capability.type != PluginCapabilityType::ResourceTexture)
|
||||
return;
|
||||
PluginResourceTextures::instance().on_cap_deregister(capability);
|
||||
GUI::bump_texture_library_generation();
|
||||
});
|
||||
}
|
||||
|
||||
// A minimal resource-texture plugin. `display_name` is the PEP723 package name and, unless
|
||||
// `capability_name` is given, the capability's get_name() too; pass `capability_name` to keep the
|
||||
// package name and the capability name independent.
|
||||
std::string make_resource_texture_source(const std::string& display_name,
|
||||
const std::string& get_texture_dir_body,
|
||||
const std::string& capability_name = "")
|
||||
{
|
||||
const std::string cap_name = capability_name.empty() ? display_name : capability_name;
|
||||
return std::string{}
|
||||
+ "# /// script\n"
|
||||
+ "# requires-python = \">=3.12\"\n"
|
||||
+ "# dependencies = []\n"
|
||||
+ "#\n"
|
||||
+ "# [tool.orcaslicer.plugin]\n"
|
||||
+ "# name = \"" + display_name + "\"\n"
|
||||
+ "# description = \"Resource texture pack fixture.\"\n"
|
||||
+ "# author = \"tests\"\n"
|
||||
+ "# version = \"1.0.0\"\n"
|
||||
+ "# type = \"resource-texture\"\n"
|
||||
+ "# ///\n"
|
||||
+ "import os\n"
|
||||
+ "import orca\n"
|
||||
+ "\n\n"
|
||||
+ "class PackTextures(orca.resource_texture.ResourceTexturePluginCapabilityBase):\n"
|
||||
+ " def get_name(self):\n"
|
||||
+ " return \"" + cap_name + "\"\n"
|
||||
+ "\n"
|
||||
+ " def get_texture_dir(self):\n"
|
||||
+ " " + get_texture_dir_body + "\n"
|
||||
+ "\n\n"
|
||||
+ "@orca.plugin\n"
|
||||
+ "class TexturePack(orca.base):\n"
|
||||
+ " def register_capabilities(self):\n"
|
||||
+ " orca.register_capability(PackTextures)\n";
|
||||
}
|
||||
|
||||
std::string default_texture_dir_body()
|
||||
{
|
||||
return "return os.path.join(os.path.dirname(__file__), \"textures\")";
|
||||
}
|
||||
|
||||
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
|
||||
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
bool load_and_wait(PluginManager& manager, const std::string& plugin_key, std::string& error)
|
||||
{
|
||||
manager.load_plugin(plugin_key, /*skip_deps=*/true);
|
||||
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
|
||||
}
|
||||
|
||||
// The Plugin-sourced slice of the texture listing, which is exactly what the picker's third heading
|
||||
// draws. Pass force to call texture_library(true); without it the listing is reused unless a pack
|
||||
// callback moved the generation.
|
||||
std::vector<GUI::TextureLibraryEntry> plugin_textures(bool force)
|
||||
{
|
||||
std::vector<GUI::TextureLibraryEntry> found;
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(force))
|
||||
if (entry.source == GUI::TextureSource::Plugin)
|
||||
found.push_back(entry);
|
||||
return found;
|
||||
}
|
||||
|
||||
void write_text_file(const fs::path& path, const std::string& content = "x")
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream out(path.string(), std::ios::binary);
|
||||
out << content;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> read_file_bytes(const fs::path& path)
|
||||
{
|
||||
std::ifstream ifs(path.string(), std::ios::binary);
|
||||
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
void write_bytes(const fs::path& path, const std::vector<unsigned char>& bytes)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream out(path.string(), std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(bytes.data()), std::streamsize(bytes.size()));
|
||||
}
|
||||
|
||||
void write_gray_png(const fs::path& path, uint8_t value, size_t w = 4, size_t h = 4)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
const std::vector<uint8_t> pixels(w * h, value);
|
||||
REQUIRE(Slic3r::png::write_gray_to_file(path.string(), w, h, pixels));
|
||||
}
|
||||
|
||||
// A 24-bit gray BMP (R == G == B), synthesised without an image toolkit. Rows are bottom-up and
|
||||
// padded to four bytes; the image is uniform, so the orientation does not matter.
|
||||
std::vector<unsigned char> make_gray_bmp(int width, int height, uint8_t value)
|
||||
{
|
||||
const int row_bytes = ((width * 3 + 3) / 4) * 4;
|
||||
const uint32_t pixel_bytes = uint32_t(row_bytes) * uint32_t(height);
|
||||
const uint32_t file_size = 14 + 40 + pixel_bytes;
|
||||
|
||||
std::vector<unsigned char> bmp(file_size, 0);
|
||||
const auto put16 = [&bmp](size_t at, uint16_t v) {
|
||||
bmp[at] = uint8_t(v);
|
||||
bmp[at + 1] = uint8_t(v >> 8);
|
||||
};
|
||||
const auto put32 = [&bmp](size_t at, uint32_t v) {
|
||||
bmp[at] = uint8_t(v);
|
||||
bmp[at + 1] = uint8_t(v >> 8);
|
||||
bmp[at + 2] = uint8_t(v >> 16);
|
||||
bmp[at + 3] = uint8_t(v >> 24);
|
||||
};
|
||||
|
||||
bmp[0] = 'B';
|
||||
bmp[1] = 'M';
|
||||
put32(2, file_size);
|
||||
put32(10, 54); // pixel data offset
|
||||
put32(14, 40); // BITMAPINFOHEADER size
|
||||
put32(18, uint32_t(width));
|
||||
put32(22, uint32_t(height));
|
||||
put16(26, 1); // planes
|
||||
put16(28, 24); // bits per pixel
|
||||
put32(34, pixel_bytes);
|
||||
|
||||
for (int y = 0; y < height; ++y) {
|
||||
unsigned char* row = bmp.data() + 54 + size_t(y) * size_t(row_bytes);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
row[x * 3] = value;
|
||||
row[x * 3 + 1] = value;
|
||||
row[x * 3 + 2] = value;
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// The app calls wxInitAllImageHandlers() during startup and, before that, wxEntryStart() runs the
|
||||
// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and
|
||||
// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or
|
||||
// a .bmp texture cannot be loaded here at all.
|
||||
void ensure_wx_image_handlers()
|
||||
{
|
||||
static bool initialized = false;
|
||||
if (initialized)
|
||||
return;
|
||||
initialized = true;
|
||||
wxImage::InitStandardHandlers();
|
||||
wxInitAllImageHandlers();
|
||||
}
|
||||
|
||||
// Stores `model` as a one-plate project, mirroring tests/libslic3r/test_3mf.cpp.
|
||||
void store_project(Model& model, const std::string& path)
|
||||
{
|
||||
ScopedTemporaryDir backup_dir("orca_tex_pack_src");
|
||||
model.set_backup_path(backup_dir.string());
|
||||
|
||||
DynamicPrintConfig cfg;
|
||||
PlateData plate;
|
||||
plate.plate_index = 0;
|
||||
StoreParams sp;
|
||||
sp.path = path.c_str();
|
||||
sp.model = &model;
|
||||
sp.config = &cfg;
|
||||
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
|
||||
sp.plate_data_list.push_back(&plate);
|
||||
REQUIRE(store_bbs_3mf(sp));
|
||||
}
|
||||
|
||||
bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
|
||||
{
|
||||
model.set_backup_path(backup_dir.string());
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
|
||||
PlateDataPtrs plates;
|
||||
std::vector<Preset*> project_presets;
|
||||
bool is_bbl_3mf = false;
|
||||
bool is_orca_3mf = false;
|
||||
Semver file_version;
|
||||
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets,
|
||||
&is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
|
||||
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
|
||||
release_PlateData_list(plates);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── Validation (collect_resource_texture_files, no Python) ──────────────────────────────────────
|
||||
|
||||
TEST_CASE("A texture file directly inside the plugin folder is listed", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-root");
|
||||
write_text_file(root.path() / "textures" / "grain.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "grain.png");
|
||||
}
|
||||
|
||||
TEST_CASE("A directory reached with a parent-directory escape is refused", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir parent("tex-escape");
|
||||
const fs::path root = parent.path() / "pack";
|
||||
const fs::path outside = parent.path() / "outside";
|
||||
fs::create_directories(root);
|
||||
write_text_file(outside / "leak.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root / ".." / "outside").string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("An absolute directory outside the plugin folder is refused", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-abs-root");
|
||||
ScopedTemporaryDir elsewhere("tex-abs-elsewhere");
|
||||
write_text_file(elsewhere.path() / "leak.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(elsewhere.string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("A symlink pointing outside the plugin folder is skipped, not reported as an error", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-symlink-root");
|
||||
ScopedTemporaryDir target("tex-symlink-target");
|
||||
write_text_file(target.path() / "secret.png");
|
||||
|
||||
const fs::path link = root.path() / "textures" / "link.png";
|
||||
fs::create_directories(link.parent_path());
|
||||
boost::system::error_code ec;
|
||||
fs::create_symlink(target.path() / "secret.png", link, ec);
|
||||
if (ec)
|
||||
SKIP("symlinks unavailable on this filesystem: " + ec.message());
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files(link.parent_path().string(), root.string(), error);
|
||||
|
||||
// The directory itself is legitimate; the escaping link is dropped, not an error.
|
||||
CHECK(error.empty());
|
||||
CHECK(files.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Files in a subdirectory of the texture directory are not listed", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-recursive");
|
||||
write_text_file(root.path() / "top.png");
|
||||
write_text_file(root.path() / "nested" / "deep.png");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "top.png");
|
||||
}
|
||||
|
||||
TEST_CASE("An entry that is not a regular file is skipped", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-nonregular");
|
||||
write_text_file(root.path() / "real.png");
|
||||
fs::create_directories(root.path() / "fake.png"); // a directory named like an image
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
REQUIRE(files.size() == 1);
|
||||
CHECK(fs::path(files.front()).filename() == "real.png");
|
||||
}
|
||||
|
||||
TEST_CASE("Image extensions are matched case-insensitively and other extensions are ignored",
|
||||
"[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-extensions");
|
||||
for (const char* name : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
|
||||
write_text_file(root.path() / name);
|
||||
for (const char* name : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
|
||||
write_text_file(root.path() / name);
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
CHECK(files.size() == 8);
|
||||
|
||||
std::vector<std::string> names;
|
||||
for (const std::string& file : files)
|
||||
names.push_back(fs::path(file).filename().string());
|
||||
for (const char* accepted : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
|
||||
CHECK(std::find(names.begin(), names.end(), accepted) != names.end());
|
||||
for (const char* rejected : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
|
||||
CHECK(std::find(names.begin(), names.end(), rejected) == names.end());
|
||||
}
|
||||
|
||||
TEST_CASE("The listing stops at 64 image files", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-cap");
|
||||
for (int i = 0; i < 70; ++i)
|
||||
write_text_file(root.path() / ("file" + std::to_string(i) + ".png"));
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
|
||||
|
||||
CHECK(error.empty());
|
||||
CHECK(files.size() == 64);
|
||||
}
|
||||
|
||||
TEST_CASE("A missing texture directory yields no files and an error", "[PluginResourceTexture]")
|
||||
{
|
||||
ScopedTemporaryDir root("tex-missing");
|
||||
|
||||
std::string error;
|
||||
const std::vector<std::string> files =
|
||||
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
|
||||
|
||||
CHECK(files.empty());
|
||||
CHECK_FALSE(error.empty());
|
||||
}
|
||||
|
||||
// ── Lifecycle through the real listing ──────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A resource-texture pack's images appear in the listing and clear on disable and unload",
|
||||
"[PluginResourceTexture][PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-listing");
|
||||
|
||||
const std::string plugin_key = "Texture_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
|
||||
|
||||
const fs::path textures = plugin_dir / "textures";
|
||||
write_gray_png(textures / "alpha.png", 0x20);
|
||||
write_gray_png(textures / "beta.png", 0x80);
|
||||
write_text_file(textures / "gamma.jpg"); // the listing only checks the extension; decode is covered below
|
||||
write_text_file(textures / "notes.txt");
|
||||
|
||||
// A user-owned texture, so the listing has a non-pack entry whose provenance must be empty.
|
||||
write_gray_png(fs::path(GUI::user_texture_dir()) / "user_made.png", 0x40);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
INFO("load error: " << error);
|
||||
CHECK(error.empty());
|
||||
|
||||
CHECK(PluginResourceTextures::instance().entries().size() == 1);
|
||||
|
||||
const uint64_t loaded_generation = GUI::texture_library_generation();
|
||||
const auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 3);
|
||||
std::vector<std::string> names;
|
||||
for (const GUI::TextureLibraryEntry& entry : loaded)
|
||||
names.push_back(entry.name);
|
||||
CHECK(std::find(names.begin(), names.end(), "alpha") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "beta") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "gamma") != names.end());
|
||||
CHECK(std::find(names.begin(), names.end(), "notes") == names.end());
|
||||
|
||||
// Every pack entry names its contributing pack; shipped and user entries carry no pack.
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) {
|
||||
if (entry.source == GUI::TextureSource::Plugin) {
|
||||
CHECK_FALSE(entry.pack_name.empty());
|
||||
CHECK(entry.pack_name == "Texture Pack");
|
||||
} else {
|
||||
CHECK(entry.pack_name.empty());
|
||||
}
|
||||
}
|
||||
|
||||
// A no-argument call reuses the cached listing until a pack callback moves the generation.
|
||||
CHECK(plugin_textures(false).size() == 3);
|
||||
|
||||
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, false);
|
||||
CHECK(GUI::texture_library_generation() > loaded_generation);
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(false).empty());
|
||||
|
||||
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, true);
|
||||
CHECK(plugin_textures(false).size() == 3);
|
||||
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(false).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Two distinctly named packs each list as one contiguous run",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-contiguous");
|
||||
|
||||
// Loaded out of name order, so it is the listing's pack-name sort that orders the runs.
|
||||
const std::string key_zeta = "Pack_Zeta";
|
||||
const std::string key_alpha = "Pack_Alpha";
|
||||
|
||||
const fs::path dir_zeta = write_plugin(
|
||||
data_dir_guard, key_zeta, make_resource_texture_source("Zeta Pack", default_texture_dir_body()));
|
||||
const fs::path dir_alpha = write_plugin(
|
||||
data_dir_guard, key_alpha, make_resource_texture_source("Alpha Pack", default_texture_dir_body()));
|
||||
|
||||
write_gray_png(dir_zeta / "textures" / "zeta_one.png", 0x20);
|
||||
write_gray_png(dir_zeta / "textures" / "zeta_two.png", 0x40);
|
||||
write_gray_png(dir_alpha / "textures" / "alpha_one.png", 0x60);
|
||||
write_gray_png(dir_alpha / "textures" / "alpha_two.png", 0x80);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, key_zeta, error));
|
||||
REQUIRE(load_and_wait(manager, key_alpha, error));
|
||||
|
||||
std::vector<GUI::TextureLibraryEntry> pack_entries;
|
||||
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true))
|
||||
if (entry.source == GUI::TextureSource::Plugin)
|
||||
pack_entries.push_back(entry);
|
||||
REQUIRE(pack_entries.size() == 4);
|
||||
|
||||
const auto count_pack = [&pack_entries](const std::string& name) {
|
||||
return std::count_if(pack_entries.begin(), pack_entries.end(),
|
||||
[&name](const GUI::TextureLibraryEntry& e) { return e.pack_name == name; });
|
||||
};
|
||||
CHECK(count_pack("Zeta Pack") == 2);
|
||||
CHECK(count_pack("Alpha Pack") == 2);
|
||||
|
||||
// Collapsing consecutive entries by pack name must leave one run per pack: a name never reappears
|
||||
// after another name intervenes, which is what lets the picker draw one section per pack.
|
||||
std::vector<std::string> runs;
|
||||
for (const GUI::TextureLibraryEntry& entry : pack_entries)
|
||||
if (runs.empty() || runs.back() != entry.pack_name)
|
||||
runs.push_back(entry.pack_name);
|
||||
|
||||
std::vector<std::string> sorted_runs = runs;
|
||||
std::sort(sorted_runs.begin(), sorted_runs.end());
|
||||
CHECK(std::unique(sorted_runs.begin(), sorted_runs.end()) == sorted_runs.end());
|
||||
CHECK(runs == std::vector<std::string>({ "Alpha Pack", "Zeta Pack" }));
|
||||
|
||||
manager.unload_plugin(key_zeta);
|
||||
manager.unload_plugin(key_alpha);
|
||||
}
|
||||
|
||||
TEST_CASE("A second pack declaring a loaded capability name is refused",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-capability-clash");
|
||||
|
||||
// Distinct package names, one capability name: the loader allows only the first.
|
||||
const std::string key_first = "Clash_First";
|
||||
const std::string key_second = "Clash_Second";
|
||||
const std::string capability = "Shared Textures";
|
||||
|
||||
const fs::path dir_first = write_plugin(
|
||||
data_dir_guard, key_first,
|
||||
make_resource_texture_source("Clash Package One", default_texture_dir_body(), capability));
|
||||
write_plugin(data_dir_guard, key_second,
|
||||
make_resource_texture_source("Clash Package Two", default_texture_dir_body(), capability));
|
||||
|
||||
write_gray_png(dir_first / "textures" / "one.png", 0x20);
|
||||
write_gray_png(dir_first / "textures" / "two.png", 0x40);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, key_first, error));
|
||||
REQUIRE(manager.is_plugin_loaded(key_first));
|
||||
|
||||
const std::vector<GUI::TextureLibraryEntry> before = plugin_textures(true);
|
||||
REQUIRE(before.size() == 2);
|
||||
for (const GUI::TextureLibraryEntry& entry : before)
|
||||
CHECK(entry.pack_name == capability);
|
||||
|
||||
// The second package claims the capability name the first already owns, so the load is refused.
|
||||
std::string second_error;
|
||||
CHECK_FALSE(load_and_wait(manager, key_second, second_error));
|
||||
CHECK_FALSE(manager.is_plugin_loaded(key_second));
|
||||
|
||||
// The first pack's textures are untouched by the refused load.
|
||||
const std::vector<GUI::TextureLibraryEntry> after = plugin_textures(true);
|
||||
REQUIRE(after.size() == before.size());
|
||||
for (size_t i = 0; i < before.size(); ++i) {
|
||||
CHECK(after[i].name == before[i].name);
|
||||
CHECK(after[i].path == before[i].path);
|
||||
CHECK(after[i].pack_name == before[i].pack_name);
|
||||
}
|
||||
|
||||
manager.unload_plugin(key_first);
|
||||
}
|
||||
|
||||
TEST_CASE("A resource-texture pack with no images contributes no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-empty");
|
||||
|
||||
const std::string plugin_key = "Empty_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Empty Pack", default_texture_dir_body()));
|
||||
fs::create_directories(plugin_dir / "textures"); // valid, but empty
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
CHECK(manager.is_plugin_loaded(plugin_key));
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(plugin_key);
|
||||
}
|
||||
|
||||
// ── Hostile get_texture_dir() overrides ─────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A texture directory outside the plugin root contributes no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-hostile");
|
||||
ScopedResourcesDir resources_dir_guard("texture-hostile-res");
|
||||
write_text_file(fs::path(resources_dir_guard.dir) / "sentinel.png"); // would list if it were scanned
|
||||
|
||||
const std::string data_pack = "Data_Dir_Pack";
|
||||
write_plugin(data_dir_guard, data_pack,
|
||||
make_resource_texture_source("Data Dir Pack", "return r\"" + data_dir_guard.dir.string() + "\""));
|
||||
const std::string resources_pack = "Resources_Dir_Pack";
|
||||
write_plugin(data_dir_guard, resources_pack,
|
||||
make_resource_texture_source("Resources Dir Pack", "return r\"" + resources_dir_guard.dir.string() + "\""));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, data_pack, error));
|
||||
REQUIRE(load_and_wait(manager, resources_pack, error));
|
||||
|
||||
// The packs load; their out-of-root directories are simply refused.
|
||||
CHECK(manager.is_plugin_loaded(data_pack));
|
||||
CHECK(manager.is_plugin_loaded(resources_pack));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(data_pack);
|
||||
manager.unload_plugin(resources_pack);
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_texture_dir leaves the pack loaded with no textures", "[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-raising");
|
||||
|
||||
const std::string plugin_key = "Raising_Pack";
|
||||
write_plugin(data_dir_guard, plugin_key,
|
||||
make_resource_texture_source("Raising Pack", "raise RuntimeError(\"no textures today\")"));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
CHECK(error.empty());
|
||||
CHECK(manager.is_plugin_loaded(plugin_key));
|
||||
CHECK(PluginResourceTextures::instance().entries().empty());
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
manager.unload_plugin(plugin_key);
|
||||
}
|
||||
|
||||
// ── Decoding parity ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("Pack images decode through the same path as hand-picked files", "[PluginResourceTexture]")
|
||||
{
|
||||
ensure_wx_image_handlers();
|
||||
|
||||
ScopedTemporaryDir pack_root("tex-pack");
|
||||
ScopedTemporaryDir hand_root("tex-hand");
|
||||
const fs::path pack_dir = pack_root.path() / "textures";
|
||||
fs::create_directories(pack_dir);
|
||||
|
||||
// PNG: grayscale, so load_texture_image_data passes it through verbatim.
|
||||
const fs::path pack_png = pack_dir / "grain.png";
|
||||
write_gray_png(pack_png, 0x40);
|
||||
const std::vector<unsigned char> png_bytes = read_file_bytes(pack_png);
|
||||
REQUIRE_FALSE(png_bytes.empty());
|
||||
write_bytes(hand_root.path() / "grain.png", png_bytes);
|
||||
|
||||
// BMP: not a PNG, so it goes through wxImage and is re-encoded; the same file must give the same
|
||||
// bytes wherever it sits.
|
||||
const std::vector<unsigned char> bmp_bytes = make_gray_bmp(4, 4, 0x40);
|
||||
const fs::path pack_bmp = pack_dir / "bump.bmp";
|
||||
write_bytes(pack_bmp, bmp_bytes);
|
||||
const fs::path hand_bmp = hand_root.path() / "bump.bmp";
|
||||
write_bytes(hand_bmp, bmp_bytes);
|
||||
|
||||
// The collector lists both pack files, so these are the paths a pack would serve.
|
||||
std::string collect_error;
|
||||
const std::vector<std::string> pack_files =
|
||||
collect_resource_texture_files(pack_dir.string(), pack_root.string(), collect_error);
|
||||
REQUIRE(collect_error.empty());
|
||||
CHECK(pack_files.size() == 2);
|
||||
|
||||
std::string pack_error;
|
||||
std::string hand_error;
|
||||
|
||||
const auto pack_png_decoded = GUI::load_texture_image_data(pack_png.string(), pack_error);
|
||||
const auto hand_png_decoded = GUI::load_texture_image_data((hand_root.path() / "grain.png").string(), hand_error);
|
||||
REQUIRE(pack_png_decoded != nullptr);
|
||||
REQUIRE(hand_png_decoded != nullptr);
|
||||
CHECK(*pack_png_decoded == png_bytes); // supported PNG is passed through unchanged
|
||||
CHECK(*pack_png_decoded == *hand_png_decoded);
|
||||
|
||||
const auto pack_bmp_decoded = GUI::load_texture_image_data(pack_bmp.string(), pack_error);
|
||||
const auto hand_bmp_decoded = GUI::load_texture_image_data(hand_bmp.string(), hand_error);
|
||||
REQUIRE(pack_bmp_decoded != nullptr);
|
||||
REQUIRE(hand_bmp_decoded != nullptr);
|
||||
REQUIRE_FALSE(pack_bmp_decoded->empty());
|
||||
CHECK(*pack_bmp_decoded == *hand_bmp_decoded);
|
||||
}
|
||||
|
||||
TEST_CASE("Reinstalling a pack with different bytes at the same path changes the decoded content",
|
||||
"[PluginResourceTexture][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-reinstall");
|
||||
|
||||
const std::string plugin_key = "Reinstall_Pack";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Reinstall Pack", default_texture_dir_body()));
|
||||
const fs::path texture = plugin_dir / "textures" / "grain.png";
|
||||
write_gray_png(texture, 0x10);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
const std::string path = loaded.front().path;
|
||||
|
||||
std::string decode_error;
|
||||
const auto first = GUI::load_texture_image_data(path, decode_error);
|
||||
REQUIRE(first != nullptr);
|
||||
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
|
||||
// The reinstalled pack writes different bytes to the same file path.
|
||||
write_gray_png(texture, 0xE0);
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
CHECK(loaded.front().path == path);
|
||||
|
||||
const auto second = GUI::load_texture_image_data(path, decode_error);
|
||||
REQUIRE(second != nullptr);
|
||||
CHECK(*first != *second);
|
||||
}
|
||||
|
||||
// ── Wheel metadata ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A minimal resource-texture wheel is accepted", "[PluginResourceTexture][Wheel]")
|
||||
{
|
||||
ScopedTemporaryDir dir("texture-wheel");
|
||||
const fs::path wheel = dir.path() / "texture_pack-1.0-py3-none-any.whl";
|
||||
|
||||
const std::string dist_info = "texture_pack-1.0.dist-info";
|
||||
const std::string metadata =
|
||||
"Metadata-Version: 2.1\n"
|
||||
"Name: texture_pack\n"
|
||||
"Version: 1.0\n"
|
||||
"Summary: Resource texture pack fixture\n"
|
||||
"Author: tests\n";
|
||||
const std::string wheel_metadata =
|
||||
"Wheel-Version: 1.0\n"
|
||||
"Generator: manual\n"
|
||||
"Root-Is-Purelib: true\n"
|
||||
"Tag: py3-none-any\n";
|
||||
const std::string record = dist_info + "/METADATA,,\n" + dist_info + "/WHEEL,,\n" + dist_info + "/RECORD,,\n";
|
||||
const std::string top_level = "texture_pack\n";
|
||||
|
||||
{
|
||||
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
|
||||
zipper.add_entry(dist_info + "/METADATA", metadata.data(), metadata.size());
|
||||
zipper.add_entry(dist_info + "/WHEEL", wheel_metadata.data(), wheel_metadata.size());
|
||||
zipper.add_entry(dist_info + "/RECORD", record.data(), record.size());
|
||||
// read_wheel_plugin_metadata leaves `error` set when an optional top_level.txt is missing.
|
||||
zipper.add_entry(dist_info + "/top_level.txt", top_level.data(), top_level.size());
|
||||
zipper.finalize();
|
||||
}
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
REQUIRE(read_wheel_plugin_metadata(wheel, descriptor, error));
|
||||
INFO("wheel metadata error: " << error);
|
||||
CHECK(error.empty());
|
||||
CHECK(descriptor.name == "texture_pack");
|
||||
CHECK(descriptor.version == "1.0");
|
||||
CHECK(descriptor.entry_package == "texture_pack");
|
||||
}
|
||||
|
||||
// ── .3mf round trip ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TEST_CASE("A project keeps a pack texture after the pack is uninstalled",
|
||||
"[PluginResourceTexture][PluginLifecycle][Python][3mf]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("texture-3mf");
|
||||
|
||||
const std::string plugin_key = "Texture_Pack_3mf";
|
||||
const fs::path plugin_dir =
|
||||
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
|
||||
write_gray_png(plugin_dir / "textures" / "grain.png", 0x60);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
install_resource_texture_wiring();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, plugin_key, error));
|
||||
|
||||
const auto loaded = plugin_textures(true);
|
||||
REQUIRE(loaded.size() == 1);
|
||||
|
||||
std::string decode_error;
|
||||
const auto pack_bytes = GUI::load_texture_image_data(loaded.front().path, decode_error);
|
||||
REQUIRE(pack_bytes != nullptr);
|
||||
REQUIRE_FALSE(pack_bytes->empty());
|
||||
|
||||
// Build a project the way the gizmo would: the layer carries the pack texture's bytes.
|
||||
Model model;
|
||||
ModelObject* object = model.add_object();
|
||||
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
|
||||
object->add_instance();
|
||||
TextureDisplacementLayer layer;
|
||||
layer.slot = 0;
|
||||
layer.name = loaded.front().name;
|
||||
layer.path = loaded.front().path;
|
||||
layer.image_data = pack_bytes;
|
||||
volume->texture_displacement_layers.push_back(layer);
|
||||
|
||||
ScopedTemporaryFile first(".3mf");
|
||||
store_project(model, first.string());
|
||||
|
||||
// Uninstall the pack; the project still has to open and re-save with the same image bytes.
|
||||
REQUIRE(manager.unload_plugin(plugin_key));
|
||||
CHECK(plugin_textures(true).empty());
|
||||
|
||||
ScopedTemporaryDir backup_dir("orca_tex_pack_dst");
|
||||
Model reloaded;
|
||||
REQUIRE(load_project(first.string(), reloaded, backup_dir));
|
||||
REQUIRE(reloaded.objects.size() == 1);
|
||||
REQUIRE(reloaded.objects.front()->volumes.size() == 1);
|
||||
const std::vector<TextureDisplacementLayer>& reloaded_layers =
|
||||
reloaded.objects.front()->volumes.front()->texture_displacement_layers;
|
||||
REQUIRE(reloaded_layers.size() == 1);
|
||||
REQUIRE(reloaded_layers.front().image_data);
|
||||
CHECK(*reloaded_layers.front().image_data == *pack_bytes);
|
||||
|
||||
ScopedTemporaryFile second(".3mf");
|
||||
store_project(reloaded, second.string());
|
||||
|
||||
ScopedTemporaryDir backup_dir2("orca_tex_pack_dst2");
|
||||
Model reloaded_again;
|
||||
REQUIRE(load_project(second.string(), reloaded_again, backup_dir2));
|
||||
REQUIRE(reloaded_again.objects.size() == 1);
|
||||
REQUIRE(reloaded_again.objects.front()->volumes.size() == 1);
|
||||
const std::vector<TextureDisplacementLayer>& again_layers =
|
||||
reloaded_again.objects.front()->volumes.front()->texture_displacement_layers;
|
||||
REQUIRE(again_layers.size() == 1);
|
||||
REQUIRE(again_layers.front().image_data);
|
||||
CHECK(*again_layers.front().image_data == *pack_bytes);
|
||||
}
|
||||
Reference in New Issue
Block a user