Texture displacement: tooltips beside the panel, with illustrations

The panel is pinned to the right edge of the canvas, so a tooltip left to
follow the mouse covers the very control it explains. panel_tooltip()
places every tooltip in the gizmo to the left of the panel instead - or of
the texture picker, while a tooltip is coming from inside that popup - the
same placement the picker itself already uses.

A tooltip can now carry pictures under its text: stills, or GIFs that loop
while it is open. Several settings in this panel describe a shape rather
than a number, and a sentence about a shape is read twice and understood
once. Files live in resources/images/texture_displacement and are named by
a tooltip; one that is missing is skipped, so a tooltip without its art yet
is exactly the text tooltip it was before.

AnimatedTexture decodes a file once and uploads a GL texture per frame -
wxImage for stills, wxGIFDecoder for GIFs, whose frames are composited here
with the disposal method each one asks for. An animation restarts when its
tooltip opens and asks the canvas for a redraw timed to its next frame.

Tile and Rotation are wired up as the first two; scripts/
gen_texture_displacement_tips.py draws their relief from the same
arithmetic the feature uses. docs/HLSD/tooltip-illustrations.md covers the
rest.
This commit is contained in:
ExPikaPaka
2026-10-13 13:21:17 +02:00
parent d2675f15d1
commit 64ddc1fbba
10 changed files with 739 additions and 53 deletions
+76
View File
@@ -0,0 +1,76 @@
# Tooltip illustrations
The texture-displacement panel's tooltips can carry a picture under their text: a still image, or a
short animation that loops while the tooltip is open. Several settings in that panel describe a shape
rather than a number - what Rotation turns, what Tile repeats, what Invert turns inside out - and a
sentence about a shape is read twice and understood once.
## Where a tooltip is drawn
`GLGizmoTextureDisplacement::panel_tooltip()` replaces `ImGuiWrapper::tooltip()` throughout the panel.
It differs in two ways.
It places the tooltip beside the panel instead of under the mouse. The panel is pinned to the right
edge of the canvas, so a tooltip following the mouse covers the control it is explaining, and one
carrying a picture covers most of the panel with it. The tooltip goes to the left of
`m_tooltip_anchor_min`/`_max` - the panel's own screen rectangle, or the texture picker's while that
popup is up and the tooltip comes from inside it - and to the right only when there is no room on the
left. This is the placement the picker popup itself already uses.
ImGui normally keeps a tooltip glued to the mouse and ignores `SetNextWindowPos()`. Orca's imgui
branch honours a position set for a tooltip (see the `ImGuiWindowFlags_Tooltip` branch of `Begin()` in
`deps_src/imgui/imgui.cpp`) and still clamps the result inside the canvas, which is what keeps a tall
tooltip near the bottom edge on screen.
It also gives the tooltip a fixed width rather than one derived from its text, because a picture has
to be laid out against something, and a column of tooltips that are all one width is steadier to read.
## Where the pictures live
`resources/images/texture_displacement/`, as PNG, JPG, BMP or GIF. A tooltip names them by file:
```cpp
hover_tip(_u8L("Repeats the texture across the painted area. ..."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
```
Several in one tooltip are laid out side by side at equal widths, which is how a setting is shown as a
comparison - the same model at two values of it. The caption under each is translated; the file name
is not.
A file that is missing or will not decode is skipped, and a tooltip whose pictures are all missing is
exactly the text tooltip it was before. So the art for a tooltip can land after the tooltip does, and
a half-finished set never breaks the panel.
## Decoding and playback
`slic3r/GUI/AnimatedTexture.hpp` decodes a file once and uploads one GL texture per frame. Stills go
through `wxImage`; GIFs go through `wxGIFDecoder`, whose raw palette indices are composited here frame
by frame, replaying the disposal method each frame asks for. That replay is what makes an animation
look continuous: a GIF frame is a sub-rectangle drawn over what came before it, so an animation that
only re-sends the pixels that changed draws as a flicker of disconnected patches without it.
A frame delay of 0 or 1 hundredths of a second is read as 100 ms, the convention browsers settle on,
so an animation authored against that plays at the speed it was authored at.
The animation restarts when its tooltip opens, rather than running off a free-running clock: these are
short loops of one setting going from one value to another, and joining one halfway through shows the
end of a change the user never saw the start of. The canvas only redraws on demand, so each frame asks
for the next through `GLCanvas3D::schedule_extra_frame()`, timed to when that frame is actually due.
## Making one
Captures of the real tool are the first choice for anything involving the model, the mesh or colour.
Settings whose meaning is a shape can be drawn from the same arithmetic instead:
`scripts/gen_texture_displacement_tips.py` shades a height field as a relief catching the light, and
produces `tile_off.png`, `tile_on.png` and `rotation.gif` that way.
Either way:
- Square, around 256 px. The tooltip is about 20 em wide, so that is roughly native size on a 100%
display and downscales cleanly above it.
- Neutral grey. These say what a shape does; colour in one reads as a third thing the setting does,
and has to work against both themes.
- Animations loop seamlessly and stay short - a dozen frames at 100 ms, under about 250 KB. They sit
in `resources/`, which ships with every copy of the application.
- No text in the picture. Captions are translated; pixels are not.
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Draws the illustrations the texture-displacement tooltips show.
The panel's tooltips can carry a picture under their text (see GLGizmoTextureDisplacement's
panel_tooltip()). A few of those settings - the ones that are a shape rather than a photograph -
are better drawn from the same arithmetic the feature uses than captured from the screen, which is
what this script does: it shades a height field exactly as a relief would catch the light, so
"what Rotation turns" and "what Tile repeats" are visible rather than described.
Output goes to resources/images/texture_displacement/. Run it after changing one of the patterns
below; nothing in the build depends on it.
python3 scripts/gen_texture_displacement_tips.py
Needs numpy and pillow.
"""
import os
import numpy as np
from PIL import Image
SIZE = 256 # px, square - the panel draws these at whatever width the tooltip has
RELIEF = 30.0 # how hard the shading reacts to a slope; purely cosmetic
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "images", "texture_displacement")
# Neutral enough to sit in a tooltip in either theme: a dark ground, a mid grey for the flat surface
# and a cool near-white for the lit slopes.
SHADOW = np.array([0.13, 0.14, 0.15])
MIDTONE = np.array([0.40, 0.42, 0.43])
HIGHLIGHT = np.array([0.78, 0.84, 0.83])
LIGHT = np.array([-0.45, -0.62, 0.65])
LIGHT /= np.linalg.norm(LIGHT)
def grid(size=SIZE):
"""Unit coordinates, -0.5 .. 0.5, y down as in an image."""
a = (np.arange(size) + 0.5) / size - 0.5
return np.meshgrid(a, a)
def shade(height):
"""A height field in 0..1, lit as a relief and tinted: the picture the tooltip shows."""
gy, gx = np.gradient(height * RELIEF)
normal = np.dstack([-gx, -gy, np.ones_like(height)])
normal /= np.linalg.norm(normal, axis=2, keepdims=True)
lam = np.clip(normal @ LIGHT, 0.0, 1.0)
# Two ramps rather than one: the dark half separates the slopes, the lit half picks up the
# accent, and the flat ground lands in the middle of both.
low = SHADOW + (MIDTONE - SHADOW) * (lam / 0.5)[..., None]
high = MIDTONE + (HIGHLIGHT - MIDTONE) * ((lam - 0.5) / 0.5)[..., None]
rgb = np.where((lam < 0.5)[..., None], low, high)
# A touch of the height itself, so a tall feature still reads as tall where it is flat on top.
#
# Deliberately almost grey: these say what a shape does, and a relief catching the light is grey.
# A coloured one would read as a third thing the setting does, and the amount of colour would move
# with the slope - a pattern at 45 degrees would flash where the same pattern upright did not.
rgb = np.clip(rgb * (0.88 + 0.18 * height[..., None]), 0.0, 1.0)
return (rgb * 255.0 + 0.5).astype(np.uint8)
def vignette(rgb):
"""Rounds the picture off at its border, so it does not end in a hard rectangle."""
x, y = grid(rgb.shape[0])
edge = np.clip((0.5 - np.maximum(np.abs(x), np.abs(y))) / 0.045, 0.0, 1.0)
return (rgb * edge[..., None]).astype(np.uint8)
def save_png(name, rgb):
path = os.path.join(OUT_DIR, name)
Image.fromarray(vignette(rgb)).save(path, optimize=True)
print("wrote", os.path.normpath(path))
def save_gif(name, frames, delay_ms):
path = os.path.join(OUT_DIR, name)
images = [Image.fromarray(vignette(f)).convert("P", palette=Image.ADAPTIVE, colors=32) for f in frames]
images[0].save(path, save_all=True, append_images=images[1:], duration=delay_ms, loop=0, optimize=True)
print("wrote", os.path.normpath(path))
# --- the patterns ------------------------------------------------------------------------------
def ridges(angle_deg, period=0.16):
"""Parallel ridges at an angle: the simplest pattern whose direction is unmistakable."""
x, y = grid()
a = np.radians(angle_deg)
t = (x * np.cos(a) + y * np.sin(a)) / period
return 0.2 + 0.6 * (0.5 + 0.5 * np.sin(2.0 * np.pi * t))
def motif(repeats):
"""A ring inside a square frame, laid out `repeats` times across the picture."""
x, y = grid()
u = (x * repeats) % 1.0 - 0.5
v = (y * repeats) % 1.0 - 0.5
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0)
def decal(motif_scale=0.46):
"""One copy of the motif in the middle of a flat field - what Tile off leaves."""
x, y = grid()
u, v = x / motif_scale, y / motif_scale
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
inside = (np.maximum(np.abs(u), np.abs(v)) <= 0.5).astype(float)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0) * inside
def main():
os.makedirs(OUT_DIR, exist_ok=True)
# Tile: one copy against the pattern filling the painted area.
save_png("tile_off.png", shade(decal()))
save_png("tile_on.png", shade(motif(repeats=3)))
# Rotation: the same ridges turning a quarter of a turn and back, so the loop has no jump.
# A half turn brings parallel ridges back onto themselves, so a single sweep loops with no jump
# and the file stays a fraction of the size a there-and-back one would be.
steps = 16
save_gif("rotation.gif", [shade(ridges(180.0 * i / steps)) for i in range(steps)], delay_ms=100)
if __name__ == "__main__":
main()
+2
View File
@@ -20,6 +20,8 @@ set(SLIC3R_GUI_SOURCES
GUI/Widgets/FilamentLoad.hpp
GUI/AboutDialog.cpp
GUI/AboutDialog.hpp
GUI/AnimatedTexture.cpp
GUI/AnimatedTexture.hpp
GUI/AmsMappingPopup.cpp
GUI/AmsMappingPopupUpdate.cpp
GUI/AmsMappingPopup.hpp
+248
View File
@@ -0,0 +1,248 @@
#include "AnimatedTexture.hpp"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <fstream>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <wx/animdecod.h>
#include <wx/gdicmn.h>
#include <wx/gifdecod.h>
#include <wx/image.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
namespace {
std::vector<unsigned char> read_file_bytes(const std::string &path)
{
std::ifstream ifs(path, std::ios::binary);
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
bool has_gif_signature(const std::vector<unsigned char> &bytes)
{
return bytes.size() > 6 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F';
}
// wxImage keeps colour and alpha in two separate buffers, and a file with no alpha at all has none;
// OpenGL wants the four channels interleaved.
std::vector<unsigned char> image_to_rgba(const wxImage &image)
{
const size_t n = size_t(image.GetWidth()) * size_t(image.GetHeight());
const unsigned char *rgb = image.GetData();
const unsigned char *a = image.HasAlpha() ? image.GetAlpha() : nullptr;
std::vector<unsigned char> out(n * 4);
for (size_t i = 0; i < n; ++i) {
out[i * 4 + 0] = rgb[i * 3 + 0];
out[i * 4 + 1] = rgb[i * 3 + 1];
out[i * 4 + 2] = rgb[i * 3 + 2];
out[i * 4 + 3] = a != nullptr ? a[i] : 255;
}
return out;
}
// One decoded frame on its way to the GPU: the whole animation's area, already composited.
struct DecodedFrame
{
std::vector<unsigned char> rgba;
double delay; // seconds
};
// Every frame of a GIF, composited onto the animation's own canvas.
//
// A GIF frame is a sub-rectangle that is drawn over what came before it, and the one before says how
// to clean up after itself (leave it, erase it, put back what it covered). Without replaying that, an
// animation that only re-sends the pixels that changed - which is most of them - draws as a flicker of
// disconnected patches. wxGIFDecoder hands over the raw palette indices, which is what makes replaying
// it here straightforward: a transparent index means "keep what is underneath", nothing else does.
bool decode_gif(const std::vector<unsigned char> &bytes, int &width, int &height, std::vector<DecodedFrame> &out)
{
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxGIFDecoder decoder;
// A truncated file still decodes everything up to the cut, which is worth showing.
const wxGIFErrorCode err = decoder.LoadGIF(stream);
if ((err != wxGIF_OK && err != wxGIF_TRUNCATED) || decoder.GetFrameCount() == 0)
return false;
const wxSize size = decoder.GetAnimationSize();
width = size.GetWidth();
height = size.GetHeight();
if (width <= 0 || height <= 0)
return false;
std::vector<unsigned char> canvas(size_t(width) * size_t(height) * 4, 0);
std::vector<unsigned char> saved;
for (unsigned int i = 0; i < decoder.GetFrameCount(); ++i) {
const wxSize frame_size = decoder.GetFrameSize(i);
const wxPoint frame_pos = decoder.GetFramePosition(i);
const wxAnimationDisposal disposal = decoder.GetDisposalMethod(i);
const unsigned char *indices = decoder.GetData(i);
const unsigned char *palette = decoder.GetPalette(i);
const int transparent = decoder.GetTransparentColourIndex(i);
if (disposal == wxANIM_TOPREVIOUS)
saved = canvas;
if (indices != nullptr && palette != nullptr)
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
for (int x = 0; x < frame_size.GetWidth(); ++x) {
const int cx = frame_pos.x + x;
if (cx < 0 || cx >= width)
continue;
const unsigned char index = indices[size_t(y) * size_t(frame_size.GetWidth()) + size_t(x)];
if (int(index) == transparent)
continue;
unsigned char *dst = &canvas[(size_t(cy) * size_t(width) + size_t(cx)) * 4];
dst[0] = palette[3 * size_t(index) + 0];
dst[1] = palette[3 * size_t(index) + 1];
dst[2] = palette[3 * size_t(index) + 2];
dst[3] = 255;
}
}
// Browsers treat 0 and 1 hundredths of a second as "as fast as the file can mean", and settle
// on 100 ms; an animation authored against that convention plays at the intended speed only if
// this does the same.
const long delay_ms = decoder.GetDelay(i);
out.push_back({ canvas, (delay_ms <= 10 ? 100.0 : double(delay_ms)) / 1000.0 });
if (disposal == wxANIM_TOBACKGROUND) {
for (int y = 0; y < frame_size.GetHeight(); ++y) {
const int cy = frame_pos.y + y;
if (cy < 0 || cy >= height)
continue;
const int x0 = std::max(0, frame_pos.x);
const int x1 = std::min(width, frame_pos.x + frame_size.GetWidth());
if (x1 > x0)
std::fill_n(&canvas[(size_t(cy) * size_t(width) + size_t(x0)) * 4], size_t(x1 - x0) * 4, 0);
}
} else if (disposal == wxANIM_TOPREVIOUS) {
canvas = saved;
}
}
return !out.empty();
}
} // namespace
bool AnimatedTexture::load_from_file(const std::string &path)
{
reset();
boost::system::error_code ec;
if (!boost::filesystem::is_regular_file(path, ec))
return false;
const std::vector<unsigned char> bytes = read_file_bytes(path);
if (bytes.empty())
return false;
std::vector<DecodedFrame> frames;
int width = 0, height = 0;
if (has_gif_signature(bytes)) {
if (!decode_gif(bytes, width, height, frames)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode GIF " << path;
return false;
}
} else {
wxLogNull no_log; // a format wxWidgets cannot read is handled here, not in a dialog
wxImage image;
wxMemoryInputStream stream(bytes.data(), bytes.size());
if (!image.LoadFile(stream, wxBITMAP_TYPE_ANY) || !image.IsOk()) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode image " << path;
return false;
}
width = image.GetWidth();
height = image.GetHeight();
frames.push_back({ image_to_rgba(image), 0.0 });
}
m_width = width;
m_height = height;
m_frames.reserve(frames.size());
for (DecodedFrame &frame : frames) {
auto texture = std::make_unique<GLTexture>();
// Mipmapped: a hint is drawn at whatever width the panel has room for, which is usually
// smaller than the source, and an unfiltered downscale of a diagram is all aliasing.
if (!texture->load_from_raw_data(std::move(frame.rgba), (unsigned int) width, (unsigned int) height, false,
/* use_mipmaps */ true)) {
BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot upload " << path;
reset();
return false;
}
m_frames.push_back({ std::move(texture), m_duration, frame.delay });
m_duration += frame.delay;
}
return true;
}
void AnimatedTexture::reset()
{
m_frames.clear();
m_width = 0;
m_height = 0;
m_duration = 0.;
}
unsigned int AnimatedTexture::texture_id(double time, double *time_to_next) const
{
if (m_frames.empty()) {
if (time_to_next != nullptr)
*time_to_next = 0.;
return 0;
}
if (m_frames.size() == 1 || m_duration <= 0.) {
if (time_to_next != nullptr)
*time_to_next = 0.;
return m_frames.front().texture->get_id();
}
const double t = time - m_duration * std::floor(time / m_duration);
// Frames are few; a linear walk beats keeping an index around and getting it stale.
size_t i = 0;
while (i + 1 < m_frames.size() && t >= m_frames[i].start + m_frames[i].delay)
++i;
if (time_to_next != nullptr)
*time_to_next = std::max(0., m_frames[i].start + m_frames[i].delay - t);
return m_frames[i].texture->get_id();
}
const AnimatedTexture *AnimatedTextureCache::get(const std::string &path)
{
const auto it = m_items.find(path);
if (it != m_items.end())
return it->second.get();
auto texture = std::make_unique<AnimatedTexture>();
if (!texture->load_from_file(path))
texture.reset(); // remembered as a failure, so the file is not re-read every frame
const AnimatedTexture *result = texture.get();
m_items.emplace(path, std::move(texture));
return result;
}
} // namespace Slic3r::GUI
+78
View File
@@ -0,0 +1,78 @@
#ifndef slic3r_AnimatedTexture_hpp_
#define slic3r_AnimatedTexture_hpp_
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "slic3r/GUI/GLTexture.hpp"
namespace Slic3r::GUI {
// An illustration for the UI to draw: one still image, or the frames of an animated GIF together with
// the delays the file asks for. Decoded on the CPU once and uploaded as one GL texture per frame.
//
// A texture per frame rather than an atlas: these are small hints a few hundred pixels across and a
// handful of frames long, so the extra textures cost little, while drawing a frame stays a plain
// ImGui::Image with no UV arithmetic and no dependence on the maximum texture size.
//
// load_from_file() uploads to the GPU, so it needs a current GL context - call it from rendering code.
class AnimatedTexture
{
public:
AnimatedTexture() = default;
AnimatedTexture(AnimatedTexture &&) = default;
AnimatedTexture &operator=(AnimatedTexture &&) = default;
AnimatedTexture(const AnimatedTexture &) = delete;
AnimatedTexture &operator=(const AnimatedTexture &) = delete;
// PNG, JPG and BMP through wxImage; GIF, animated or not, through wxGIFDecoder. False, and nothing
// loaded, if the file is missing or cannot be decoded.
bool load_from_file(const std::string &path);
void reset();
bool is_valid() const { return !m_frames.empty(); }
bool is_animated() const { return m_frames.size() > 1; }
int width() const { return m_width; }
int height() const { return m_height; }
// Length of one loop, in seconds; 0 for a still image.
double duration() const { return m_duration; }
// The texture to draw `time` seconds into the loop (which wraps), and - when `time_to_next` is
// given - how much longer that frame stays up. A canvas that only redraws on demand uses the
// latter to ask for the next redraw at the right moment instead of spinning at full speed.
// 0, and a time_to_next of 0, when nothing is loaded.
unsigned int texture_id(double time, double *time_to_next = nullptr) const;
private:
struct Frame
{
std::unique_ptr<GLTexture> texture;
double start{0.}; // seconds into the loop this frame appears at
double delay{0.}; // seconds it stays up
};
std::vector<Frame> m_frames;
int m_width{0};
int m_height{0};
double m_duration{0.};
};
// The illustrations a panel has shown at least once, keyed by absolute path, decoded and uploaded on
// first use and kept until clear() - which has to run while the GL context is still current.
class AnimatedTextureCache
{
public:
// Null when the file is missing or cannot be decoded. The failure is remembered too, so a tooltip
// whose illustration has not been drawn yet costs one lookup, not one decode attempt per frame.
const AnimatedTexture *get(const std::string &path);
void clear() { m_items.clear(); }
private:
std::map<std::string, std::unique_ptr<AnimatedTexture>> m_items; // null value: tried once, failed
};
} // namespace Slic3r::GUI
#endif // slic3r_AnimatedTexture_hpp_
@@ -3701,6 +3701,106 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
m_panel_icon_map[names[i]] = icons[i];
}
void GLGizmoTextureDisplacement::panel_tooltip(const wxString &text, const std::vector<TipMedia> &media)
{
panel_tooltip(into_u8(text), media);
}
void GLGizmoTextureDisplacement::panel_tooltip(const std::string &text, const std::vector<TipMedia> &media)
{
const ImGuiStyle &style = ImGui::GetStyle();
const float gap = m_imgui->scaled(0.5f);
const float wrap = m_imgui->scaled(20.f);
// The width has to be known here, not left to ImGui, because the tooltip is placed by its left edge
// (below) and an illustration has to be laid out against something. One carrying illustrations takes
// the full wrap width; a plain one takes exactly what its text needs, as it did when ImGui sized it,
// so a two-word tooltip is not a wide box with two words in it.
const float text_w = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap).x;
const float width = (media.empty() ? std::min(wrap, std::ceil(text_w)) : wrap) + 2.f * style.WindowPadding.x;
// Beside the anchor (the panel, or the texture picker when the tooltip comes from inside it), never
// over it - the same reasoning, and the same placement, as the picker popup itself. ImGui normally
// keeps a tooltip glued to the mouse; it honours a position set here instead (see the Orca branch in
// imgui's Begin()), and still clamps the result inside the canvas.
const bool left = m_tooltip_anchor_min.x - gap - width >= 0.f;
const float x = left ? m_tooltip_anchor_min.x - gap - width : m_tooltip_anchor_max.x + gap;
ImGui::SetNextWindowPos(ImVec2(x, ImGui::GetItemRectMin().y)); // level with the control it explains
ImGui::SetNextWindowSize(ImVec2(width, 0.f)); // height still follows the content
ImGui::BeginTooltip();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f));
ImGui::PushTextWrapPos(0.f); // the window's own edge, which is what the fixed width above sets
ImGui::TextUnformatted(text.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
render_tip_media(media, width - 2.f * style.WindowPadding.x);
ImGui::EndTooltip();
}
void GLGizmoTextureDisplacement::render_tip_media(const std::vector<TipMedia> &media, float width)
{
// Collect what is actually there first: the layout divides the width between the illustrations, so
// one that is missing has to drop out of the count rather than leave a gap.
std::vector<std::pair<const AnimatedTexture *, const TipMedia *>> shown;
std::string key;
for (const TipMedia &m : media) {
const AnimatedTexture *texture =
m_tip_media.get((boost::filesystem::path(resources_dir()) / "images" / "texture_displacement" / m.file).string());
if (texture != nullptr && texture->is_valid() && texture->width() > 0 && texture->height() > 0) {
shown.emplace_back(texture, &m);
key += m.file + "|";
}
}
if (shown.empty())
return;
// Each animation plays from its first frame when its tooltip opens: these are short loops showing
// one setting going from one value to another, and joining one halfway through shows the user the
// end of a change they never saw the start of.
if (key != m_tip_media_key) {
m_tip_media_key = key;
m_tip_media_start = ImGui::GetTime();
}
const double time = ImGui::GetTime() - m_tip_media_start;
const ImGuiStyle &style = ImGui::GetStyle();
const float spacing = style.ItemSpacing.x;
const float cell_w = (width - spacing * float(shown.size() - 1)) / float(shown.size());
const ImU32 frame = ImGui::GetColorU32(ImGuiCol_Text, 0.25f);
ImGui::Spacing();
for (size_t i = 0; i < shown.size(); ++i) {
const AnimatedTexture *texture = shown[i].first;
const TipMedia &entry = *shown[i].second;
ImGui::BeginGroup();
double next = 0.;
const unsigned int id = texture->texture_id(time, &next);
const float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(cell_w, h));
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), frame, style.FrameRounding);
// The canvas only redraws on demand, so an animation has to ask - and asks for exactly when its
// next frame is due, rather than for a redraw as fast as the machine will go.
if (texture->is_animated())
m_parent.schedule_extra_frame(int(next * 1000.0));
if (!entry.caption.empty()) {
// Centred under its own illustration, and never wider than it.
const std::string caption = into_u8(entry.caption);
const float text_w = ImGui::CalcTextSize(caption.c_str()).x;
const float cell_x = ImGui::GetCursorPosX();
ImGui::SetCursorPosX(cell_x + std::max(0.f, (cell_w - text_w) * 0.5f));
ImGui::PushTextWrapPos(cell_x + cell_w);
ImGui::TextDisabled("%s", caption.c_str());
ImGui::PopTextWrapPos();
}
ImGui::EndGroup();
if (i + 1 < shown.size())
ImGui::SameLine(0.f, spacing);
}
}
void GLGizmoTextureDisplacement::add_texture_layer()
{
ModelVolume *mv = texture_volume();
@@ -3845,6 +3945,14 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
return;
}
// While this popup is up its own tooltips belong beside it, not beside the panel it is already
// covering the left of. Restored before every exit below.
const ImVec2 prev_anchor_min = m_tooltip_anchor_min;
const ImVec2 prev_anchor_max = m_tooltip_anchor_max;
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
m_imgui->push_bold_font();
m_imgui->text(_L("Choose a texture"));
m_imgui->pop_bold_font();
@@ -3903,7 +4011,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (entry.path == layer->path)
dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f);
if (ImGui::IsItemHovered())
m_imgui->tooltip(entry.name, m_imgui->scaled(18.f));
panel_tooltip(entry.name);
if (user && ImGui::IsMouseHoveringRect(a, b)) {
const float r = std::round(tile * 0.17f);
@@ -3916,7 +4024,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
dl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), IM_COL32_WHITE, 1.5f);
dl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), IM_COL32_WHITE, 1.5f);
if (hot)
m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f));
panel_tooltip(_u8L("Remove from My textures"));
if (remove_clicked)
to_remove = entry;
}
@@ -3943,10 +4051,9 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
import_clicked = true;
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."),
m_imgui->scaled(20.f));
panel_tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer "
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."));
end_grid(origin, count + 1);
}
@@ -3956,6 +4063,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (chosen || import_clicked)
ImGui::CloseCurrentPopup();
m_tooltip_anchor_min = prev_anchor_min;
m_tooltip_anchor_max = prev_anchor_max;
ImGui::EndPopup();
// Only once the loop over the library is done: importing, picking (which reorders the recently used)
@@ -5587,18 +5696,16 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
run_stage_debug();
m_imgui->disabled_end();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."),
m_imgui->scaled(20.f));
panel_tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every "
"stage, and step through them here. Nothing is committed to the model - "
"this is for finding which stage a bad result came from."));
ImGui::SameLine();
m_imgui->bbl_checkbox(_L("Check topology"), m_debug_check_topology);
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."),
m_imgui->scaled(20.f));
panel_tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage "
"that tore the mesh is spotted. It scans every edge, so it adds noticeably "
"to the capture on a fine bake."));
if (m_debug_stages.empty())
return;
@@ -5666,9 +5773,8 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv)
show_error(nullptr, _u8L("Could not write the stage meshes."));
}
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."),
m_imgui->scaled(20.f));
panel_tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened "
"side by side in a mesh viewer."));
}
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams &params,
@@ -5793,6 +5899,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::push_toolbar_style(m_parent.get_scale());
GizmoImguiBegin(get_name(), flags);
// Where this frame's tooltips place themselves (see panel_tooltip()). The size is last frame's -
// this window auto-resizes, so its height is only known once its content has been laid out - which
// is close enough for a tooltip that needs a frame of hovering to appear in the first place.
m_tooltip_anchor_min = ImGui::GetWindowPos();
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
ensure_panel_icons();
process_uv_commands(); // clicks from the UV editor pane, run here where the GL context is current
const float previous_body_h = m_panel_body_h;
@@ -5816,7 +5928,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float gap_s = std::round(m_imgui->scaled(0.4f));
const float label_w = m_imgui->scaled(4.8f); // label column of a label + slider row
const float card_pad = std::round(m_imgui->scaled(0.55f));
const float wrap_w = m_imgui->scaled(20.f);
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
const ImVec4 col_link = ImGuiWrapper::COL_ORCA;
const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator);
@@ -5830,9 +5941,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGuiWrapper::pop_combo_style();
return changed;
};
const auto hover_tip = [&](const auto &text) {
const auto hover_tip = [&](const auto &text, const std::vector<TipMedia> &media = {}) {
if (ImGui::IsItemHovered())
m_imgui->tooltip(text, wrap_w);
panel_tooltip(text, media);
};
// Framed icon toggle: a 1 px frame, and when on a teal frame over a teal tint with the icon in its
@@ -5840,7 +5951,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// `unavailable`, when not empty, draws it faded but still hoverable, so the tooltip can say why it
// cannot be used - a disabled ImGui item would swallow the hover and leave the user guessing.
const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, float sz, const wxString &label,
const wxString &tip_text, const wxString &unavailable = wxString()) -> bool {
const wxString &tip_text, const wxString &unavailable = wxString(),
const std::vector<TipMedia> &media = {}) -> bool {
const bool na = !unavailable.empty();
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
@@ -5867,12 +5979,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar(2);
ImGui::PopID();
if (ImGui::IsItemHovered())
m_imgui->tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, wrap_w);
panel_tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, media);
return clicked && !na;
};
// Borderless icon button (non-toggle): always the grey monochrome variant.
const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label,
const wxString &tip_text) -> bool {
const wxString &tip_text, const std::vector<TipMedia> &media = {}) -> bool {
const auto it = m_panel_icon_map.find(iconfile);
const float pad = std::max(1.f, std::round(sz * 0.14f));
bool clicked = false;
@@ -5890,7 +6002,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::PopStyleVar();
ImGui::PopID();
if (!tip_text.empty() && ImGui::IsItemHovered())
m_imgui->tooltip(tip_text, wrap_w);
panel_tooltip(tip_text, media);
return clicked;
};
// A short vertical rule between groups of buttons on one row; call it right after an item.
@@ -6460,7 +6572,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"single copy."));
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad);
hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."));
"edge of the model."),
{ { "rotation.gif" } });
if (m_layer_expanded[size_t(layer.slot)]) {
{
@@ -6526,13 +6639,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."),
wrap_w);
panel_tooltip(has_color ?
_u8L("Prints the painted area in the texture's colors as well as its "
"relief. Each color is matched to the nearest of your loaded "
"filaments; anything you did not paint keeps the object's own.") :
_u8L("This texture is a grayscale height map, so it has no colors to "
"apply. Import a color image to use this."));
// The rest of colour belongs to the whole stack, not to this layer, so it only appears once -
// under whichever layer turned colour on.
@@ -6702,7 +6814,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Tile") + "##tile_enabled", layer.tile_enabled);
hover_tip(_u8L("Repeats the texture across the painted area. Off places one copy, like a decal, "
"at the size set by Tile size."));
"at the size set by Tile size."),
{ { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } });
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const wxString tile_na = layer.tile_enabled ? wxString() : _L("Turn Tile on to choose how the texture repeats.");
@@ -6744,14 +6857,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
m_imgui->disabled_end();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."),
wrap_w);
panel_tooltip(base_layer ?
_u8L("The base layer has nothing beneath it to combine with, so it always adds.") :
_u8L("What this layer does where it overlaps the ones below. Add and "
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."));
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),
@@ -7405,18 +7517,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->pop_bold_font();
GLGizmoUtils::pop_orca_button_style();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."),
wrap_w);
panel_tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ?
(m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so "
"strokes on the model mark seams instead of painting - turn "
"it off in the pane to paint.") :
_u8L("Nothing is painted yet.")) :
pro_mode() ?
_u8L("Turn the painted height maps into real geometry, by moving the vertices that are "
"already there. Use Subdivide first if the mesh is too coarse to show the detail.") :
_u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even "
"density and refined where the texture bends first, so the detail has vertices to "
"land on - all in one step."));
ImGui::SameLine();
if (busy) {
@@ -4,6 +4,7 @@
#include "GLGizmoPainterBase.hpp"
#include "libslic3r/TextureBake/TextureBakeDebug.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "slic3r/GUI/AnimatedTexture.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include "slic3r/GUI/GLTexture.hpp"
#include "slic3r/GUI/I18N.hpp"
@@ -1025,6 +1026,40 @@ private:
std::map<std::string, IconManager::Icons> m_panel_icon_map; // file name -> [normal, colour, disabled]
bool m_panel_icons_tried = false;
void ensure_panel_icons();
// One illustration under a tooltip's text: a still image or an animation (see AnimatedTexture),
// named by file within resources/images/texture_displacement, with a caption under it. Several of
// them in one tooltip are laid out side by side, which is how a setting is shown as a comparison -
// the same model at two values of it.
struct TipMedia
{
std::string file;
wxString caption;
};
// Every tooltip the panel shows. Placed beside the panel rather than under the mouse: the panel is
// pinned to the right edge of the canvas, so a tooltip left to follow the mouse covers the control
// it is explaining, and one carrying an illustration covers most of the panel with it.
//
// `media` is drawn under the text, at the tooltip's full width; files that are missing or will not
// decode are skipped, so a tooltip whose illustration has not been drawn yet is exactly the text
// tooltip it was before.
void panel_tooltip(const std::string &text, const std::vector<TipMedia> &media = {});
void panel_tooltip(const wxString &text, const std::vector<TipMedia> &media = {});
void render_tip_media(const std::vector<TipMedia> &media, float width);
// Screen rectangle a tooltip places itself beside: the panel window, or - while the texture picker
// is up and tooltips are coming from inside it - the picker popup, which already sits beside the
// panel. Both are set from the window's own rectangle as it is drawn.
ImVec2 m_tooltip_anchor_min{0.f, 0.f};
ImVec2 m_tooltip_anchor_max{0.f, 0.f};
// Illustrations any tooltip has shown, decoded and uploaded once (see AnimatedTexture.hpp).
AnimatedTextureCache m_tip_media;
// Which tooltip's illustrations are on screen, and when they first appeared: an animation plays
// from its first frame each time its tooltip opens, rather than from wherever a free-running clock
// happened to be.
std::string m_tip_media_key;
double m_tip_media_start = 0.;
};
} // namespace Slic3r::GUI