Texture displacement: tooltips beside the panel, with illustrations (#16472)

Draft - description to follow.
This commit is contained in:
SoftFever
2026-10-15 00:03:57 +08:00
committed by GitHub
60 changed files with 1638 additions and 111 deletions
+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
+240
View File
@@ -0,0 +1,240 @@
#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) const
{
if (m_frames.empty())
return 0;
if (m_frames.size() == 1 || m_duration <= 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;
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
+75
View File
@@ -0,0 +1,75 @@
#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. 0 when nothing is loaded.
unsigned int texture_id(double time) 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_
@@ -3830,6 +3830,189 @@ void GLGizmoTextureDisplacement::ensure_panel_icons()
m_panel_icon_map[names[i]] = icons[i];
}
// Seconds on a real clock, for the tooltips' own timing.
//
// Not ImGui::GetTime(): nothing in OrcaSlicer ever sets io.DeltaTime, so it keeps imgui's default of
// 1/60 per frame and its clock counts frames rather than seconds. On a canvas that renders only when
// something asks it to, that is not a clock at all - an animation driven by it crawled while the canvas
// was idle and raced while a moving mouse was rendering it flat out.
static double tip_now()
{
using clock = std::chrono::steady_clock;
static const clock::time_point base = clock::now();
return std::chrono::duration<double>(clock::now() - base).count();
}
// How long the pointer has to rest on a control before its tooltip appears. The panel is dense and the
// tooltip lands on the model beside it, so with no wait at all a sweep from the top of the panel down to
// Bake flashes a dozen large panels over the scene. Once one has just been up the wait drops to almost
// nothing, so reading several settings in a row stays quick - but not to nothing, or that same sweep
// would flash again the moment one tooltip had been seen.
static constexpr double TIP_DELAY = 0.45;
static constexpr double TIP_DELAY_AGAIN = 0.10;
static constexpr double TIP_STICKY = 0.70;
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)
{
// Nothing explains a control while the control is being used: dragging a slider, typing a value
// into one, picking from a combo, dragging a layer card. The pointer sits on the very thing being
// edited, so without this the tooltip covers the model exactly while the user is watching it change.
// The full wait comes back afterwards rather than the short one - having just let go of a slider is
// not a reason to be shown a picture of it.
if (ImGui::IsAnyItemActive() || ImGui::IsAnyMouseDown()) {
m_tip_item = ImVec2(-1.f, -1.f);
m_tip_last_shown = -1e9;
return;
}
// The item's position stands in for its identity: moving to another control restarts the wait, and
// staying on this one carries it on.
const double now = tip_now();
const ImVec2 item = ImGui::GetItemRectMin();
if (item.x != m_tip_item.x || item.y != m_tip_item.y) {
m_tip_item = item;
m_tip_hover_since = now;
}
if (now - m_tip_hover_since < (now - m_tip_last_shown < TIP_STICKY ? TIP_DELAY_AGAIN : TIP_DELAY)) {
// A pointer resting on a control produces no events at all, so unless the wait asks for frames
// itself nothing redraws the canvas and the wait never finishes - the tooltip then appears only
// on the next pixel of mouse movement.
//
// request_extra_frame() and not schedule_extra_frame(): the latter only arranges a timer, whose
// event an idle event is assumed to follow, and GLCanvas3D::on_idle() renders nothing unless the
// canvas was already dirty when it ran. request_extra_frame() ends on_idle with
// evt.RequestMore(), which obliges wx to deliver the next one. It is what 3DBed's fade uses.
// Costing a few hundred milliseconds of frames is the cheaper half of that trade.
m_parent.request_extra_frame();
return;
}
m_tip_last_shown = now;
// ImGui hides a window on the first frame it is submitted, to measure it before showing it - and
// for a tooltip it does so every time one reappears, because the windows are recycled (see the
// Popup|Tooltip branch of Begin()). So the frame on which a tooltip is decided is never the frame
// on which it is seen, and one that then asks for nothing more stays invisible until the next pixel
// of mouse movement happens to produce another frame. Keep frames coming until it has settled.
const int shown_frame = ImGui::GetFrameCount();
m_tip_settled = (shown_frame == m_tip_shown_frame + 1) ? m_tip_settled + 1 : 0;
m_tip_shown_frame = shown_frame;
if (m_tip_settled < 2)
m_parent.request_extra_frame();
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.
// Restart on a different tooltip, and on the same one opening again - which is what a gap in the
// frames it was drawn on means, since it is drawn on every frame for as long as it is on screen.
const int this_frame = ImGui::GetFrameCount();
if (key != m_tip_media_key || this_frame != m_tip_media_frame + 1) {
m_tip_media_key = key;
m_tip_media_start = tip_now();
}
m_tip_media_frame = this_frame;
const double time = tip_now() - 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);
// A single illustration would otherwise be drawn at the tooltip's full width, and a square one at
// full width is a picture as tall as the text beside it is long. Capped and centred in its column.
const float max_h = m_imgui->scaled(10.f);
ImGui::Spacing();
for (size_t i = 0; i < shown.size(); ++i) {
const AnimatedTexture *texture = shown[i].first;
const TipMedia &entry = *shown[i].second;
const float cell_x = ImGui::GetCursorPosX();
float w = cell_w;
float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
if (h > max_h) {
w = std::round(max_h * float(texture->width()) / float(texture->height()));
h = max_h;
}
ImGui::BeginGroup();
const unsigned int id = texture->texture_id(time);
ImGui::SetCursorPosX(cell_x + std::round(0.5f * (cell_w - w)));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(w, h));
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), frame, style.FrameRounding);
// The canvas only redraws on demand, so an animation has to ask for its own frames. It asks for
// them as fast as they come rather than pacing itself with a timer: which frame is drawn is
// decided by the clock above, so a redraw that arrives early simply draws the same frame again,
// and a guaranteed frame is worth more here than a frugal one that may never arrive.
if (texture->is_animated())
m_parent.request_extra_frame();
if (!entry.caption.empty()) {
// Centred under its own illustration, and never wider than its column.
const std::string caption = into_u8(entry.caption);
const float text_w = ImGui::CalcTextSize(caption.c_str()).x;
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();
@@ -3979,6 +4162,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();
@@ -4037,7 +4228,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);
@@ -4050,7 +4241,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;
}
@@ -4077,10 +4268,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);
}
@@ -4090,6 +4280,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)
@@ -6066,18 +6258,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;
@@ -6145,9 +6335,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,
@@ -6274,6 +6463,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
// After the pane's commands, which can close it or change the view, and before the View row, so the row
@@ -6300,7 +6495,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);
@@ -6314,9 +6508,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
@@ -6324,7 +6518,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 std::vector<TipMedia> &media = {},
const wxString &unavailable = wxString()) -> 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));
@@ -6351,12 +6546,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;
@@ -6374,7 +6569,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.
@@ -6453,10 +6648,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
};
// Draws `slider` at `item_w` (SetNextItemWidth's convention: negative measures back from the right
// edge, and the caller has already left edit_icon_room in it), then the edit icon. `tip` and the
// range go on both. Returns whatever the slider returned.
const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::string &range,
const std::function<bool()> &slider) -> bool {
// edge, and the caller has already left edit_icon_room in it), then the edit icon. The range goes on
// both tooltips; `tip` and its illustrations `media` on the slider's. Returns whatever the slider returned.
const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::vector<TipMedia> &media,
const std::string &range, const std::function<bool()> &slider) -> bool {
// Deferred from the click a frame ago: SetKeyboardFocusHere() is a request for the *next*
// frame, so it has to be issued before the slider it names, not from the icon beside it.
if (m_slider_type_request == id) {
@@ -6472,7 +6667,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
wxString full = tip;
if (!full.IsEmpty())
full += "\n\n";
m_imgui->tooltip(full + from_u8(range), wrap_w);
panel_tooltip(full + from_u8(range), media);
}
// SameLine() puts the cursor back at the slider's own top (ItemSize records the Y the item was
// drawn at), so an icon of the same height lands on the track wherever the row placed it.
@@ -6489,15 +6684,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// already given, so no row states its bounds or its format twice and the tooltip cannot drift from
// the track. `item_w` is as typed_row() takes it.
const auto typed_slider = [&](const char *id, float item_w, float *v, float v_min, float v_max, const char *format,
bool log, const wxString &tip) -> bool {
return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] {
bool log, const wxString &tip, const std::vector<TipMedia> &media = {}) -> bool {
return typed_row(id, item_w, tip, media, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] {
return ImGui::SliderFloat(id, v, v_min, v_max, format,
ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0));
});
};
const auto typed_slider_int = [&](const char *id, float item_w, int *v, int v_min, int v_max, const char *format,
bool log, const wxString &tip) -> bool {
return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] {
bool log, const wxString &tip, const std::vector<TipMedia> &media = {}) -> bool {
return typed_row(id, item_w, tip, media, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] {
return ImGui::SliderInt(id, v, v_min, v_max, format,
ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0));
});
@@ -6505,14 +6700,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Label column + the slider filling the rest of the row.
const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, const char *format,
bool log, float right_inset, const wxString &tip) -> bool {
bool log, float right_inset, const wxString &tip, const std::vector<TipMedia> &media = {}) -> bool {
slider_label(label);
return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip);
return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip, media);
};
const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max, const char *format,
float right_inset, const wxString &tip) -> bool {
float right_inset, const wxString &tip, const std::vector<TipMedia> &media = {}) -> bool {
slider_label(label);
return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip);
return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip, media);
};
const auto layer_name = [](const TextureDisplacementLayer &l) {
return l.name.empty() ? Slic3r::format(_u8L("Layer %1%"), l.slot + 1) : l.name;
@@ -6668,11 +6863,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SetCursorPosX(toggles_x);
if (icon_toggle(820, "texture_displacement_add.svg", !m_erase_mode, icon_sm, _L("Paint"),
_L("Paint - add the active layer where you click or drag. The right button erases, and so does "
"holding Shift.")))
"holding Shift."),
{ { "paint.gif" } }))
m_erase_mode = false;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(821, "texture_displacement_add_negative.svg", m_erase_mode, icon_sm, _L("Erase"),
_L("Erase - remove the active layer where you click or drag. The right button paints.")))
_L("Erase - remove the active layer where you click or drag. The right button paints."),
{ { "erase.gif" } }))
m_erase_mode = true;
}
@@ -6686,20 +6883,22 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const bool is_area_mode = m_tool_type == ToolType::SMART_FILL;
const float row_y = ImGui::GetCursorPosY();
if (icon_toggle(801, "texture_displacement_brush.svg", is_brush_mode, icon_md, _L("Brush"),
_L("Brush - paint over the surface by dragging"))) {
_L("Brush - paint over the surface by dragging"), { { "paint.gif" } })) {
m_tool_type = ToolType::BRUSH;
if (m_cursor_type == TriangleSelector::CursorType::POINTER)
m_cursor_type = TriangleSelector::CursorType::CIRCLE;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"))) {
if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"),
{ { "tool_face.gif" } })) {
m_tool_type = ToolType::BRUSH;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, icon_md, _L("Connected area"),
_L("Connected area - flood-fill the region reachable without crossing an edge sharper than the "
"angle threshold"))) {
"angle threshold"),
{ { "tool_smart_fill.gif" } })) {
m_tool_type = ToolType::SMART_FILL;
m_cursor_type = TriangleSelector::CursorType::POINTER;
}
@@ -6713,11 +6912,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
wxString();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"),
_L("Select whole model - paint every face of the model with the active layer"), whole_na))
_L("Select whole model - paint every face of the model with the active layer"),
{ { "select_all.png" } }, whole_na))
select_whole_model();
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"),
_L("Erase whole model - clear the active layer's paint from every face"), erase_na)) {
_L("Erase whole model - clear the active layer's paint from every face"),
{ { "erase_all.png" } }, erase_na)) {
Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"),
UndoRedo::SnapshotType::GizmoAction);
int idx = -1;
@@ -6739,7 +6940,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
typed_slider("##cursor_radius",
std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm) - edit_icon_room),
&m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size"));
&m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size"),
{ { "cursor_size.gif" } });
vsep(icon_sm);
const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE;
centre_on_row(icon_sm);
@@ -6755,7 +6957,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
centre_on_row(frame_h);
typed_slider("##smart_fill_angle", std::max(1.f, row_end - ImGui::GetCursorPosX() - edit_icon_room),
&m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false,
_L("Angle threshold - the fill stops at edges sharper than this"));
_L("Angle threshold - the fill stops at edges sharper than this"),
{ { "smart_fill_angle_low.png", _L("Low") }, { "smart_fill_angle_high.png", _L("High") } });
} else {
centre_on_row(frame_h);
ImGui::AlignTextToFramePadding();
@@ -6791,19 +6994,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + m_imgui->scaled(2.6f)));
if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, icon_md, _L("Normal"),
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update")))
_L("Normal - the real geometry, exactly what Bake will produce. Slower to update"),
{ { "tile_on.png" } }))
new_mode = picked = 0;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, icon_md, _L("Fast"),
_L("Fast - the relief is only shaded on, not built, and only for the layer you are "
"editing. Updates instantly while you paint")))
"editing. Updates instantly while you paint"),
{ { "view_fast.png" } }))
new_mode = picked = 1;
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"),
_L("Checker - a test grid instead of the texture. Where the squares stay square the "
"texture is undistorted; where they stretch, it will too. Opens the UV editor if "
"it is closed"),
uv_view_na)) {
{ { "view_checker.png" } }, uv_view_na)) {
new_mode = picked = 2;
open_uv_editor = true;
}
@@ -6811,13 +7016,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"),
_L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if "
"it is closed"),
uv_view_na)) {
{ { "view_distortion.png" } }, uv_view_na)) {
new_mode = picked = 3;
open_uv_editor = true;
}
vsep(icon_md);
if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"),
_L("Wireframe - overlay the mesh edges; independent of the view above")))
_L("Wireframe - overlay the mesh edges; independent of the view above"),
{ { "view_wireframe.png" } }))
wf_toggle = true;
const std::string auto_label = _u8L("Auto");
@@ -7038,7 +7244,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Depth and tile size are logarithmic, so both ends of their wide ranges stay usable.
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad,
_L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
"millimetres. Black does not move it at all, unless Midlevel says otherwise."),
{ { "depth.gif" } });
// Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the
// placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing. The row
// states why rather than hiding: float_row()'s tooltip is shown for a disabled slider too.
@@ -7050,11 +7257,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"copy of the texture covers it exactly, so there is no tile size to set.") :
_L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."));
"single copy."),
tile_size_na ? std::vector<TipMedia>{} :
std::vector<TipMedia>{ { "tile_size_large.png", _L("Larger") },
{ "tile_size_small.png", _L("Smaller") } });
m_imgui->disabled_end();
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad,
_L("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)]) {
{
@@ -7074,7 +7285,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones "
"still push out - one image both embosses and engraves.\n\n"
"What cuts in has to fit: inside a sharp corner or through a thin wall, a deep "
"cut can pass through the other side."));
"cut can pass through the other side."),
{ { "midlevel.gif" } });
if (layer.midlevel > 0.f && layer.depth_mm > 1.f) {
ImGui::PushTextWrapPos(content_rx - card_pad);
m_imgui->warning_text(_L("Deep inward displacement may self-intersect."));
@@ -7084,12 +7296,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad,
_L("Blurs the image before it is used, which rounds off hard steps and removes "
"speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it "
"costs fine detail."));
"costs fine detail."),
{ { "smoothing_off.png", _L("Off") }, { "smoothing_on.png", _L("On") } });
// Edge fade: the relief flattens toward the boundary of the painted area.
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing);
hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it "
"blends into the bare surface instead of stopping at a step."));
"blends into the bare surface instead of stopping at a step."),
{ { "edge_fade_off.png", _L("Off") }, { "edge_fade_on.png", _L("On") } });
ImGui::SameLine();
m_imgui->disabled_begin(!layer.edge_smoothing);
m_preview_params_dirty |= typed_slider("##edge_amount", -(card_pad + edit_icon_room),
@@ -7103,7 +7317,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("Invert"), layer.invert);
hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way "
"round. The same as using a negative of the image."));
"round. The same as using a negative of the image."),
{ { "invert_off.png", _L("Off") }, { "invert_on.png", _L("On") } });
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0)));
@@ -7119,13 +7334,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.
@@ -7135,7 +7349,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty = true;
hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful "
"of filaments can cover a photo or a gradient. An image of flat colors "
"prints the same either way. Off uses one filament per area."));
"prints the same either way. Off uses one filament per area."),
{ { "mix_off.png", _L("Off") }, { "mix_on.png", _L("On") } });
if (opts.color_mix_enabled) {
cached_palette(); // brings m_palette_filaments up to date
// Every mix can become a filament slot, and there are only so many beside the
@@ -7143,7 +7358,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size()));
if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad,
_L("The most mixed filaments a bake may add. They are picked from the "
"texture's colors, and only the ones the bake actually uses are created.")))
"texture's colors, and only the ones the bake actually uses are created."),
{ { "mixed_colors_few.png", _L("Few") }, { "mixed_colors_many.png", _L("Many") } }))
m_preview_params_dirty = true;
// After the slider, so a change shows in the same frame.
const int n_colors = int(cached_palette().size());
@@ -7157,7 +7373,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad,
_L("Cleans up single stray triangles of the wrong color, which detail finer "
"than the mesh leaves behind. Raise it if the result looks speckled, "
"lower it if small features are being swallowed.")))
"lower it if small features are being swallowed."),
{ { "denoise_off.png", _L("Off") }, { "denoise_on.png", _L("On") } }))
m_preview_params_dirty = true;
}
}
@@ -7174,26 +7391,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const char *file;
wxString label;
wxString tip;
const char *picture; // the same ball with the checker laid on it this way, or null for none
};
const MappingIcon mappings[] = {
{ "menu_obj_cube.svg", _L("Triplanar (blended)"),
_L("Triplanar - projects the texture from all three axes at once and blends between them, so a "
"patch wrapping around a sharp edge has no seam.") },
"patch wrapping around a sharp edge has no seam."),
"map_cube.png" },
{ "menu_obj_cylinder.svg", _L("Cylindrical"),
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes.") },
_L("Cylindrical - wraps the texture around the painted area's own center, for round shapes."),
"map_cylinder.png" },
{ "menu_obj_sphere.svg", _L("Spherical"),
_L("Spherical - wraps the texture around the painted area's own center in both directions.") },
_L("Spherical - wraps the texture around the painted area's own center in both directions."),
"map_sphere.png" },
{ "texture_displacement_map_unwrap.svg", _L("Unwrap (LSCM)"),
_L("Unwrap - flattens the painted area and maps the texture onto it with as little stretching as "
"possible. The area is cut into pieces at its sharp edges first (see Seam angle), so each "
"piece can lie flat on its own.") },
"piece can lie flat on its own."),
"map_unwrap.png" },
{ "texture_displacement_map_view.svg", _L("From view"),
_L("From view - projects straight onto the painted area from where you are looking, like a slide "
"projector.") },
"projector."),
"map_view.png" },
{ "texture_displacement_map_model_uv.svg", _L("Model UV"),
_L("Model UV - uses the texture coordinates the model itself came with, so a texture made for "
"that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does "
"not apply: the model's own layout is the placement.") },
"not apply: the model's own layout is the placement."),
nullptr },
};
// Only a volume that actually carries a UV channel can be mapped by it. Shown
// greyed out rather than hidden, so the reason is visible instead of the control
@@ -7208,8 +7432,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine(0.f, gap_s);
const auto method = static_cast<TextureProjectionMethod>(mi);
const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString();
const std::vector<TipMedia> picture = mappings[mi].picture != nullptr ?
std::vector<TipMedia>{ { mappings[mi].picture } } :
std::vector<TipMedia>{};
if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label,
mappings[mi].tip, na) &&
mappings[mi].tip, picture, na) &&
layer.projection_method != method) {
// Capture the current view the moment "From view" is chosen, so it does something
// sensible immediately rather than projecting from a stale/default direction.
@@ -7314,19 +7541,20 @@ 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.");
if (icon_toggle(840, "texture_displacement_tile_repeat.svg", layer.tile_method == static_cast<TextureTileMethod>(0),
icon_sm, _L("Repeat"), _L("Repeat"), tile_na)) {
icon_sm, _L("Repeat"), _L("Repeat"), { { "tile_repeat.png" } }, tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(0);
m_preview_params_dirty = true;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(841, "menu_mirror_x.svg", layer.tile_method == static_cast<TextureTileMethod>(1), icon_sm,
_L("Mirrored repeat"), _L("Mirrored repeat - every other tile is flipped, so edges meet seamlessly"),
tile_na)) {
{ { "tile_mirror.png" } }, tile_na)) {
layer.tile_method = static_cast<TextureTileMethod>(1);
m_preview_params_dirty = true;
}
@@ -7371,15 +7599,17 @@ 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. Override replaces them, "
"so only this layer's relief is left where it is painted. 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. Override replaces them, "
"so only this layer's relief is left where it is painted. 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."),
base_layer ? std::vector<TipMedia>{} :
std::vector<TipMedia>{ { "blend_add.png", from_u8(blend_add) },
{ "blend_multiply.png", from_u8(blend_multiply) } });
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),
@@ -7562,7 +7792,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
hover_tip(_u8L("Adds triangles only where you painted, instead of everywhere. On a large part with a small "
"decal that is the difference between thousands of triangles and millions, and your paint "
"survives the refinement."));
"survives the refinement."),
{ { "painted_only_off.png", _L("Off") }, { "painted_only_on.png", _L("On") } });
if (m_subdivide_adaptive) {
if (m_subdivide_target_mm <= 0.f && mv != nullptr) {
@@ -7590,7 +7821,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
preview_live();
hover_tip(_u8L("Spends the triangles where the texture actually bends - packed along ridges and edges, "
"sparse over flat ground - instead of spreading them evenly. The same detail for fewer "
"triangles on most textures."));
"triangles on most textures."),
{ { "adaptive_off.png", _L("Off") }, { "adaptive_on.png", _L("On") } });
// The edge-length target is a baseline in both modes. In feature mode it is what guarantees the
// curvature test can actually see the texture: left too coarse, a big triangle over a fine
@@ -7643,7 +7875,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f,
_L("Splits every triangle of the model into four, this many times over. Each step "
"quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining "
"only the painted area unless you need the whole model finer."))) {
"only the painted area unless you need the whole model finer."),
{ { "subdivide.gif" } })) {
if (m_subdivide_editing)
rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one
m_parent.set_as_dirty();
@@ -7711,12 +7944,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
}
float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f,
_L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an "
"even mesh; this is what makes one out of an uneven import."));
"even mesh; this is what makes one out of an uneven import."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges);
hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it "
"the remesher slides vertices along the surface and rounds every crisp edge off - "
"a cube comes back with wobbly edges."));
"a cube comes back with wobbly edges."),
{ { "preserve_edges_off.png", _L("Off") }, { "preserve_edges_on.png", _L("On") } });
ImGui::SameLine();
m_imgui->disabled_begin(!m_remesh_keep_sharp_edges);
typed_slider("##remesh_sharp_angle", -(1.f + edit_icon_room), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", false,
@@ -7731,7 +7966,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big "
"ones and merging the small ones - so displacement has an even density to work with. "
"Replaces the geometry; your paint is carried onto the new triangles spatially, so it "
"survives (already-baked relief is kept too)."));
"survives (already-baked relief is kept too)."),
{ { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } });
// Settings for the whole stack rather than one layer. Standard mode pins them instead of showing them.
if (mv != nullptr) {
@@ -7747,7 +7983,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Smooth result"), opts.smooth_enabled);
hover_tip(_u8L("Smooths the geometry after the texture has been applied, to take the hard steps out of a "
"low-resolution image. Only what the displacement moved is touched. The Smoothing slider "
"on a layer is a different thing: it blurs the image before it is used."));
"on a layer is a different thing: it blurs the image before it is used."),
{ { "geometry_smoothing.gif" } });
if (opts.smooth_enabled) {
float percent = opts.smooth_strength * 100.f;
if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f,
@@ -7807,7 +8044,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"Relief already baked into the rest of the model is kept on top of it, so a "
"second bake somewhere else gets the same budget as the first. Raise it if the "
"warning below Resolution says the detail will not fit; 0 keeps every triangle "
"the refinement produced, however many that is."))) {
"the refinement produced, however many that is."),
{ { "subdivide.gif" } })) {
opts.v2_max_triangles_k = shown_k;
m_preview_params_dirty = true;
}
@@ -7826,7 +8064,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
hover_tip(_u8L("Collapse the thin triangles refinement inherits from the model's own "
"tessellation, before displacement samples them. A sliver's three corners "
"land on three unrelated parts of the texture, which is what makes the "
"relief look jagged."));
"relief look jagged."),
{ { "collapse_slivers_off.png", _L("Off") }, { "collapse_slivers_on.png", _L("On") } });
}
// Below both pipelines' own settings, because it replays whichever of them is selected.
if (SHOW_PIPELINE_DEV_CONTROLS)
@@ -7927,8 +8166,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Held in a local: the format outlives the call that reads it, and the slider and the
// range in its tooltip are then formatted the same way.
const std::string v2edge_format = auto_res ? _u8L("%.2f mm (auto)") : std::string("%.2f mm");
const bool v2edge_chosen = auto_res && rec.edge_mm > 0.f;
const wxString v2edge_tip =
auto_res && rec.edge_mm > 0.f ?
v2edge_chosen ?
from_u8(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, "
"budget %2% k. Untick Auto to set them yourself."),
rec.edge_mm, rec.budget_k)) :
@@ -7939,7 +8179,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"back down and the detail goes with it.");
m_imgui->disabled_begin(busy || auto_res);
if (typed_slider("##v2edge", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, &shown, 0.02f, 2.f,
v2edge_format.c_str(), true, v2edge_tip)) {
v2edge_format.c_str(), true, v2edge_tip,
v2edge_chosen ? std::vector<TipMedia>{} : std::vector<TipMedia>{ { "subdivide.gif" } })) {
opts.v2_refine_mm = shown;
m_preview_params_dirty = true;
m_parent.set_as_dirty();
@@ -7981,7 +8222,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
&m_subdivide_budget_k, 10, 2000, "+%d k", true,
_L("How many thousand triangles the refinement may add to the model. The triangle "
"that fits worst is always split first, so even a run that spends the lot has "
"spent it where it shows most. Raise it if the relief still looks coarse."))) {
"spent it where it shows most. Raise it if the relief still looks coarse."),
{ { "subdivide.gif" } })) {
if (m_subdivide_editing)
rebuild_subdivide_preview();
m_parent.set_as_dirty();
@@ -8038,18 +8280,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"
@@ -1155,6 +1156,50 @@ 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.;
int m_tip_media_frame = -2; // the frame it was last drawn on, to spot the tooltip reopening
// How long the pointer has rested on the control a tooltip belongs to, and when one was last on
// screen, both on the real clock tip_now() keeps (see the .cpp for why ImGui's own will not do).
ImVec2 m_tip_item{-1.f, -1.f};
double m_tip_hover_since = 0.;
double m_tip_last_shown = -1e9;
// The frame a tooltip was last drawn on, and how many it has been drawn on in a row: ImGui hides
// one on its first frame while it measures it, so it has to be kept on screen to be seen at all.
int m_tip_shown_frame = -2;
int m_tip_settled = 0;
};
} // namespace Slic3r::GUI