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;