From e003b61e17dc5211c5a4f843b5a7a01bb53ac681 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 10:20:44 +0200 Subject: [PATCH] feat: make the Image gizmo a minimal load, click, correct, apply tool The first cut was the displacement panel with depth hidden, which left the brushes, the eight layer slots, the UV editor, seams, tiling and the mesh-preparation sections in a tool that needs none of them, and offered no way to correct the picture at all. This replaces that panel and adds the corrections. The flow is now: load a picture, click the model to put it there, adjust it, press Apply. - The Image tool gets its own window, render_image_input_window(), defined in GLGizmoImage.cpp. on_render_input_window() dispatches to it on the first line, so the displacement panel goes back to exactly what it was rather than being threaded with tests for which tool is drawing. - A click places the picture: the tool pins SMART_FILL with a pointer cursor, so one click floods the surface it lands on out to the first fold sharper than the Spread angle. No brush, no erase toggle. - Projection keeps Flat, Around, Ball and From view. Unwrap is left out and a layer arriving on it falls back to Flat: seams, islands and the UV pane are an editor's worth of interface for a tool that wants a click and a slider. - The preview is pinned to the fast one. With no relief to build it is both instant and exactly the final colour, since the shader's colour path does not depend on depth_mm. - The window shows only what this case needs: the picture, Spread, the four projections, Size, Rotation, the corrections, Apply and Close. Colour correction is new in libslic3r. TextureDisplacementLayer gains brightness, contrast, saturation, gamma and color_invert, all defaulting to the identity, and apply_color_adjustments() applies them to an image's RGB only, never to the height: a brightness slider has no business moving geometry. It runs at the single point every bake and preview job reads a layer's colour from, prepare_sampleable_layers(), and at the two caches that would otherwise miss a change the image_data pointer does not show: the fast preview's GPU texture and the mix ranking. Measured with the texture-bake harness, one cube face painted with a colour image, four filaments: run out tris coloured max offset mm filament shares relief, depth 0.4 mm 41040 33902 0.216000 1:91% 2:6% 3:3% image, no correction 24226 17090 0.000007 1:71% 2:25% 3:4% image, brightness +0.5 8204 1068 0.000004 4:100% image, contrast +0.8 22688 15552 0.000007 1:75% 2:24% 3:1% image, saturation 0 8204 1068 0.000004 3:100% image, gamma 2.2 24754 17618 0.000007 1:66% 4:34% image, negative 8204 1068 0.000004 4:100% Max offset is the distance from every output vertex to the original surface, not a vertex-array diff: the bake refines, so a new vertex only counts as unmoved if it lies in the old surface. Every correction moves which filaments the picture prints in, and none of them moves the surface. --- src/libslic3r/TextureDisplacement.cpp | 60 ++- src/libslic3r/TextureDisplacement.hpp | 28 +- src/slic3r/GUI/Gizmos/GLGizmoImage.cpp | 352 +++++++++++++++++- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 312 +++++++--------- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 25 ++ 5 files changed, 594 insertions(+), 183 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..e564147af8 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -261,6 +261,60 @@ bool height_texture_has_color(const TextureDisplacementLayer &layer) return decode_height_texture(layer).has_color(); } +bool has_color_adjustments(const TextureDisplacementLayer &layer) +{ + return layer.brightness != 0.f || layer.contrast != 0.f || layer.saturation != 1.f || + layer.gamma != 1.f || layer.color_invert; +} + +void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer) +{ + if (!tex.has_color() || !has_color_adjustments(layer)) + return; + + // A contrast of -1 collapses the image to mid grey and +1 doubles the slope about it, which is + // the range a single slider can offer without a scale nobody can read off the handle. + const float slope = 1.f + std::clamp(layer.contrast, -1.f, 1.f); + const float bright = std::clamp(layer.brightness, -1.f, 1.f); + const float sat = std::max(0.f, layer.saturation); + const float inv_g = 1.f / std::max(0.01f, layer.gamma); + const bool negate = layer.color_invert; + + // Each output byte depends only on its own pixel, so the rows are independent. At 1024 x 1024 + // this runs on every preview rebuild and every bake, which is what makes it worth splitting. + const size_t rows = size_t(tex.height); + const size_t cols = size_t(tex.width); + tbb::parallel_for(tbb::blocked_range(0, rows), [&](const tbb::blocked_range &range) { + for (size_t y = range.begin(); y < range.end(); ++y) { + uint8_t *row = tex.rgb.data() + y * cols * 3; + for (size_t x = 0; x < cols; ++x) { + uint8_t *px = row + x * 3; + float c[3]; + for (int k = 0; k < 3; ++k) { + float v = float(px[k]) * (1.f / 255.f); + v = std::pow(v, inv_g); + v = (v - 0.5f) * slope + 0.5f; + v += bright; + c[k] = v; + } + if (sat != 1.f) { + // Rec. 709 luma, the same weights the rest of the colour path uses to decide + // what counts as light and dark. + const float luma = 0.2126f * c[0] + 0.7152f * c[1] + 0.0722f * c[2]; + for (float &v : c) + v = luma + (v - luma) * sat; + } + for (int k = 0; k < 3; ++k) { + float v = std::clamp(c[k], 0.f, 1.f); + if (negate) + v = 1.f - v; + px[k] = uint8_t(std::lround(v * 255.f)); + } + } + } + }); +} + DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) { DecodedHeightTexture result; @@ -3241,9 +3295,13 @@ std::shared_ptr> prepare_sampleable_layers( const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)]; if (data.triangles_to_split.empty()) continue; - const DecodedHeightTexture tex = decode_height_texture(*layer); + DecodedHeightTexture tex = decode_height_texture(*layer); if (tex.empty() || (need_color && !tex.has_color())) continue; + // The one place the bake and every preview job read a layer's colour from, so correcting it + // here is what keeps what the panel shows and what the bake prints the same image. + if (need_color) + apply_color_adjustments(tex, *layer); TriangleSelector selector(selector_mesh); selector.deserialize(data, false); diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..5f9168ad86 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -293,6 +293,16 @@ struct TextureDisplacementLayer // for the boundary to be drawn on. bool color_enabled = false; + // Colour correction, applied to the image's RGB before it is matched against the filaments and + // before the preview quantizes it. Never to the height: the relief is what the grayscale says, + // and a brightness slider has no business moving geometry. The defaults below are the identity, + // so a layer that never meets the Image tool behaves exactly as it did. + float brightness = 0.f; // -1 .. 1, added last, after contrast + float contrast = 0.f; // -1 .. 1, pivoted on mid grey; -1 flattens to grey, 1 doubles the slope + float saturation = 1.f; // 0 .. 2, 1 leaves it alone, 0 is grayscale + float gamma = 1.f; // 0.2 .. 5, 1 leaves it alone; above 1 lifts the midtones + bool color_invert = false; // negative of the colour, as opposed to `invert` which flips relief + bool empty() const { return !image_data || image_data->empty(); } template void save(Archive &ar) const @@ -302,7 +312,8 @@ struct TextureDisplacementLayer static_cast(tile_method), static_cast(projection_method), lscm_seam_angle_deg, islands, static_cast(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, - lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled); + lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, brightness, + contrast, saturation, gamma, color_invert); } template void load(Archive &ar) { @@ -314,7 +325,7 @@ struct TextureDisplacementLayer tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, - view_project_matrix, color_enabled); + view_project_matrix, color_enabled, brightness, contrast, saturation, gamma, color_invert); image_data = blob.empty() ? nullptr : std::make_shared>(blob.begin(), blob.end()); tile_method = static_cast(tile_method_int); projection_method = static_cast(projection_method_int); @@ -489,6 +500,19 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer // texture - cheap enough to ask every frame. Smoothing does not change it, so the raw decode is what is read. bool height_texture_has_color(const TextureDisplacementLayer &layer); +// True when `layer`'s colour correction is anything other than the identity, so callers can skip +// both the work and the cache invalidation it would otherwise force. +bool has_color_adjustments(const TextureDisplacementLayer &layer); + +// Applies that correction to an already decoded image, in place, to `rgb` only. Order is gamma, +// contrast, brightness, saturation, invert, each clamped back into range - the order a photo editor +// applies them in, and the one that keeps contrast pivoted on mid grey rather than on a brightness +// the slider has already moved. +// +// Deliberately not folded into decode_height_texture(): that result is cached per image, shared by +// every layer using the same file, and keyed on the image bytes alone. +void apply_color_adjustments(DecodedHeightTexture &tex, const TextureDisplacementLayer &layer); + // Maps a linear RGB colour in [0, 1] to an index into the caller's palette, or -1 for "no colour". // // Deliberately a callback rather than a function here: matching a colour to a filament is a diff --git a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp index 1349f7c2a8..47814e970c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoImage.cpp @@ -1,10 +1,25 @@ #include "GLGizmoImage.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/format.hpp" + +#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GLTexture.hpp" +#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/ImGuiWrapper.hpp" +#include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/Shortcuts.hpp" #include "slic3r/GUI/Gizmos/GLGizmoPainterBase.hpp" +#include "slic3r/GUI/Gizmos/GLGizmoUtils.hpp" +#include "slic3r/GUI/Jobs/Worker.hpp" +#include +#include +#include #include +#include #include namespace Slic3r::GUI { @@ -18,8 +33,8 @@ bool GLGizmoImage::on_init() { if (!GLGizmoTextureDisplacement::on_init()) return false; - // Everything the base set up stays - the brush descriptions and the shortcut lists are the same - // tools - except the key that opens it, which is this tool's own. + // Everything the base set up stays - it is the same painting machinery - except the key that + // opens it, which is this tool's own. m_shortcut = Shortcut::GizmoImage; return true; } @@ -33,7 +48,338 @@ wxString GLGizmoImage::handle_snapshot_action_name(bool shift_down, GLGizmoPaint { // Same paint, same masks; only the undo step's wording follows the tool the user is in. const bool erasing = shift_down || (m_erase_mode != (button_down == GLGizmoPainterBase::Button::Right)); - return erasing ? _L("Erase image paint") : _L("Paint image"); + return erasing ? _L("Remove image from the area") : _L("Place image"); +} + +// --------------------------------------------------------------------------------------------- +// The Image tool's window. +// +// A member of GLGizmoTextureDisplacement rather than of GLGizmoImage, and defined here rather than +// in that class's own file: it needs the same private machinery the displacement panel uses (the +// layer, the texture picker, the previews, the bake), and keeping it here leaves that 1700 line +// panel untouched instead of threading it with tests for which tool is drawing. +// +// What it deliberately leaves out, all of which only means something once the surface moves or once +// there is more than one picture: brushes and the erase toggle, the layer stack, blend modes, depth, +// midlevel, edge fade, tiling mode, the unwrap projection and its UV editor, seams, the wireframe +// and the mesh-preparation sections. +// --------------------------------------------------------------------------------------------- +void GLGizmoTextureDisplacement::render_image_input_window(float x, float y, float bottom_limit) +{ + ModelObject *mo = m_c->selection_info()->model_object(); + if (mo == nullptr) + return; + ModelVolume *mv = texture_volume(); + + // The palette follows project state nobody tells this gizmo about (the filaments in the + // sidebar), so it is checked once a frame and a change re-runs the preview. + cached_palette(); + if (m_palette_changed) + m_preview_params_dirty = true; + + // Everything this tool fixes, re-pinned each frame so what the preview shows is what Apply will + // produce: Standard's recipe, zero depth with colour on, a click that floods the connected + // surface rather than a brush, and the fast preview, which with no relief to build is both + // instant and exactly the final colour. + if (apply_standard_mode_presets(mv) || apply_image_mode_presets(mv)) + m_preview_params_dirty = true; + m_tool_type = ToolType::SMART_FILL; + m_cursor_type = TriangleSelector::CursorType::POINTER; + m_erase_mode = false; + m_auto_update = true; + if (!m_use_shaded_preview) + apply_view_mode(1); + + float scale = m_parent.get_scale(); +#ifdef WIN32 + const int dpi = get_dpi_for_window(wxGetApp().GetTopWindow()); + scale *= (float) dpi / (float) DPI_DEFAULT; +#endif // WIN32 + + const float approx_height = m_imgui->scaled(22.f); + y = std::min(y, bottom_limit - approx_height); + GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 0.0f, 0.0f); + + ImGuiWrapper::push_toolbar_style(m_parent.get_scale()); + GizmoImguiBegin(get_name(), ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar); + ensure_panel_icons(); + + const ImGuiStyle &style = ImGui::GetStyle(); + const bool busy = m_bake_in_progress || m_prepare_in_progress; + const float panel_w = m_imgui->scaled(16.f); + const float frame_h = ImGui::GetFrameHeight(); + const float icon_sm = std::round(frame_h * 1.05f); + const float gap_s = std::round(m_imgui->scaled(0.4f)); + const float label_w = m_imgui->scaled(4.6f); + const float wrap_w = m_imgui->scaled(15.f); + const ImVec4 orca = ImGuiWrapper::COL_ORCA; + const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator); + + const auto hover_tip = [&](const auto &text) { + if (ImGui::IsItemHovered()) + m_imgui->tooltip(text, wrap_w); + }; + // Label column plus a slider filling the rest of the row, the same shape the displacement panel + // uses, so the two tools read as one family. + const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, + const char *format, bool log) -> bool { + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + m_imgui->text(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + ImGui::SetNextItemWidth(x0 + panel_w - ImGui::GetCursorPosX()); + return ImGui::SliderFloat(id, v, v_min, v_max, format, + ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); + }; + // Framed icon toggle, the displacement panel's one cut down to what this window needs. + const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, const wxString &label, + const wxString &tip_text) -> bool { + const auto it = m_panel_icon_map.find(iconfile); + const float pad = std::max(1.f, std::round(icon_sm * 0.14f)); + bool clicked = false; + ImGui::PushID(uid); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f); + ImGui::PushStyleColor(ImGuiCol_Button, active ? ImVec4(orca.x, orca.y, orca.z, 0.22f) : ImVec4(0.f, 0.f, 0.f, 0.f)); + if (it != m_panel_icon_map.end() && int(it->second.size()) > (active ? 1 : 0) && + it->second[active ? 1 : 0]->is_valid()) { + const IconManager::Icon &ic = *it->second[active ? 1 : 0]; + clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id, + ImVec2(icon_sm - 2.f * pad, icon_sm - 2.f * pad), ic.tl, ic.br, int(pad)); + } else { + clicked = ImGui::Button(label.ToUTF8().data(), ImVec2(0.f, icon_sm)); + } + ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), + ImGui::GetColorU32(active ? orca : col_frame), style.FrameRounding); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + ImGui::PopID(); + hover_tip(tip_text); + return clicked; + }; + + TextureDisplacementLayer *layer = mv != nullptr ? active_layer() : nullptr; + + m_imgui->push_bold_font(); + m_imgui->text(wxString::FromUTF8(get_name(false))); + m_imgui->pop_bold_font(); + ImGui::Separator(); + + if (layer == nullptr) { + // Nothing loaded yet, so the window is one button. Picking an image is what creates the + // layer; there is no "add a layer first" step to understand. + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w); + ImGui::TextDisabled("%s", _u8L("Load a picture, then click the model to put it there.").c_str()); + ImGui::PopTextWrapPos(); + m_imgui->disabled_begin(busy || mv == nullptr); + if (ImGui::Button((_u8L("Load image") + "##load").c_str(), ImVec2(panel_w, 0.f))) { + add_texture_layer(); // seeds the first library image that has colour in it + m_picker_slot = m_active_layer_slot; + m_picker_open_request = true; // ... and offers the library, Import included, straight away + } + m_imgui->disabled_end(); + } else { + // The picture: thumbnail and name both reopen the library, which is also where Import lives. + const std::string name = layer->name.empty() ? _u8L("Image") : layer->name; + if (GLTexture *thumb = get_layer_thumbnail(*layer)) { + if (ImGui::ImageButton((ImTextureID) (intptr_t) thumb->get_id(), ImVec2(frame_h, frame_h), + ImVec2(0.f, 0.f), ImVec2(1.f, 1.f), 0)) { + m_picker_slot = layer->slot; + m_picker_open_request = true; + } + } else if (ImGui::Button("?##thumb", ImVec2(frame_h, frame_h))) { + m_picker_slot = layer->slot; + m_picker_open_request = true; + } + hover_tip(_u8L("Choose a different picture. Where you placed it and how it is corrected stay as they are.")); + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(name.c_str()); + + if (!height_texture_has_color(*layer)) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w); + m_imgui->warning_text(_L("This image is grayscale, so it has no colors to print. Choose a color picture.")); + ImGui::PopTextWrapPos(); + } + + // Placing it: one click floods the surface it lands on, out to the first fold sharper than + // the angle below. No brush, no erase toggle - on a flat side or a cube face the click alone + // is the whole gesture, and the slider is there for the cases where it is not. + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + panel_w); + ImGui::TextDisabled("%s", mv != nullptr && mv->is_texture_displacement_painted() ? + _u8L("Click the model to move the picture somewhere else.").c_str() : + _u8L("Click the model to put the picture there.").c_str()); + ImGui::PopTextWrapPos(); + float fill_angle = m_smart_fill_angle; + if (float_row("##fill_angle", _L("Spread"), &fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false)) + m_smart_fill_angle = fill_angle; + hover_tip(_u8L("How far a click spreads from the spot you hit: it keeps going while the surface stays " + "flatter than this, and stops at a sharper fold. Small values keep it to one face.")); + + ImGui::Separator(); + + // Projection, minus Unwrap: that one is an editor's worth of UI (seams, islands, the UV pane) + // for a tool whose whole point is a click and a few sliders. + { + struct Mapping { const char *file; wxString label; wxString tip; TextureProjectionMethod method; }; + const Mapping mappings[] = { + { "menu_obj_cube.svg", _L("Flat"), + _L("Flat - lays the picture straight onto the surface from whichever axis it faces, and blends " + "where it wraps around an edge. The right choice for a face or a flat side."), + TextureProjectionMethod::Triplanar }, + { "menu_obj_cylinder.svg", _L("Around"), + _L("Around - wraps the picture around the area, for a round part like a mug or a tube."), + TextureProjectionMethod::Cylindrical }, + { "menu_obj_sphere.svg", _L("Ball"), + _L("Ball - wraps the picture around the area in both directions, for a dome or a sphere."), + TextureProjectionMethod::Spherical }, + { "texture_displacement_map_view.svg", _L("From view"), + _L("From view - throws the picture onto the area from where you are looking now, like a slide " + "projector. Line the view up with the surface first, because faces turned away stretch."), + TextureProjectionMethod::ViewProjected }, + }; + const float x0 = ImGui::GetCursorPosX(); + ImGui::AlignTextToFramePadding(); + // Plain text, like every other label in this window: the displacement panel greys its + // headings because it has many, and here there are five rows in total. + m_imgui->text(_L_CONTEXT("Mapping", "Texture Displacement")); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + for (int mi = 0; mi < int(IM_ARRAYSIZE(mappings)); ++mi) { + if (mi > 0) + ImGui::SameLine(0.f, gap_s); + const bool on = layer->projection_method == mappings[mi].method; + if (icon_toggle(900 + mi, mappings[mi].file, on, mappings[mi].label, mappings[mi].tip) && !on) { + // Capture the current view the moment "From view" is chosen, so it does something + // sensible immediately rather than projecting from a stale direction. + if (mappings[mi].method == TextureProjectionMethod::ViewProjected) + capture_view_projection(*layer); + layer->projection_method = mappings[mi].method; + m_preview_params_dirty = true; + } + } + } + if (layer->projection_method == TextureProjectionMethod::ViewProjected) { + if (m_imgui->button(_u8L("Capture current view"))) { + capture_view_projection(*layer); + m_preview_params_dirty = true; + } + hover_tip(_u8L("Throws the picture on again from where you are looking now. Orbit the model, press " + "this, and it is re-laid from the new angle.")); + } + + m_preview_params_dirty |= float_row("##size", _L("Size"), &layer->tiling_scale, 1.f, 300.f, "%.1f mm", true); + hover_tip(_u8L("How wide the picture is on the model, in millimetres.")); + m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f°", false); + hover_tip(_u8L("Turns the picture on the surface, for lining it up with an edge of the model.")); + + ImGui::Separator(); + + // Colour correction. Every one of these changes which filament each area lands in, which is + // why they are here rather than left to whatever the file happened to contain: a photo that + // prints as mud usually only needs contrast. + m_preview_params_dirty |= float_row("##brightness", _L("Brightness"), &layer->brightness, -1.f, 1.f, "%.2f", false); + hover_tip(_u8L("Lightens or darkens the whole picture before it is matched to your filaments.")); + m_preview_params_dirty |= float_row("##contrast", _L("Contrast"), &layer->contrast, -1.f, 1.f, "%.2f", false); + hover_tip(_u8L("Pushes light and dark apart. Raise it when a photo prints as mud; lower it when " + "everything collapses into the lightest and darkest filament.")); + m_preview_params_dirty |= float_row("##saturation", _L("Saturation"), &layer->saturation, 0.f, 2.f, "%.2f", false); + hover_tip(_u8L("How strong the colors are. At 0 the picture is gray, so it prints in your grays and " + "blacks; raising it pushes each area towards a purer filament.")); + m_preview_params_dirty |= float_row("##gamma", _L("Gamma"), &layer->gamma, 0.2f, 5.f, "%.2f", true); + hover_tip(_u8L("Lifts or drops the midtones without touching the brightest and darkest parts. Use it " + "when the middle of the picture is the part coming out wrong.")); + if (m_imgui->bbl_checkbox(_L("Negative"), layer->color_invert)) + m_preview_params_dirty = true; + hover_tip(_u8L("Prints the picture inverted, light for dark and each color for its opposite.")); + ImGui::SameLine(); + if (m_imgui->button(_u8L("Reset"))) { + layer->brightness = 0.f; + layer->contrast = 0.f; + layer->saturation = 1.f; + layer->gamma = 1.f; + layer->color_invert = false; + m_preview_params_dirty = true; + } + hover_tip(_u8L("Puts every correction above back to where it started, leaving the picture as it was loaded.")); + } + + // The library popup lives in this window's scope, where its open request and the popup itself + // share one ID stack. + { + const ImVec2 win_min = ImGui::GetWindowPos(); + const ImVec2 win_sz = ImGui::GetWindowSize(); + render_texture_library_popup(win_min, ImVec2(win_min.x + win_sz.x, win_min.y + win_sz.y)); + } + + // ImGui sliders report "changed" on every frame of a drag, and a preview rebuild is real work, + // so with the mouse still down it waits for the release. + if (m_preview_params_dirty && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + rebuild_preview(); + m_preview_params_dirty = false; + } + + ImGui::Separator(); + { + const bool painted = mv != nullptr && mv->is_texture_displacement_painted(); + const bool no_colors = mv != nullptr && !any_layer_colors(*mv); + // Colour is the only thing this bake produces, so with a grayscale picture there would be + // nothing to show for it - and it is not a cheap no-op: the bake still refines the painted + // area and replaces the mesh with the result. + const bool can_apply = !busy && mv != nullptr && painted && !no_colors; + + const float button_h = std::round(frame_h * 1.25f); + const float button_w = std::max({ ImGui::CalcTextSize(_u8L("Apply").c_str()).x, + ImGui::CalcTextSize(_u8L("Close").c_str()).x, + ImGui::CalcTextSize(_u8L("Stop").c_str()).x, + ImGui::CalcTextSize(_u8L("Working...").c_str()).x }) + + m_imgui->scaled(0.5f); + const float row_y = ImGui::GetCursorPosY(); + const float icon_h = 21.f * scale; + const float row_h = std::max(icon_h, button_h); + + ImGui::SetCursorPosY(row_y + (row_h - icon_h) * .5f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(int(m_imgui->scaled(.5f)), style.ItemSpacing.y)); + GLGizmoUtils::render_wiki_guide_button(m_parent, scale, + "https://www.orcaslicer.com/wiki/print_prepare/prepare_texture_displacement"); + ImGui::PopStyleVar(1); + + ImGui::SameLine(panel_w - button_w * 2 - style.ItemSpacing.x); + GLGizmoUtils::push_orca_button_style(); + m_imgui->push_bold_font(); + m_imgui->disabled_begin(!can_apply); + ImGui::SetCursorPosY(row_y + (row_h - button_h) * .5f); + const std::string apply_label = busy ? _u8L("Working...") : _u8L("Apply"); + if (ImGui::Button((apply_label + "##apply").c_str(), ImVec2(button_w, button_h))) + bake_standard(); + m_imgui->disabled_end(); + m_imgui->pop_bold_font(); + GLGizmoUtils::pop_orca_button_style(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + m_imgui->tooltip(layer == nullptr ? _u8L("Load a picture first.") : + !painted ? _u8L("Click the model to place the picture first.") : + no_colors ? _u8L("This picture is grayscale, so there is nothing to print. " + "Choose a color picture.") : + _u8L("Prints the picture where you placed it, in the nearest of your " + "loaded filaments. The surface itself is left exactly where it " + "is: this adds color, not relief."), + wrap_w); + + ImGui::SameLine(); + if (busy) { + if (ImGui::Button((_u8L("Stop") + "##stop").c_str(), ImVec2(button_w, button_h))) + wxGetApp().plater()->get_ui_job_worker().cancel_all(); + hover_tip(_u8L("Stops it. Whatever it had already finished stays on the model, and can be undone.")); + } else { + if (ImGui::Button((_u8L("Close") + "##close").c_str(), ImVec2(button_w, button_h))) + m_parent.reset_all_gizmos(); + hover_tip(_u8L("Closes the tool. Your picture and where you put it stay with the model.")); + } + } + + GizmoImguiEnd(); + ImGuiWrapper::pop_toolbar_style(); } } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 660bd76f62..93f26ffeca 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -531,6 +531,7 @@ void GLGizmoTextureDisplacement::on_shutdown() m_palette_pure_quantizer = nullptr; m_palette_filaments.clear(); m_palette_images.clear(); + m_palette_adjusts.clear(); m_mix_ranking.reset(); m_palette_slots.clear(); m_palette_changed = false; @@ -4544,15 +4545,22 @@ const std::vector &GLGizmoTextureDispl // The images themselves rather than their addresses, so a freed and reallocated image can never // pass for the old one. Whether one has colour, and whether its colours are flat, follows from it. std::vector>> images; + // ... and what each one is corrected by, which changes the colours the mixes are ranked against + // without changing the image it came from. + std::vector adjusts; if (mv != nullptr) for (const TextureDisplacementLayer &layer : mv->texture_displacement_layers) - if (layer.color_enabled && !layer.empty()) + if (layer.color_enabled && !layer.empty()) { images.push_back(layer.image_data); + adjusts.push_back(ColorAdjustKey::of(layer)); + } - const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images; + const bool ranking_stale = filaments != m_palette_filaments || images != m_palette_images || + adjusts != m_palette_adjusts; if (ranking_stale) { m_palette_filaments = std::move(filaments); m_palette_images = std::move(images); + m_palette_adjusts = std::move(adjusts); m_mix_ranking.reset(); } if (mixing && !m_mix_ranking) @@ -4629,9 +4637,12 @@ std::vector GLGizmoTextureDisplacement::m continue; TextureDisplacementLayer raw = layer; raw.smoothing = 0.f; - const DecodedHeightTexture tex = decode_height_texture(raw); + DecodedHeightTexture tex = decode_height_texture(raw); if (!tex.has_color()) continue; + // Corrected first: the mixes are chosen to cover the colours the bake will actually ask for, + // and a darkened or desaturated image asks for different ones. + apply_color_adjustments(tex, layer); // 16 levels per channel, each bin keeping the mean of the colours that fell in it: a coarse grid // to gather on, without snapping every colour to a bin corner. @@ -5337,10 +5348,16 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp { if (layer.empty() || !layer.color_enabled) return nullptr; - if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing) + const ColorAdjustKey adjust = ColorAdjustKey::of(layer); + if (m_color_tex && m_color_tex_source == layer.image_data.get() && m_color_tex_smoothing == layer.smoothing && + m_color_tex_adjust == adjust) return m_color_tex.get(); - std::unique_ptr texture = upload_color_texture(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); + // Corrected here rather than inside the decode, exactly as prepare_sampleable_layers() does for + // the bake, so the fast preview shades the image the bake will print. + DecodedHeightTexture tex = decode_height_texture(layer); + apply_color_adjustments(tex, layer); + std::unique_ptr texture = upload_color_texture(tex, HEIGHT_TEX_MAX_PX); if (!texture) { m_color_tex.reset(); m_color_tex_source = nullptr; @@ -5349,6 +5366,7 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_color_texture(const TextureDisp m_color_tex = std::move(texture); m_color_tex_source = layer.image_data.get(); m_color_tex_smoothing = layer.smoothing; + m_color_tex_adjust = adjust; return m_color_tex.get(); } @@ -5456,14 +5474,14 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv) } // Pinned into the volume rather than applied on the way to the bake, exactly as Standard mode pins -// displace_border and smooth_enabled: the preview, the bake, the triangle estimate and the UV pane all -// read the layers straight off the volume, and a value that only some of them saw would put the -// preview and the bake out of step. +// displace_border and smooth_enabled above: the preview, the bake, the triangle estimate and the +// palette all read the layers straight off the volume, and a value that only some of them saw would +// put the preview and the bake out of step. // // The two tools share one layer stack - that is what "not a fork" means here - so opening the Image // tool on a volume whose layers carry relief flattens them, and they stay flat when the displacement -// tool is reopened. Only that one number changes: the paint, the image, the tiling, the mapping and -// any relief already baked into the mesh are untouched. +// tool is reopened. Only that one number changes: the paint, the image, the placement and any relief +// already baked into the mesh are untouched. bool GLGizmoTextureDisplacement::apply_image_mode_presets(ModelVolume *mv) { if (mv == nullptr) @@ -5478,13 +5496,19 @@ bool GLGizmoTextureDisplacement::apply_image_mode_presets(ModelVolume *mv) changed = true; } // Colour is the only thing an image bake produces, so it is on wherever there is colour to - // use rather than being a checkbox. A grayscale picture leaves it off, which is what makes - // the panel's "this image has no colors" note the honest answer instead of a silent no-op. + // use rather than being a checkbox the user could leave off and wonder at. const bool wanted = height_texture_has_color(layer); if (layer.color_enabled != wanted) { layer.color_enabled = wanted; changed = true; } + // Unwrap is the one projection the Image tool does not offer: it is an editor's worth of UI + // (seams, islands, the UV pane) for a case that wants a click and a slider. A layer arriving + // from the displacement tool set to it falls back to the triplanar default. + if (layer.projection_method == TextureProjectionMethod::LSCM) { + layer.projection_method = TextureProjectionMethod::Triplanar; + changed = true; + } } return changed; } @@ -5797,6 +5821,14 @@ void GLGizmoTextureDisplacement::render_paint_cursor_hint() void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit) { + // The Image tool gets its own, much smaller window (see GLGizmoImage.cpp): everything below this + // point is the displacement panel, and it is left exactly as it was rather than being threaded + // with tests for which of the two tools is drawing. + if (image_mode()) { + render_image_input_window(x, y, bottom_limit); + return; + } + ModelObject *mo = m_c->selection_info()->model_object(); if (!mo) return; @@ -5845,14 +5877,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const float previous_body_h = m_panel_body_h; const float previous_footer_h = m_panel_footer_h; - // The Image tool is Standard with the relief taken out of it; there is no Pro there to switch to. - if (image_mode()) - m_panel_mode = 0; // Pinned every frame while Standard is active, so what Preview shows is always what Bake will do. if (!pro_mode() && apply_standard_mode_presets(mv)) m_preview_params_dirty = true; - if (image_mode() && apply_image_mode_presets(mv)) - m_preview_params_dirty = true; // Layout follows the Option C design: a fixed-width panel, a header pinned at the top, a body that // scrolls as one, and a footer with Bake pinned at the bottom. Rows are laid out against panel_w and @@ -6007,26 +6034,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float { const float x0 = ImGui::GetCursorPosX(); ImGui::AlignTextToFramePadding(); - // The tool's own name, so the Image panel does not announce itself as the one it is built on. - heading(wxString::FromUTF8(get_name(false))); + heading(_L("Texture displacement")); const std::string labels[2] = { _u8L("Standard"), _u8L("Pro") }; const float seg_pad = m_imgui->scaled(0.5f); const float seg_w[2] = { ImGui::CalcTextSize(labels[0].c_str()).x + 2.f * seg_pad, ImGui::CalcTextSize(labels[1].c_str()).x + 2.f * seg_pad }; - // Standard / Pro is a mode, not an option: Standard hides every mesh-preparation control and folds - // the whole recipe into Bake, Pro shows all of it and hands the ordering to the user. The Image - // tool is the restricted case of Standard, so it offers neither and the segment is left out. - const int mode_buttons = image_mode() ? 0 : 2; ImGui::SameLine(); - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), - x0 + panel_w - icon_sm - - (mode_buttons > 0 ? seg_w[0] + seg_w[1] + gap_s : 0.f))); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + panel_w - (seg_w[0] + seg_w[1] + gap_s + icon_sm))); + // Standard / Pro is a mode, not an option: Standard hides every mesh-preparation control and folds + // the whole recipe into Bake, Pro shows all of it and hands the ordering to the user. const ImVec2 seg_min = ImGui::GetCursorScreenPos(); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.f, style.ItemSpacing.y)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f); m_imgui->disabled_begin(busy); - for (int m = 0; m < mode_buttons; ++m) { + for (int m = 0; m < 2; ++m) { if (m > 0) ImGui::SameLine(); const bool on = m_panel_mode == m; @@ -6057,11 +6079,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_imgui->disabled_end(); ImGui::PopStyleVar(2); - if (mode_buttons > 0) { - ImGui::GetWindowDrawList()->AddRect(seg_min, ImVec2(ImGui::GetItemRectMax().x, seg_min.y + frame_h), - ImGui::GetColorU32(col_frame), style.FrameRounding); - ImGui::SameLine(0.f, gap_s); - } + ImGui::GetWindowDrawList()->AddRect(seg_min, ImVec2(ImGui::GetItemRectMax().x, seg_min.y + frame_h), + ImGui::GetColorU32(col_frame), style.FrameRounding); + + ImGui::SameLine(0.f, gap_s); if (icon_button(806, "canvas_drag.svg", icon_sm, m_undocked ? _L("Dock panel") : _L("Undock panel"), _L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it back beside " "the toolbar."))) @@ -6334,7 +6355,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // ---- Texture layers: the active layer as an open card, every other one as a single row ---- { ImGui::AlignTextToFramePadding(); - heading(image_mode() ? _L("Images") : _L("Texture layers")); + heading(_L("Texture layers")); if (mv != nullptr) { const std::string count = std::to_string(mv->texture_displacement_layers.size()) + " / " + std::to_string(TEXTURE_DISPLACEMENT_MAX_LAYERS); @@ -6509,13 +6530,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // The three controls nearly every layer needs; everything else waits behind "More settings". // Depth and tile size are logarithmic, so both ends of their wide ranges stay usable. - // Depth is the one the Image tool has no use for: it is pinned to 0 there, and a slider - // that must not be moved is worse than none. - if (!image_mode()) { - m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); - hover_tip(_u8L("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.")); - } + m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); + hover_tip(_u8L("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.")); m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad); hover_tip(_u8L("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 " @@ -6536,87 +6553,65 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } // Midlevel: the height that means "don't move". At 0 the surface only ever bulges outwards; - // at 0.5 mid-grey is neutral and darker texels cut inwards. Every setting from here to - // Smoothing describes relief only, so the Image tool leaves the lot out. - if (!image_mode()) { - m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); - hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " - "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.")); - 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.")); - ImGui::PopTextWrapPos(); - } + // at 0.5 mid-grey is neutral and darker texels cut inwards. + m_preview_params_dirty |= float_row("##midlevel", _L("Midlevel"), &layer.midlevel, 0.f, 10.f, "%.2f", false, card_pad); + hover_tip(_u8L("Which gray stays where the surface already is. At 0 the texture only pushes " + "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.")); + 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.")); + ImGui::PopTextWrapPos(); } - // Blurs the image itself, so it is as much a colour control as a relief one - the Image - // tool keeps it for exactly that. m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad); hover_tip(_u8L("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.")); - if (!image_mode()) { - // 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.")); - ImGui::SameLine(); - m_imgui->disabled_begin(!layer.edge_smoothing); - ImGui::SetNextItemWidth(-card_pad); - m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", - ImGuiSliderFlags_AlwaysClamp); - m_imgui->disabled_end(); - hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " - "soften a narrow band at the edge; 1 flattens almost all of it.")); - } + // 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.")); + ImGui::SameLine(); + m_imgui->disabled_begin(!layer.edge_smoothing); + ImGui::SetNextItemWidth(-card_pad); + m_preview_params_dirty |= ImGui::SliderFloat("##edge_amount", &layer.edge_smoothing_amount, 0.02f, 1.f, "%.2f", + ImGuiSliderFlags_AlwaysClamp); + m_imgui->disabled_end(); + hover_tip(_u8L("How far in the fade reaches, as a share of the painted area. Small values " + "soften a narrow band at the edge; 1 flattens almost all of it.")); // Invert and Colours share a row. { const float x0 = ImGui::GetCursorPosX(); - if (!image_mode()) { - 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.")); - ImGui::SameLine(); - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0))); - } + 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.")); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0))); // Only offered for a texture that actually has colour - the shipped library is grayscale, // and a checkbox that silently does nothing on nine textures out of ten is worse than no // checkbox. Disabled rather than hidden so it is clear the feature exists and what it wants. - // - // In the Image tool colour is not a choice at all - it is the only thing the bake produces - - // so the checkbox gives way to a warning on the one case that cannot work: a picture with no - // colour in it, which would bake to nothing. const bool has_color = decode_height_texture(layer).has_color(); - const bool color_enabled = image_mode() ? has_color : (layer.color_enabled && has_color); - if (image_mode()) { - if (!has_color) { - ImGui::PushTextWrapPos(content_rx - card_pad); - m_imgui->warning_text(_L("This image is grayscale - pick one with colors in it.")); - ImGui::PopTextWrapPos(); - } - } else { - bool enable = color_enabled; - m_imgui->disabled_begin(!has_color); - if (m_imgui->bbl_checkbox(_L("Colors"), enable)) { - layer.color_enabled = enable; - m_preview_params_dirty = true; - } - 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); + bool color_enabled = layer.color_enabled && has_color; + m_imgui->disabled_begin(!has_color); + if (m_imgui->bbl_checkbox(_L("Colors"), color_enabled)) { + layer.color_enabled = color_enabled; + m_preview_params_dirty = true; } + 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); // The rest of colour belongs to the whole stack, not to this layer, so it only appears once - // under whichever layer turned colour on. @@ -6804,51 +6799,40 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } } - // Blend, and placement on the model. Blend combines *displacements*, so with every - // layer pinned to zero depth it has nothing to combine - the Image tool shows only the - // placement handle. Colour does not blend either way: a higher layer simply paints over - // a lower one where they overlap. + // Blend, and placement on the model. { const float x0 = ImGui::GetCursorPosX(); - if (!image_mode()) { - ImGui::AlignTextToFramePadding(); - ImGui::TextDisabled("%s", _u8L("Blend").c_str()); - ImGui::SameLine(); - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); - const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement"); - const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement"); - const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement"); - const std::string blend_divide = _u8L("Divide"); - const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() }; - // The first layer has nothing before it to combine with - build_texture_displacement() makes it - // add regardless - so its combo shows Add and cannot be changed. - const bool base_layer = li == 0; - int blend_mode = base_layer ? 0 : static_cast(layer.blend_mode); - ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm)); - m_imgui->disabled_begin(base_layer); - if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) { - layer.blend_mode = static_cast(blend_mode); - m_preview_params_dirty = true; - } - 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); - - ImGui::SameLine(0.f, gap_s); - } else { - // On its own, the handle toggle needs the label the Blend row would have carried. - ImGui::AlignTextToFramePadding(); - ImGui::TextDisabled("%s", _u8L("Placement").c_str()); - ImGui::SameLine(); - ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", _u8L("Blend").c_str()); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w)); + const std::string blend_add = _u8L_CONTEXT("Add", "Texture Displacement"); + const std::string blend_subtract = _u8L_CONTEXT("Subtract", "Texture Displacement"); + const std::string blend_multiply = _u8L_CONTEXT("Multiply", "Texture Displacement"); + const std::string blend_divide = _u8L("Divide"); + const char *blend_items[] = { blend_add.c_str(), blend_subtract.c_str(), blend_multiply.c_str(), blend_divide.c_str() }; + // The first layer has nothing before it to combine with - build_texture_displacement() makes it + // add regardless - so its combo shows Add and cannot be changed. + const bool base_layer = li == 0; + int blend_mode = base_layer ? 0 : static_cast(layer.blend_mode); + ImGui::SetNextItemWidth(-(card_pad + gap_s + icon_sm)); + m_imgui->disabled_begin(base_layer); + if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items)) && !base_layer) { + layer.blend_mode = static_cast(blend_mode); + m_preview_params_dirty = true; } + 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); + + ImGui::SameLine(0.f, gap_s); if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"), _L("Adjust placement - drag the handle on the model to move the texture"))) { if (!m_adjust_texture_mode) { @@ -6942,13 +6926,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float hover_tip(_u8L("Pick a different image for this layer. The paint, depth and tiling stay as they are.")); ImGui::SameLine(); - // Depth is what a closed relief layer is summarised by; with it pinned to zero that says - // nothing, so the Image tool reports the one state a closed row still has. char depth_text[32]; std::snprintf(depth_text, sizeof(depth_text), "%.3f mm", layer.depth_mm); - const std::string summary = !painted ? _u8L("not painted") : - image_mode() ? _u8L("painted") : - std::string(depth_text); + const std::string summary = painted ? std::string(depth_text) : _u8L("not painted"); const float sum_w = ImGui::CalcTextSize(summary.c_str()).x; const float right_edge = ImGui::GetWindowContentRegionMax().x - inner; const float name_w = std::max(0.f, right_edge - ImGui::GetCursorPosX() - 2.f * gap_s - sum_w - frame_h); @@ -7002,9 +6982,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float char depth_text[32]; std::snprintf(depth_text, sizeof(depth_text), "%.3f mm", l.depth_mm); - const std::string summary = !slot_painted(l.slot) ? _u8L("not painted") : - image_mode() ? _u8L("painted") : - std::string(depth_text); + const std::string summary = slot_painted(l.slot) ? std::string(depth_text) : _u8L("not painted"); const float sum_w = ImGui::CalcTextSize(summary.c_str()).x; const float text_y = mn.y + 0.5f * (row_h - ImGui::GetFontSize()); fg->AddText(ImVec2(mx.x - inner - sum_w, text_y), ImGui::GetColorU32(ImGuiCol_TextDisabled), summary.c_str()); @@ -7015,9 +6993,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (mv != nullptr) { const bool full = mv->texture_displacement_layers.size() >= TEXTURE_DISPLACEMENT_MAX_LAYERS; - const std::string label = full ? Slic3r::format(_u8L("All %1% layers used"), TEXTURE_DISPLACEMENT_MAX_LAYERS) : - image_mode() ? "+ " + _u8L("Add image") : - "+ " + _u8L("Add layer"); + const std::string label = full ? Slic3r::format(_u8L("All %1% layers used"), TEXTURE_DISPLACEMENT_MAX_LAYERS) : + "+ " + _u8L("Add layer"); m_imgui->disabled_begin(busy || full); if (ImGui::Button((label + "##add_layer").c_str(), ImVec2(ImGui::GetContentRegionAvail().x, 0.f))) add_texture_layer(); @@ -7407,14 +7384,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float hover_tip(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)); - else if (image_mode()) - // Same number, different thing to look at: with no relief to carry, the mesh under the - // paint is only ever as fine as the color edges printed along it need it to be. - hover_tip(_u8L("How fine the mesh is made under the paint, in millimetres. Each triangle " - "prints in one filament, so this is how sharp an edge between two colors " - "can come out. Smaller costs triangles fast: halving it needs four times " - "as many, and once they no longer fit the budget the bake simplifies back " - "down and the sharpness goes with it.")); else hover_tip(_u8L("How fine the mesh is made under the paint, in millimetres. It has to be " "smaller than the detail you want out of the texture - a 0.5 mm groove needs " @@ -7495,11 +7464,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(x0 + panel_w - button_w * 2 - style.ItemSpacing.x); - // Colour is the only thing an image bake produces, so with no colouring layer there would be - // nothing to show for it - and it is not a cheap no-op: the bake would still refine the painted - // area and replace the mesh with the result. - const bool no_colors = image_mode() && mv != nullptr && !any_layer_colors(*mv); - const bool can_bake = !busy && mv != nullptr && mv->is_texture_displacement_painted() && !no_colors; + const bool can_bake = !busy && mv != nullptr && mv->is_texture_displacement_painted(); const std::string bake_label = m_prepare_in_progress ? _u8L("Preparing...") : m_bake_in_progress ? _u8L("Baking...") : into_u8(m_desc.at("bake")); @@ -7524,13 +7489,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "strokes on the model mark seams instead of painting - turn " "it off in the pane to paint.") : _u8L("Nothing is painted yet.")) : - no_colors ? - _u8L("None of the images have colors in them, so there is nothing to print. Pick a " - "color picture.") : - image_mode() ? - _u8L("Prints the painted area in the picture's colors. Each color is matched to the " - "nearest of your loaded filaments; the surface itself is left exactly where it " - "is, and anything you did not paint keeps the object's own color.") : 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.") : diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index d369b1dc75..8e3d655b62 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -154,6 +154,25 @@ public: // What the cache was built from. std::vector m_palette_filaments; std::vector>> m_palette_images; // the colouring layers' images + // One layer's colour correction, as a value that can be compared and cached on. It belongs to + // every cache keyed on "what the image looks like", because a correction slider changes the + // pixels while leaving the image_data pointer alone. + struct ColorAdjustKey + { + float brightness = 0.f, contrast = 0.f, saturation = 1.f, gamma = 1.f; + bool invert = false; + bool operator==(const ColorAdjustKey &o) const + { + return brightness == o.brightness && contrast == o.contrast && saturation == o.saturation && + gamma == o.gamma && invert == o.invert; + } + bool operator!=(const ColorAdjustKey &o) const { return !(*this == o); } + static ColorAdjustKey of(const TextureDisplacementLayer &l) + { + return { l.brightness, l.contrast, l.saturation, l.gamma, l.color_invert }; + } + }; + std::vector m_palette_adjusts; // alongside m_palette_images, one per entry std::optional> m_mix_ranking; // rank_mixes(), computed on demand bool m_palette_mixing = false; int m_palette_mix_count = 0; @@ -278,6 +297,11 @@ private: // picture has any. Those two are what turn the displacement bake into "put this picture on the // model" without a second code path. Returns true if it actually changed something. bool apply_image_mode_presets(ModelVolume *mv); + // The Image tool's whole window: load a picture, click the model to place it, correct it, Apply. + // Defined in GLGizmoImage.cpp, beside the class it exists for, rather than here - it is a member + // only so that it can reach the same private machinery the displacement panel uses, and keeping + // it out of this file leaves that panel's 1700 lines alone. + void render_image_input_window(float x, float y, float bottom_limit); // Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace. // The order matters and is the whole reason this is one button - a height map can only move // existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop @@ -948,6 +972,7 @@ private: std::unique_ptr m_color_tex; const void *m_color_tex_source = nullptr; float m_color_tex_smoothing = -1.f; + ColorAdjustKey m_color_tex_adjust; // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures;