Compare commits

..
Author SHA1 Message Date
ExPikaPaka fbaed8a512 Project the texture in the model's own orientation, and stop the preview tiling
The bake frame took its orientation from the world, so turning a part on the
plate slid the decoration across its surface. Turning a part is a print-time
decision - clearance, supports, warping - and it should not be a design one.
The frame now takes its orientation from the model and keeps world scale, so a
tile is still tiling_scale printed millimetres whatever the instance's scale.

The rotation is separated out by polar decomposition rather than by assuming
the linear part is a rotation times a scale, since a sheared or mirrored
placement is legal here. A reflection is deliberately left with the scale, so a
mirrored instance still makes the frame's determinant negative and the bake
still flips the normals it derives.

The preview shaders rebuild that frame themselves from a world position, so
they take the same rotation through a uniform; without it the fast preview and
the bake would disagree, which is the class of bug this is fixing. The gizmo's
own way back out to world carries the rotation too, which it did not need to
when the frame was world-aligned.

Tiling in the fast preview:

- the sampler wrap mode was set on the height texture but not on the colour
  texture, which sits on its own unit and is sampled at the same uv. So with
  Tile off the relief stopped at one placement while the colour carried on
  repeating around it.
- outside a single placement the colour then came out black, because a zero
  texture border cannot say "no colour here", only "the colour is black here".
  The shader now tests the uv the way sample_layer_color() does and leaves the
  surface its own colour.

The placement gizmo is a tile of a planar projection, so it is offered only for
the mappings that have one. An unwrap cuts the surface into islands and packs
them, and a model's own UV channel is whatever its author drew: one tile lands
there as an arbitrary curved patch with no rectangle to take hold of. Those two
say so and point at the UV editor instead.
2026-10-15 15:45:47 +02:00
ExPikaPaka 597b2abac4 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 13:13:26 +02:00
ExPikaPaka ba4de8c1fc Measure the placement gizmo in the frame the texture is projected in
The gizmo worked in mesh-local millimetres while the texture is projected in
the bake frame - world orientation and scale, anchored at the volume's origin.
On an unscaled object the two agree, which is why this went unnoticed; on a
scaled one the drawn tile differed from the rendered one by exactly the
object's scale, and a rotated one would have skewed its axes as well.

The anchor is carried into that frame at the point of use rather than stored
converted, since the object can be moved, turned or scaled without leaving the
mode. Getting from the frame to the screen is then a translation and nothing
more, which is the point: rotation and scale are what a tile's millimetres must
not be measured through.

Also in this commit: a pale casing outside the dark halo, because a dark halo
alone disappears against a dark backdrop and a photographic texture offers both
within one tile; one rotate ring instead of four, moved to whichever corner the
cursor is nearest and held there by a margin so it does not flit between two;
one constant for the casing's width, after the move grip's dots turned out to
carry nearly twice the casing of everything around them; a request for one more
frame when a panel edit changes a preview parameter, since the panel is drawn
after the 3D view and the edit was otherwise a frame late; and an animated
illustration for the square-tile lock.
2026-10-15 13:13:26 +02:00
ExPikaPaka 7bce53a773 Merge branch 'main' into texture-placement-gizmo
Three files conflicted, all in the texture displacement subsystem that main
has been working on in parallel.

apply_uv_transform and UVTransform gained main's normalized_uv parameter for
TextureProjectionMethod::ModelUV. A normalized uv has no millimetre tile to
divide by, so the extents are skipped for it, but the mirroring still applies:
a mirror is a placement choice and means the same thing on a model's own UV
layout as on a projected one. The aspect correction stays skipped, as main has
it.

adjust_tangent_basis now calls main's box_projection_axes instead of restating
the dominant-axis choice, which also picks up main's fix to the texture's
orientation. The gizmo derives its whole frame from that basis, so it follows
the new conventions without needing to know about them.

The basic settings rows took main's float_row, which carries the tooltip and
its media as arguments, and its disabling of tile size under ModelUV. The
square lock and the second extent join that disabled block, being the same
millimetres from another angle.

Layers now persist to the .3mf as JSON, which they did not when this branch
started, so tiling_scale_v, mirror_u and mirror_v are written and read there
too. The reader keeps the default for a missing key, so a project written
before the tile could be rectangular or mirrored still loads as a square,
unmirrored one.
2026-10-15 09:54:52 +02:00
ExPikaPaka e65113af6d Add edge and corner resize, mirroring and a square lock to the gizmo
Follows the first pass on the placement gizmo.

Handles and hit radii are twice the size, and the minimum frame with them,
so the corner zones cannot bury the move area on a small tile.

Each edge carries a bar that sets one tile extent, so width and height can
be set on the model rather than only in the panel. The four axis arrows
stay, further out on the same rays, kept apart from the bars by distance.

Dragging a width or height handle back through the tile's middle mirrors
the texture on that axis instead of sticking at the minimum extent. The
new mirror_u and mirror_v ride as a sign on the same division as the tile
extents, in apply_uv_transform and in all four shaders, and the shaded
shaders' normal gradient carries the sign too.

The frame was centred on uv 0, which is a corner of the image, so it
straddled the four tiles meeting there and rotation pivoted on that
corner. It is now centred on uv (0.5, 0.5): the frame bounds one whole
tile and rotating or scaling turns and resizes the image about its middle.

A scale drag read the extents straight off the cursor, so grabbing a
handle anywhere inside its hit radius jumped the tile by that much. The
offset of the grab from the handle is now subtracted.

The square-tile lock is an icon on the Tile size row instead of a text
link, reusing lock_closed.svg and lock_open.svg.
2026-10-12 09:31:32 +02:00
ExPikaPaka 91ab04e823 Redesign the texture placement gizmo and give the tile two extents
The on-canvas control for a texture layer's placement was a translucent
orange quad with two hairline GL arrows. It moved the texture and nothing
else, had no hover state, took no notice of the theme, and sized itself
from the volume's bounding box, which left it tiny on small parts and
huge on large ones. Rotation and tile size were panel-only numbers.

The gizmo is now one tile of the texture lying in the paint patch's
tangent plane, so it draws the three values it edits: where it sits is
the offset, how it is turned is the rotation, how big it is is the tile
extents. It carries four chevron arms for an axis-constrained move (the
silhouette of the panel's own Adjust placement icon), a six-dot move grip
taken from the drag icon, a corner grip on each corner for the extents,
and a ring on a short stem for the rotation. Colours come from
ImGuiWrapper's COL_ORCA and the icon grey rather than raw orange and
yellow.

It is drawn through ImGui's foreground draw list instead of the flat
shader, which is what allows antialiased sub-pixel strokes, rounded
joins, a contrast halo under every stroke and a numeric readout by the
cursor. That halo matters because a volume stays the same neutral grey
whatever the theme, so the accent alone cannot carry contrast; the halo
strength and the handle fill do. Handle sizes and hit radii are in screen
pixels, so the control is equally usable at any zoom or model size, and a
tile that projects below 44px is drawn at that floor and dashed to say it
is not to scale. Picking is a real point-in-quad test rather than a
circle through the corners, so a move can no longer start from empty
space beside the tile.

Rotating or resizing changes where a given offset places the tile, so
every drag recomputes the offset from the centre it means to keep. That
is what makes rotate and scale act in place instead of swinging the tile
around the anchor, and it leaves one place that writes offset.

Tile size gains a second extent. tiling_scale_v holds the v extent in mm
and anything <= 0 means "same as u", which is both the default and what
every layer written before this meant, so existing layers and older undo
snapshots still read back as the square tiles they were. The new
layer_tile_mm() states that fallback once and every consumer goes through
it: the bake, both preview shaders, the UV pane and the gizmo. The panel
shows one slider until the lock is opened, and the lock state is the data
rather than a separate flag that could fall out of step with it.

The extents now divide after the rotation in apply_uv_transform() and in
all four shaders. Dividing two different extents before it would shear
the tile rather than rotate it; with a square tile both orders are
algebraically identical, so no existing layer changes. The inverse in
UVEditorCanvas::uv_delta_to_unwrap() and the island matrix in
uv_editor_island_transforms() follow the same reordering, and the texel
size used for refinement and step cutting now takes the finer extent.
2026-10-12 08:04:35 +02:00
59 changed files with 7186 additions and 12243 deletions
@@ -569,18 +569,12 @@ on the **first** call. So:
#endif
```
**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()`
**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()`
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 rules (c12912e0df) are in
WAYLAND_DISPLAY set"). The details and the WebKit rule (c12912e0df) are in
`references/webview-gl-aui-media.md` §WebKitGTK on Linux sessions.
**wx's own detectors**, for reference:
@@ -61,8 +61,7 @@ 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 the no-EGL default path where Orca forces
`GDK_BACKEND=x11`, for WebKitGTK older than 2.42, and never
13. Do not widen `WEBKIT_DISABLE_COMPOSITING_MODE` beyond Orca's default-path XWayland case, 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
@@ -568,69 +567,33 @@ 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)
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.
branches on `GDK_BACKEND`:
- **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`, 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.
`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.
- **`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, which keeps hardware acceleration for Device / Setup Wizard / login pages.
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).
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:** 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
- **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
waits forever for a C++→JS call.
**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.
**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.
```cpp
// Wrong: unconditional, kills HW acceleration everywhere
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", false);
// 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)
// Right (default path only): non-replacing, before GTK init, Wayland session with XWayland
if (display && *display && wayland && *wayland)
::setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", /*replace*/ false);
```
Cite: c12912e0df (`src/OrcaSlicer.cpp`; `resources/web/guide/0/load.js` `OnInit`
+1 -15
View File
@@ -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: disable precompiled headers (on by default): more ccache hits after header changes, slower misses"
echo " -p: boost ccache hit rate by disabling precompiled headers (default: ON)"
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,20 +549,6 @@ 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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><rect x="1.5" y="3.5" width="12" height="8" rx="1" ry="1" style="fill:none; stroke:#fefefe; stroke-linecap:round; stroke-linejoin:round;"/><path d="M3,5h1v1h-1Z M5,5h1v1h-1Z M7,5h1v1h-1Z M9,5h1v1h-1Z M11,5h1v1h-1Z M4,7h1v1h-1Z M6,7h1v1h-1Z M8,7h1v1h-1Z M10,7h1v1h-1Z M5,9h5v1h-5Z" style="fill:#fefefe;"/></svg>
<?xml version="1.0" encoding="UTF-8"?><svg id="a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><rect x="7" y="12" width="1" height="1" style="fill:#fefefe;"/><path d="M4.5,5.5c0-1.71,1.43-3.09,3.16-3,1.62.08,2.84,1.54,2.84,3.17h0c0,.61-.25,1.2-.69,1.62l-2.31,2.24v.97" style="fill:none; stroke:#fefefe; stroke-linecap:round; stroke-linejoin:round;"/></svg>

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 366 B

@@ -36,6 +36,11 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -45,6 +50,14 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel;
uniform float depth_mm;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile
// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the
// CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an
// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before.
uniform bool mirror_u;
uniform bool mirror_v;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
@@ -54,6 +67,12 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv;
// 2x3 affine (lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the dragged island's uv; see the
// 140 variant. Identity when nothing is dragged.
@@ -162,13 +181,29 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
}
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
@@ -237,9 +272,12 @@ void main()
// World millimetres throughout, like the bake - see the 140 variant.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -270,14 +308,14 @@ void main()
} else if (weight > 0.0) {
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -289,18 +327,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -322,18 +360,23 @@ void main()
float hU = texture2D(height_tex, uv + vec2(0.0, height_tex_texel.y)).r;
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
vec2 tile = tile_mm();
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
// One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of
// the uv-space gradient carries its own factor before being rotated back into t/b.
// Each component carries its own extent and its own mirror sign, both of which project_uv()
// applied on the way in; without the sign a mirrored layer would light as though it were not.
vec2 ms = mirror_sign();
vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / tile.y);
// ...and back out of raw-projection units into millimetres along t / b; see the 140 variant.
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm;
vec3 gradient = slope.x * t + slope.y * b;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -353,7 +396,9 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -16,7 +16,16 @@ uniform bool volume_mirrored;
uniform int mode;
uniform float checker_freq;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see
// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour.
uniform bool mirror_u;
uniform bool mirror_v;
uniform vec3 tex_anchor; // the volume's origin in world space
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -79,13 +88,30 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear
return r + uv_offset;
}
@@ -105,7 +131,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor;
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -113,7 +139,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
base = (check < 0.5) ? vec3(0.22, 0.23, 0.26) : vec3(0.82, 0.83, 0.86);
@@ -95,6 +95,11 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -104,6 +109,14 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel; // (1/width, 1/height) of height_tex
uniform float depth_mm;
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile
// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the
// CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an
// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before.
uniform bool mirror_u;
uniform bool mirror_v;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
@@ -113,6 +126,12 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv; // true: sample at vertex_uv with a derived tangent frame (LSCM)
// A 2x3 affine (columns packed as lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the uv of
// the island currently being dragged in the UV editor (island_active > 0.5). Identity when nothing is
@@ -228,13 +247,29 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_
}
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
@@ -305,9 +340,12 @@ void main()
// printed part whatever the instance's scale or rotation, so the preview has to project from the
// world position and perturb the world normal.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -351,14 +389,14 @@ void main()
// closed-form uv here, unlike the LSCM case.
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -370,18 +408,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// a texel there is no parallax to find and the march would be pure cost - which is the common
// case of looking straight down at a surface.
float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) {
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) {
// A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the
// undisplaced surface. Start at the top of the shell, where the ray is outside the surface
// by construction, and step inward; the crossing is what this pixel actually sees.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -405,23 +443,28 @@ void main()
// Central difference, per uv unit (not per texel).
vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y));
// uv -> mm is 1/tiling_scale for the triplanar projection, so this turns the uv-space
// gradient into a real surface slope.
float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0);
// uv -> mm is the reciprocal of the tile extents, applied per component below, which is what
// turns the uv-space gradient into a real surface slope.
vec2 tile = tile_mm();
float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
// uv was rotated by project_uv() while t/b are the unrotated model axes, so rotate the
// gradient back into the axes' frame.
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
// One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of
// the uv-space gradient carries its own factor before being rotated back into t/b.
// Each component carries its own extent and its own mirror sign, both of which project_uv()
// applied on the way in; without the sign a mirrored layer would light as though it were not.
vec2 ms = mirror_sign();
vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / tile.y);
// ...and back out of raw-projection units into millimetres of travel along t / b, which is a
// no-op except for the spherical longitude (see projection_axes()). After the inverse rotation,
// because units_per_mm is expressed in the t/b frame rather than in uv.
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm;
vec3 gradient = slope.x * t + slope.y * b;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -441,7 +484,9 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -25,7 +25,16 @@ uniform bool volume_mirrored;
uniform int mode; // 0 checker, 1 distortion
uniform float checker_freq; // checker squares per uv unit (one uv unit == one texture tile)
uniform float tiling_scale;
// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see
// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did.
uniform float tiling_scale_v;
// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour.
uniform bool mirror_u;
uniform bool mirror_v;
uniform vec3 tex_anchor; // the volume's origin in world space
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -90,13 +99,30 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies.
vec2 tile_mm()
{
float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0;
return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su);
}
// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side.
vec2 mirror_sign()
{
return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0);
}
vec2 project_uv(vec3 p, vec3 n)
{
vec2 planar = projection_raw(p, n);
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle
// instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on
// the same division, in the tile's own frame.
r *= mirror_sign() / tile_mm();
r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear
return r + uv_offset;
}
@@ -117,7 +143,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor;
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -125,7 +151,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
// Two distinct greys, plus a faint tint on one set so orientation is readable at a glance.
@@ -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()">
+2 -6
View File
@@ -156,16 +156,12 @@ 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");
}
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
# 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://gitlab.gnome.org/GNOME/gnome-build-meta/-/issues/754
grep -q org.freedesktop.Platform.GL.nvidia /.flatpak-info && export WEBKIT_DISABLE_DMABUF_RENDERER=1
# Work-around https://github.com/bambulab/BambuStudio/issues/3440
# Use LC_NUMERIC instead of LC_ALL to prevent decimal separator issues
+15 -21
View File
@@ -163,7 +163,6 @@ using json = nlohmann::json;
#include <X11/Xlib.h>
#endif
#include <unistd.h>
#include "slic3r/GUI/Widgets/WebView.hpp"
#endif
#ifdef SLIC3R_GUI
@@ -1157,10 +1156,8 @@ 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 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.
// Wizard / login / store. The default path still applies it on
// XWayland sessions as a conservative fallback for older WebKit.
// ------------------------------------------------------------------
{
const char* gdk_backend = ::getenv("GDK_BACKEND");
@@ -1168,14 +1165,6 @@ 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.";
@@ -1215,18 +1204,23 @@ 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,8 +429,7 @@ 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
# WebKit's NVIDIA DMABUF workaround is applied by the binary itself,
# gated on the runtime WebKitGTK version (src/OrcaSlicer.cpp).
export WEBKIT_DISABLE_DMABUF_RENDERER=1
fi
fi
fi
-29
View File
@@ -211,35 +211,6 @@ bool decode_colored_png(const ReadBuf &in_buf, ImageColorscale &out_img)
return decode_colored_png(stream, out_img);
}
bool is_decodable_8bit_png(const ReadBuf &pngbuf)
{
static const constexpr int PNG_SIG_BYTES = 8;
struct ReadBufStream stream{pngbuf};
std::vector<png_byte> sig(PNG_SIG_BYTES, 0);
stream.read(sig.data(), PNG_SIG_BYTES);
if (!png_check_sig(sig.data(), PNG_SIG_BYTES))
return false;
PNGDescr dsc;
dsc.png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
if (!dsc.png)
return false;
dsc.info = png_create_info_struct(dsc.png);
if (!dsc.info)
return false;
if (!png_read_header_guarded(dsc.png, dsc.info, &stream, PNG_SIG_BYTES))
return false;
const size_t color_type = png_get_color_type(dsc.png, dsc.info);
const size_t bit_depth = png_get_bit_depth(dsc.png, dsc.info);
const bool grey = color_type == PNG_COLOR_TYPE_GRAY;
const bool colour = color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_RGB_ALPHA;
return png_get_image_width(dsc.png, dsc.info) > 0 && png_get_image_height(dsc.png, dsc.info) > 0 &&
bit_depth == 8 && (grey || colour);
}
// Down to earth function to store a packed RGB image to file. Mostly useful for debugging purposes.
// Based on https://www.lemoda.net/c/write-png/
-3
View File
@@ -50,9 +50,6 @@ struct ReadBuf { const void *buf = nullptr; const size_t sz = 0; };
bool is_png(const ReadBuf &pngbuf);
// True if decode_png() or decode_colored_png() would accept these bytes, judged from the header alone.
bool is_decodable_8bit_png(const ReadBuf &pngbuf);
struct ReadBufStream: public IStream {
const ReadBuf &rbuf_ref;
size_t pos = 0;
+95 -26
View File
@@ -1,6 +1,7 @@
#include "TextureDisplacement.hpp"
#include <Eigen/Core>
#include <Eigen/SVD>
#include <Eigen/Geometry>
#include <algorithm>
#include <array>
@@ -722,7 +723,10 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set
const DecodedHeightTexture &tex = decode_height_texture(layer);
if (tex.width <= 0)
continue;
const float texel = layer.tiling_scale / float(tex.width);
// Texels are square in both axes (the aspect correction is what keeps them so), so the finer
// of the two tile extents sets the finest texel the refinement has to resolve.
const Vec2f tile = layer_tile_mm(layer);
const float texel = std::min(tile.x(), tile.y()) / float(tex.width);
if (out.texel_mm <= 0.f || texel < out.texel_mm) {
out.texel_mm = texel;
out.pixels_per_edge = analyze_texture_detail(layer).pixels_per_edge;
@@ -2164,7 +2168,7 @@ namespace {
// of them was most of the per-sample arithmetic.
struct UVTransform
{
float scale, cs, sn, aspect;
float inv_u, inv_v, cs, sn, aspect;
Vec2f offset;
// `normalized_uv` drops the two steps that only mean something for a coordinate measured on the
@@ -2172,7 +2176,18 @@ struct UVTransform
// rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it.
UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false)
{
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
// The mirror folds into the reciprocal: it is a negation of the same coordinate, so there is
// nothing to do per sample beyond carrying the sign here. A normalized uv has no millimetre
// tiling to divide by, but a mirror is still a mirror, so the sign applies either way.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
inv_u = sign.x();
inv_v = sign.y();
} else {
const Vec2f tile = layer_tile_mm(layer);
inv_u = sign.x() / tile.x();
inv_v = sign.y() / tile.y();
}
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
cs = std::cos(rad);
sn = std::sin(rad);
@@ -2181,9 +2196,11 @@ struct UVTransform
}
Vec2f operator()(const Vec2f &planar) const
{
const Vec2f scaled = planar * scale;
Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
// See apply_uv_transform() for why the aspect correction follows the rotation.
// See apply_uv_transform() for why both the tile extents and the aspect correction follow
// the rotation rather than preceding it.
Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs);
rotated.x() *= inv_u;
rotated.y() *= inv_v;
if (aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + offset;
@@ -2203,26 +2220,43 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
{
// A uv that is already normalized has no millimetre scale to divide by and no aspect to correct -
// see the header. UVTransform does the same, and the two must stay in step.
const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
const Vec2f scaled = planar * scale;
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad);
const float sn = std::sin(rad);
Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs);
// The tile extents. Dividing here rather than before the rotation is what makes a non-square
// tile a rotated *rectangle* on the surface: scaling the two axes of an already-rotated
// coordinate by different amounts would be a shear, so the rotation has to come first and the
// extents divide in the tile's own frame. With a square tile the two orders agree exactly,
// R being linear - which is why this is not a change in behaviour for any existing layer.
//
// The mirror rides along as a sign on the same division. It is in the tile's own frame too, so
// it mirrors along the tile's axes rather than the world's, and because the offset is what
// positions the tile, mirroring about the tile's middle is a matter of the offset the gizmo
// writes alongside it, not of anything here.
// A uv that is already normalized has no millimetre scale to divide by - see the header - but it
// can still be mirrored, so the sign is applied in both cases and only the extents are skipped.
// UVTransform does the same, and the two must stay in step.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
rotated.x() *= sign.x();
rotated.y() *= sign.y();
} else {
const Vec2f tile = layer_tile_mm(layer);
rotated.x() *= sign.x() / tile.x();
rotated.y() *= sign.y() / tile.y();
}
// Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its
// proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out
// half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is
// `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so
// dividing v by that extent is the same as multiplying it by width / height. A square texture has
// aspect 1 and is untouched, which is why this changes nothing for the shipped library.
// proportions, so a 2:1 image is squeezed into one tile and every feature in it comes out half
// as wide as it should be. The tile is `layer_tile_mm().y() * height / width` mm along v, which
// is exactly what keeps texels square - so dividing v by that extent is the same as multiplying
// it by width / height. A square texture has aspect 1 and is untouched, which is why this
// changes nothing for the shipped library.
//
// Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a
// shear, and doing it the other way round would make "Rotation" skew the pattern instead of
// turning it.
// Applied after the rotation, like the extents, and for the same reason: scaling one axis of an
// already-rotated coordinate before turning it would be a shear.
if (!normalized_uv && aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
@@ -3566,13 +3600,36 @@ void texture_displacement_patch_frame(const indexed_triangle_set &patch, const s
(an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ();
}
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world)
{
// Separated out by polar decomposition rather than by assuming the linear part is a clean
// rotation times a scale: a sheared or mirrored placement is legal here. A reflection is left
// behind with the scale on purpose, so a mirrored instance still makes the bake frame's
// determinant negative and the bake still knows to flip the normals it derives.
const Matrix3d linear = volume_to_world.linear();
Eigen::JacobiSVD<Matrix3d> svd(linear, Eigen::ComputeFullU | Eigen::ComputeFullV);
Matrix3d rotation = svd.matrixU() * svd.matrixV().transpose();
if (rotation.determinant() < 0.0) {
Matrix3d u = svd.matrixU();
u.col(2) *= -1.0;
rotation = u * svd.matrixV().transpose();
}
return rotation.transpose(); // world -> texture frame
}
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world)
{
// World orientation and scale, but the origin moved to where the volume's own origin sits: the
// texture then rides with the model when it is moved about the plate (and a single, untiled stamp
// starts on the model rather than at the plate's corner), while a tile is still `tiling_scale`
// printed millimetres whatever the instance's scale.
return Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
// The model's own orientation, world scale, and the origin moved to where the volume's origin
// sits. So the texture rides with the model however it is moved *and* however it is turned,
// while a tile is still `tiling_scale` printed millimetres whatever the instance's scale, and a
// single untiled stamp starts on the model rather than at the plate's corner.
//
// Orientation used to come from the world too, which meant turning a part on the plate slid the
// decoration across its surface. Turning a part is a print-time decision - clearance, supports,
// warping - and it should not be a design one.
Transform3d unrotate = Transform3d::Identity();
unrotate.linear() = texture_displacement_frame_rotation(volume_to_world);
return unrotate * Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
}
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume)
@@ -5783,7 +5840,13 @@ std::string texture_displacement_layers_to_json(const std::vector<TextureDisplac
j["depth_mm"] = l.depth_mm;
j["tiling_scale"] = l.tiling_scale;
j["rotation_deg"] = l.rotation_deg;
// The v extent and the mirroring. Written unconditionally, including the <= 0 "same as u"
// sentinel, so a project says plainly that the tile was square rather than leaving a reader
// to infer it from a missing key.
j["tiling_scale_v"] = l.tiling_scale_v;
j["mirror_u"] = l.mirror_u;
j["mirror_v"] = l.mirror_v;
j["rotation_deg"] = l.rotation_deg;
j["offset"] = vec2_to_json(l.offset);
j["invert"] = l.invert;
j["midlevel"] = l.midlevel;
@@ -5891,6 +5954,12 @@ bool texture_displacement_layers_from_json(const std::string
read(j, "depth_mm", l.depth_mm);
read(j, "tiling_scale", l.tiling_scale);
// Absent in a project written before the tile could be rectangular or mirrored, and
// then each keeps its default: -1 for the v extent, which layer_tile_mm() reads as
// "square", and false for the mirrors. So an older project loads as what it was.
read(j, "tiling_scale_v", l.tiling_scale_v);
read(j, "mirror_u", l.mirror_u);
read(j, "mirror_v", l.mirror_v);
read(j, "rotation_deg", l.rotation_deg);
if (const auto o = j.find("offset"); o != j.end())
l.offset = vec2_from_json(*o, l.offset);
+47 -8
View File
@@ -225,7 +225,17 @@ struct TextureDisplacementLayer
std::shared_ptr<std::vector<unsigned char>> image_data;
float depth_mm = 0.4f; // maximum displacement along the surface normal, in mm
float tiling_scale = 12.5f; // size of one texture tile, in mm
float tiling_scale = 12.5f; // size of one texture tile along u, in mm
// Size of one tile along v, in mm. Anything <= 0 means "whatever tiling_scale is", which is both
// the default and what a layer created before the tile could be non-square meant - so an older
// undo snapshot still comes back as the square tile it was.
float tiling_scale_v = -1.f;
// Mirror the texture along one axis of the tile. Set by dragging a width or height handle back
// through the tile's middle, which is how a side handle behaves in an image editor: the extent
// cannot go negative, so the sign lives here instead. Both default to off, which is what every
// layer written before this meant.
bool mirror_u = false;
bool mirror_v = false;
float rotation_deg = 0.f;
Vec2f offset = Vec2f::Zero();
bool invert = false;
@@ -362,7 +372,8 @@ struct TextureDisplacementLayer
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled);
lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, tiling_scale_v,
mirror_u, mirror_v);
}
template<class Archive> void load(Archive &ar)
{
@@ -374,7 +385,7 @@ struct TextureDisplacementLayer
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix, color_enabled);
view_project_matrix, color_enabled, tiling_scale_v, mirror_u, mirror_v);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
@@ -383,6 +394,25 @@ struct TextureDisplacementLayer
};
// The layer's tile extents in mm, u first. The v extent falls back to the u one whenever
// tiling_scale_v is unset, so "unset means square" is written down once and every consumer - the
// bake, the preview shaders, the UV pane and the placement gizmo - agrees on what a layer with no
// v extent means.
inline Vec2f layer_tile_mm(const TextureDisplacementLayer &layer)
{
const float su = (layer.tiling_scale > 1e-6f) ? layer.tiling_scale : 1.f;
return Vec2f(su, (layer.tiling_scale_v > 1e-6f) ? layer.tiling_scale_v : su);
}
// +1 or -1 per axis, for the layer's mirroring. A separate factor from the extents rather than a
// signed extent: the extents are a size the panel shows and the refinement measures texels against,
// and letting one of them go negative would mean every one of those places had to remember to take
// the magnitude.
inline Vec2f layer_mirror_sign(const TextureDisplacementLayer &layer)
{
return Vec2f(layer.mirror_u ? -1.f : 1.f, layer.mirror_v ? -1.f : 1.f);
}
// Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next
// to texture_displacement_layers and consumed by build_texture_displacement().
struct TextureDisplacementOptions
@@ -668,14 +698,19 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// projection method, without going through project_texture_displacement_uv()'s own dispatch
// (which only knows how to compute the *analytic* methods from a single vertex + normal).
// `aspect` is the height map's width / height. It scales the v axis so a non-square image is not
// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is
// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the
// square case and leaves the coordinate exactly as it always was.
// squeezed into a square tile: the tile is `layer_tile_mm().y() * height / width` mm along v, which
// keeps texels square. 1 (the default) is the square case and leaves the coordinate exactly as it
// always was.
//
// The tile extents divide *after* the rotation, not before. With one extent that made no difference
// (R is linear, so R(p / s) == R(p) / s), but with two it is what keeps a non-square tile a rotated
// rectangle on the surface instead of a sheared parallelogram. The layer's mirroring is a sign on
// that same division.
//
// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tile extents (mm
// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for
// the image's proportions) applies, and only the rotation and offset are left.
// the image's proportions) applies, and only the rotation, the mirroring and the offset are left.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f,
bool normalized_uv = false);
@@ -1047,6 +1082,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume);
// The frame the bake and the previews project the texture in: `volume_to_world` with its translation
// removed, i.e. world orientation and scale about the volume's own origin. See build_texture_displacement().
// The rotation that carries a world direction into the texture's frame, i.e. the inverse of the
// placement's own rotation. Exposed because the preview shaders rebuild the frame themselves from a
// world position and have to turn it the same way the bake does, or the two disagree.
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world);
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world);
// Area-weighted vertex normals of `its` - the directions the bake both projects and displaces along.
-5
View File
@@ -689,8 +689,6 @@ 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
@@ -714,9 +712,6 @@ 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
+72 -260
View File
@@ -85,96 +85,6 @@ static std::string float_to_string_with_precision(float value, int precision = 3
return stream.str();
}
static std::string colour_to_ams_string(const wxColour& color)
{
char col_buf[10];
sprintf(col_buf, "%02X%02X%02X%02X", (int)color.Red(), (int)color.Green(), (int)color.Blue(), (int)color.Alpha());
return col_buf;
}
static wxColour mix_colour(const wxColour& left, const wxColour& right, double ratio)
{
ratio = std::max(0.0, std::min(1.0, ratio));
auto mix_channel = [ratio](unsigned char l, unsigned char r) {
return static_cast<unsigned char>(std::round(l + (r - l) * ratio));
};
return wxColour(mix_channel(left.Red(), right.Red()),
mix_channel(left.Green(), right.Green()),
mix_channel(left.Blue(), right.Blue()),
mix_channel(left.Alpha(), right.Alpha()));
}
static std::vector<ColorPickerPopup::ColorItem> collect_ams_color_items(DevFilaSystem* fila_system)
{
std::vector<ColorPickerPopup::ColorItem> items;
if (!fila_system) return items;
for (const auto& ams_pair : fila_system->GetAmsList()) {
DevAms* ams = ams_pair.second;
if (!ams) continue;
for (const auto& tray_pair : ams->GetTrays()) {
DevAmsTray* tray = tray_pair.second;
if (!tray || !tray->is_tray_info_ready()) continue;
ColorPickerPopup::ColorItem item;
for (const std::string& col : tray->cols) {
item.colors.emplace_back(DevAmsTray::decode_color(col));
}
if (item.colors.empty() && !tray->color.empty()) {
item.colors.emplace_back(DevAmsTray::decode_color(tray->color));
}
if (item.colors.empty()) continue;
item.ctype = item.colors.size() > 1 ? tray->ctype : 2;
items.emplace_back(std::move(item));
}
}
return items;
}
static std::vector<ColorPickerPopup::ColorItem> collect_preset_color_items(const std::string& filament_id)
{
std::vector<ColorPickerPopup::ColorItem> items;
if (filament_id.empty()) return items;
auto* clr_query = GUI::wxGetApp().get_filament_color_code_query();
if (!clr_query) return items;
auto* agent = wxGetApp().getAgent();
const std::string catalog_id = agent ? agent->from_orca_filament_id(filament_id) : filament_id;
FilamentColorCodes* color_codes = clr_query->GetFilaInfoMap(wxString::FromUTF8(catalog_id));
if (!color_codes || !color_codes->GetFilamentColor2CodeMap()) return items;
std::set<std::string> seen;
for (const auto& color_pair : *color_codes->GetFilamentColor2CodeMap()) {
const FilamentColor& fila_color = color_pair.first;
FilamentColorCode* color_code = color_pair.second;
if (!color_code || fila_color.GetColors().empty()) continue;
ColorPickerPopup::ColorItem item;
item.ctype = fila_color.ColorCount() < 2 ? 2 :
(fila_color.m_color_type == FilamentColor::ColorType::GRADIENT_CLR ? 0 : 1);
item.name = color_code->GetFilaColorName();
std::string key = std::to_string(item.ctype);
for (const wxColour& color : fila_color.GetColors()) {
item.colors.emplace_back(color);
char rgba[9];
std::snprintf(rgba, sizeof(rgba), "%02X%02X%02X%02X",
static_cast<unsigned>(color.Red()), static_cast<unsigned>(color.Green()),
static_cast<unsigned>(color.Blue()), static_cast<unsigned>(color.Alpha()));
key += "|" + std::string(rgba);
}
if (item.colors.empty() || !seen.insert(key).second) continue;
items.emplace_back(std::move(item));
}
return items;
}
AMSMaterialsSetting::AMSMaterialsSetting(wxWindow *parent, wxWindowID id)
: DPIDialog(parent, id, _L("AMS Materials Setting"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX)
, m_color_picker_popup(ColorPickerPopup(this))
@@ -830,19 +740,8 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
nozzle_temp_min.ToLong(&nozzle_temp_min_int);
nozzle_temp_max.ToLong(&nozzle_temp_max_int);
wxColour color = m_clr_picker->m_colour;
std::string tray_color = colour_to_ams_string(color);
std::vector<std::string> tray_colors;
int tray_ctype = 2;
// Only firmware advertising fun2[23] receives the manual multi-color fields.
if (obj->is_support_filament_manual_multi_color) {
for (const wxColour& selected_color : m_clr_picker->m_cols) {
tray_colors.emplace_back(colour_to_ams_string(selected_color));
}
if (tray_colors.empty()) {
tray_colors.emplace_back(tray_color);
}
tray_ctype = tray_colors.size() > 1 ? m_clr_picker->ctype : 2;
}
char col_buf[10];
sprintf(col_buf, "%02X%02X%02X%02X", (int)color.Red(), (int)color.Green(), (int)color.Blue(), (int)color.Alpha());
if (ams_filament_id.empty() || nozzle_temp_min.empty() || nozzle_temp_max.empty() || m_filament_type.empty()) {
BOOST_LOG_TRIVIAL(trace) << "Invalid Setting id";
@@ -861,33 +760,13 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
<< ", tray_info_idx (filament_id) = " << ams_filament_id
<< ", setting_id = " << ams_setting_id
<< ", tray_type = " << m_filament_type
<< ", tray_color = " << tray_color
<< ", tray_color = " << col_buf
<< ", nozzle_temp_min = " << nozzle_temp_min_int
<< ", nozzle_temp_max = " << nozzle_temp_max_int;
// set filament
if (m_is_third) {
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, tray_color, m_filament_type, nozzle_temp_min_int, nozzle_temp_max_int,
tray_colors, tray_ctype);
// Refresh the edited tray immediately while its MQTT acknowledgement is pending.
DevAmsTray* tray = nullptr;
if (is_virtual_tray()) {
for (auto& virtual_tray : obj->vt_slot) {
if (virtual_tray.id == std::to_string(ams_id)) {
tray = &virtual_tray;
break;
}
}
} else {
tray = obj->GetFilaSystem()->GetAmsTray(std::to_string(ams_id), std::to_string(slot_id));
}
if (tray) {
tray->color = tray_color;
tray->cols = tray_colors;
tray->ctype = tray_ctype;
tray->set_hold_count();
}
obj->command_ams_filament_settings(ams_id, slot_id, ams_filament_id, ams_setting_id, std::string(col_buf), m_filament_type, nozzle_temp_min_int, nozzle_temp_max_int);
}
//reset param
@@ -1044,15 +923,8 @@ void AMSMaterialsSetting::set_ctype(int ctype)
void AMSMaterialsSetting::on_picker_color(wxCommandEvent& event)
{
std::vector<wxColour> colors = m_color_picker_popup.get_selected_colours();
if (colors.empty()) {
unsigned int color_num = event.GetInt();
colors.emplace_back(color_num >> 24 & 0xFF, color_num >> 16 & 0xFF, color_num >> 8 & 0xFF, color_num & 0xFF);
}
set_ctype(m_color_picker_popup.get_selected_ctype());
set_color(colors.front());
set_colors(colors);
unsigned int color_num = event.GetInt();
set_color(wxColour(color_num>>24&0xFF, color_num>>16&0xFF, color_num>>8&0xFF, color_num&0xFF));
}
void AMSMaterialsSetting::on_clr_picker(wxMouseEvent &event)
@@ -1067,12 +939,14 @@ void AMSMaterialsSetting::on_clr_picker(wxMouseEvent &event)
}
}
m_color_picker_popup.set_ams_colours(collect_ams_color_items(obj->GetFilaSystem().get()));
m_color_picker_popup.set_preset_colours(collect_preset_color_items(ams_filament_id));
m_color_picker_popup.set_def_colour(m_clr_picker->m_colour, m_clr_picker->m_cols, m_clr_picker->ctype);
std::vector<wxColour> ams_colors;
obj->GetFilaSystem()->CollectAmsColors(ams_colors);
wxPoint img_pos = m_clr_picker->ClientToScreen(wxPoint(0, 0));
wxPoint popup_pos(img_pos.x - m_color_picker_popup.GetSize().x - FromDIP(95), img_pos.y - FromDIP(65));
m_color_picker_popup.Position(popup_pos, wxSize(0, 0));
m_color_picker_popup.set_ams_colours(ams_colors);
m_color_picker_popup.set_def_colour(m_clr_picker->m_colour);
m_color_picker_popup.Popup();
}
@@ -1861,46 +1735,7 @@ void ColorPicker::doRender(wxDC& dc)
auto radius = m_show_full ? size.x / 2 - FromDIP(1) : size.x / 2;
if (m_selected) radius -= FromDIP(1);
if (m_show_full && m_cols.size() > 1) {
if (ctype == 0) {
const double center_x = size.x / 2.0;
const double center_y = size.y / 2.0;
const double draw_radius = radius;
dc.SetPen(*wxTRANSPARENT_PEN);
for (int x = 0; x < size.x; ++x) {
const double dx = x + 0.5 - center_x;
if (std::abs(dx) > draw_radius) continue;
const double half_height = std::sqrt(std::max(0.0, draw_radius * draw_radius - dx * dx));
const int top = std::max(0, static_cast<int>(std::ceil(center_y - half_height)));
const int bottom = std::min(size.y - 1, static_cast<int>(std::floor(center_y + half_height)));
const double pos = size.x > 1 ? static_cast<double>(x) / (size.x - 1) : 0.0;
const double scaled = pos * (m_cols.size() - 1);
const size_t idx = std::min(static_cast<size_t>(scaled), m_cols.size() - 2);
const double ratio = scaled - idx;
if (top <= bottom) {
dc.SetBrush(wxBrush(mix_colour(m_cols[idx], m_cols[idx + 1], ratio)));
dc.DrawRectangle(x, top, 1, bottom - top + 1);
}
}
}
else {
float ev_angle = 360.0 / m_cols.size();
const float overlap = 2.0f;
wxPoint center(size.x / 2, size.y / 2);
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(m_cols.front()));
dc.DrawCircle(center.x, center.y, radius);
for (int i = 0; i < m_cols.size(); i++) {
dc.SetBrush(m_cols[i]);
float startAngle = 270.0f + i * ev_angle;
float endAngle = startAngle + ev_angle;
dc.DrawEllipticArc(center.x - radius, center.y - radius, 2 * radius, 2 * radius, startAngle - overlap, endAngle + overlap);
}
}
}
else if (alpha == 0) {
if (alpha == 0) {
wxSize bmp_size = m_bitmap_transparent_def.GetBmpSize();
int center_x = (size.x - bmp_size.x) / 2;
int center_y = (size.y - bmp_size.y) / 2;
@@ -1942,6 +1777,52 @@ void ColorPicker::doRender(wxDC& dc)
dc.SetPen(wxPen(wxColour("#6B6B6B")));
dc.SetBrush(*wxTRANSPARENT_BRUSH);
dc.DrawCircle(size.x / 2, size.y / 2, radius);
if (m_cols.size() > 1) {
if (ctype == 0) {
int left = FromDIP(0);
float total_width = size.x;
int gwidth = std::round(total_width / (m_cols.size() - 1));
for (int i = 0; i < m_cols.size() - 1; i++) {
if ((left + gwidth) > (size.x)) {
gwidth = size.x - left;
}
auto rect = wxRect(left, 0, gwidth, size.y);
dc.GradientFillLinear(rect, m_cols[i], m_cols[i + 1], wxEAST);
left += gwidth;
}
if (wxGetApp().dark_mode()) {
dc.DrawBitmap(m_bitmap_border_dark, wxPoint(0, 0));
}
else {
dc.DrawBitmap(m_bitmap_border, wxPoint(0, 0));
}
}
else {
float ev_angle = 360.0 / m_cols.size();
float startAngle = 270.0;
float endAngle = 270.0;
dc.SetPen(*wxTRANSPARENT_PEN);
for (int i = 0; i < m_cols.size(); i++) {
dc.SetBrush(m_cols[i]);
endAngle += ev_angle;
endAngle = endAngle > 360.0 ? endAngle - 360.0 : endAngle;
wxPoint center(size.x / 2, size.y / 2);
dc.DrawEllipticArc(center.x - radius, center.y - radius, 2 * radius, 2 * radius, startAngle, endAngle);
startAngle += ev_angle;
startAngle = startAngle > 360.0 ? startAngle - 360.0 : startAngle;
}
if (wxGetApp().dark_mode()) {
dc.DrawBitmap(m_bitmap_border_dark, wxPoint(0, 0));
}
else {
dc.DrawBitmap(m_bitmap_border, wxPoint(0, 0));
}
}
}
}
if (m_is_empty) {
@@ -2010,21 +1891,19 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_ams_fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
//other
m_other_fg_sizer = new wxFlexGridSizer(0, 8, 0, 0);
m_other_fg_sizer->SetFlexibleDirection(wxBOTH);
m_other_fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
wxFlexGridSizer* fg_sizer;
fg_sizer = new wxFlexGridSizer(0, 8, 0, 0);
fg_sizer->SetFlexibleDirection(wxBOTH);
fg_sizer->SetNonFlexibleGrowMode(wxFLEX_GROWMODE_SPECIFIED);
for (wxColour col : m_def_colors) {
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(col);
cp->set_colors({col});
cp->ctype = 2;
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
m_color_pickers.push_back(cp);
m_default_color_pickers.push_back(cp);
m_other_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour);
@@ -2091,7 +1970,7 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_sizer_box->Add(m_sizer_ams, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_ams_fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_sizer_other, 1, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_other_fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(fg_sizer, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_sizer_custom, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(10));
m_sizer_box->Add(m_custom_cp, 0, wxEXPAND|wxLEFT|wxRIGHT, FromDIP(16));
m_sizer_box->Add(0, 0, 0, wxTOP, FromDIP(10));
@@ -2102,7 +1981,7 @@ ColorPickerPopup::ColorPickerPopup(wxWindow* parent)
m_def_color_box->Fit();
m_sizer_main->Add(m_def_color_box, 0, wxALL | wxEXPAND, 10);
SetSizerAndFit(m_sizer_main);
SetSizer(m_sizer_main);
Layout();
Fit();
@@ -2140,15 +2019,13 @@ void ColorPickerPopup::on_custom_clr_picker(wxMouseEvent& /*event*/)
wxPostEvent(GetParent(), evt);
}
void ColorPickerPopup::set_ams_colours(const std::vector<ColorItem>& ams)
void ColorPickerPopup::set_ams_colours(std::vector<wxColour> ams)
{
m_ams_fg_sizer->Clear(false);
if (m_ams_color_pickers.size() > 0) {
for (ColorPicker* col_pick:m_ams_color_pickers) {
std::vector<ColorPicker*>::iterator iter = find(m_color_pickers.begin(), m_color_pickers.end(), col_pick);
if (iter != m_color_pickers.end()) {
col_pick->Hide();
col_pick->Destroy();
m_color_pickers.erase(iter);
}
@@ -2158,22 +2035,17 @@ void ColorPickerPopup::set_ams_colours(const std::vector<ColorItem>& ams)
}
m_ams_color_items = ams;
for (const ColorItem& item : m_ams_color_items) {
if (item.colors.empty()) continue;
m_ams_colors = ams;
for (wxColour col : m_ams_colors) {
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(item.colors.front());
cp->set_colors(item.colors);
cp->ctype = item.colors.size() > 1 ? item.ctype : 2;
cp->set_show_full(true);
cp->set_color(col);
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
m_color_pickers.push_back(cp);
m_ams_color_pickers.push_back(cp);
m_ams_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour, cp->m_cols, cp->ctype);
set_def_colour(cp->m_colour);
wxCommandEvent evt(EVT_SELECTED_COLOR);
unsigned long g_col = ((cp->m_colour.Red() & 0xff) << 24) + ((cp->m_colour.Green() & 0xff) << 16) + ((cp->m_colour.Blue() & 0xff) << 8) + (cp->m_colour.Alpha() & 0xff);
@@ -2186,68 +2058,9 @@ void ColorPickerPopup::set_ams_colours(const std::vector<ColorItem>& ams)
Fit();
}
void ColorPickerPopup::set_preset_colours(const std::vector<ColorItem>& preset_colors)
{
m_other_fg_sizer->Clear(false);
if (!m_preset_color_pickers.empty()) {
for (ColorPicker* col_pick : m_preset_color_pickers) {
auto iter = std::find(m_color_pickers.begin(), m_color_pickers.end(), col_pick);
if (iter != m_color_pickers.end()) {
col_pick->Hide();
col_pick->Destroy();
m_color_pickers.erase(iter);
}
}
m_preset_color_pickers.clear();
}
for (const ColorItem& item : preset_colors) {
if (item.colors.empty()) continue;
auto cp = new ColorPicker(m_def_color_box, wxID_ANY, wxDefaultPosition, wxDefaultSize);
cp->set_color(item.colors.front());
cp->set_colors(item.colors);
cp->ctype = item.colors.size() > 1 ? item.ctype : 2;
cp->set_show_full(true);
cp->set_selected(false);
cp->SetBackgroundColour(StateColor::darkModeColorFor(wxColour(238,238,238)));
if (!item.name.empty()) {
cp->SetToolTip(item.name);
}
m_color_pickers.push_back(cp);
m_preset_color_pickers.push_back(cp);
cp->Bind(wxEVT_LEFT_DOWN, [this, cp](auto& e) {
set_def_colour(cp->m_colour, cp->m_cols, cp->ctype);
wxCommandEvent evt(EVT_SELECTED_COLOR);
unsigned long g_col = ((cp->m_colour.Red() & 0xff) << 24) + ((cp->m_colour.Green() & 0xff) << 16) + ((cp->m_colour.Blue() & 0xff) << 8) + (cp->m_colour.Alpha() & 0xff);
evt.SetInt(g_col);
wxPostEvent(GetParent(), evt);
});
}
const bool show_default_colours = m_preset_color_pickers.empty();
for (ColorPicker* cp : m_default_color_pickers) {
cp->Show(show_default_colours);
}
for (ColorPicker* cp : (show_default_colours ? m_default_color_pickers : m_preset_color_pickers)) {
m_other_fg_sizer->Add(cp, 0, wxALL, FromDIP(3));
}
m_other_fg_sizer->Layout();
Layout();
Fit();
}
void ColorPickerPopup::set_def_colour(wxColour col, std::vector<wxColour> cols, int ctype)
void ColorPickerPopup::set_def_colour(wxColour col)
{
m_def_col = col;
if (cols.empty()) {
cols.push_back(col);
}
m_def_cols = cols;
m_def_ctype = m_def_cols.size() > 1 ? ctype : 2;
for (ColorPicker* cp : m_color_pickers) {
if (cp->m_selected) {
@@ -2256,8 +2069,7 @@ void ColorPickerPopup::set_def_colour(wxColour col, std::vector<wxColour> cols,
}
for (ColorPicker* cp : m_color_pickers) {
if (!cp->IsShown()) continue;
if (cp->ctype == m_def_ctype && cp->m_cols == m_def_cols) {
if (cp->m_colour == m_def_col) {
cp->set_selected(true);
break;
}
+4 -19
View File
@@ -82,22 +82,14 @@ public:
class ColorPickerPopup : public PopupWindow
{
public:
struct ColorItem
{
std::vector<wxColour> colors;
int ctype = 2;
wxString name;
};
ScalableBitmap m_ts_bitmap_custom;
wxStaticBitmap* m_ts_stbitmap_custom;
StaticBox* m_custom_cp;
StaticBox* m_def_color_box;
wxFlexGridSizer* m_ams_fg_sizer;
wxFlexGridSizer* m_other_fg_sizer;
wxColour m_def_col;
std::vector<wxColour> m_def_colors;
std::vector<ColorItem> m_ams_color_items;
std::vector<wxColour> m_ams_colors;
std::vector<ColorPicker*> m_color_pickers;
std::vector<ColorPicker*> m_ams_color_pickers;
@@ -105,20 +97,13 @@ public:
ColorPickerPopup(wxWindow* parent);
~ColorPickerPopup() {};
void on_custom_clr_picker(wxMouseEvent& event);
void set_ams_colours(const std::vector<ColorItem>& ams);
void set_preset_colours(const std::vector<ColorItem>& preset_colors);
void set_def_colour(wxColour col, std::vector<wxColour> cols = {}, int ctype = 2);
const std::vector<wxColour>& get_selected_colours() const { return m_def_cols; }
int get_selected_ctype() const { return m_def_ctype; }
void set_ams_colours(std::vector<wxColour> ams);
void set_def_colour(wxColour col);
void paintEvent(wxPaintEvent& evt);
virtual void OnDismiss() wxOVERRIDE;
virtual bool ProcessLeftDown(wxMouseEvent& event) wxOVERRIDE;
private:
std::vector<ColorPicker*> m_default_color_pickers;
std::vector<ColorPicker*> m_preset_color_pickers;
std::vector<wxColour> m_def_cols;
int m_def_ctype = 2;
public:
};
+1 -3
View File
@@ -747,9 +747,7 @@ Print::ApplyStatus BackgroundSlicingProcess::apply(const Model& model, const Dyn
// Orca: prevent resetting under gcode viewer mode
if (invalidated != PrintBase::APPLY_STATUS_UNCHANGED) {
// Use the app’s plater accessor instead of going through mainframe, otherwise anything during startup that reaches apply()
// (loading presets, config or input files, e.g. from the CLI) therefore dereferences a null `mainframe` and crashes.
const auto plater = GUI::wxGetApp().plater();
const auto plater = GUI::wxGetApp().mainframe->m_plater;
if (plater && plater->only_gcode_mode()) {
invalidated = PrintBase::APPLY_STATUS_UNCHANGED;
}
+1 -7
View File
@@ -1798,8 +1798,7 @@ int MachineObject::command_ams_calibrate(int ams_id)
return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type,
int nozzle_temp_min, int nozzle_temp_max, const std::vector<std::string>& tray_colors, int tray_ctype)
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
{
int tag_tray_id = 0;
int tag_ams_id = ams_id;
@@ -1828,10 +1827,6 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
j["print"]["nozzle_temp_min"] = nozzle_temp_min;
j["print"]["nozzle_temp_max"] = nozzle_temp_max;
j["print"]["tray_type"] = tray_type;
if (!tray_colors.empty()) {
j["print"]["cols"] = tray_colors;
j["print"]["ctype"] = tray_ctype;
}
return this->publish_json(j);
}
@@ -5483,7 +5478,6 @@ void MachineObject::parse_new_info(json print)
is_support_pa_mode = (get_flag_bits_no_border(fun2, 3) == 1);
is_support_remote_dry = (get_flag_bits_no_border(fun2, 5) == 1);
is_support_check_track_switch_match_slice_printer = get_flag_bits_no_border(fun2, 19) == 1;
is_support_filament_manual_multi_color = (get_flag_bits_no_border(fun2, 23) == 1);
if (DevPrinterConfigUtil::support_print_check_firmware_for_tpu_left(printer_type)) {
m_firmware_support_print_tpu_left = get_flag_bits_no_border(fun2, 7) == 1;
+1 -2
View File
@@ -674,7 +674,6 @@ public:
bool is_support_remote_dry = false;
bool is_support_check_track_switch_match_slice_printer{false};
bool is_support_pa_mode{false};
bool is_support_filament_manual_multi_color{false};
std::optional<bool> m_firmware_support_print_tpu_left;
bool installed_upgrade_kit{false};
@@ -805,7 +804,7 @@ public:
int command_ams_switch_filament(bool switch_filament);
int command_ams_air_print_detect(bool air_print_detect);
int command_ams_calibrate(int ams_id);
int command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max, const std::vector<std::string>& tray_colors = {}, int tray_ctype = 2);
int command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max);
int command_ams_select_tray(std::string tray_id);
int command_ams_refresh_rfid(int ams_id, int slot_id);
int command_ams_control(std::string action);
+1 -1
View File
@@ -96,7 +96,7 @@ void FilamentColorCodeQuery::LoadFromLocal()
const auto& fila_color_strs = json_data_item["fila_color"].get<std::vector<wxString>>();
for (const auto& color_str : fila_color_strs) {
if (color_str.size() > 3) /* Skip the value like "#0"*/{
fila_color.AddColor(wxColour(color_str));
fila_color.m_colors.emplace(wxColour(color_str));
}
}
}
-11
View File
@@ -87,17 +87,6 @@ struct FilamentColor
public:
size_t ColorCount() const noexcept { return m_colors.size(); }
// Preserve catalog endpoint order for display while retaining set-based lookup identity.
const std::vector<wxColour>& GetColors() const noexcept { return m_color_list; }
void AddColor(const wxColour& color)
{
if (m_colors.insert(color).second) m_color_list.push_back(color);
}
private:
std::vector<wxColour> m_color_list;
public:
void EndSet(int ctype)
{
+15 -108
View File
@@ -252,11 +252,9 @@
#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>
@@ -335,23 +333,6 @@ 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;
@@ -3082,37 +3063,6 @@ 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()
@@ -5484,7 +5434,12 @@ std::string GUI_App::handle_web_request(std::string cmd)
this->request_open_project({});
}
else if (command_str.compare("homepage_open_cloud") == 0) {
this->open_orca_cloud();
// 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());
});
}
else if (command_str.compare("homepage_close_cloud") == 0) {
CallAfter([]() {
@@ -5633,22 +5588,6 @@ 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 {
@@ -5926,7 +5865,7 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
}*/
}
wxString GUI_App::cloud_webview_handoff(const std::string& next)
wxString GUI_App::cloud_webview_handoff()
{
// 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
@@ -5934,7 +5873,7 @@ wxString GUI_App::cloud_webview_handoff(const std::string& next)
auto orca = get_orca_cloud_agent(m_agent);
if (!orca)
return {};
return from_u8(orca->build_web_session_start_url(next));
return from_u8(orca->build_web_session_start_url());
}
void GUI_App::request_cloud_web_session(const std::string& state)
@@ -6077,7 +6016,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(m_cloud_open_path));
home->set_cloud_mode(true, cloud_webview_handoff());
}
@@ -6924,7 +6863,7 @@ void GUI_App::push_notification(const MachineObject* obj, wxString msg, wxStrin
}
}
bool GUI_App::reload_settings()
void GUI_App::reload_settings()
{
if (preset_bundle && m_agent) {
// Load user's personal presets
@@ -6932,7 +6871,7 @@ bool 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 false;
return;
}
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 {
@@ -7007,30 +6946,13 @@ bool 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)
@@ -7075,9 +6997,6 @@ 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) {
@@ -7844,7 +7763,6 @@ 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).
@@ -7918,16 +7836,8 @@ void GUI_App::start_sync_user_preset(bool with_progress_dlg)
finishFn(ret == 0);
// 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);
});
if (ret == 0 && m_agent && !t.expired())
reload_settings();
// For orca specific syncing
auto orca_agent = std::dynamic_pointer_cast<OrcaCloudServiceAgent>(m_agent->get_cloud_agent());
@@ -7942,11 +7852,8 @@ 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 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) {
// 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)) {
tick_tock = 0;
if (m_agent) {
if (!m_agent->is_user_login()) {
+3 -13
View File
@@ -355,9 +355,6 @@ 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 };
@@ -368,8 +365,6 @@ 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;
@@ -471,9 +466,6 @@ 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();
@@ -580,7 +572,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(const std::string& next = std::string());
wxString cloud_webview_handoff();
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);
@@ -597,8 +589,7 @@ 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; 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.
// reminded; 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);
@@ -609,8 +600,7 @@ 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);
// Returns false when the cloud presets could not be fetched, so nothing was reloaded.
bool reload_settings();
void reload_settings();
void remove_user_presets();
bool maybe_migrate_user_presets_on_login();
File diff suppressed because it is too large Load Diff
@@ -428,10 +428,6 @@ 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);
@@ -512,25 +508,120 @@ private:
// is where the live adjustment is applied. Called whenever the panel moves or reverts it.
void invalidate_adjust_textures();
// "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active
// layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane
// (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's
// own U/V axes that constrain the drag to just that one axis for precise nudging. Anchored to
// the centroid/average-normal of the active layer's current paint patch (see
// libslic3r::compute_layer_paint_anchor()), so nothing is drawn if it has nothing painted yet.
// "Adjust Texture" mode: instead of painting, the active layer's placement is a control lying
// on the model. The gizmo is one tile of the texture in the paint patch's own tangent plane:
// where it sits is the layer's offset, how it is turned is rotation_deg, how big it is is the
// tile extents - so each handle is one of the values it edits rather than a stand-in for it.
// Anchored to the centroid/average-normal of the active layer's paint patch (see
// libslic3r::compute_layer_paint_anchor()), so nothing is drawn until something is painted.
//
// NOTE: the drag direction/sign below is this session's best-effort reasoning about which way
// the texture should appear to move as the handle is dragged - it could not be visually
// confirmed while writing it (no way to render/see pixels in this environment), so it may
// need a one-line sign flip once actually tested.
// Drawn through ImGui's foreground draw list rather than the "flat" shader, which is what gives
// it antialiased sub-pixel strokes, rounded joins, a contrast halo and the numeric readout. The
// cost is that render_adjust_texture_gizmo() has to be called from inside the gizmo's ImGui
// frame (see on_render_input_window()), not from on_render() with the rest of the GL drawing.
// Move is the whole interior, free in both axes. AxisMove is the four arrows, each holding a
// move to one of the tile's axes. EdgeScale is the four edge bars, one tile extent each, and
// CornerScale the four corner grips, which take both extents at once.
//
// The arrows and the bars share the same four rays out of the tile, so they are kept apart by
// distance rather than direction: the bar hugs its edge, then a clear gap, then the arrow well
// outside it. adjust_pick() leans on that - see the order it tests them in.
enum class AdjustHandle { None, Move, AxisMove, EdgeScale, CornerScale, Rotate };
// The gizmo's geometry for one frame, in screen pixels. Worked out once and shared by the
// drawing and the hit test, so the two cannot disagree about where a handle is. Everything
// except the frame itself is a fixed number of pixels, which is what keeps the control equally
// usable on a 5 mm part and a 500 mm one - the old handle scaled with the volume's bounding box
// and so was unusable at both ends.
struct AdjustFrame
{
bool valid = false;
Vec2d center = Vec2d::Zero();
// +u+v, +u-v, -u-v, -u+v. A scale grip sits on each.
std::array<Vec2d, 4> corner{};
// +u, -u, +v, -v: the middle of each edge, and the two ends of the bar that sits just
// outside it. The bar's ends are kept rather than recomputed so the drawing and the hit test
// cannot end up with it in two different places.
std::array<Vec2d, 4> edge_mid{};
std::array<Vec2d, 4> bar_a{};
std::array<Vec2d, 4> bar_b{};
// The axis arrow further out on the same ray: where its shaft starts and where its chevron
// ends. Kept, like the bar's ends, so the drawing and the hit test cannot disagree.
std::array<Vec2d, 4> arm_base{};
std::array<Vec2d, 4> arm_tip{};
std::array<Vec2d, 4> arm_dir{}; // unit, pointing outward from the centre
// A ring is placed off every corner, but only one is drawn and only that one can be picked:
// four marks for a single operation is clutter, and whichever corner the cursor is near is
// the one worth offering. `rotate_shown` says which, and see update_rotate_corner() for why
// it does not simply track the nearest.
std::array<Vec2d, 4> rotate_at{};
int rotate_shown = 0;
// The tile projects to less than ADJUST_MIN_TILE_PX across, so the frame was drawn at that
// floor instead. It is dashed to say so, and the readout still gives the true millimetres.
bool clamped = false;
// What the frame was built from, in mesh-local mm: the tile's own (rotated) edge directions
// and its extents. The drag arithmetic reuses these rather than recomputing them, so it
// cannot end up working against a different tile than the one on screen.
Vec3f e_u = Vec3f::UnitX(), e_v = Vec3f::UnitY();
Vec2f tile_mm = Vec2f::Ones(); // (u, v) as stored on the layer
Vec2f half_planar = Vec2f::Ones(); // half extents in planar mm, v already aspect-corrected
Vec2f center_planar = Vec2f::Zero();
};
bool update_adjust_anchor(); // recomputes m_adjust_anchor_pos/normal; false if nothing painted
// The selected instance's volume-to-world transform, which the bake frame is derived from.
bool adjust_volume_to_world(Transform3d &out) const;
// The anchor, carried from mesh-local into the bake frame. Converted on every use rather than
// stored converted: the object can be moved, rotated or scaled while this mode is open, and a
// stored copy would quietly describe the shape it used to be. The expensive part - finding the
// paint patch's centroid - stays cached in the local members.
bool adjust_anchor_in_frame(Vec3f &pos, Vec3f &normal) const;
// Drops every scrap of live gizmo state. Called from each of the several ways out of the mode -
// the panel's toggle, collapsing the card, switching to seam marking, closing the gizmo - so a
// stale hover highlight or a half-finished drag cannot survive into the next time it is opened.
void end_adjust_texture_mode();
bool on_mouse_adjust_texture(const wxMouseEvent &mouse_event);
void render_adjust_texture_gizmo();
// This frame's gizmo geometry, or an invalid frame when there is nothing to draw.
AdjustFrame adjust_frame() const;
// Which handle is under `mouse`, with `index` set to the arm or corner it belongs to. Tested
// outermost first; see the hit-area board of the design for the order and the radii.
AdjustHandle adjust_pick(const Vec2d &mouse, const AdjustFrame &f, int &index) const;
// Chooses which corner carries the rotate ring. The nearest to the cursor, but it only moves once
// the newcomer is clearly nearer, because without that margin the ring flits between two corners
// every time the cursor wanders along the line equidistant from both. Never moves during a drag.
void update_rotate_corner(const AdjustFrame &f, const Vec2d &mouse);
// Where the tile's centre sits, in planar mm relative to the anchor, for a layer's placement.
// Not the patch centroid: the gizmo *is* the texture's placement, so it has to travel as the
// offset changes - pinning it to the centroid is what made the old handle look broken, the
// texture sliding while the handle stayed put.
static Vec2f adjust_handle_planar(const TextureDisplacementLayer &layer, float aspect);
// The exact inverse: the offset that puts the tile's centre at `planar`. Rotating or resizing
// the tile changes where a given offset places it, so every drag recomputes the offset from the
// centre it wants to keep - which is what makes rotate and scale act *in place* rather than
// swinging the tile around the anchor.
static Vec2f adjust_offset_for_planar(const Vec2f &planar, float rot_deg, const Vec2f &tile_mm,
const Vec2f &mirror_sign, float aspect);
// The mesh-local-to-world transform of the selected instance, the (unrotated) tangent basis and
// the lifted anchor the whole gizmo is built on. False when there is nothing to build on - which
// is the single place that decides the gizmo is not drawable, so the drawing, the hit test and
// the drag cannot disagree about it.
bool adjust_basis(Transform3d &trafo, Vec3f &u_axis, Vec3f &v_axis, Vec3f &base) const;
// Screen-space quad of a tile with the given placement. Used for the pre-drag ghost, which is
// drawn from the captured start placement rather than from the layer, the layer having already
// been moved by then.
bool adjust_quad_for(const Vec2f &center_planar, float rot_deg, const Vec2f &tile_mm,
std::array<Vec2d, 4> &out) const;
// Where the cursor meets the drag plane, in planar mm relative to the anchor. The plane is
// deliberately the *fixed* anchor plane rather than the tile's current position: measuring a
// drag against a plane the drag itself moves is a feedback loop.
bool adjust_planar_under(const Vec2d &mouse, Vec2f &planar) const;
// Draws a small '+'/'-' next to the mouse over the 3D view while painting/selecting, so it is
// obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's
// foreground draw list, so it must be called from inside the gizmo's ImGui frame.
void render_paint_cursor_hint();
// Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// Bake-frame tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// on-canvas maps consistently onto offset whichever face the anchor is on.
void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const;
@@ -1066,8 +1157,6 @@ 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
@@ -1109,15 +1198,12 @@ private:
// The config key behind m_undocked.
static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked";
// 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);
// 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.
float m_panel_scroll_target = 0.f;
float m_panel_scroll_applied = -1.f;
float m_panel_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one
// 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.
@@ -1127,23 +1213,47 @@ private:
// See the "Adjust Texture" block of private methods above.
bool m_adjust_texture_mode = false;
bool m_adjust_anchor_valid = false;
Vec3f m_adjust_anchor_pos = Vec3f::Zero(); // mesh-local
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ(); // mesh-local
// Mesh-local, as compute_layer_paint_anchor() produces them. Everything else about the gizmo
// works in the bake frame - world orientation and scale, origin at the volume's own, which is
// where the texture is projected (texture_displacement_bake_frame()) - so these go through
// adjust_anchor_in_frame() at the point of use. A tile is so many *printed* millimetres whatever
// the instance's scale, which is why measuring the gizmo in mesh-local millimetres made its tile
// differ from the rendered one by exactly that scale.
Vec3f m_adjust_anchor_pos = Vec3f::Zero();
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ();
// Pan: free drag anywhere on the flat panel, moves offset along both axes. AxisU/AxisV: drag
// the corresponding arrow, moves offset along only that one axis.
enum class AdjustHandle { None, Pan, AxisU, AxisV };
AdjustHandle m_adjust_drag_handle = AdjustHandle::None;
Vec2f m_adjust_drag_start_offset = Vec2f::Zero();
// Anchor-relative planar position (see project_planar()) of the point under the mouse at the
// moment the current drag started; every subsequent frame's delta is measured against this,
// rather than accumulated frame-to-frame, to avoid drift.
Vec2f m_adjust_drag_start_planar = Vec2f::Zero();
// What the cursor is over, and what is being dragged. `index` picks the arm or the corner
// (0..3, in AdjustFrame's order); it is -1 for Move and Rotate, which have only one of each.
// Only ever one of each is set, so two handles can never both look live.
// Which corner currently carries the rotate ring. Persists between frames, which is what gives
// update_rotate_corner() something to apply its margin against.
int m_adjust_rotate_corner = 0;
AdjustHandle m_adjust_hover_kind = AdjustHandle::None;
int m_adjust_hover_index = -1;
AdjustHandle m_adjust_drag_kind = AdjustHandle::None;
int m_adjust_drag_index = -1;
// Lazily-built unit quad (the pan panel) and unit line-with-arrowhead (reused, rotated, for
// both the U and V axis arrows), transformed into place at render time.
GLModel m_adjust_panel_glmodel;
GLModel m_adjust_arrow_glmodel;
// Everything the live drag is measured against, captured once on mouse-down. Every frame's
// delta is taken from these rather than accumulated frame to frame, so a long drag cannot
// drift away from the cursor.
Vec2f m_adjust_drag_start_center = Vec2f::Zero(); // tile centre, planar mm
Vec2f m_adjust_drag_start_grab = Vec2f::Zero(); // the grabbed point, planar mm
// How far the grab landed from the handle's own position, in planar mm. A scale drag reads the
// extents straight off the cursor, so without subtracting this the tile would jump by however
// far inside the generous hit radius the click happened to fall.
Vec2f m_adjust_drag_grab_bias = Vec2f::Zero();
float m_adjust_drag_start_rot = 0.f;
Vec2f m_adjust_drag_start_tile = Vec2f::Ones(); // (u, v) extents as stored
float m_adjust_drag_start_angle = 0.f; // planar angle of the grab, radians
// The mirroring the layer had when the drag began. A width or height handle dragged back through
// the tile's middle toggles its axis against this, rather than against whatever the last frame
// left behind, so dragging back and forth across the middle cannot ratchet the flag.
bool m_adjust_drag_start_mirror_u = false;
bool m_adjust_drag_start_mirror_v = false;
// The live value to put in the readout pill, built by the drag and drawn by the renderer in the
// same frame. Empty when no drag is running, which is also when no pill is drawn.
std::string m_adjust_readout;
std::map<std::string, wxString> m_desc;
+4 -6
View File
@@ -83,13 +83,11 @@ namespace Slic3r::GUI::GLGizmoUtils {
bool clicked = toolbar_circular_button(icon, "##wiki_guide_btn", scale);
if (url && *url && clicked)
wxLaunchDefaultBrowser(wxString::FromUTF8(url));
// ImGuiWrapper::tooltip keeps the text readable on the dark tooltip background whatever text colour the
// host window pushed; a negative wrap position keeps the URL on one line.
if (ImGui::IsItemHovered()){
if(url && *url)
wxGetApp().imgui()->tooltip(_u8L("Wiki Guide") + "\n" + url, -1.f);
ImGui::SetTooltip("%s\n%s", _u8L("Wiki Guide").c_str(), url);
else
wxGetApp().imgui()->tooltip(_u8L("Wiki Guide"), -1.f);
ImGui::SetTooltip("%s", _u8L("Wiki Guide").c_str());
}
return clicked; // for dynamically generated links
}
@@ -103,9 +101,9 @@ namespace Slic3r::GUI::GLGizmoUtils {
wxLaunchDefaultBrowser(wxString::FromUTF8(url));
if (ImGui::IsItemHovered()){
if(url && *url)
wxGetApp().imgui()->tooltip(_u8L("Video Guide") + "\n" + url, -1.f);
ImGui::SetTooltip("%s\n%s", _u8L("Video Guide").c_str(), url);
else
wxGetApp().imgui()->tooltip(_u8L("Video Guide"), -1.f);
ImGui::SetTooltip("%s", _u8L("Video Guide").c_str());
}
return clicked; // for dynamically generated links
}
@@ -857,12 +857,11 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
if (render_combo(imgui_wrapper, "", modes, selection_idx, 0, coord_combo_width)) {
combox_changed = true;
}
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
auto tooltip_str = _L("Coordinate system used for transform actions.");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
// ORCA use TextColored to match axes color
float offset_to_center = (unit_size - ImGui::CalcTextSize("O").x) / 2;
@@ -965,12 +964,11 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
if (render_combo(imgui_wrapper, "", align_modes, align_mode_idx, 0, coord_combo_width))
m_align_to_parent = align_mode_idx + 1 == align_modes.size();
ImGui::PopID();
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
wxString tooltip_str = _L("Reference for alignment");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
const bool dark_mode = wxGetApp().dark_mode();
int current_axis = -1;
@@ -1379,12 +1377,11 @@ void GizmoObjectManipulation::do_render_scale_input_window(ImGuiWrapper* imgui_w
if (render_combo(imgui_wrapper, "", modes, selection_idx, 0, coord_combo_width)) {
combox_changed = true;
}
ImGuiWrapper::pop_combo_style();
// After pop_combo_style(): its light popup background would hide the tooltip's white text.
if (ImGui::IsItemHovered()) {
auto tooltip_str = _L("Coordinate system used for transform actions.");
imgui_wrapper->tooltip(tooltip_str, imgui_wrapper->calc_text_size(tooltip_str).x + 3 * space_size);
}
ImGuiWrapper::pop_combo_style();
// ORCA use TextColored to match axes color
float offset_to_center = (unit_size - ImGui::CalcTextSize("O").x) / 2;
+3 -5
View File
@@ -505,12 +505,10 @@ bool ImGuiWrapper::update_mouse_data(wxMouseEvent& evt)
io.MouseDown[0] = evt.LeftIsDown();
io.MouseDown[1] = evt.RightIsDown();
io.MouseDown[2] = evt.MiddleIsDown();
// 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)
float wheel_delta = static_cast<float>(evt.GetWheelDelta());
if (wheel_delta != 0.0f) {
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;
+25 -75
View File
@@ -1,7 +1,6 @@
#include "TextureLibrary.hpp"
#include <algorithm>
#include <atomic>
#include <boost/filesystem/operations.hpp>
#include <cstddef>
#include <boost/filesystem/directory.hpp>
@@ -22,7 +21,6 @@
#include <boost/system/error_code.hpp>
#include <vector>
#include <wx/app.h>
#include <wx/gdicmn.h>
#include <utility>
#include <wx/image.h>
@@ -38,9 +36,6 @@
#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 {
@@ -55,7 +50,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, TextureSource source, std::vector<TextureLibraryEntry> &out)
void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<TextureLibraryEntry> &out)
{
boost::system::error_code ec;
if (!boost::filesystem::is_directory(dir, ec))
@@ -65,43 +60,12 @@ void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vec
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(), source });
out.push_back({ it->path().stem().string(), it->path().string(), is_user });
}
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
@@ -223,20 +187,27 @@ std::vector<unsigned char> read_file_bytes(const std::string &path)
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
// True if decode_height_texture() can read these bytes, so they can be stored on a layer as-is.
// Anything else is converted on load.
// True if these bytes are a PNG libslic3r can decode, i.e. can be stored on a layer as-is. Mirrors
// exactly what decode_height_texture() accepts: 8-bit grayscale, or 8-bit colour (whose luminance is
// the height and whose RGB is available to colour the model). Anything else is converted on load.
bool is_supported_height_map(const std::vector<unsigned char> &bytes)
{
return !bytes.empty() && png::is_decodable_8bit_png(png::ReadBuf{ bytes.data(), bytes.size() });
if (bytes.empty())
return false;
const png::ReadBuf rbuf{ bytes.data(), bytes.size() };
if (!png::is_png(rbuf))
return false;
png::ImageGreyscale gray;
if (png::decode_png(rbuf, gray) && gray.cols > 0 && gray.rows > 0)
return true;
png::ImageColorscale color;
return png::decode_colored_png(rbuf, color) && color.cols > 0 && color.rows > 0 &&
color.bytes_per_pixel >= 3 &&
color.buf.size() == color.cols * color.rows * size_t(color.bytes_per_pixel);
}
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
@@ -284,8 +255,6 @@ 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);
@@ -300,8 +269,6 @@ 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)
@@ -312,9 +279,8 @@ void store_recent_texture_names(std::vector<std::string> names)
wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value);
}
// 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.
// 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.
void sort_user_textures(std::vector<TextureLibraryEntry> &library)
{
const std::vector<std::string> names = recent_texture_names();
@@ -325,11 +291,8 @@ 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.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,
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(),
[&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); });
}
@@ -348,16 +311,6 @@ 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";
@@ -372,20 +325,17 @@ std::string user_texture_dir()
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
{
const uint64_t generation = texture_library_generation();
if (g_library_scanned && !force_rescan && generation == g_library_scan_generation)
if (g_library_scanned && !force_rescan)
return g_library;
g_library.clear();
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", TextureSource::Shipped, g_library);
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", false, g_library);
const std::string user_dir = user_texture_dir();
if (!user_dir.empty())
scan_dir(boost::filesystem::path(user_dir), TextureSource::User, g_library);
scan_dir(boost::filesystem::path(user_dir), true, g_library);
sort_user_textures(g_library);
scan_plugin_textures(g_library);
g_library_scan_generation = generation;
g_library_scanned = true;
g_library_scanned = true;
return g_library;
}
+11 -31
View File
@@ -1,7 +1,6 @@
#ifndef slic3r_TextureLibrary_hpp_
#define slic3r_TextureLibrary_hpp_
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
@@ -11,45 +10,26 @@
namespace Slic3r::GUI {
// 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.
// One selectable height-map texture in the texture-displacement gizmo's texture picker.
struct TextureLibraryEntry
{
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
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
};
// 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.
// 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.
//
// 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.
// 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.
//
// 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. A rescan also happens on its own when the pack
// registry changes, which is what texture_library_generation() tracks.
// into either folder by hand while the app was running.
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().
+18 -5
View File
@@ -297,9 +297,12 @@ void UVEditorCanvas::set_island_fill_colors(std::vector<ColorRGBA> colors)
Refresh();
}
void UVEditorCanvas::set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored)
void UVEditorCanvas::set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign,
float rotation_deg, bool tile_enabled, bool tile_mirrored)
{
m_tiling_scale = (std::abs(tiling_scale) > 1e-6f) ? tiling_scale : 1.f;
m_tiling_scale_v = (tiling_scale_v > 1e-6f) ? tiling_scale_v : m_tiling_scale;
m_mirror_sign = mirror_sign;
m_rotation_deg = rotation_deg;
m_tile_enabled = tile_enabled;
m_tile_mirrored = tile_mirrored;
@@ -661,11 +664,18 @@ Vec2f UVEditorCanvas::island_centroid(int island) const
Vec2f UVEditorCanvas::uv_delta_to_unwrap(const Vec2f &delta_uv) const
{
// apply_uv_transform() maps unwrap -> uv as uv = R(unwrap / tiling) + offset, so the inverse of
// a *delta* (the translation drops out) is unwrap = tiling * R^-1(delta_uv).
// apply_uv_transform() maps unwrap -> uv as uv = S^-1 R(unwrap) + offset, with S the tile
// extents, so the inverse of a *delta* (the translation drops out) is unwrap = R^-1(S delta_uv).
// The extents therefore multiply *before* the inverse rotation, which is the mirror image of the
// forward order; with a square tile the two are interchangeable, with a rectangular one they are
// not.
const float rad = m_rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad), sn = std::sin(rad);
return Vec2f(delta_uv.x() * cs + delta_uv.y() * sn, -delta_uv.x() * sn + delta_uv.y() * cs) * m_tiling_scale;
// The mirror is its own sign on the same axis, and a negation is its own inverse, so it comes
// back out here exactly as it went in.
const Vec2f scaled(delta_uv.x() * m_tiling_scale * m_mirror_sign.x(),
delta_uv.y() * m_tiling_scale_v * m_mirror_sign.y());
return Vec2f(scaled.x() * cs + scaled.y() * sn, -scaled.x() * sn + scaled.y() * cs);
}
Vec2f UVEditorCanvas::snap_correction(int island) const
@@ -2180,7 +2190,10 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY)
void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s)
{
const wxString name = s.has_layer ? s.layer_name : _L("No layer mapped with Unwrap");
const wxString tile = s.has_layer ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) : wxString();
// A square tile reads as one number; only a genuinely rectangular one is worth two.
const wxString tile = !s.has_layer ? wxString() :
std::abs(s.tile_mm_v - s.tile_mm) < 0.005f ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) :
wxString::Format(_L("%.1f x %.1f mm tile"), s.tile_mm, s.tile_mm_v);
const bool relayout = m_layer_name->GetLabel() != name || m_tile->GetLabel() != tile;
m_layer_name->SetLabel(name);
m_tile->SetLabel(tile);
+10 -4
View File
@@ -91,7 +91,10 @@ public:
// space, back into the unwrap's mm space - which is where a TextureIsland's offset actually
// lives (see apply_uv_transform()). The tile settings come along because the background has to
// repeat exactly the way the height sampler does, or the pane would stop showing what gets baked.
void set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored);
// `tiling_scale_v` is the tile's extent along v; pass <= 0 for a square tile, matching
// layer_tile_mm().
void set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign,
float rotation_deg, bool tile_enabled, bool tile_mirrored);
// Same 8-bit grayscale pixels build_texture_displacement()'s height sampling uses, shown
// beneath the wireframe (expanded to RGBA on upload) so the unwrap can be checked against the
@@ -129,7 +132,8 @@ public:
{
bool has_layer = false; // an active layer mapped with Unwrap
wxString layer_name;
float tile_mm = 0.f;
float tile_mm = 0.f; // tile extent along u
float tile_mm_v = 0.f; // along v; equal to tile_mm for a square tile
std::vector<unsigned char> thumbnail_rgb; // thumbnail_px square, 3 bytes per pixel; empty for none
int thumbnail_px = 0;
float seam_angle_deg = 40.f;
@@ -292,8 +296,10 @@ private:
GLModel m_grid_glmodel;
float m_grid_step = 0.f; // the UV step m_grid_glmodel was built for; 0 = not built
float m_tiling_scale = 1.f;
float m_rotation_deg = 0.f;
float m_tiling_scale = 1.f;
float m_tiling_scale_v = 1.f; // already resolved: never the "unset" sentinel, see set_uv_transform()
Vec2f m_mirror_sign = Vec2f::Ones(); // +1 or -1 per axis, see layer_mirror_sign()
float m_rotation_deg = 0.f;
bool m_tile_enabled = true;
bool m_tile_mirrored = false;
bool m_snap_enabled = false;
+1 -9
View File
@@ -537,7 +537,7 @@ void WebViewPanel::cloud_signed_out(const wxString& signout_url)
}
}
void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString& page_url)
void WebViewPanel::set_cloud_mode(bool on, const wxString& url)
{
if (on) {
// load_cloud_url records the origin from whatever it loads, including the plain cloud
@@ -546,10 +546,6 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString&
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
@@ -560,10 +556,6 @@ void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString&
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.
+1 -4
View File
@@ -118,10 +118,7 @@ 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.
// `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());
void set_cloud_mode(bool on, const wxString& url = wxString());
// True while the OrcaCloud pane exists and is visible.
bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); }
+33 -58
View File
@@ -38,8 +38,6 @@
#include <cstddef>
#include <wx/simplebook.h>
#include <wx/dcgraph.h>
#include <wx/graphics.h>
#include <memory>
#include <boost/log/trivial.hpp>
#include <wx/timer.h>
@@ -1411,17 +1409,22 @@ void AMSLib::render_lite_lib(wxDC& dc)
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
if (m_info.material_cols.size() > 1) {
wxRect color_rect(FromDIP(10), FromDIP(10), libsize.x - FromDIP(18), libsize.y - FromDIP(18));
int left = FromDIP(10);
int gwidth = std::round(libsize.x / (m_info.material_cols.size() - 1));
//gradient
if (m_info.ctype == 0) {
fill_gradient_rect_east(dc, color_rect, m_info.material_cols.front(), m_info.material_cols.back());
for (int i = 0; i < m_info.material_cols.size() - 1; i++) {
auto rect = wxRect(left, FromDIP(10), libsize.x - FromDIP(18), libsize.y - FromDIP(18));
fill_gradient_rect_east(dc, rect, m_info.material_cols[i], m_info.material_cols[i + 1]);
left += gwidth;
}
}
else {
int cols_size = m_info.material_cols.size();
for (int i = 0; i < cols_size; i++) {
dc.SetBrush(wxBrush(m_info.material_cols[i]));
int x = color_rect.x + color_rect.width * i / cols_size;
int next_x = color_rect.x + color_rect.width * (i + 1) / cols_size;
dc.DrawRectangle(x, color_rect.y, next_x - x, color_rect.height);
float x = FromDIP(10) + ((float)libsize.x - FromDIP(18)) * i / cols_size;
dc.DrawRoundedRectangle(x, FromDIP(10), ((float)libsize.x - FromDIP(17)) / cols_size, libsize.y - FromDIP(20), 0);
}
dc.SetBrush(wxBrush(tmp_lib_colour));
}
@@ -1513,27 +1516,7 @@ void AMSLib::render_generic_lib(wxDC &dc)
int top = height - curr_height;
if (m_ams_model == EXT_AMS){
wxRect color_rect(FromDIP(1), FromDIP(1), size.x - FromDIP(2), size.y - FromDIP(1));
if ((m_info.ctype == 0 || m_info.ctype == 1) && m_info.material_cols.size() > 1 && alpha != 0) {
if (m_info.ctype == 0) {
fill_gradient_rect_east(dc, color_rect, m_info.material_cols.front(), m_info.material_cols.back());
}
else {
int cols_size = static_cast<int>(m_info.material_cols.size());
for (int i = 0; i < cols_size; i++) {
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(m_info.material_cols[i]));
int x = color_rect.x + color_rect.width * i / cols_size;
int next_x = color_rect.x + color_rect.width * (i + 1) / cols_size;
dc.DrawRectangle(x, color_rect.y, next_x - x, color_rect.height);
}
}
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(tmp_lib_colour));
}
else {
dc.DrawRoundedRectangle(color_rect.x, color_rect.y, color_rect.width, color_rect.height, m_radius - 1);
}
dc.DrawRoundedRectangle(FromDIP(1), FromDIP(1), size.x - FromDIP(2), size.y - FromDIP(1), m_radius - 1);
if (alpha == 0) {
dc.DrawBitmap(m_bitmap_transparent_def.bmp(), FromDIP(2), FromDIP(2));
}
@@ -2861,38 +2844,30 @@ void AMSPreview::doRender(wxDC &dc)
//dc.DrawRoundedRectangle((size.x - rec_size.x) / 2, (size.y - rec_size.y) / 2, rec_size.x, rec_size.y, FromDIP(2));
if (iter.material_cols.size() > 1)
{
const wxSize swatch_size = m_ams_item_type == AMSModel::N3S_AMS ? AMS_ITEM_CUBE_SIZE : AMS_ITEM_CUBE_SIZE2;
const wxRect rect((size.x - swatch_size.x) / 2, (size.y - swatch_size.y) / 2, swatch_size.x, swatch_size.y);
const int radius = m_ams_item_type == AMSModel::N3S_AMS ? 0 : FromDIP(3);
std::unique_ptr<wxGraphicsContext> owned_gc;
wxGraphicsContext* gc = dc.GetGraphicsContext();
if (!gc) {
owned_gc.reset(wxGraphicsContext::CreateFromUnknownDC(dc));
gc = owned_gc.get();
}
if (gc) {
wxGraphicsGradientStops stops(iter.material_cols.front(), iter.material_cols.back());
const size_t count = iter.material_cols.size();
if (iter.ctype == 0) {
for (size_t i = 1; i + 1 < count; ++i)
stops.Add(iter.material_cols[i], static_cast<float>(i) / (count - 1));
} else {
// Two stops at each boundary keep multicolour bands distinct.
for (size_t i = 1; i < count; ++i) {
const float position = static_cast<float>(i) / count;
stops.Add(iter.material_cols[i - 1], position);
stops.Add(iter.material_cols[i], position);
int fleft = (size.x - AMS_ITEM_CUBE_SIZE.x) / 2;
float total_width = AMS_ITEM_CUBE_SIZE.x;
int gwidth = (total_width / (iter.material_cols.size()));
if (iter.ctype == 0) {
for (int i = 0; i < iter.material_cols.size() - 1; i++) {
if ((fleft + gwidth) > (AMS_ITEM_CUBE_SIZE.x)) {
gwidth = (fleft + AMS_ITEM_CUBE_SIZE.x) - fleft;
}
auto rect = wxRect(fleft, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, gwidth, AMS_ITEM_CUBE_SIZE.y);
fill_gradient_rect_east(dc, rect, iter.material_cols[i], iter.material_cols[i + 1]);
fleft += gwidth;
}
}
else {
int cols_size = iter.material_cols.size();
for (int i = 0; i < cols_size; i++) {
dc.SetPen(wxPen(*wxTRANSPARENT_PEN));
dc.SetBrush(wxBrush(iter.material_cols[i]));
float x = (size.x - AMS_ITEM_CUBE_SIZE.x) / 2 + total_width * i / cols_size;
dc.DrawRectangle(x, (size.y - AMS_ITEM_CUBE_SIZE.y) / 2, total_width / cols_size, AMS_ITEM_CUBE_SIZE.y);
}
gc->PushState();
gc->SetPen(*wxTRANSPARENT_PEN);
gc->SetBrush(gc->CreateLinearGradientBrush(rect.x, rect.y, rect.x + rect.width, rect.y, stops));
gc->DrawRoundedRectangle(rect.x, rect.y, rect.width, rect.height, radius);
gc->PopState();
} else {
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(iter.material_colour);
dc.DrawRoundedRectangle(rect, radius);
}
}
else {
-27
View File
@@ -74,10 +74,6 @@ 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
@@ -431,29 +427,6 @@ 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()
{
-10
View File
@@ -34,16 +34,6 @@ 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();
};
-1
View File
@@ -529,7 +529,6 @@ 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");
}
}
-73
View File
@@ -9,7 +9,6 @@
#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"
@@ -1256,76 +1255,4 @@ 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
-6
View File
@@ -176,12 +176,6 @@ 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.
-32
View File
@@ -9,7 +9,6 @@
#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"
@@ -388,37 +387,6 @@ 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) {
-3
View File
@@ -25,7 +25,6 @@
#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>
@@ -375,7 +374,6 @@ 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();
@@ -529,7 +527,6 @@ 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";
+1 -5
View File
@@ -14,7 +14,7 @@
namespace Slic3r {
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, ResourceTexture, Unknown };
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
struct PluginCapabilityId
{
@@ -48,7 +48,6 @@ 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";
}
}
@@ -64,7 +63,6 @@ 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";
}
}
@@ -94,8 +92,6 @@ 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;
}
@@ -1,115 +0,0 @@
#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
@@ -1,50 +0,0 @@
#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
@@ -1,32 +0,0 @@
#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
@@ -1,28 +0,0 @@
#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
@@ -1,30 +0,0 @@
#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
-2
View File
@@ -32,7 +32,6 @@ 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
@@ -45,7 +44,6 @@ 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
)
@@ -1,6 +1,5 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_all.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
@@ -17,7 +16,6 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
using namespace Slic3r;
using json = nlohmann::json;
@@ -51,21 +49,6 @@ public:
AgentInfo get_agent_info() override { return m_info; }
int send_message(std::string dev_id, std::string json_str, int, int) override
{
published_device = std::move(dev_id);
published_message = json::parse(json_str);
return 0;
}
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override
{
return send_message(std::move(dev_id), std::move(json_str), qos, flag);
}
std::string published_device;
json published_message;
private:
AgentInfo m_info;
};
@@ -177,62 +160,3 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
TEST_CASE("AMS filament settings keep legacy single-color commands compatible", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", "FF000080", "PLA", 190, 220) == 0);
const json& payload = agent->published_message.at("print");
CHECK(agent->published_device == object.get_dev_id());
CHECK(payload.at("command") == "ams_filament_setting");
CHECK(payload.at("ams_id") == 1);
CHECK(payload.at("slot_id") == 2);
CHECK(payload.at("tray_color") == "FF000080");
CHECK_FALSE(payload.contains("cols"));
CHECK_FALSE(payload.contains("ctype"));
}
TEST_CASE("AMS filament settings preserve ordered gradient and multicolor RGBA components", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const int color_type = GENERATE(0, 1);
const std::vector<std::string> colors{"FF000080", "0000FF40", "00FF00FF"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors, color_type) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("tray_color") == colors.front());
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == color_type);
}
TEST_CASE("AMS filament settings default a supplied single-color list to type two", "[DeviceManager][AMSMaterialsSetting][integration]")
{
ScopedAppConfig app_config;
auto agent = std::make_shared<TestPrinterAgent>("integration-agent");
NetworkAgent network(nullptr, agent);
DeviceManager manager(&network, false, &app_config.config);
MachineObject object(&manager, &network, "Test printer", "integration-device", "192.0.2.10");
object.dev_connection_type = GENERATE("lan", "cloud");
const std::vector<std::string> colors{"FF000080"};
REQUIRE(object.command_ams_filament_settings(1, 2, "filament-id", "setting-id", colors.front(), "PLA", 190, 220,
colors) == 0);
const json& payload = agent->published_message.at("print");
CHECK(payload.at("cols").get<std::vector<std::string>>() == colors);
CHECK(payload.at("ctype") == 2);
}
-61
View File
@@ -1,61 +0,0 @@
// 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,941 +0,0 @@
// 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);
}