diff --git a/docs/HLSD/tooltip-illustrations.md b/docs/HLSD/tooltip-illustrations.md new file mode 100644 index 0000000000..c150d3ac2f --- /dev/null +++ b/docs/HLSD/tooltip-illustrations.md @@ -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. diff --git a/resources/images/texture_displacement/rotation.gif b/resources/images/texture_displacement/rotation.gif new file mode 100644 index 0000000000..590b48eda3 Binary files /dev/null and b/resources/images/texture_displacement/rotation.gif differ diff --git a/resources/images/texture_displacement/tile_off.png b/resources/images/texture_displacement/tile_off.png new file mode 100644 index 0000000000..399e56dca2 Binary files /dev/null and b/resources/images/texture_displacement/tile_off.png differ diff --git a/resources/images/texture_displacement/tile_on.png b/resources/images/texture_displacement/tile_on.png new file mode 100644 index 0000000000..910fa5aa8f Binary files /dev/null and b/resources/images/texture_displacement/tile_on.png differ diff --git a/scripts/gen_texture_displacement_tips.py b/scripts/gen_texture_displacement_tips.py new file mode 100644 index 0000000000..1fbe9aff01 --- /dev/null +++ b/scripts/gen_texture_displacement_tips.py @@ -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() diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 65c8384ef2..9fef3a1766 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/AnimatedTexture.cpp b/src/slic3r/GUI/AnimatedTexture.cpp new file mode 100644 index 0000000000..cb0adf58a1 --- /dev/null +++ b/src/slic3r/GUI/AnimatedTexture.cpp @@ -0,0 +1,248 @@ +#include "AnimatedTexture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "slic3r/GUI/GLTexture.hpp" + +namespace Slic3r::GUI { + +namespace { + +std::vector read_file_bytes(const std::string &path) +{ + std::ifstream ifs(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); +} + +bool has_gif_signature(const std::vector &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 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 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 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 &bytes, int &width, int &height, std::vector &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 canvas(size_t(width) * size_t(height) * 4, 0); + std::vector 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 bytes = read_file_bytes(path); + if (bytes.empty()) + return false; + + std::vector 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(); + // 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(); + 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 diff --git a/src/slic3r/GUI/AnimatedTexture.hpp b/src/slic3r/GUI/AnimatedTexture.hpp new file mode 100644 index 0000000000..ab05aaecf8 --- /dev/null +++ b/src/slic3r/GUI/AnimatedTexture.hpp @@ -0,0 +1,78 @@ +#ifndef slic3r_AnimatedTexture_hpp_ +#define slic3r_AnimatedTexture_hpp_ + +#include +#include +#include +#include + +#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 texture; + double start{0.}; // seconds into the loop this frame appears at + double delay{0.}; // seconds it stays up + }; + + std::vector 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> m_items; // null value: tried once, failed +}; + +} // namespace Slic3r::GUI + +#endif // slic3r_AnimatedTexture_hpp_ diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..0339806381 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -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 &media) +{ + panel_tooltip(into_u8(text), media); +} + +void GLGizmoTextureDisplacement::panel_tooltip(const std::string &text, const std::vector &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 &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> 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 ¶ms, @@ -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 &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 &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 &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) { diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..dc01a36295 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -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 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 &media = {}); + void panel_tooltip(const wxString &text, const std::vector &media = {}); + void render_tip_media(const std::vector &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