diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 4b498a3185..38bd235ff2 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -301,6 +301,8 @@ set(SLIC3R_GUI_SOURCES GUI/Jobs/SLAImportJob.hpp GUI/Jobs/TextureDisplacementBakeJob.cpp GUI/Jobs/TextureDisplacementBakeJob.hpp + GUI/Jobs/TextureDisplacementPreviewJob.cpp + GUI/Jobs/TextureDisplacementPreviewJob.hpp GUI/Jobs/ThreadSafeQueue.hpp GUI/Jobs/UpgradeNetworkJob.cpp GUI/Jobs/UpgradeNetworkJob.hpp @@ -470,6 +472,8 @@ set(SLIC3R_GUI_SOURCES GUI/TaskManager.hpp GUI/TextLines.cpp GUI/TextLines.hpp + GUI/TextureLibrary.cpp + GUI/TextureLibrary.hpp GUI/TickCode.cpp GUI/TickCode.hpp GUI/TroubleshootDialog.cpp @@ -486,6 +490,8 @@ set(SLIC3R_GUI_SOURCES GUI/UserManager.hpp GUI/UserNotification.cpp GUI/UserNotification.hpp + GUI/UVEditorCanvas.cpp + GUI/UVEditorCanvas.hpp GUI/WebDownPluginDlg.cpp GUI/WebDownPluginDlg.hpp GUI/WebGuideDialog.cpp diff --git a/src/slic3r/GUI/GLTexture.cpp b/src/slic3r/GUI/GLTexture.cpp index d670181b1b..113b9c13dc 100644 --- a/src/slic3r/GUI/GLTexture.cpp +++ b/src/slic3r/GUI/GLTexture.cpp @@ -171,7 +171,8 @@ bool GLTexture::load_from_svg_file(const std::string& filename, bool use_mipmaps return false; } -bool GLTexture::load_from_raw_data(std::vector data, unsigned int w, unsigned int h, bool apply_anisotropy) +bool GLTexture::load_from_raw_data(std::vector data, unsigned int w, unsigned int h, bool apply_anisotropy, + bool use_mipmaps) { m_width = w; m_height = h; @@ -195,18 +196,51 @@ bool GLTexture::load_from_raw_data(std::vector data, unsigned int glsafe(::glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, (GLsizei)m_width, (GLsizei)m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, (const void*)data.data())); - bool use_mipmaps = true; if (use_mipmaps) { - // we manually generate mipmaps because glGenerateMipmap() function is not reliable on all graphics cards - int lod_w = m_width; - int lod_h = m_height; + // We generate the mipmap chain ourselves rather than calling glGenerateMipmap(), which this + // codebase has historically considered unreliable on some graphics cards. + // + // Each level is a 2x2 box filter of the level above it. Note this used to re-upload the + // *level-0* buffer at every level instead, which does not downscale anything -- it just + // reinterprets the image's first lod_w * lod_h texels as the whole smaller level, i.e. every + // level below 0 held a crop of the top-left corner. It went unnoticed for as long as every + // caller drew these textures at roughly their native size (where only level 0 is ever + // sampled); it shows up the moment one is drawn small enough to select a lower level, as a + // texture that visibly turns into something else as it shrinks. + std::vector scratch; + const std::vector *src = &data; + int src_w = m_width; + int src_h = m_height; GLint level = 0; - while (lod_w > 1 || lod_h > 1) { + while (src_w > 1 || src_h > 1) { ++level; - lod_w = std::max(lod_w / 2, 1); - lod_h = std::max(lod_h / 2, 1); - n_pixels = lod_w * lod_h; - glsafe(::glTexImage2D(GL_TEXTURE_2D, level, GL_RGBA, (GLsizei)lod_w, (GLsizei)lod_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, (const void*)data.data())); + const int lod_w = std::max(src_w / 2, 1); + const int lod_h = std::max(src_h / 2, 1); + + std::vector lod(size_t(lod_w) * size_t(lod_h) * 4); + for (int y = 0; y < lod_h; ++y) { + // min() rather than a plain 2*y+1: an odd source extent leaves the last output texel + // with only one source row/column to average, not two. + const int y0 = std::min(2 * y, src_h - 1); + const int y1 = std::min(2 * y + 1, src_h - 1); + for (int x = 0; x < lod_w; ++x) { + const int x0 = std::min(2 * x, src_w - 1); + const int x1 = std::min(2 * x + 1, src_w - 1); + for (int c = 0; c < 4; ++c) { + const unsigned int sum = (*src)[(size_t(y0) * size_t(src_w) + size_t(x0)) * 4 + size_t(c)] + + (*src)[(size_t(y0) * size_t(src_w) + size_t(x1)) * 4 + size_t(c)] + + (*src)[(size_t(y1) * size_t(src_w) + size_t(x0)) * 4 + size_t(c)] + + (*src)[(size_t(y1) * size_t(src_w) + size_t(x1)) * 4 + size_t(c)]; + lod[(size_t(y) * size_t(lod_w) + size_t(x)) * 4 + size_t(c)] = (unsigned char)(sum / 4); + } + } + } + glsafe(::glTexImage2D(GL_TEXTURE_2D, level, GL_RGBA, (GLsizei)lod_w, (GLsizei)lod_h, 0, GL_RGBA, GL_UNSIGNED_BYTE, (const void*)lod.data())); + + scratch = std::move(lod); + src = &scratch; + src_w = lod_w; + src_h = lod_h; } glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, level)); diff --git a/src/slic3r/GUI/GLTexture.hpp b/src/slic3r/GUI/GLTexture.hpp index 1bb63651f0..ca1f4db17d 100644 --- a/src/slic3r/GUI/GLTexture.hpp +++ b/src/slic3r/GUI/GLTexture.hpp @@ -100,7 +100,10 @@ namespace GUI { bool load_from_file(const std::string& filename, bool use_mipmaps, ECompressionType compression_type, bool apply_anisotropy); bool load_from_svg_file(const std::string& filename, bool use_mipmaps, bool compress, bool apply_anisotropy, unsigned int max_size_px); //BBS load GLTexture from raw pixel data - bool load_from_raw_data(std::vector data, unsigned int w, unsigned int h, bool apply_anisotropy = false); + // `data` is RGBA, w * h * 4 bytes. With use_mipmaps, a real box-filtered mipmap chain is + // built, so the texture may safely be drawn smaller than its pixel size. + bool load_from_raw_data(std::vector data, unsigned int w, unsigned int h, bool apply_anisotropy = false, + bool use_mipmaps = true); // meanings of states: (std::pair) // first field (int): // 0 -> no changes diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 021d6c31a0..a8dcbaf7c4 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1,29 +1,129 @@ #include "GLGizmoTextureDisplacement.hpp" -#include - -#include -#include +#include +#include "libslic3r/MeshBoolean.hpp" #include "libslic3r/Model.hpp" -#include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/Utils.hpp" +#include "slic3r/GUI/Camera.hpp" +#include "slic3r/GUI/CameraUtils.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" #include "slic3r/GUI/MsgDialog.hpp" +#include "slic3r/GUI/OpenGLManager.hpp" #include "slic3r/GUI/Plater.hpp" +#include "slic3r/GUI/TextureLibrary.hpp" +#include "slic3r/GUI/UVEditorCanvas.hpp" #include "slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp" +#include "slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp" #include "slic3r/Utils/UndoRedo.hpp" #include "GLGizmoUtils.hpp" #include #include +#include +#include +#include +#include +#include namespace Slic3r::GUI { +namespace { +// ImGuiWrapper::slider_float()'s trailing `power` parameter is forwarded straight into +// ImGui::SliderFloat()'s ImGuiSliderFlags argument - this ImGui (1.83) replaced the old float +// "power" curve API with a flags word but kept the parameter in the same position, and +// ImGuiWrapper never caught up with the rename. Passing the Logarithmic flag through it is +// therefore how a log-scaled slider gets requested here. +// +// Depth and tile size both want it: they span two to three orders of magnitude, and the values +// that need the most precision (a few hundredths of a millimetre of relief, a very fine tile) are +// all bunched into the very bottom of a linear range, where a single pixel of slider travel is a +// bigger jump than the whole useful region. +constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic); + +// Uploads decoded 8-bit grayscale height data as an RGBA GPU texture for display in the panel. +// Shared by the per-layer thumbnail and the picker's library entries, which differ only in where +// their pixels came from. +// +// The source is downscaled to THUMBNAIL_MAX_PX first, with a box filter, and uploaded *without* +// mipmaps. Both halves of that matter, and together they are the fix for thumbnails rendering as +// garbage: +// - GLTexture's mipmap levels are not real downscales (see load_from_raw_data()'s header) - every +// level re-uploads the full-resolution buffer reinterpreted at a smaller size. A 512px texture +// drawn into a ~48px row is minified ~10x, which is exactly the regime where OpenGL picks those +// broken levels, so the thumbnail showed a scrambled crop of the image rather than the image. +// Turning mipmaps off makes it sample level 0, which is the real picture. +// - But level 0 at 512px sampled down to 48px with plain GL_LINEAR (a 2x2 tap) would alias badly +// on exactly the high-frequency patterns these textures are (knurl, hexagons, weave). Box- +// filtering down to roughly twice the displayed size first is what actually removes the +// frequencies that would alias, and it cuts the VRAM these hold by ~16x as a bonus. +constexpr int THUMBNAIL_MAX_PX = 128; + +std::unique_ptr upload_height_thumbnail(const DecodedHeightTexture &decoded) +{ + if (decoded.empty()) + return nullptr; + + // Preserve aspect; never upscale a texture that is already small. + const int scale = std::max(1, (std::max(decoded.width, decoded.height) + THUMBNAIL_MAX_PX - 1) / THUMBNAIL_MAX_PX); + const int w = std::max(1, decoded.width / scale); + const int h = std::max(1, decoded.height / scale); + + std::vector rgba(size_t(w) * size_t(h) * 4); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + // Average the source block this destination pixel covers. + const int x0 = x * decoded.width / w, x1 = std::max(x0 + 1, (x + 1) * decoded.width / w); + const int y0 = y * decoded.height / h, y1 = std::max(y0 + 1, (y + 1) * decoded.height / h); + unsigned int sum = 0, n = 0; + for (int sy = y0; sy < y1 && sy < decoded.height; ++sy) + for (int sx = x0; sx < x1 && sx < decoded.width; ++sx, ++n) + sum += decoded.pixels[size_t(sy) * size_t(decoded.width) + size_t(sx)]; + + const unsigned char gray = (n > 0) ? static_cast(sum / n) : 0; + const size_t di = (size_t(y) * size_t(w) + size_t(x)) * 4; + rgba[di + 0] = gray; + rgba[di + 1] = gray; + rgba[di + 2] = gray; + rgba[di + 3] = 255; + } + + auto texture = std::make_unique(); + if (!texture->load_from_raw_data(std::move(rgba), (unsigned int) w, (unsigned int) h, false, /* use_mipmaps */ false)) + return nullptr; + return texture; +} + +// Intersects the camera ray through `mouse_pos` (screen coords) with the plane passing through +// `plane_point_local`/`plane_normal_local` (mesh-local coords, transformed to world by `trafo`). +// Returns false if the ray is parallel to the plane or the plane is behind the camera. +bool ray_plane_hit(const Camera &camera, const Vec2d &mouse_pos, const Transform3d &trafo, + const Vec3f &plane_point_local, const Vec3f &plane_normal_local, Vec3d &out_world_hit) +{ + Vec3d ray_origin, ray_dir; + CameraUtils::ray_from_screen_pos(camera, mouse_pos, ray_origin, ray_dir); + + const Vec3d plane_point_world = trafo * plane_point_local.cast(); + const Vec3d plane_normal_world = (trafo.matrix().block(0, 0, 3, 3).inverse().transpose() * plane_normal_local.cast()).normalized(); + + const double denom = ray_dir.dot(plane_normal_world); + if (std::abs(denom) < 1e-8) + return false; + + const double t = (plane_point_world - ray_origin).dot(plane_normal_world) / denom; + if (t < 0.0) + return false; + + out_world_hit = ray_origin + ray_dir * t; + return true; +} +} // namespace + GLGizmoTextureDisplacement::GLGizmoTextureDisplacement(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id) : GLGizmoPainterBase(parent, icon_filename, sprite_id) { @@ -34,7 +134,7 @@ bool GLGizmoTextureDisplacement::on_init() m_desc["cursor_size"] = _L("Brush size"); m_desc["circle"] = _L("Circle"); m_desc["sphere"] = _L("Sphere"); - m_desc["add_texture"] = _L("Add texture..."); + m_desc["add_texture"] = _L("Add layer"); m_desc["remove_layer"] = _L("Remove"); m_desc["bake"] = _L("Bake"); m_desc["remove_all"] = _L("Erase all"); @@ -49,6 +149,34 @@ std::string GLGizmoTextureDisplacement::on_get_name() const void GLGizmoTextureDisplacement::on_shutdown() { m_parent.toggle_model_objects_visibility(true); + m_preview_glmodel.reset(); + m_bump_preview_glmodel.reset(); + m_uvcheck_glmodel.reset(); + m_wireframe_overlay_glmodel.reset(); + m_wireframe_overlay_vcount = 0; + m_seam_glmodel.reset(); + m_seam_hover_glmodel.reset(); + m_seam_hover_edge = { -1, -1 }; + m_seam_hover_vertex = -1; + m_seam_anchor_glmodel.reset(); + m_seam_path_mode = false; + m_seam_path_anchor = -1; + m_subdivide_editing = false; + m_subdivide_preview_count = -1; + m_subdivide_preview_glmodel.reset(); + m_bump_active_chart = -1; + m_bump_active_vertex.clear(); + m_bump_island_delta = Eigen::Matrix::Identity(); + m_island_drag_active = false; + m_island_move_set.clear(); + m_adjust_texture_mode = false; + m_seam_edit_mode = false; + m_adjust_drag_handle = AdjustHandle::None; + m_adjust_anchor_valid = false; + // The pane is request-only: closing the gizmo drops the request, so reopening it later doesn't + // silently reopen the pane too. + m_show_uv_editor = false; + wxGetApp().plater()->show_uv_editor(false); } PainterGizmoType GLGizmoTextureDisplacement::get_painter_type() const @@ -68,19 +196,1806 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() glsafe(::glEnable(GL_BLEND)); glsafe(::glEnable(GL_DEPTH_TEST)); - // Phase 1 reuses the standard paint-mask overlay (as every other paint gizmo does) to show - // which area of the active layer is painted. A dedicated bump-map preview shader - // (resources/shaders/*/texture_displacement_bump.*) is registered and ready to use, but - // wiring it into this render path is deferred to a later phase -- see the project plan. - // "Bake" already shows the true, real-geometry result, which is the most important preview. - render_triangles(selection); + // Once anything is painted, m_preview_glmodel holds the true displaced result (same algorithm + // Bake uses). The untouched original topology (what render_triangles() draws) coincides + // exactly with it everywhere except the painted/displaced area, so both are drawn: the real + // preview geometry first, then the usual selection-highlight overlay with a small depth bias + // so it wins the depth test on the coincident (unpainted) surface - keeping the familiar + // enforcer/blocker highlight for precise brush editing there. Where the surface has actually + // been displaced, the raised preview geometry legitimately occludes the flat overlay - that + // visible bump is itself the "this is painted" indicator in that area. + // + // The bump preview is different: it never actually moves geometry (it's a shading trick), so + // its depth is identical to the overlay's *everywhere*, not just in the unpainted area - the + // depth-biased overlay would win the depth test across the whole surface and hide the bump + // shading entirely. So the overlay is skipped for it; the bump shading itself is the only + // feedback in that mode (still fine for painting, since render_cursor() below shows the brush). + // Coalesced bump rebuild from an in-progress UV island drag (see on_island_edited): done here, at + // most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler. + if (m_use_bump_preview && m_bump_preview_dirty) { + rebuild_bump_preview_mesh(); + m_bump_preview_dirty = false; + } + const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized(); + if (use_bump) { + m_parent.toggle_model_objects_visibility(true); + if (ModelVolume *mv = texture_volume()) + m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), + m_c->selection_info()->get_active_instance(), mv); + render_bump_preview_mesh(); + } else if (m_preview_glmodel.is_initialized()) { + m_parent.toggle_model_objects_visibility(true); + if (ModelVolume *mv = texture_volume()) + m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), + m_c->selection_info()->get_active_instance(), mv); + render_preview_mesh(); + + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-1.0f, -1.0f)); + render_triangles(selection); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + } else { + render_triangles(selection); + } + + // Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera + // with a polygon offset so they win the depth test against the coincident surface). + if (m_uv_check_mode != UVCheckMode::None) + render_uvcheck_mesh(); + // While previewing a subdivision, its wireframe stands in for the mesh wireframe - it shows the + // density the model *would* have. The normal wireframe toggle is left untouched underneath, so it + // returns to whatever it was once the preview ends (which is what keeps an already-on wireframe on). + if (m_subdivide_editing) + render_subdivide_preview(); + else if (m_wireframe_overlay) + render_wireframe_overlay(); + // Marked seams are always shown for an LSCM layer, so existing cuts are visible before entering + // seam-edit mode - but they matter most while marking. + render_seam_overlay(); + m_c->object_clipper()->render_cut(); m_c->instances_hider()->render_cut(); - render_cursor(); + if (m_adjust_texture_mode) + render_adjust_texture_gizmo(); + else if (!m_seam_edit_mode) // the brush cursor is meaningless while marking seams + render_cursor(); glsafe(::glDisable(GL_BLEND)); } +bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event) +{ + if (m_seam_edit_mode) + return on_mouse_seam(mouse_event); + if (m_adjust_texture_mode) + return on_mouse_adjust_texture(mouse_event); + return GLGizmoPainterBase::on_mouse(mouse_event); +} + +bool GLGizmoTextureDisplacement::on_mouse_seam(const wxMouseEvent &mouse_event) +{ + // Hold Ctrl to orbit/pan the camera while in seam mode, exactly as the base painter lets you do + // while painting: with Ctrl down we consume nothing, so the canvas gets the drag and moves the + // view. Without this, seam mode swallowed every left-drag and the camera couldn't be rotated. + if (mouse_event.CmdDown()) + return false; + + // Left-click marks/unmarks an edge; swallow the rest of the left-button stream so a drag doesn't + // paint, but let everything else through so the camera still orbits/pans/zooms normally. + if (mouse_event.LeftDown()) { + const Vec2d pos(mouse_event.GetX(), mouse_event.GetY()); + if (m_seam_path_mode) { + // Two-click shortest-path seam: first click sets the start vertex, the next seams the whole + // path to it and becomes the new start (so a seam line chains click by click). + const int v = seam_vertex_at(pos); + if (v >= 0) { + if (m_seam_path_anchor < 0) + m_seam_path_anchor = v; + else { + mark_seam_path(m_seam_path_anchor, v); + m_seam_path_anchor = v; + } + rebuild_seam_anchor_overlay(); + m_parent.set_as_dirty(); + } + } else { + toggle_seam_at(pos); + } + return true; + } + // Live hover: highlight what a click would pick, so the clickable target is obvious (the "I don't + // know how it works" the user hit). In normal mode that is an edge; in shortest-path mode it is a + // vertex. Don't swallow the motion - the camera still needs it. + if (mouse_event.Moving()) { + const Vec2d pos(mouse_event.GetX(), mouse_event.GetY()); + if (m_seam_path_mode) { + const int v = seam_vertex_at(pos); + if (v != m_seam_hover_vertex) { + m_seam_hover_vertex = v; + m_seam_hover_edge = { -1, -1 }; + rebuild_seam_hover_overlay(); + m_parent.set_as_dirty(); + } + } else { + const std::pair edge = seam_edge_at(pos); + if (edge != m_seam_hover_edge) { + m_seam_hover_edge = edge; + m_seam_hover_vertex = -1; + rebuild_seam_hover_overlay(); + m_parent.set_as_dirty(); + } + } + } + if (mouse_event.LeftUp() || (mouse_event.Dragging() && mouse_event.LeftIsDown())) + return true; + return false; +} + +std::pair GLGizmoTextureDisplacement::seam_edge_at(const Vec2d &mouse_pos) const +{ + const ModelVolume *mv = texture_volume(); + const ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr) + return { -1, -1 }; + + // The mesh raycasters are built one per model-part volume, in that order; find this volume's slot. + int idx = -1, count = 0; + for (const ModelVolume *v : mo->volumes) { + if (!v->is_model_part()) + continue; + if (v == mv) { idx = count; break; } + ++count; + } + const auto &raycasters = m_c->raycaster()->raycasters(); + if (idx < 0 || idx >= int(raycasters.size())) + return { -1, -1 }; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero(); + size_t facet = 0; + if (!raycasters[size_t(idx)]->unproject_on_mesh(mouse_pos, trafo, camera, hit, normal, + m_c->object_clipper()->get_clipping_plane(), &facet)) + return { -1, -1 }; + + const indexed_triangle_set &its = mv->mesh().its; + if (facet >= its.indices.size()) + return { -1, -1 }; + const stl_triangle_vertex_indices &tri = its.indices[facet]; + + // The facet edge nearest the hit point (point-to-segment distance in mesh space). + const auto seg_dist = [](const Vec3f &p, const Vec3f &a, const Vec3f &b) { + const Vec3f ab = b - a; + const float l2 = ab.squaredNorm(); + const float t = (l2 > 1e-12f) ? std::clamp((p - a).dot(ab) / l2, 0.f, 1.f) : 0.f; + return (p - (a + ab * t)).norm(); + }; + int best_i = 0; + float best_d = std::numeric_limits::max(); + for (int i = 0; i < 3; ++i) { + const float d = seg_dist(hit, its.vertices[tri[i]], its.vertices[tri[(i + 1) % 3]]); + if (d < best_d) { best_d = d; best_i = i; } + } + const int a = tri[best_i], b = tri[(best_i + 1) % 3]; + return { std::min(a, b), std::max(a, b) }; +} + +void GLGizmoTextureDisplacement::toggle_seam_at(const Vec2d &mouse_pos) +{ + TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr) + return; + const std::pair edge = seam_edge_at(mouse_pos); + if (edge.first < 0) + return; + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Mark texture seam"), UndoRedo::SnapshotType::GizmoAction); + auto &seams = layer->lscm_seam_edges; + if (const auto it = std::find(seams.begin(), seams.end(), edge); it != seams.end()) + seams.erase(it); + else + seams.push_back(edge); + rebuild_preview(); +} + +int GLGizmoTextureDisplacement::seam_vertex_at(const Vec2d &mouse_pos) const +{ + const ModelVolume *mv = texture_volume(); + const ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr) + return -1; + int idx = -1, count = 0; + for (const ModelVolume *v : mo->volumes) { + if (!v->is_model_part()) + continue; + if (v == mv) { idx = count; break; } + ++count; + } + const auto &raycasters = m_c->raycaster()->raycasters(); + if (idx < 0 || idx >= int(raycasters.size())) + return -1; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + Vec3f hit = Vec3f::Zero(), normal = Vec3f::Zero(); + size_t facet = 0; + if (!raycasters[size_t(idx)]->unproject_on_mesh(mouse_pos, trafo, camera, hit, normal, + m_c->object_clipper()->get_clipping_plane(), &facet)) + return -1; + const indexed_triangle_set &its = mv->mesh().its; + if (facet >= its.indices.size()) + return -1; + const stl_triangle_vertex_indices &tri = its.indices[facet]; + int best = tri[0]; + float best_d = std::numeric_limits::max(); + for (int i = 0; i < 3; ++i) { + const float d = (hit - its.vertices[tri[i]]).squaredNorm(); + if (d < best_d) { best_d = d; best = tri[i]; } + } + return best; +} + +void GLGizmoTextureDisplacement::mark_seam_path(int v_from, int v_to) +{ + TextureDisplacementLayer *layer = active_layer(); + const ModelVolume *mv = texture_volume(); + if (layer == nullptr || mv == nullptr || v_from < 0 || v_to < 0 || v_from == v_to) + return; + const indexed_triangle_set &its = mv->mesh().its; + const size_t n = its.vertices.size(); + if (size_t(v_from) >= n || size_t(v_to) >= n) + return; + + // Shortest path over the mesh's edge graph (Dijkstra, edge weight = length). Built on demand; one + // pass per click is fine even on a dense mesh. + std::vector>> adj(n); + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) { + const int a = tri[i], b = tri[(i + 1) % 3]; + const float w = (its.vertices[size_t(a)] - its.vertices[size_t(b)]).norm(); + adj[size_t(a)].push_back({ b, w }); + adj[size_t(b)].push_back({ a, w }); + } + + std::vector dist(n, std::numeric_limits::infinity()); + std::vector prev(n, -1); + using QN = std::pair; + std::priority_queue, std::greater> pq; + dist[size_t(v_from)] = 0.f; + pq.push({ 0.f, v_from }); + while (!pq.empty()) { + const auto [d, u] = pq.top(); + pq.pop(); + if (d > dist[size_t(u)]) + continue; + if (u == v_to) + break; + for (const auto &[w, ew] : adj[size_t(u)]) { + const float nd = d + ew; + if (nd < dist[size_t(w)]) { + dist[size_t(w)] = nd; + prev[size_t(w)] = u; + pq.push({ nd, w }); + } + } + } + if (prev[size_t(v_to)] < 0) + return; // unreachable (disconnected components) + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Mark texture seam path"), UndoRedo::SnapshotType::GizmoAction); + auto &seams = layer->lscm_seam_edges; + for (int v = v_to; v != v_from && v >= 0; v = prev[size_t(v)]) { + const int p = prev[size_t(v)]; + if (p < 0) + break; + const std::pair e{ std::min(v, p), std::max(v, p) }; + if (std::find(seams.begin(), seams.end(), e) == seams.end()) + seams.push_back(e); + } + rebuild_preview(); +} + +void GLGizmoTextureDisplacement::rebuild_seam_anchor_overlay() +{ + m_seam_anchor_glmodel.reset(); + const ModelVolume *mv = texture_volume(); + if (!m_seam_edit_mode || !m_seam_path_mode || mv == nullptr || m_seam_path_anchor < 0) + return; + const indexed_triangle_set &its = mv->mesh().its; + if (size_t(m_seam_path_anchor) >= its.vertices.size()) + return; + + // The anchor's incident edges, so the path's start vertex is visible on the model. + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + unsigned nn = 0; + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) { + const int a = tri[i], b = tri[(i + 1) % 3]; + if (a == m_seam_path_anchor || b == m_seam_path_anchor) { + init_data.add_vertex(its.vertices[size_t(a)]); + init_data.add_vertex(its.vertices[size_t(b)]); + init_data.add_line(nn, nn + 1); + nn += 2; + } + } + if (!init_data.is_empty()) + m_seam_anchor_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::rebuild_seam_hover_overlay() +{ + m_seam_hover_glmodel.reset(); + const ModelVolume *mv = texture_volume(); + if (!m_seam_edit_mode || mv == nullptr) + return; + const indexed_triangle_set &its = mv->mesh().its; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + + if (m_seam_path_mode) { + // Highlight the hovered vertex as its ring of incident edges, so the click target is legible on + // a dense mesh (matching the green anchor's style, in the hover yellow render_seam_overlay uses). + if (m_seam_hover_vertex < 0 || size_t(m_seam_hover_vertex) >= its.vertices.size()) + return; + unsigned nn = 0; + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) { + const int a = tri[i], b = tri[(i + 1) % 3]; + if (a == m_seam_hover_vertex || b == m_seam_hover_vertex) { + init_data.add_vertex(its.vertices[size_t(a)]); + init_data.add_vertex(its.vertices[size_t(b)]); + init_data.add_line(nn, nn + 1); + nn += 2; + } + } + if (!init_data.is_empty()) + m_seam_hover_glmodel.init_from(std::move(init_data)); + return; + } + + if (m_seam_hover_edge.first < 0 || size_t(m_seam_hover_edge.first) >= its.vertices.size() || + size_t(m_seam_hover_edge.second) >= its.vertices.size()) + return; + init_data.reserve_vertices(2); + init_data.reserve_indices(2); + init_data.add_vertex(its.vertices[size_t(m_seam_hover_edge.first)]); + init_data.add_vertex(its.vertices[size_t(m_seam_hover_edge.second)]); + init_data.add_line(0, 1); + m_seam_hover_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::rebuild_seam_overlay() +{ + m_seam_glmodel.reset(); + const ModelVolume *mv = texture_volume(); + const TextureDisplacementLayer *layer = active_layer(); + if (mv == nullptr || layer == nullptr || layer->lscm_seam_edges.empty()) + return; + const indexed_triangle_set &its = mv->mesh().its; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(layer->lscm_seam_edges.size() * 2); + init_data.reserve_indices(layer->lscm_seam_edges.size() * 2); + unsigned n = 0; + for (const auto &[a, b] : layer->lscm_seam_edges) { + if (a < 0 || b < 0 || size_t(a) >= its.vertices.size() || size_t(b) >= its.vertices.size()) + continue; + init_data.add_vertex(its.vertices[size_t(a)]); + init_data.add_vertex(its.vertices[size_t(b)]); + init_data.add_line(n, n + 1); + n += 2; + } + if (!init_data.is_empty()) + m_seam_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::render_seam_overlay() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + const bool have_marked = m_seam_glmodel.is_initialized(); + const bool have_hover = m_seam_edit_mode && m_seam_hover_glmodel.is_initialized(); + const bool have_anchor = m_seam_edit_mode && m_seam_anchor_glmodel.is_initialized(); + if (mo == nullptr || mv == nullptr || (!have_marked && !have_hover && !have_anchor)) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + glsafe(::glEnable(GL_POLYGON_OFFSET_LINE)); + glsafe(::glPolygonOffset(-2.0f, -2.0f)); // pull further forward than the wireframe so seams read on top + // A seam edge is geometrically the same line as a wireframe edge, so a mere polygon offset is a + // fragile way to make the red seam beat the white wireframe -- drivers apply GL_POLYGON_OFFSET_LINE + // inconsistently, and the two lines then z-fight and the wireframe wins. When the wireframe is on, + // or while actively marking, just draw the seams with depth testing off so they are unconditionally + // on top -- being visible is the one thing this overlay has to guarantee. + const bool seams_on_top = m_wireframe_overlay || m_seam_edit_mode; + if (seams_on_top) + glsafe(::glDisable(GL_DEPTH_TEST)); +#if !SLIC3R_OPENGL_ES + const bool wide = !OpenGLManager::get_gl_info().is_core_profile(); + if (wide) + glsafe(::glLineWidth(4.0f)); +#endif // !SLIC3R_OPENGL_ES + if (have_marked) { + m_seam_glmodel.set_color(ColorRGBA(1.0f, 0.15f, 0.15f, 1.0f)); // Blender's seam red + m_seam_glmodel.render(); + } + // The edge a click would toggle, in yellow and pulled the furthest forward, so it is unmistakable + // which edge is being targeted while marking seams. + if (have_hover) { + glsafe(::glPolygonOffset(-3.0f, -3.0f)); + m_seam_hover_glmodel.set_color(ColorRGBA(1.0f, 0.9f, 0.15f, 1.0f)); + m_seam_hover_glmodel.render(); + } + // The shortest-path start vertex, shown as its ring of incident edges in green. + if (have_anchor) { + glsafe(::glPolygonOffset(-3.0f, -3.0f)); + m_seam_anchor_glmodel.set_color(ColorRGBA(0.2f, 1.0f, 0.4f, 1.0f)); + m_seam_anchor_glmodel.render(); + } +#if !SLIC3R_OPENGL_ES + if (wide) + glsafe(::glLineWidth(1.0f)); +#endif // !SLIC3R_OPENGL_ES + glsafe(::glDisable(GL_POLYGON_OFFSET_LINE)); + if (seams_on_top) + glsafe(::glEnable(GL_DEPTH_TEST)); + shader->stop_using(); +} + +void GLGizmoTextureDisplacement::render_preview_mesh() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + + auto *shader = wxGetApp().get_shader("gouraud_light"); + if (shader == nullptr) + return; + shader->start_using(); + const Camera &camera = wxGetApp().plater()->get_camera(); + const Transform3d &view_matrix = camera.get_view_matrix(); + shader->set_uniform("view_model_matrix", view_matrix * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + const Matrix3d view_normal_matrix = view_matrix.matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); + shader->set_uniform("view_normal_matrix", view_normal_matrix); + m_preview_glmodel.render(); + shader->stop_using(); +} + +std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &patch, + const TextureDisplacementLayer &layer) const +{ + if (layer.projection_method == TextureProjectionMethod::LSCM) + return compute_lscm_uvs(patch, layer); // one final uv per patch vertex (0 where unassigned) + if (layer.projection_method == TextureProjectionMethod::ViewProjected) { + std::vector uv(patch.vertices.size()); + for (size_t vi = 0; vi < patch.vertices.size(); ++vi) { + const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), + patch.vertices[vi].dot(layer.view_project_up)); + uv[vi] = apply_uv_transform(planar, layer); + } + return uv; + } + return {}; // Triplanar / Cylindrical / Spherical: the shader projects on its own +} + +void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() +{ + m_bump_preview_glmodel.reset(); + + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || m_triangle_selectors.empty()) + return; + + // Uses the *live* selector (not the flushed model facet data), so this reflects an in-progress + // stroke immediately rather than only once it ends - the point of this preview mode is to be + // the fast, no-CPU-meshing one. + const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + if (patch.indices.empty()) + return; + + const indexed_triangle_set &base = mv->mesh().its; + if (base.vertices.size() != patch.vertices.size()) + return; // shouldn't happen: get_facets_strict() always returns the full vertex array + + std::vector is_painted(patch.vertices.size(), false); + for (const stl_triangle_vertex_indices &tri : patch.indices) + for (int i = 0; i < 3; ++i) + is_painted[tri[i]] = true; + + // For LSCM we hand the shader the finished per-vertex texture uv (island placement + tiling/ + // rotation/offset already folded in, exactly what the bake samples), because it cannot be + // reconstructed in the fragment shader the way a triplanar projection can. This is also what + // makes the fast preview follow the UV editor: the uvs move when an island is dragged, so this + // mesh rebuilds (on drag end) with them. The other projections keep projecting in-shader. + const TextureDisplacementLayer *active = active_layer(); + std::vector vertex_uv = active != nullptr ? compute_layer_vertex_uvs(patch, *active) : std::vector{}; + m_bump_preview_uses_vertex_uv = vertex_uv.size() == base.vertices.size(); + if (!m_bump_preview_uses_vertex_uv) + vertex_uv.clear(); + + GLModel::Geometry init_data; + // P3N3T2: normal.x carries the paint weight, tex_coord carries the precomputed uv (see the vertex + // shader). Reuses a standard GLModel layout rather than a bespoke vertex buffer. + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 }; + init_data.reserve_vertices(base.vertices.size()); + init_data.reserve_indices(base.indices.size() * 3); + // Every triangle of the whole mesh is included (not just the painted ones) so the surrounding, + // un-bumped surface still renders - the per-vertex weight (0 outside the patch) is what fades + // the shader's bump effect to nothing there, exactly like the true-displacement preview fades + // to the untouched surface at its boundary. + // normal.y flags the island currently dragged in the UV editor, so the shader can move just that + // island through the island_delta uniform (see the bump shaders / on_island_edited). + const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty(); + for (size_t vi = 0; vi < base.vertices.size(); ++vi) { + const float active = (have_active && vi < m_bump_active_vertex.size() && m_bump_active_vertex[vi]) ? 1.f : 0.f; + const Vec3f weight_normal(is_painted[vi] ? 1.f : 0.f, active, 0.f); + const Vec2f uv = m_bump_preview_uses_vertex_uv ? vertex_uv[vi] : Vec2f::Zero(); + init_data.add_vertex(base.vertices[vi], weight_normal, uv); + } + for (const stl_triangle_vertex_indices &tri : base.indices) + init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2])); + + m_bump_preview_glmodel.init_from(std::move(init_data)); + // GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal + // color field right before drawing (see GLModel.cpp) - setting the uniform manually in + // render_bump_preview_mesh() would just get overwritten by it, so it must be set here instead. + // GLModel::Geometry defaults to BLACK, which is exactly what showed up before this was added. + m_bump_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR); + + // The mesh now reflects the islands' current placement, so any live drag delta is measured from + // here: reset it to identity and record the dragged island's baked transform. + m_bump_island_delta = Eigen::Matrix::Identity(); + const TextureDisplacementLayer *al = active_layer(); + if (m_bump_active_chart >= 0 && al != nullptr) { + const std::vector> xf = uv_editor_island_transforms(*al); + m_bump_baked_active_xf = (size_t(m_bump_active_chart) < xf.size()) ? xf[size_t(m_bump_active_chart)] + : Eigen::Matrix::Identity(); + } else { + m_bump_baked_active_xf = Eigen::Matrix::Identity(); + } +} + +void GLGizmoTextureDisplacement::compute_bump_active_vertices(const std::vector &charts) +{ + m_bump_active_vertex.clear(); + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || charts.empty()) + return; + const PatchUnwrap &u = m_uv_editor_unwrap; + m_bump_active_vertex.assign(mv->mesh().its.vertices.size(), 0); + // Flag the base vertices of every chart being moved. For a group/multi move that is more than one + // chart, but since such a move is a pure translation the shader applies the same delta to them all + // (see on_island_edited) -- exactly the "joined islands move together" behaviour. + for (size_t i = 0; i < u.uvs.size(); ++i) { + if (i >= u.vertex_chart.size() || + std::find(charts.begin(), charts.end(), u.vertex_chart[i]) == charts.end()) + continue; + const int sv = (i < u.source_vertex.size()) ? u.source_vertex[i] : -1; + if (sv >= 0 && size_t(sv) < m_bump_active_vertex.size()) + m_bump_active_vertex[size_t(sv)] = 1; + } +} + +int GLGizmoTextureDisplacement::island_group_of(const std::vector &groups, int c) +{ + return (c >= 0 && size_t(c) < groups.size() && groups[size_t(c)] >= 0) ? groups[size_t(c)] : c; +} + +void GLGizmoTextureDisplacement::join_island_groups(std::vector &groups, int a, int b, int chart_count) +{ + if (a < 0 || b < 0 || chart_count <= 0) + return; + // Materialise to a full explicit table first, so singletons (which were implicit) get a concrete id + // that the relabel loop below can match on. + if (int(groups.size()) < chart_count) { + const size_t old = groups.size(); + groups.resize(size_t(chart_count)); + for (size_t i = old; i < groups.size(); ++i) + groups[i] = int(i); + } + for (size_t i = 0; i < groups.size(); ++i) + if (groups[i] < 0) + groups[i] = int(i); + const int ga = groups[size_t(a)], gb = groups[size_t(b)]; + if (ga == gb) + return; + const int g = std::min(ga, gb); + for (int &x : groups) + if (x == ga || x == gb) + x = g; +} + +std::vector GLGizmoTextureDisplacement::build_island_move_set(const TextureDisplacementLayer &layer, int primary) const +{ + std::vector set; + const int chart_count = std::max(m_uv_editor_unwrap.chart_count, 0); + + // Seed with the pane's multi-selection (falling back to just the primary if the canvas has none). + std::vector seeds; + if (const UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas()) + seeds = canvas->selected_islands(); + if (seeds.empty() && primary >= 0) + seeds.push_back(primary); + + const auto add = [&set](int c) { + if (c >= 0 && std::find(set.begin(), set.end(), c) == set.end()) + set.push_back(c); + }; + for (int s : seeds) { + add(s); + // Pull in every chart sharing s's join group, so a joined pair moves as one. + const int gs = island_group_of(layer.island_groups, s); + for (int c = 0; c < chart_count; ++c) + if (island_group_of(layer.island_groups, c) == gs) + add(c); + } + add(primary); // never leave the primary out, whatever the selection state + return set; +} + +void GLGizmoTextureDisplacement::render_bump_preview_mesh() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_bump_preview_glmodel.is_initialized()) + return; + + const TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr || layer->empty()) + return; + + // Reuses the layer-list panel's already-decoded, already-uploaded GPU thumbnail (smoothing-aware), + // whose grayscale value lives in the R channel exactly as the shader samples it. Its width/height + // are read straight off the texture -- decoding the PNG here every frame would re-run the smoothing + // blur on every camera move, which is what tanked the frame rate at high smoothing. + GLTexture *tex = get_layer_thumbnail(*layer); + if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0) + return; + + GLShaderProgram *shader = wxGetApp().get_shader("texture_displacement_bump"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + shader->set_uniform("volume_world_matrix", trafo_matrix); + const ClippingPlaneDataWrapper clp_data = this->get_clipping_plane_data(); + shader->set_uniform("clipping_plane", clp_data.clp_dataf); + shader->set_uniform("z_range", clp_data.z_range); + const Matrix3d view_normal_matrix = + camera.get_view_matrix().matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); + shader->set_uniform("view_normal_matrix", view_normal_matrix); + shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0); + glsafe(::glActiveTexture(GL_TEXTURE0)); + glsafe(::glBindTexture(GL_TEXTURE_2D, tex->get_id())); + shader->set_uniform("height_tex", 0); + shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height()))); + shader->set_uniform("depth_mm", layer->depth_mm); + shader->set_uniform("tiling_scale", layer->tiling_scale); + shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f); + shader->set_uniform("uv_offset", layer->offset); + shader->set_uniform("invert", layer->invert); + // When set, the shader samples at the per-vertex uv baked into the mesh (LSCM) rather than + // projecting; see rebuild_bump_preview_mesh(). + shader->set_uniform("use_vertex_uv", m_bump_preview_uses_vertex_uv); + // The live UV-editor island drag rides this 2x3 affine (identity except mid-drag); only the flagged + // island's vertices apply it, so a drag is a uniform update rather than a mesh rebuild. + const Eigen::Matrix &d = m_bump_island_delta; + shader->set_uniform("island_delta_lin", std::array{ d(0, 0), d(0, 1), d(1, 0), d(1, 1) }); + shader->set_uniform("island_delta_tr", Vec2f(d(0, 2), d(1, 2))); + m_bump_preview_glmodel.render(); + glsafe(::glBindTexture(GL_TEXTURE_2D, 0)); + shader->stop_using(); +} + +void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() +{ + m_uvcheck_glmodel.reset(); + if (m_uv_check_mode == UVCheckMode::None) + return; + + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || m_triangle_selectors.empty()) + return; + const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + if (patch.indices.empty()) + return; + const indexed_triangle_set &base = mv->mesh().its; + if (base.vertices.size() != patch.vertices.size()) + return; + const TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr) + return; + + // The checker samples wherever the projection puts it; the projections the shader can't + // reconstruct (LSCM, ViewProjected) get a precomputed per-vertex uv, the rest project in-shader. + std::vector uv = compute_layer_vertex_uvs(patch, *layer); + const bool have_uvs = uv.size() == base.vertices.size(); + m_uvcheck_uses_vertex_uv = have_uvs; + + // Per-vertex area distortion in [0,1] (0.5 == ideal), only when both requested and possible. + std::vector distortion(base.vertices.size(), 0.5f); + if (m_uv_check_mode == UVCheckMode::Distortion && have_uvs) { + std::vector tri_log(patch.indices.size(), 0.f); + for (size_t f = 0; f < patch.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = patch.indices[f]; + const float a3 = 0.5f * (base.vertices[t[1]] - base.vertices[t[0]]).cross(base.vertices[t[2]] - base.vertices[t[0]]).norm(); + const Vec2f e0 = uv[t[1]] - uv[t[0]]; + const Vec2f e1 = uv[t[2]] - uv[t[0]]; + const float a2 = 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x()); + tri_log[f] = (a3 > 1e-12f && a2 > 1e-12f) ? std::log2(a2 / a3) : 0.f; + } + // Centre the heatmap on the patch's own median stretch, so a globally-scaled unwrap reads as + // uniformly "ideal" and only *relative* stretching (the thing that matters) shows up as colour. + std::vector sorted = tri_log; + float median = 0.f; + if (!sorted.empty()) { + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end()); + median = sorted[sorted.size() / 2]; + } + std::vector sum(base.vertices.size(), 0.f); + std::vector cnt(base.vertices.size(), 0); + for (size_t f = 0; f < patch.indices.size(); ++f) { + // +/- 2 stops (4x stretch either way) spans the full blue->red range. + const float d = std::clamp(0.5f + (tri_log[f] - median) / 4.f, 0.f, 1.f); + for (int k = 0; k < 3; ++k) { + sum[patch.indices[f][k]] += d; + ++cnt[patch.indices[f][k]]; + } + } + for (size_t v = 0; v < distortion.size(); ++v) + if (cnt[v] > 0) + distortion[v] = sum[v] / float(cnt[v]); + } + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 }; + init_data.reserve_vertices(base.vertices.size()); + init_data.reserve_indices(patch.indices.size() * 3); + for (size_t vi = 0; vi < base.vertices.size(); ++vi) + init_data.add_vertex(base.vertices[vi], Vec3f(distortion[vi], 0.f, 0.f), + have_uvs ? uv[vi] : Vec2f::Zero()); + for (const stl_triangle_vertex_indices &tri : patch.indices) + init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2])); + + m_uvcheck_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::render_uvcheck_mesh() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_uvcheck_glmodel.is_initialized()) + return; + const TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr) + return; + GLShaderProgram *shader = wxGetApp().get_shader("texture_displacement_uvcheck"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + shader->set_uniform("volume_world_matrix", trafo_matrix); + const ClippingPlaneDataWrapper clp_data = this->get_clipping_plane_data(); + shader->set_uniform("clipping_plane", clp_data.clp_dataf); + shader->set_uniform("z_range", clp_data.z_range); + const Matrix3d view_normal_matrix = + camera.get_view_matrix().matrix().block(0, 0, 3, 3) * trafo_matrix.matrix().block(0, 0, 3, 3).inverse().transpose(); + shader->set_uniform("view_normal_matrix", view_normal_matrix); + shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0); + shader->set_uniform("mode", m_uv_check_mode == UVCheckMode::Distortion ? 1 : 0); + shader->set_uniform("checker_freq", 4.f); // squares per texture tile + shader->set_uniform("tiling_scale", layer->tiling_scale); + shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f); + shader->set_uniform("uv_offset", layer->offset); + shader->set_uniform("use_vertex_uv", m_uvcheck_uses_vertex_uv); + + // Coincident with the base surface, so pull it toward the camera to win the depth test. + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-1.0f, -1.0f)); + m_uvcheck_glmodel.render(); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + shader->stop_using(); +} + +void GLGizmoTextureDisplacement::build_wireframe_from_its(const indexed_triangle_set &its) +{ + m_wireframe_overlay_glmodel.reset(); + m_wireframe_overlay_vcount = its.vertices.size(); + if (its.indices.empty()) + return; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(its.vertices.size()); + init_data.reserve_indices(its.indices.size() * 6); + for (const Vec3f &v : its.vertices) + init_data.add_vertex(v); + // One segment per triangle edge; shared edges drawn twice, harmless for a wireframe and far + // cheaper than deduplicating a million of them. + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) + init_data.add_line(unsigned(tri[i]), unsigned(tri[(i + 1) % 3])); + + if (!init_data.is_empty()) + m_wireframe_overlay_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::rebuild_wireframe_overlay() +{ + if (!m_wireframe_overlay) { + m_wireframe_overlay_glmodel.reset(); + m_wireframe_overlay_vcount = 0; + return; + } + const ModelVolume *mv = texture_volume(); + if (mv == nullptr) + return; + const indexed_triangle_set &its = mv->mesh().its; + if (its.indices.empty()) + return; + + // Building from the base mesh (bump/paint mode); its topology only changes on bake/subdivide, and + // this runs on every rebuild_preview(), so rebuild only when the vertex count actually changes. + if (m_wireframe_overlay_glmodel.is_initialized() && m_wireframe_overlay_vcount == its.vertices.size()) + return; + build_wireframe_from_its(its); +} + +void GLGizmoTextureDisplacement::refresh_wireframe() +{ + if (!m_wireframe_overlay) { + m_wireframe_overlay_glmodel.reset(); + m_wireframe_overlay_vcount = 0; + return; + } + // The wireframe has to sit on whatever mesh is actually on screen. In the true-displacement view + // that is the raised preview geometry (m_preview_its) - drawing the flat base mesh's edges there + // leaves them buried inside the bumps, which is why the wireframe "didn't show in real mode". In + // Fast (bump) mode or with nothing painted, the surface is the undisplaced base mesh. + if (!m_use_bump_preview && !m_preview_its.indices.empty()) + build_wireframe_from_its(m_preview_its); + else + rebuild_wireframe_overlay(); +} + +void GLGizmoTextureDisplacement::render_wireframe_overlay() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_wireframe_overlay_glmodel.is_initialized()) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + // Pull the lines toward the camera so they sit on the surface rather than z-fighting into it. + glsafe(::glEnable(GL_POLYGON_OFFSET_LINE)); + glsafe(::glPolygonOffset(-1.0f, -1.0f)); + m_wireframe_overlay_glmodel.set_color(ColorRGBA(1.0f, 1.0f, 1.0f, 0.6f)); // white, so it reads on any material + m_wireframe_overlay_glmodel.render(); + glsafe(::glDisable(GL_POLYGON_OFFSET_LINE)); + shader->stop_using(); +} + +void GLGizmoTextureDisplacement::rebuild_preview() +{ + // Bumped first: any in-flight job's result (captured generation from before this call) will + // now compare unequal to m_preview_generation and be discarded when it completes, even if it + // finishes after the job queued below. + const uint64_t generation = ++m_preview_generation; + update_uv_editor(); + rebuild_bump_preview_mesh(); + rebuild_uvcheck_mesh(); + rebuild_seam_overlay(); + + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || !mv->is_texture_displacement_painted()) { + m_preview_glmodel.reset(); + m_preview_its = indexed_triangle_set{}; // no displaced mesh; wireframe falls back to the base + refresh_wireframe(); + return; + } + // In Fast/paint modes the wireframe follows the base mesh and can be built now; the true-displacement + // view's wireframe needs the displaced mesh, which only exists once the job below completes. + if (m_use_bump_preview) + refresh_wireframe(); + + TextureDisplacementPreviewInput input; + input.base_mesh = mv->mesh().its; + input.layers = mv->texture_displacement_layers; + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); + + auto &worker = wxGetApp().plater()->get_ui_job_worker(); + queue_job(worker, std::make_unique(std::move(input), generation, + [this](indexed_triangle_set its, uint64_t result_generation) { + if (result_generation != m_preview_generation) + return; // superseded by a newer edit while this was computing + m_preview_glmodel.reset(); + if (!its.indices.empty()) { + m_preview_glmodel.init_from(its); + m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR); + } + // Keep the displaced mesh so the wireframe overlay can be drawn on it (the true-displacement + // view), then refresh the wireframe from it. + m_preview_its = std::move(its); + refresh_wireframe(); + m_parent.set_as_dirty(); + })); +} + +void GLGizmoTextureDisplacement::update_uv_editor() +{ + Plater *plater = wxGetApp().plater(); + UVEditorCanvas *uv_canvas = plater->get_uv_editor_canvas(); + if (uv_canvas == nullptr) + return; + + const ModelVolume *mv = texture_volume(); + TextureDisplacementLayer *layer = active_layer(); + // The pane is opened only on the user's explicit request (m_show_uv_editor), and only for an LSCM + // layer - never automatically just because something is painted. Keep the cached state/unwrap so + // that switching the toggle back on re-shows instantly (and re-solves if the paint changed while + // it was hidden, via the state comparison below). + if (!m_show_uv_editor || mv == nullptr || layer == nullptr || + layer->projection_method != TextureProjectionMethod::LSCM) { + plater->show_uv_editor(false); + return; + } + + // A vertex/edge edit committing (or an undo reverting one) changes the per-vertex UV overrides + // without going through the Unwrap button. Detect that and force a re-solve, so the pane's geometry + // stays in step with what will bake -- the one exception to "only re-solve on Unwrap". + { + size_t sig = 1469598103934665603ull; // FNV-1a seed + const auto mix = [&sig](uint64_t x) { sig = (sig ^ x) * 1099511628211ull; }; + mix(layer->lscm_uv_overrides.size()); + for (const auto &[v, uv] : layer->lscm_uv_overrides) { + mix(uint64_t(uint32_t(v))); + mix(uint64_t(uint32_t(int32_t(std::llround(uv.x() * 1024.f))))); + mix(uint64_t(uint32_t(int32_t(std::llround(uv.y() * 1024.f))))); + } + if (sig != m_uv_overrides_sig) { + m_uv_overrides_sig = sig; + m_uv_unwrap_pending = true; + } + } + + UVEditorState state; + state.slot = m_active_layer_slot; + state.image_data = layer->image_data.get(); + state.seam_angle = layer->lscm_seam_angle_deg; + state.padding = layer->island_padding_mm; + state.facets = mv->texture_displacement_facet(m_active_layer_slot).get_data(); + state.seam_edges = layer->lscm_seam_edges; + + // The re-solve happens only when the user pressed "Unwrap" (m_uv_unwrap_pending). Every other call + // into here -- a paint stroke ending, a slider release, the check mode changing -- must not pay for + // a fresh LSCM solve; it just re-applies the cheap affine transforms over whatever unwrap already + // exists. If the paint changed underneath but the user hasn't asked to re-unwrap, the pane keeps + // showing the last unwrap on purpose (that is the whole point of making it an explicit action). + bool unwrap_changed = false; + if (m_uv_unwrap_pending) { + m_uv_unwrap_pending = false; + const indexed_triangle_set patch = extract_painted_patch(mv->mesh().its, state.facets); + if (patch.indices.empty()) { + m_uv_editor_state = UVEditorState{}; + m_uv_editor_unwrap = PatchUnwrap{}; + m_uv_editor_distortion_colors.clear(); + plater->show_uv_editor(false); + return; + } + // Padding disabled (0): the user asked to pack islands with no gap between them. + m_uv_editor_unwrap = compute_patch_unwrap(patch, layer->lscm_seam_angle_deg, 0.f, layer->lscm_seam_edges); + // Re-apply any stored per-vertex UV edits onto the fresh unwrap, so the pane shows exactly what + // compute_lscm_uvs() will bake (which applies the same overrides). Keyed by mesh vertex, so every + // unwrapped copy of that vertex gets it -- matching the bake's single-UV-per-vertex settle. + if (!layer->lscm_uv_overrides.empty()) { + std::map ov; + for (const auto &[mv2, uv] : layer->lscm_uv_overrides) + ov[mv2] = uv; + for (size_t i = 0; i < m_uv_editor_unwrap.uvs.size(); ++i) { + const int sv = (i < m_uv_editor_unwrap.source_vertex.size()) ? m_uv_editor_unwrap.source_vertex[i] : -1; + const auto it = ov.find(sv); + if (it != ov.end()) + m_uv_editor_unwrap.uvs[i] = it->second; + } + } + m_uv_editor_state = std::move(state); + unwrap_changed = true; + // Precompute the distortion heatmap now, while the patch is in hand - relative stretch doesn't + // change when islands are only moved, so this need not be redone on a drag. It is fed to the + // canvas below only while the Distortion check mode is on. + compute_uv_editor_distortion_colors(patch); + } + + if (m_uv_editor_unwrap.empty()) { + // Nothing has been unwrapped yet (or the paint was cleared): keep the pane hidden until the user + // presses Unwrap. The panel shows a "Press Unwrap" hint in this state. + plater->show_uv_editor(false); + return; + } + + // The pane background either mirrors the height texture (default) or shows a UV checker (#7), and is + // only re-uploaded when that choice, or the unwrap, actually changes - the height image is large. + const UVBackground desired_bg = (m_uv_check_mode == UVCheckMode::Checker) ? UVBackground::Checker : UVBackground::Height; + const bool bg_smoothing_changed = (desired_bg == UVBackground::Height) && (m_uv_editor_bg_smoothing != layer->smoothing); + if (unwrap_changed || desired_bg != m_uv_editor_bg || bg_smoothing_changed) { + m_uv_editor_bg_smoothing = layer->smoothing; + if (desired_bg == UVBackground::Checker) { + // An even squares-per-axis count so the pattern tiles seamlessly across the UV unit + // boundary (texcoord == position repeats it once per tile). Softened grays, not pure + // black/white, so it doesn't fight the island wires drawn over it. + constexpr int tex = 512, squares = 8, cell = tex / squares; + std::vector checker(size_t(tex) * size_t(tex)); + for (int y = 0; y < tex; ++y) + for (int x = 0; x < tex; ++x) + checker[size_t(y) * tex + x] = ((x / cell + y / cell) & 1) ? 205 : 70; + uv_canvas->set_background_texture(checker, tex, tex); + } else { + const DecodedHeightTexture height = decode_height_texture(*layer); + if (!height.empty()) + uv_canvas->set_background_texture(height.pixels, height.width, height.height); + else + uv_canvas->set_background_texture({}, 0, 0); + } + m_uv_editor_bg = desired_bg; + } + + // Grow (never shrink) the layer's island list to cover every chart. Shrinking would throw away a + // hand placement the moment a stroke temporarily merged two islands, and a stale extra entry is + // harmless - island_transform_matrix() only looks up the charts that actually exist. + if (layer->islands.size() < size_t(m_uv_editor_unwrap.chart_count)) + layer->islands.resize(size_t(m_uv_editor_unwrap.chart_count)); + + // Connected-net layout (on by default): a *fresh* unwrap is unfolded so adjacent charts sit + // edge-to-edge (cube -> a net), rather than as separately packed squares. Only when the user pressed + // Unwrap (m_uv_apply_connected_net) -- a re-segmentation renumbers charts anyway, so any hand + // placement from before is already meaningless. A *refresh* re-solve (a committed vertex edit, or an + // undo) must NOT relayout, or it would throw away every island placement on every vertex edit. + if (unwrap_changed && m_uv_apply_connected_net && layer->auto_connect_islands) { + std::vector net = compute_connected_net(m_uv_editor_unwrap); + if (net.size() == size_t(m_uv_editor_unwrap.chart_count)) { + if (layer->islands.size() < net.size()) + layer->islands.resize(net.size()); + for (size_t i = 0; i < net.size(); ++i) + layer->islands[i] = net[i]; + } + } + if (unwrap_changed) + m_uv_apply_connected_net = false; // consumed; a refresh re-solve leaves placements alone + + // The geometry goes over in the unwrap's *raw* mm coordinates and is only re-uploaded when the + // unwrap itself changed. Everything a slider or a drag can touch - island placement, tiling, + // rotation, offset - is an affine map on top of that, so it goes over as one 2x3 matrix per + // island instead. That is the whole reason dragging an island is now free: a patch of a million + // triangles has a million UVs to re-transform and re-upload otherwise, and it was doing exactly + // that on every single mouse-move event. + if (unwrap_changed) { + UVEditorCanvas::Islands view; + view.uvs = m_uv_editor_unwrap.uvs; + view.indices = m_uv_editor_unwrap.indices; + view.vertex_island = m_uv_editor_unwrap.vertex_chart; + view.boundary_edges = m_uv_editor_unwrap.boundary_edges; + view.island_count = m_uv_editor_unwrap.chart_count; + + uv_canvas->set_island_edit_callback( + [this](int island, const Vec2f &offset_delta, float rotation_delta, float scale_factor, bool finished) { + on_island_edited(island, offset_delta, rotation_delta, scale_factor, finished); + }); + uv_canvas->set_vertex_edit_callback( + [this](const std::vector> &edits) { on_uv_vertex_edited(edits); }); + uv_canvas->set_command_callback([this](UVEditorCanvas::Command cmd) { on_uv_command(int(cmd)); }); + uv_canvas->set_islands(std::move(view)); + } + + uv_canvas->set_select_mode(static_cast(m_uv_select_mode)); + + uv_canvas->set_uv_transform(layer->tiling_scale, layer->rotation_deg, layer->tile_enabled, + layer->tile_method == TextureTileMethod::MirroredRepeat); + uv_canvas->set_island_transforms(uv_editor_island_transforms(*layer)); + // The distortion heatmap tints the island fills only while its check mode is on; otherwise the + // canvas falls back to its default light-green wash. + if (m_uv_check_mode == UVCheckMode::Distortion) + uv_canvas->set_island_fill_colors(m_uv_editor_distortion_colors); + else + uv_canvas->set_island_fill_colors({}); + + plater->show_uv_editor(true); +} + +void GLGizmoTextureDisplacement::compute_uv_editor_distortion_colors(const indexed_triangle_set &patch) +{ + m_uv_editor_distortion_colors.clear(); + const PatchUnwrap &u = m_uv_editor_unwrap; + if (u.empty() || u.chart_count <= 0) + return; + + // log2(uv area / 3D area) per unwrap triangle - the same measure the 3D distortion overlay uses. + std::vector tri_log(u.indices.size(), 0.f); + std::vector tri_chart(u.indices.size(), -1); + for (size_t f = 0; f < u.indices.size(); ++f) { + const stl_triangle_vertex_indices &t = u.indices[f]; + if (t[0] < 0 || size_t(t[0]) >= u.vertex_chart.size()) + continue; + tri_chart[f] = u.vertex_chart[size_t(t[0])]; + const auto p3 = [&](int uv_idx) -> Vec3f { + const int sv = (size_t(uv_idx) < u.source_vertex.size()) ? u.source_vertex[size_t(uv_idx)] : -1; + return (sv >= 0 && size_t(sv) < patch.vertices.size()) ? patch.vertices[size_t(sv)] : Vec3f::Zero(); + }; + const float a3 = 0.5f * (p3(t[1]) - p3(t[0])).cross(p3(t[2]) - p3(t[0])).norm(); + const Vec2f e0 = u.uvs[size_t(t[1])] - u.uvs[size_t(t[0])]; + const Vec2f e1 = u.uvs[size_t(t[2])] - u.uvs[size_t(t[0])]; + const float a2 = 0.5f * std::abs(e0.x() * e1.y() - e0.y() * e1.x()); + tri_log[f] = (a3 > 1e-12f && a2 > 1e-12f) ? std::log2(a2 / a3) : 0.f; + } + + // Centre on the median stretch, so a globally scaled unwrap reads as uniform and only *relative* + // stretching shows up - matching the 3D overlay's convention. + std::vector sorted = tri_log; + float median = 0.f; + if (!sorted.empty()) { + std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end()); + median = sorted[sorted.size() / 2]; + } + + std::vector chart_sum(size_t(u.chart_count), 0.0); + std::vector chart_cnt(size_t(u.chart_count), 0); + for (size_t f = 0; f < tri_log.size(); ++f) { + const int c = tri_chart[f]; + if (c >= 0 && c < u.chart_count) { + chart_sum[size_t(c)] += tri_log[f]; + ++chart_cnt[size_t(c)]; + } + } + + // Blue (compressed) -> green (ideal) -> red (stretched), +/- 2 stops spanning the full range. + const auto heat = [](float t) -> ColorRGBA { + t = std::clamp(t, 0.f, 1.f); + const ColorRGBA blue{ 0.15f, 0.35f, 1.0f, 0.5f }, green{ 0.2f, 0.9f, 0.3f, 0.5f }, red{ 1.0f, 0.2f, 0.15f, 0.5f }; + if (t < 0.5f) { const float s = t * 2.f; return blue * (1.f - s) + green * s; } + const float s = (t - 0.5f) * 2.f; return green * (1.f - s) + red * s; + }; + + m_uv_editor_distortion_colors.resize(size_t(u.chart_count), heat(0.5f)); + for (int c = 0; c < u.chart_count; ++c) + if (chart_cnt[size_t(c)] > 0) { + const float avg = float(chart_sum[size_t(c)] / chart_cnt[size_t(c)]); + m_uv_editor_distortion_colors[size_t(c)] = heat(std::clamp(0.5f + (avg - median) / 4.f, 0.f, 1.f)); + } +} + +void GLGizmoTextureDisplacement::on_uv_command(int cmd) +{ + TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr) + return; + + if (cmd == int(UVEditorCanvas::Command::AverageScale)) { + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Average island scale"), UndoRedo::SnapshotType::GizmoAction); + average_island_scales(layer->islands); + rebuild_preview(); + } else if (cmd == int(UVEditorCanvas::Command::CutSelectedIsland)) { + UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas(); + const int chart = canvas != nullptr ? canvas->selected_island() : -1; + if (chart < 0) { + show_error(nullptr, _u8L("Select an island in the UV editor first.")); + return; + } + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Cut texture island"), UndoRedo::SnapshotType::GizmoAction); + cut_island(*layer, chart); + rebuild_preview(); + } else if (cmd == int(UVEditorCanvas::Command::JoinSelected)) { + UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas(); + const int chart = canvas != nullptr ? canvas->selected_island() : -1; + if (chart < 0) { + show_error(nullptr, _u8L("Select an island in the UV editor first.")); + return; + } + // Join to whichever neighbouring island (one it shares an edge with) is currently placed + // nearest -- i.e. the one it was dragged up against. + const Eigen::Matrix sel_m = island_transform_matrix(chart, m_uv_editor_unwrap, layer->islands); + const Vec2f sel_c = sel_m.block<2, 2>(0, 0) * m_uv_editor_unwrap.chart_centroid[size_t(chart)] + sel_m.col(2); + int best_parent = -1; + float best_d2 = std::numeric_limits::max(); + TextureIsland best_place, cand; + for (int p = 0; p < m_uv_editor_unwrap.chart_count; ++p) { + if (p == chart) + continue; + if (!join_chart_placement(m_uv_editor_unwrap, layer->islands, chart, p, cand)) + continue; // not a neighbour + const Eigen::Matrix pm = island_transform_matrix(p, m_uv_editor_unwrap, layer->islands); + const Vec2f pc = pm.block<2, 2>(0, 0) * m_uv_editor_unwrap.chart_centroid[size_t(p)] + pm.col(2); + const float d2 = (pc - sel_c).squaredNorm(); + if (d2 < best_d2) { best_d2 = d2; best_parent = p; best_place = cand; } + } + if (best_parent < 0) { + show_error(nullptr, _u8L("This island has no neighbour it shares an edge with.")); + return; + } + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Join texture island"), UndoRedo::SnapshotType::GizmoAction); + if (layer->islands.size() <= size_t(chart)) + layer->islands.resize(size_t(chart) + 1); + layer->islands[size_t(chart)] = best_place; + // Record the join so the two (and anything already grouped with either) move together from now + // on, not just visually snap once. + join_island_groups(layer->island_groups, chart, best_parent, m_uv_editor_unwrap.chart_count); + rebuild_preview(); + } else if (cmd == int(UVEditorCanvas::Command::UnjoinSelected)) { + UVEditorCanvas *canvas = wxGetApp().plater()->get_uv_editor_canvas(); + const int chart = canvas != nullptr ? canvas->selected_island() : -1; + if (chart < 0 || size_t(chart) >= layer->islands.size()) { + show_error(nullptr, _u8L("Select an island in the UV editor first.")); + return; + } + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Unjoin texture island"), UndoRedo::SnapshotType::GizmoAction); + layer->islands[size_t(chart)] = TextureIsland{}; // back to its own packed position + // Break its join link too, so it stops moving with the others (the rest stay grouped). + if (size_t(chart) < layer->island_groups.size()) + layer->island_groups[size_t(chart)] = chart; + rebuild_preview(); + } + // FrameAll/ToggleSnap are handled inside the canvas; ProjectFromView is not wired yet. +} + +void GLGizmoTextureDisplacement::capture_view_projection(TextureDisplacementLayer &layer) +{ + const ModelVolume *mv = texture_volume(); + const ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr) + return; + + const Camera &camera = wxGetApp().plater()->get_camera(); + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + + // The view matrix's rotation rows are the camera axes in world space; bring them into the + // volume's local frame (where the mesh vertices live) so the projector rides along with the part. + const Matrix3d view_rot = camera.get_view_matrix().matrix().block<3, 3>(0, 0); + const Matrix3d trafo_rot = trafo.matrix().block<3, 3>(0, 0); + const Matrix3d world_to_local = trafo_rot.inverse(); + const Vec3d local_right = world_to_local * Vec3d(view_rot.row(0).transpose()); + const Vec3d local_up = world_to_local * Vec3d(view_rot.row(1).transpose()); + + // Unit axes: the projected planar coordinate must stay in mm so tiling_scale keeps meaning mm. + layer.view_project_right = local_right.norm() > 1e-9 ? Vec3f(local_right.normalized().cast()) : Vec3f::UnitX(); + layer.view_project_up = local_up.norm() > 1e-9 ? Vec3f(local_up.normalized().cast()) : Vec3f::UnitY(); +} + +void GLGizmoTextureDisplacement::cut_island(TextureDisplacementLayer &layer, int chart) +{ + const ModelVolume *mv = texture_volume(); + if (mv == nullptr) + return; + const std::vector &verts = mv->mesh().its.vertices; + const PatchUnwrap &u = m_uv_editor_unwrap; + + // Collect the chart's triangles back in mesh-vertex space, and its 3D bounding box. + std::vector> tris; + Vec3f lo(std::numeric_limits::max(), std::numeric_limits::max(), std::numeric_limits::max()); + Vec3f hi(std::numeric_limits::lowest(), std::numeric_limits::lowest(), std::numeric_limits::lowest()); + for (const stl_triangle_vertex_indices &t : u.indices) { + if (t[0] < 0 || size_t(t[0]) >= u.vertex_chart.size() || u.vertex_chart[size_t(t[0])] != chart) + continue; + std::array bt{}; + bool ok = true; + for (int k = 0; k < 3; ++k) { + const int uvv = t[k]; + if (uvv < 0 || size_t(uvv) >= u.source_vertex.size()) { ok = false; break; } + const int base = u.source_vertex[size_t(uvv)]; + if (base < 0 || size_t(base) >= verts.size()) { ok = false; break; } + bt[k] = base; + lo = lo.cwiseMin(verts[size_t(base)]); + hi = hi.cwiseMax(verts[size_t(base)]); + } + if (ok) + tris.push_back(bt); + } + if (tris.empty()) + return; + + // Cut perpendicular to the longest axis, through the centroid - a long thin island is split + // across its narrow middle, which is exactly the "islands might be very long" case. + const Vec3f ext = hi - lo; + const int axis = (ext.x() >= ext.y() && ext.x() >= ext.z()) ? 0 : (ext.y() >= ext.z() ? 1 : 2); + const float mid = 0.5f * (lo[axis] + hi[axis]); + + std::set> seams(layer.lscm_seam_edges.begin(), layer.lscm_seam_edges.end()); + for (const std::array &bt : tris) + for (int i = 0; i < 3; ++i) { + const int a = bt[i], b = bt[(i + 1) % 3]; + // Endpoints on opposite sides of the plane -> this edge crosses it -> make it a seam. + if ((verts[size_t(a)][axis] < mid) != (verts[size_t(b)][axis] < mid)) + seams.insert({ std::min(a, b), std::max(a, b) }); + } + layer.lscm_seam_edges.assign(seams.begin(), seams.end()); +} + +// unwrap mm -> texture uv, per island: the island's own hand placement, then the layer's +// tiling/rotation/offset. Both are affine, so they compose into one matrix the canvas can hand +// straight to a shader. +std::vector> +GLGizmoTextureDisplacement::uv_editor_island_transforms(const TextureDisplacementLayer &layer) +{ + const float uv_scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + const float rad = layer.rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad) * uv_scale; + const float sn = std::sin(rad) * uv_scale; + + Eigen::Matrix2f uv_linear; + uv_linear << cs, -sn, + sn, cs; + + m_uv_editor_bbox_min = Vec2f(std::numeric_limits::max(), std::numeric_limits::max()); + m_uv_editor_bbox_max = Vec2f(std::numeric_limits::lowest(), std::numeric_limits::lowest()); + + std::vector> transforms(size_t(std::max(m_uv_editor_unwrap.chart_count, 0))); + for (int c = 0; c < m_uv_editor_unwrap.chart_count; ++c) { + const Eigen::Matrix island = island_transform_matrix(c, m_uv_editor_unwrap, layer.islands); + Eigen::Matrix &m = transforms[size_t(c)]; + m.block<2, 2>(0, 0) = uv_linear * island.block<2, 2>(0, 0); + m.col(2) = uv_linear * island.col(2) + layer.offset; + } + + // Only for the panel's readout; the canvas computes its own bounds. + for (size_t i = 0; i < m_uv_editor_unwrap.uvs.size(); ++i) { + const int c = m_uv_editor_unwrap.vertex_chart[i]; + if (c < 0 || size_t(c) >= transforms.size()) + continue; + const Vec2f uv = transforms[size_t(c)].block<2, 2>(0, 0) * m_uv_editor_unwrap.uvs[i] + transforms[size_t(c)].col(2); + m_uv_editor_bbox_min = m_uv_editor_bbox_min.cwiseMin(uv); + m_uv_editor_bbox_max = m_uv_editor_bbox_max.cwiseMax(uv); + } + return transforms; +} + +void GLGizmoTextureDisplacement::on_island_edited(int island, const Vec2f &offset_delta, float rotation_delta, + float scale_factor, bool finished) +{ + TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr || island < 0) + return; + if (size_t(island) >= layer->islands.size()) + layer->islands.resize(size_t(island) + 1); + + // A pure translation (no rotation, no scale) is the only gesture that moves a whole group/selection + // together; rotate and scale stay on the primary island alone. This split is what makes flagging a + // group's worth of vertices on the GPU safe: the shared shader delta is a pure translation, so the + // same offset is correct for every flagged island. + const bool is_move = rotation_delta == 0.f && scale_factor == 1.f; + + if (!m_island_drag_active) { + // Taken before the first delta lands, so one undo reverts the whole drag rather than just + // its final mouse-move. + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Move texture island"), UndoRedo::SnapshotType::GizmoAction); + m_island_drag_active = true; + // Decide the moved set once, at drag start: the whole selection + join groups for a move, or + // just the primary for a rotate/scale. + m_island_move_set = is_move ? build_island_move_set(*layer, island) : std::vector{ island }; + // Set up the GPU drag: flag the moved islands' vertices and bake the mesh once (via the dirty + // flag). From then on the drag is a uniform update, no rebuild -- see render_bump_preview_mesh(). + m_bump_active_chart = island; + compute_bump_active_vertices(m_island_move_set); + m_bump_island_delta = Eigen::Matrix::Identity(); + m_bump_preview_dirty = true; + } + + // Apply the edit. A move goes to every island in the moved set (same offset -> they translate as + // one); a rotate/scale goes only to the primary, about its own centroid. + const std::vector single{ island }; + const std::vector &targets = (is_move && !m_island_move_set.empty()) ? m_island_move_set : single; + for (int c : targets) { + if (c < 0) + continue; + if (size_t(c) >= layer->islands.size()) + layer->islands.resize(size_t(c) + 1); + TextureIsland &target = layer->islands[size_t(c)]; + target.offset += offset_delta; + if (c == island) { + target.rotation_deg += rotation_delta; + // Guarded: a scale that reaches zero is unrecoverable (every subsequent factor multiplies it) + // and would collapse the island to a point -- exactly the failure this feature hit once already. + target.scale = std::clamp(target.scale * scale_factor, 0.001f, 1000.f); + } + } + + if (finished) { + m_island_drag_active = false; + m_bump_active_chart = -1; + m_bump_active_vertex.clear(); + m_island_move_set.clear(); + m_bump_island_delta = Eigen::Matrix::Identity(); + rebuild_preview(); // the real displaced geometry moved: recompute it once, at the end + } else { + const std::vector> xf = uv_editor_island_transforms(*layer); + if (UVEditorCanvas *uv_canvas = wxGetApp().plater()->get_uv_editor_canvas()) { + // Live feedback in the pane, and deliberately *just* the transforms: nothing about the + // unwrap changed, so none of the vertex buffers need touching. This is what makes a drag + // interactive on a patch with a million triangles. + uv_canvas->set_island_transforms(xf); + } + // Move the island on the model live through the shader's island_delta uniform -- no mesh + // rebuild. delta = F_current * F_baked^-1 in final-uv space (the bump mesh bakes F_baked; the + // shader applies delta to the flagged island's uv). The one rebuild that bakes the flags is + // scheduled at drag start above and consumed once per frame by render_painter_gizmo(). + if (m_use_bump_preview && m_bump_active_chart == island && size_t(island) < xf.size()) { + Eigen::Matrix3f cur = Eigen::Matrix3f::Identity(); + cur.topRows<2>() = xf[size_t(island)]; + Eigen::Matrix3f bak = Eigen::Matrix3f::Identity(); + bak.topRows<2>() = m_bump_baked_active_xf; + m_bump_island_delta = (cur * bak.inverse()).topRows<2>(); + m_parent.set_as_dirty(); + } + } +} + +void GLGizmoTextureDisplacement::on_uv_vertex_edited(const std::vector> &edits) +{ + TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr || edits.empty() || m_uv_editor_unwrap.empty()) + return; + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Edit texture UV"), UndoRedo::SnapshotType::GizmoAction); + + for (const auto &[unwrapped, raw_uv] : edits) { + if (unwrapped < 0 || size_t(unwrapped) >= m_uv_editor_unwrap.source_vertex.size()) + continue; + // Keep the gizmo's own unwrap copy in step, so the pane and the bake agree without a re-solve. + if (size_t(unwrapped) < m_uv_editor_unwrap.uvs.size()) + m_uv_editor_unwrap.uvs[size_t(unwrapped)] = raw_uv; + + const int mesh_v = m_uv_editor_unwrap.source_vertex[size_t(unwrapped)]; + if (mesh_v < 0) + continue; + // Store (or update) the override for this mesh vertex. Small list, linear scan is fine. + auto it = std::find_if(layer->lscm_uv_overrides.begin(), layer->lscm_uv_overrides.end(), + [mesh_v](const std::pair &p) { return p.first == mesh_v; }); + if (it != layer->lscm_uv_overrides.end()) + it->second = raw_uv; + else + layer->lscm_uv_overrides.emplace_back(mesh_v, raw_uv); + } + + rebuild_preview(); // the baked displacement samples the moved uv now +} + +bool GLGizmoTextureDisplacement::update_adjust_anchor() +{ + const ModelVolume *mv = texture_volume(); + m_adjust_anchor_valid = mv != nullptr && + compute_layer_paint_anchor(mv->mesh().its, mv->texture_displacement_facet(m_active_layer_slot).get_data(), + m_adjust_anchor_pos, m_adjust_anchor_normal); + return m_adjust_anchor_valid; +} + +TextureDisplacementLayer *GLGizmoTextureDisplacement::active_layer() +{ + ModelVolume *mv = texture_volume(); + if (mv == nullptr) + return nullptr; + for (TextureDisplacementLayer &l : mv->texture_displacement_layers) + if (l.slot == m_active_layer_slot) + return &l; + return nullptr; +} + +const TextureDisplacementLayer *GLGizmoTextureDisplacement::active_layer() const +{ + return const_cast(this)->active_layer(); +} + +Vec3f GLGizmoTextureDisplacement::adjust_plane_point() const +{ + const ModelVolume *mv = texture_volume(); + const float bbox_size = (mv != nullptr) ? float(mv->mesh().bounding_box().size().norm()) : 1.f; + const float lift = bbox_size * 0.01f + 0.2f; // clear of the surface, to avoid z-fighting + return m_adjust_anchor_pos + m_adjust_anchor_normal * lift; +} + +Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacementLayer &layer) const +{ + // apply_uv_transform() maps a planar mm coordinate p to uv = R(p / tiling_scale) + offset, and + // on_mouse_adjust_texture() drives offset by offset = offset_start - R(delta / tiling_scale). + // Inverting that, the handle's displacement from the anchor is - R^-1(offset) * tiling_scale -- + // which, substituted into the drag equation, moves the handle by exactly `delta`. So the handle + // follows the cursor precisely, and is back on the anchor exactly when offset is zero. + const float rad = layer.rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + const Vec2f unrotated(layer.offset.x() * cs + layer.offset.y() * sn, -layer.offset.x() * sn + layer.offset.y() * cs); + const Vec2f planar = -unrotated * layer.tiling_scale; + + Vec3f u_axis, v_axis; + adjust_tangent_basis(u_axis, v_axis); + return adjust_plane_point() + u_axis * planar.x() + v_axis * planar.y(); +} + +void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const +{ + // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and + // the offset handle's plane always agree with what project_texture_displacement_uv() does. + const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); + if (n.x() >= n.y() && n.x() >= n.z()) { + u_axis = Vec3f::UnitY(); + v_axis = Vec3f::UnitZ(); + } else if (n.y() >= n.x() && n.y() >= n.z()) { + u_axis = Vec3f::UnitX(); + v_axis = Vec3f::UnitZ(); + } else { + u_axis = Vec3f::UnitX(); + v_axis = Vec3f::UnitY(); + } +} + +void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() +{ + if (!m_adjust_anchor_valid) + return; + + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + const TextureDisplacementLayer *layer = active_layer(); + if (mo == nullptr || mv == nullptr || layer == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + + // Handle sizes scale with the volume so they stay usable on both tiny and huge models. + const float bbox_size = float(mv->mesh().bounding_box().size().norm()); + const float panel_half = bbox_size * 0.04f + 1.f; + const float arrow_length = panel_half * 2.2f; + + // Tracks the layer's offset, so the handle actually travels with the texture as it is dragged. + const Vec3f handle_center_local = adjust_handle_center(*layer); + + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_BLEND)); + shader->start_using(); + + const Camera &camera = wxGetApp().plater()->get_camera(); + + Vec3f u_axis, v_axis; + adjust_tangent_basis(u_axis, v_axis); + Transform3d plane_transform = Transform3d::Identity(); + plane_transform.linear().col(0) = u_axis.cast(); + plane_transform.linear().col(1) = v_axis.cast(); + plane_transform.linear().col(2) = m_adjust_anchor_normal.cast(); + plane_transform.translation() = handle_center_local.cast(); + + // Pan panel: a flat square lying in the patch's own tangent plane. Dragging anywhere on it + // moves the texture freely along both axes at once. + if (!m_adjust_panel_glmodel.is_initialized()) { + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(4); + init_data.reserve_indices(6); + init_data.add_vertex(Vec3f(-1.f, -1.f, 0.f)); + init_data.add_vertex(Vec3f(1.f, -1.f, 0.f)); + init_data.add_vertex(Vec3f(1.f, 1.f, 0.f)); + init_data.add_vertex(Vec3f(-1.f, 1.f, 0.f)); + init_data.add_triangle(0, 1, 2); + init_data.add_triangle(0, 2, 3); + m_adjust_panel_glmodel.init_from(std::move(init_data)); + } + Transform3d view_model_matrix = camera.get_view_matrix() * trafo_matrix * plane_transform * + Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(panel_half, panel_half, panel_half)); + shader->set_uniform("view_model_matrix", view_model_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + ColorRGBA panel_color = m_adjust_drag_handle == AdjustHandle::Pan ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE(); + panel_color.a(0.45f); + m_adjust_panel_glmodel.set_color(panel_color); + m_adjust_panel_glmodel.render(); + + // Axis arrows: a shaft plus a small V-shaped arrowhead, both along local +X. Reused for both + // the U and V axes below by swapping which world direction local +X is transformed to. + if (!m_adjust_arrow_glmodel.is_initialized()) { + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(4); + init_data.reserve_indices(6); + init_data.add_vertex(Vec3f(0.f, 0.f, 0.f)); + init_data.add_vertex(Vec3f(1.f, 0.f, 0.f)); + init_data.add_vertex(Vec3f(0.82f, 0.08f, 0.f)); + init_data.add_vertex(Vec3f(0.82f, -0.08f, 0.f)); + init_data.add_line(0, 1); + init_data.add_line(1, 2); + init_data.add_line(1, 3); + m_adjust_arrow_glmodel.init_from(std::move(init_data)); + } +#if !SLIC3R_OPENGL_ES + if (!OpenGLManager::get_gl_info().is_core_profile()) + glsafe(::glLineWidth(2.0f)); +#endif // !SLIC3R_OPENGL_ES + + auto render_arrow = [&](const Vec3f &axis, const Vec3f &other_axis, bool is_active) { + Transform3d arrow_transform = Transform3d::Identity(); + arrow_transform.linear().col(0) = axis.cast(); + arrow_transform.linear().col(1) = other_axis.cast(); + arrow_transform.linear().col(2) = m_adjust_anchor_normal.cast(); + arrow_transform.translation() = handle_center_local.cast(); + + const Transform3d vmm = camera.get_view_matrix() * trafo_matrix * arrow_transform * + Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(arrow_length, arrow_length, arrow_length)); + shader->set_uniform("view_model_matrix", vmm); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + m_adjust_arrow_glmodel.set_color(is_active ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE()); + m_adjust_arrow_glmodel.render(); + }; + render_arrow(u_axis, v_axis, m_adjust_drag_handle == AdjustHandle::AxisU); + render_arrow(v_axis, u_axis, m_adjust_drag_handle == AdjustHandle::AxisV); + + shader->stop_using(); + glsafe(::glDisable(GL_BLEND)); + glsafe(::glEnable(GL_DEPTH_TEST)); +} + +bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mouse_event) +{ + if (!m_adjust_anchor_valid) + return false; + + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + TextureDisplacementLayer *layer = active_layer(); + if (mv == nullptr || mo == nullptr || layer == nullptr) + return false; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + const float bbox_size = float(mv->mesh().bounding_box().size().norm()); + const float panel_half = bbox_size * 0.04f + 1.f; + const float arrow_length = panel_half * 2.2f; + // Where the handle is drawn (moves with the layer's offset) vs. the plane the drag is measured + // against (fixed at the anchor). Keeping them apart is what stops the handle's own motion from + // feeding back into the delta that produced it. + const Vec3f handle_center_local = adjust_handle_center(*layer); + const Vec3f drag_plane_local = adjust_plane_point(); + const Vec3d handle_center_world = trafo_matrix * handle_center_local.cast(); + const Vec2d mouse_pos(mouse_event.GetX(), mouse_event.GetY()); + + Vec3f u_axis, v_axis; + adjust_tangent_basis(u_axis, v_axis); + + const Point handle_screen = CameraUtils::project(camera, handle_center_world); + const Vec2d handle_screen_d(double(handle_screen.x()), double(handle_screen.y())); + + // Point-to-segment distance in screen space, for the arrow shafts. + auto dist_to_segment_px = [](const Vec2d &p, const Vec2d &a, const Vec2d &b) { + const Vec2d ab = b - a; + const double len2 = ab.squaredNorm(); + const double t = (len2 > 1e-9) ? std::clamp((p - a).dot(ab) / len2, 0.0, 1.0) : 0.0; + return (p - (a + ab * t)).norm(); + }; + + if (mouse_event.LeftDown()) { + const Vec3d u_tip_world = trafo_matrix * (handle_center_local + u_axis * arrow_length).cast(); + const Vec3d v_tip_world = trafo_matrix * (handle_center_local + v_axis * arrow_length).cast(); + const Point u_tip_screen = CameraUtils::project(camera, u_tip_world); + const Point v_tip_screen = CameraUtils::project(camera, v_tip_world); + const Vec2d u_tip_screen_d(double(u_tip_screen.x()), double(u_tip_screen.y())); + const Vec2d v_tip_screen_d(double(v_tip_screen.x()), double(v_tip_screen.y())); + + // Panel screen-space "radius", approximated from one corner (a loose circle around the + // square is close enough for hit-testing purposes). + const Vec3d panel_corner_world = trafo_matrix * (handle_center_local + (u_axis + v_axis) * panel_half).cast(); + const Point panel_corner_screen = CameraUtils::project(camera, panel_corner_world); + const double panel_screen_radius = (Vec2d(double(panel_corner_screen.x()), double(panel_corner_screen.y())) - handle_screen_d).norm(); + + constexpr double pick_tolerance_px = 8.0; + const double dist_to_u = dist_to_segment_px(mouse_pos, handle_screen_d, u_tip_screen_d); + const double dist_to_v = dist_to_segment_px(mouse_pos, handle_screen_d, v_tip_screen_d); + const double dist_to_panel = (handle_screen_d - mouse_pos).norm(); + + // Arrows take priority over the panel (their tips extend past it), then the panel covers + // the broader central area. + if (dist_to_u <= pick_tolerance_px && dist_to_u <= dist_to_v) + m_adjust_drag_handle = AdjustHandle::AxisU; + else if (dist_to_v <= pick_tolerance_px) + m_adjust_drag_handle = AdjustHandle::AxisV; + else if (dist_to_panel <= panel_screen_radius) + m_adjust_drag_handle = AdjustHandle::Pan; + else { + m_adjust_drag_handle = AdjustHandle::None; + return false; + } + + m_adjust_drag_start_offset = layer->offset; + + Vec3d world_hit; + if (ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit)) { + const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast(); + m_adjust_drag_start_planar = project_planar(local_hit, m_adjust_anchor_normal); + } + return true; + } + + if (mouse_event.Dragging() && m_adjust_drag_handle != AdjustHandle::None) { + Vec3d world_hit; + if (!ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit)) + return true; + const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast(); + const Vec2f current_planar = project_planar(local_hit, m_adjust_anchor_normal); + + Vec2f delta_planar = current_planar - m_adjust_drag_start_planar; + // project_planar()'s (x, y) axes are exactly u_axis/v_axis (see adjust_tangent_basis()), + // so zeroing one component constrains the drag to only the other axis. + if (m_adjust_drag_handle == AdjustHandle::AxisU) + delta_planar.y() = 0.f; + else if (m_adjust_drag_handle == AdjustHandle::AxisV) + delta_planar.x() = 0.f; + + const float scale = (layer->tiling_scale > 1e-6f) ? (1.f / layer->tiling_scale) : 1.f; + const Vec2f delta_scaled = delta_planar * scale; + const float rad = layer->rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + const Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs); + // Increasing `offset` shifts which texel is sampled at a fixed world position, which + // visually slides the pattern the *opposite* way - subtracting is this session's + // best-effort reasoning about the direction that feels like "dragging the texture", + // unverified against an actual render (see header comment). + layer->offset = m_adjust_drag_start_offset - delta_rotated; + + m_preview_params_dirty = true; + m_parent.set_as_dirty(); + return true; + } + + if (mouse_event.LeftUp() && m_adjust_drag_handle != AdjustHandle::None) { + m_adjust_drag_handle = AdjustHandle::None; + rebuild_preview(); + m_preview_params_dirty = false; + return true; + } + + return false; +} + ModelVolume* GLGizmoTextureDisplacement::texture_volume() { ModelObject *mo = m_c->selection_info()->model_object(); @@ -113,6 +2028,7 @@ void GLGizmoTextureDisplacement::update_model_object() const ModelObjectPtrs &mos = wxGetApp().model().objects; wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin()); m_parent.post_event(SimpleEvent(EVT_GLCANVAS_SCHEDULE_BACKGROUND_PROCESS)); + rebuild_preview(); } } @@ -136,6 +2052,18 @@ void GLGizmoTextureDisplacement::update_from_model_object(bool first_update) m_triangle_selectors.back()->deserialize(mv->texture_displacement_facet(m_active_layer_slot).get_data(), false); m_triangle_selectors.back()->request_update_render_data(); } + + // Start a freshly opened, never-textured volume with one layer already in place, so the panel is + // ready to paint straight away rather than showing an empty layer list. Only on first open, and + // only when there are none - never during an undo/redo or layer-switch reload (which also come + // through here), where silently adding a layer would be wrong. + if (first_update) + if (ModelVolume *tv = texture_volume(); tv != nullptr && tv->texture_displacement_layers.empty()) { + add_texture_layer(); // takes its own snapshot and rebuilds the preview + return; + } + + rebuild_preview(); } void GLGizmoTextureDisplacement::set_active_layer(int slot) @@ -143,10 +2071,26 @@ void GLGizmoTextureDisplacement::set_active_layer(int slot) if (slot == m_active_layer_slot) return; // Flush edits made while the previous layer was active before switching what the selectors - // reflect -- otherwise they would be silently lost. + // reflect - otherwise they would be silently lost. update_model_object(); m_active_layer_slot = slot; update_from_model_object(false); + // The on-canvas gizmo (if on) is anchored to whichever layer is active - keep it in sync + // instead of leaving it pointing at the previous layer's (now stale) paint patch. + if (m_adjust_texture_mode) + update_adjust_anchor(); + // Refresh every preview/overlay (bump, UV editor, seams, ...) for the newly active layer. + rebuild_preview(); +} + +unsigned int GLGizmoTextureDisplacement::tool_icon_id() +{ + if (!m_tool_icon_tried) { + m_tool_icon_tried = true; + // Runs from the panel render, i.e. with a GL context current, so the upload is safe here. + m_tool_icon.load_from_svg_file(resources_dir() + "/images/toolbar_texture_displacement.svg", false, false, false, 32); + } + return m_tool_icon.get_id(); } void GLGizmoTextureDisplacement::add_texture_layer() @@ -167,65 +2111,176 @@ void GLGizmoTextureDisplacement::add_texture_layer() return; } + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Add texture displacement layer"), UndoRedo::SnapshotType::GizmoAction); + + TextureDisplacementLayer layer; + layer.slot = free_slot; + // Start the layer off on the first library texture rather than on nothing at all: a textureless + // layer looks broken (painting on it appears to do nothing, because there is no height map to + // displace by). The user swaps it for another from the layer's own picker. + const std::vector &library = texture_library(); + if (!library.empty()) + if (const LibraryTexture *tex = get_library_texture(library.front().path)) { + layer.name = library.front().name; + layer.path = library.front().path; + layer.image_data = tex->image_data; + } + mv->texture_displacement_layers.push_back(std::move(layer)); + + set_active_layer(free_slot); + // set_active_layer() is a no-op when the new slot happens to be the one already active (slot 0, + // for the very first layer added), so the preview would not pick the new texture up on its own. + rebuild_preview(); + m_parent.set_as_dirty(); +} + +const GLGizmoTextureDisplacement::LibraryTexture *GLGizmoTextureDisplacement::get_library_texture(const std::string &path) +{ + if (auto it = m_library_textures.find(path); it != m_library_textures.end()) + return it->second.image_data ? &it->second : nullptr; + + LibraryTexture entry; + std::string error; + entry.image_data = load_texture_image_data(path, error); + if (entry.image_data) { + TextureDisplacementLayer probe; + probe.image_data = entry.image_data; + entry.thumbnail = upload_height_thumbnail(decode_height_texture(probe)); + if (!entry.thumbnail) + entry.image_data.reset(); // decoded to nothing usable - treat it as a failed load + } else { + BOOST_LOG_TRIVIAL(error) << "Texture displacement: could not load texture " << path << ": " << error; + } + + // Cached whether it loaded or not: a file that failed is remembered as unusable, so the picker + // does not retry (and re-log) it on every frame it is on screen. + const auto [pos, inserted] = m_library_textures.emplace(path, std::move(entry)); + return pos->second.image_data ? &pos->second : nullptr; +} + +void GLGizmoTextureDisplacement::set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry) +{ + const LibraryTexture *tex = get_library_texture(entry.path); + if (tex == nullptr) { + show_error(nullptr, _u8L("Could not load the selected texture.")); + return; + } + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Change texture displacement texture"), UndoRedo::SnapshotType::GizmoAction); + layer.name = entry.name; + layer.path = entry.path; + // Handing over the library's own buffer (rather than a copy) is what lets decode_height_texture() + // - whose cache is keyed by exactly this pointer - hit straight away instead of re-decoding + // the PNG the first time the layer is previewed or baked. + layer.image_data = tex->image_data; + + rebuild_preview(); + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::import_custom_texture(TextureDisplacementLayer &layer) +{ const wxString wildcard = "Images (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp"; wxFileDialog dialog(nullptr, _L("Choose a texture image (height map)"), wxEmptyString, wxEmptyString, wildcard, wxFD_OPEN | wxFD_FILE_MUST_EXIST); if (dialog.ShowModal() != wxID_OK) return; - const std::string path = into_u8(dialog.GetPath()); - - wxImage image; - if (!image.LoadFile(dialog.GetPath()) || !image.IsOk()) { - show_error(nullptr, _u8L("Could not load the selected image.")); + // Converts to the 8-bit grayscale PNG the bake code understands and copies it into the user's + // own texture folder, so it stays available for later models (and survives an app update, which + // rewrites the shipped folder wholesale). + std::string error; + const std::optional entry = import_texture_to_library(into_u8(dialog.GetPath()), error); + if (!entry) { + show_error(nullptr, error); return; } + set_layer_texture(layer, *entry); +} - // libslic3r's PNG decoder (used by the baking code, which has no wxWidgets dependency) only - // understands true 8-bit grayscale PNG. Convert and re-encode through Slic3r's own PNG writer - // here rather than relying on wxImage's PNG encoder to pick a compatible color type. - const wxImage gray = image.ConvertToGreyscale(); - const int w = gray.GetWidth(); - const int h = gray.GetHeight(); - if (w <= 0 || h <= 0) { - show_error(nullptr, _u8L("The selected image is empty.")); - return; +float GLGizmoTextureDisplacement::texture_row_height() const +{ + return m_imgui->scaled(3.f); +} + +bool GLGizmoTextureDisplacement::texture_row(const char *id, const std::string &name, GLTexture *thumbnail, bool selected, float width) +{ + const float row_h = texture_row_height(); + const ImVec2 start = ImGui::GetCursorPos(); + const bool clicked = ImGui::Selectable(id, selected, 0, ImVec2(width, row_h)); + const ImVec2 after = ImGui::GetCursorPos(); + + // Lay the image and the name back over the Selectable that was just emitted, so the whole row + // - preview included - is the clickable target rather than just a strip of text next to it. + ImGui::SetCursorPos(ImVec2(start.x + ImGui::GetStyle().FramePadding.x, start.y)); + if (thumbnail != nullptr) { + // Fit inside a row_h square without distorting a non-square source image. + const float aspect = (thumbnail->get_height() > 0) ? float(thumbnail->get_width()) / float(thumbnail->get_height()) : 1.f; + const ImVec2 dim = (aspect >= 1.f) ? ImVec2(row_h, row_h / aspect) : ImVec2(row_h * aspect, row_h); + ImGui::Image((ImTextureID) (intptr_t) thumbnail->get_id(), dim); + ImGui::SameLine(); } - std::vector gray_pixels(size_t(w) * size_t(h)); - const unsigned char *rgb = gray.GetData(); - for (size_t i = 0; i < gray_pixels.size(); ++i) - gray_pixels[i] = rgb[i * 3]; + ImGui::SetCursorPosY(start.y + (row_h - ImGui::GetTextLineHeight()) * 0.5f); // centre the name on the image + ImGui::TextUnformatted(name.c_str()); - const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() - / boost::filesystem::unique_path("orca_texdisp_%%%%%%%%.png"); - if (!Slic3r::png::write_gray_to_file(tmp_path.string(), size_t(w), size_t(h), gray_pixels)) { - show_error(nullptr, _u8L("Failed to prepare the texture for use.")); - return; + ImGui::SetCursorPos(after); + return clicked; +} + +void GLGizmoTextureDisplacement::render_texture_picker(TextureDisplacementLayer &layer) +{ + const float row_h = texture_row_height(); + const float import_btn_w = m_imgui->scaled(1.6f); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float picker_w = std::max(m_imgui->scaled(10.f), ImGui::GetContentRegionAvail().x - import_btn_w - spacing); + + const ImVec2 start = ImGui::GetCursorPos(); + if (texture_row("##texture_picker", layer.name.empty() ? _u8L("Choose a texture...") : layer.name, + get_layer_thumbnail(layer), false, picker_w)) + ImGui::OpenPopup("##texture_library"); + const ImVec2 after = ImGui::GetCursorPos(); + + // Positioned explicitly rather than with SameLine(): texture_row() draws several widgets and + // then rewinds the cursor, so ImGui's notion of "the previous line" is not the row's own. + ImGui::SetCursorPos(ImVec2(start.x + picker_w + spacing, start.y)); + if (ImGui::Button("+", ImVec2(import_btn_w, row_h))) + import_custom_texture(layer); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer " + "bakes from and saved to your personal texture folder, kept separate from the " + "textures shipped with OrcaSlicer."), + m_imgui->scaled(20.f)); + ImGui::SetCursorPos(after); + + if (ImGui::BeginPopup("##texture_library")) { + const std::vector &library = texture_library(); + if (library.empty()) + m_imgui->text(_L("No textures found.")); + + bool shipped_heading = false; + bool user_heading = false; + for (const TextureLibraryEntry &entry : library) { + if (!entry.is_user && !shipped_heading) { + ImGui::TextDisabled("%s", _u8L("Built-in").c_str()); + shipped_heading = true; + } else if (entry.is_user && !user_heading) { + if (shipped_heading) + ImGui::Separator(); + ImGui::TextDisabled("%s", _u8L("My textures").c_str()); + user_heading = true; + } + + ImGui::PushID(entry.path.c_str()); + const LibraryTexture *tex = get_library_texture(entry.path); + if (texture_row("##entry", entry.name, tex != nullptr ? tex->thumbnail.get() : nullptr, + entry.path == layer.path, m_imgui->scaled(14.f))) { + set_layer_texture(layer, entry); + ImGui::CloseCurrentPopup(); + } + ImGui::PopID(); + } + ImGui::EndPopup(); } - std::vector bytes; - { - std::ifstream ifs(tmp_path.string(), std::ios::binary); - bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); - } - boost::system::error_code ec; - boost::filesystem::remove(tmp_path, ec); - - if (bytes.empty()) { - show_error(nullptr, _u8L("Failed to prepare the texture for use.")); - return; - } - - Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Add texture displacement layer"), UndoRedo::SnapshotType::GizmoAction); - - TextureDisplacementLayer layer; - layer.slot = free_slot; - layer.name = boost::filesystem::path(path).filename().string(); - layer.path = path; - layer.image_data = std::make_shared>(std::move(bytes)); - mv->texture_displacement_layers.push_back(std::move(layer)); - - set_active_layer(free_slot); - m_parent.set_as_dirty(); } void GLGizmoTextureDisplacement::remove_texture_layer(int slot) @@ -240,15 +2295,197 @@ void GLGizmoTextureDisplacement::remove_texture_layer(int slot) layers.erase(std::remove_if(layers.begin(), layers.end(), [slot](const TextureDisplacementLayer &l) { return l.slot == slot; }), layers.end()); - if (slot >= 0 && slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) + if (slot >= 0 && slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS)) { mv->texture_displacement_facet(slot).reset(); + m_thumbnails[size_t(slot)].reset(); + m_thumbnail_source[size_t(slot)] = nullptr; + } if (m_active_layer_slot == slot) - update_from_model_object(false); + update_from_model_object(false); // also rebuilds the preview + else + rebuild_preview(); // a non-active layer's contribution to the combined preview changed m_parent.set_as_dirty(); } +void GLGizmoTextureDisplacement::select_whole_model() +{ + ModelObject *mo = m_c->selection_info()->model_object(); + if (!mo) + return; + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Select whole model for texture displacement"), UndoRedo::SnapshotType::GizmoAction); + + int idx = -1; + for (const ModelVolume *v : mo->volumes) { + if (!v->is_model_part()) + continue; + ++idx; + const size_t facet_count = v->mesh().its.indices.size(); + for (size_t i = 0; i < facet_count; ++i) + m_triangle_selectors[idx]->set_facet(int(i), EnforcerBlockerType::ENFORCER); + m_triangle_selectors[idx]->request_update_render_data(); + } + update_model_object(); + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::subdivide_model() +{ + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr) + return; + + Plater *plater = wxGetApp().plater(); + Plater::TakeSnapshot snapshot(plater, _u8L("Subdivide model for texture displacement"), UndoRedo::SnapshotType::GizmoAction); + + // Same save/replace/restore-painting dance GLGizmoSimplify uses when it re-tessellates a + // volume's mesh: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles, + // texture-displacement doesn't (no remap support for it yet) and is dropped instead of being + // left referring to triangle indices that no longer mean the same thing. + std::optional saved_painting = mv->save_painting(); + + // max_edge_length 0 means "no triangle is ever small enough", so every non-degenerate edge is + // split on each of the m_subdivide_count passes - i.e. a plain "subdivide the whole mesh N times". + TriangleMesh new_mesh(subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count)); + mv->set_mesh(std::move(new_mesh)); + mv->set_new_unique_id(); + mv->calculate_convex_hull(); + mv->restore_painting(saved_painting); + + if (ObjectList *obj_list = wxGetApp().obj_list()) { + const ModelObjectPtrs &objs = plater->model().objects; + auto it = std::find(objs.begin(), objs.end(), mo); + if (it != objs.end()) + obj_list->update_info_items(size_t(it - objs.begin())); + } + + plater->changed_object(*mo); + update_from_model_object(false); // reload selectors/preview against the new mesh + cleared paint + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::remesh_model() +{ + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr || m_remesh_target_edge_mm <= 0.f) + return; + + Plater *plater = wxGetApp().plater(); + + // CGAL isotropic remeshing can be slow on a big mesh; do it before taking the snapshot so a failure + // (it returns the input unchanged) doesn't leave an empty undo step. + indexed_triangle_set remeshed; + { + wxBusyCursor wait; + remeshed = MeshBoolean::cgal::remesh_isotropic(mv->mesh().its, double(m_remesh_target_edge_mm), 3); + } + if (remeshed.indices.empty() || remeshed.vertices.size() == mv->mesh().its.vertices.size()) { + show_error(nullptr, _u8L("Remeshing did not change the model (it may be non-manifold or the target size " + "is already met).")); + return; + } + + Plater::TakeSnapshot snapshot(plater, _u8L("Remesh model for texture displacement"), UndoRedo::SnapshotType::GizmoAction); + // Same save/replace/restore-painting dance as subdivide: texture-displacement paint has no remap + // across a topology change, so it is dropped rather than left pointing at triangles that moved. + std::optional saved_painting = mv->save_painting(); + mv->set_mesh(TriangleMesh(std::move(remeshed))); + mv->set_new_unique_id(); + mv->calculate_convex_hull(); + mv->restore_painting(saved_painting); + + if (ObjectList *obj_list = wxGetApp().obj_list()) { + const ModelObjectPtrs &objs = plater->model().objects; + auto it = std::find(objs.begin(), objs.end(), mo); + if (it != objs.end()) + obj_list->update_info_items(size_t(it - objs.begin())); + } + plater->changed_object(*mo); + update_from_model_object(false); + m_parent.set_as_dirty(); +} + +void GLGizmoTextureDisplacement::rebuild_subdivide_preview() +{ + m_subdivide_preview_glmodel.reset(); + m_subdivide_preview_count = -1; + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || m_subdivide_count < 1) + return; + + // The same subdivision Apply would commit, but kept in a throwaway mesh and shown only as a + // wireframe - the model itself is not touched until Apply. + const indexed_triangle_set its = subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count); + if (its.indices.empty()) + return; + m_subdivide_preview_count = m_subdivide_count; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(its.vertices.size()); + init_data.reserve_indices(its.indices.size() * 6); + for (const Vec3f &v : its.vertices) + init_data.add_vertex(v); + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) + init_data.add_line(unsigned(tri[i]), unsigned(tri[(i + 1) % 3])); + if (!init_data.is_empty()) + m_subdivide_preview_glmodel.init_from(std::move(init_data)); +} + +void GLGizmoTextureDisplacement::render_subdivide_preview() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_subdivide_preview_glmodel.is_initialized()) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + glsafe(::glEnable(GL_POLYGON_OFFSET_LINE)); + glsafe(::glPolygonOffset(-1.0f, -1.0f)); + m_subdivide_preview_glmodel.set_color(ColorRGBA(0.2f, 0.9f, 1.0f, 0.7f)); // cyan, reads as "preview" + m_subdivide_preview_glmodel.render(); + glsafe(::glDisable(GL_POLYGON_OFFSET_LINE)); + shader->stop_using(); +} + +GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplacementLayer &layer) +{ + if (layer.empty() || layer.slot < 0 || size_t(layer.slot) >= TEXTURE_DISPLACEMENT_MAX_LAYERS) + return nullptr; + + const size_t slot = size_t(layer.slot); + if (m_thumbnails[slot] && m_thumbnail_source[slot] == layer.image_data.get() && + m_thumbnail_smoothing[slot] == layer.smoothing) + return m_thumbnails[slot].get(); + + // Reuses the already-decoded, already-cached grayscale pixels (see decode_height_texture()'s + // own cache in TextureDisplacement.cpp) - only the gray-to-RGBA expansion and GPU upload below are + // new work. Rebuilt when the texture *or the smoothing* changes, so the fast/bump preview - which + // samples this GPU texture directly - reflects the current smoothing rather than the raw image. + std::unique_ptr texture = upload_height_thumbnail(decode_height_texture(layer)); + if (!texture) + return nullptr; + + m_thumbnails[slot] = std::move(texture); + m_thumbnail_source[slot] = layer.image_data.get(); + m_thumbnail_smoothing[slot] = layer.smoothing; + return m_thumbnails[slot].get(); +} + void GLGizmoTextureDisplacement::bake() { ModelVolume *mv = texture_volume(); @@ -265,7 +2502,17 @@ void GLGizmoTextureDisplacement::bake() } m_bake_in_progress = true; - queue_texture_displacement_bake(*mv, [this]() { m_bake_in_progress = false; }); + queue_texture_displacement_bake(*mv, [this]() { + m_bake_in_progress = false; + // Baking replaces the volume's mesh (new id, new topology) without changing the object's + // id or volume count, so GLGizmoPainterBase::data_changed()'s usual change-detection never + // notices it needs to reload - do it explicitly here, otherwise the gizmo keeps painting + // and rendering against the stale, pre-bake TriangleSelectorPatch until the object is + // deselected and reselected. + if (m_state == On && m_c->selection_info() && m_c->selection_info()->model_object()) + update_from_model_object(false); + m_parent.set_as_dirty(); + }); } void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit) @@ -277,25 +2524,177 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const float approx_height = m_imgui->scaled(24.f); y = std::min(y, bottom_limit - approx_height); - GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 1.0f, 0.0f); + + // Docked (the default) the panel is pinned next to the gizmo toolbar and cannot be moved, like + // every other gizmo's. Undocked it becomes an ordinary floating window: a title bar to drag it + // by, and no forced position - this panel is tall enough (layer stack, per-layer controls) that + // it can cover the very part of the model being painted, and being able to shove it aside is the + // point. The position is deliberately *not* seeded on undock, so the window stays exactly where + // it already was and the user just gains the ability to move it from there. + ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse; + if (!m_undocked) { + flags |= ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar; + GizmoImguiSetNextWIndowPos(x, y, ImGuiCond_Always, 1.0f, 0.0f); + } ImGuiWrapper::push_toolbar_style(m_parent.get_scale()); - GizmoImguiBegin(get_name(), ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar); + GizmoImguiBegin(get_name(), flags); - m_imgui->text(m_desc.at("cursor_size")); - ImGui::SameLine(); - ImGui::PushItemWidth(m_imgui->scaled(7.f)); - m_imgui->slider_float("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f"); + // Combo drop-downs otherwise inherit ImGui's near-black default popup background; under the light + // theme that leaves the dark item text unreadable ("the dropbox is black"). Pushed only around each + // Combo below (never around a tooltip, whose own near-black default is what makes it readable). + const ImVec4 combo_popup_bg = wxGetApp().dark_mode() ? ImVec4(0.18f, 0.18f, 0.19f, 1.f) + : ImVec4(0.93f, 0.93f, 0.93f, 1.f); + const auto scoped_combo = [&](const char *id, int *v, const char *const items[], int n) { + ImGui::PushStyleColor(ImGuiCol_PopupBg, combo_popup_bg); + const bool changed = ImGui::Combo(id, v, items, n); + ImGui::PopStyleColor(); + return changed; + }; - bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE; - if (ImGui::RadioButton(m_desc.at("circle").ToUTF8().data(), is_circle)) + if (m_imgui->button(m_undocked ? _L("Dock panel") : _L("Undock panel"))) + m_undocked = !m_undocked; + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Detach this panel so it can be dragged anywhere over the 3D view, or dock it " + "back beside the toolbar."), + m_imgui->scaled(20.f)); + ImGui::Separator(); + + // Selection mode: which of TriangleSelector's existing click/brush mechanisms drives painting. + // "Face" and "Connected area" reuse the exact same underlying selection machinery every other + // paint gizmo already has (single-facet click, and angle-limited flood fill respectively) -- + // just exposed here as an alternative to brushing, one triangle/region at a time. + m_imgui->text(_L("Selection mode")); + const bool is_brush_mode = m_tool_type == ToolType::BRUSH && m_cursor_type != TriangleSelector::CursorType::POINTER; + const bool is_face_mode = m_tool_type == ToolType::BRUSH && m_cursor_type == TriangleSelector::CursorType::POINTER; + const bool is_area_mode = m_tool_type == ToolType::SMART_FILL; + if (ImGui::RadioButton(_u8L("Brush").c_str(), is_brush_mode)) { + m_tool_type = ToolType::BRUSH; m_cursor_type = TriangleSelector::CursorType::CIRCLE; + } ImGui::SameLine(); - if (ImGui::RadioButton(m_desc.at("sphere").ToUTF8().data(), !is_circle)) - m_cursor_type = TriangleSelector::CursorType::SPHERE; + if (ImGui::RadioButton(_u8L("Face").c_str(), is_face_mode)) { + m_tool_type = ToolType::BRUSH; + m_cursor_type = TriangleSelector::CursorType::POINTER; + } + ImGui::SameLine(); + if (ImGui::RadioButton(_u8L("Connected area").c_str(), is_area_mode)) { + m_tool_type = ToolType::SMART_FILL; + m_cursor_type = TriangleSelector::CursorType::POINTER; + } + + if (is_brush_mode) { + m_imgui->text(m_desc.at("cursor_size")); + ImGui::SameLine(); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_imgui->slider_float("##cursor_radius", &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f"); + ImGui::PopItemWidth(); + + bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE; + if (ImGui::RadioButton(m_desc.at("circle").ToUTF8().data(), is_circle)) + m_cursor_type = TriangleSelector::CursorType::CIRCLE; + ImGui::SameLine(); + if (ImGui::RadioButton(m_desc.at("sphere").ToUTF8().data(), !is_circle)) + m_cursor_type = TriangleSelector::CursorType::SPHERE; + } else if (is_area_mode) { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_imgui->slider_float(_u8L("Angle threshold"), &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f"); + ImGui::PopItemWidth(); + } + + if (m_imgui->button(_u8L("Select whole model"))) + select_whole_model(); + + // View mode (#: "make View Mode with just icons"): Normal / Fast / Checker / Distortion behave as + // one radio group, Wireframe as an independent toggle. All reuse the one tool icon for now, so the + // tooltip carries the meaning. The underlying state stays m_use_bump_preview + m_uv_check_mode. + { + const unsigned int icon = tool_icon_id(); + const float vsz = m_imgui->scaled(1.5f); + const ImVec4 teal(0.0f, 0.59f, 0.53f, 1.0f); + const int cur_mode = m_use_bump_preview ? 1 : + (m_uv_check_mode == UVCheckMode::Checker ? 2 : + m_uv_check_mode == UVCheckMode::Distortion ? 3 : 0); + int new_mode = cur_mode; + bool wf_toggle = false; + + // One icon button; active state shown by a teal backing and a full-brightness (vs dimmed) icon. + // Falls back to a text toggle if the icon can't load. + const auto icon_toggle = [&](int uid, bool active, const wxString &label, const wxString &tip) -> bool { + bool clicked; + ImGui::PushID(uid); + if (icon != 0) + clicked = m_imgui->image_button((ImTextureID) (intptr_t) icon, ImVec2(vsz, vsz), ImVec2(0, 0), ImVec2(1, 1), + -1, active ? teal : ImVec4(0, 0, 0, 0), + active ? ImVec4(1, 1, 1, 1) : ImVec4(0.65f, 0.65f, 0.65f, 1.f)); + else { + bool v = active; + clicked = ImGui::Checkbox(label.ToUTF8().data(), &v); + } + ImGui::PopID(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(tip, m_imgui->scaled(18.f)); + return clicked; + }; + + m_imgui->text(_L("View")); + ImGui::SameLine(); + if (icon_toggle(701, cur_mode == 0, _L("Normal"), _L("Normal - the true displaced geometry (what Bake produces)"))) new_mode = 0; + ImGui::SameLine(); + if (icon_toggle(702, cur_mode == 1, _L("Fast"), _L("Fast - a bump-shaded approximation of the active layer only; quick to update, not exact"))) new_mode = 1; + ImGui::SameLine(); + if (icon_toggle(703, cur_mode == 2, _L("Checker"), _L("Checker - a test grid over the unwrap; squares stay square where it does not stretch"))) new_mode = 2; + ImGui::SameLine(); + if (icon_toggle(704, cur_mode == 3, _L("Distortion"), _L("Distortion - blue-to-red stretch heatmap over the unwrap (needs the Unwrap/LSCM projection)"))) new_mode = 3; + ImGui::SameLine(); + ImGui::Dummy(ImVec2(m_imgui->scaled(0.6f), 0.f)); + ImGui::SameLine(); + if (icon_toggle(705, m_wireframe_overlay, _L("Wireframe"), _L("Wireframe - overlay the mesh edges; independent of the view above"))) wf_toggle = true; + + if (new_mode != cur_mode) { + m_use_bump_preview = (new_mode == 1); + m_uv_check_mode = (new_mode == 2) ? UVCheckMode::Checker : + (new_mode == 3) ? UVCheckMode::Distortion : UVCheckMode::None; + rebuild_uvcheck_mesh(); + if (m_use_bump_preview) + rebuild_bump_preview_mesh(); + refresh_wireframe(); // Normal<->Fast swaps the wireframe between displaced and base mesh + update_uv_editor(); // mirror the checker / distortion heatmap into the UV pane too (#7) + m_parent.set_as_dirty(); + } + if (wf_toggle) { + m_wireframe_overlay = !m_wireframe_overlay; + refresh_wireframe(); + m_parent.set_as_dirty(); + } + } + + if (ImGui::Checkbox(_u8L("Auto update").c_str(), &m_auto_update)) + if (m_auto_update) + rebuild_preview(); // catch up anything that changed while it was off + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Rebuild the displaced geometry as soon as anything changes (painting, textures, " + "sliders). Turn off to only rebuild when you release a slider, on very heavy models."), + m_imgui->scaled(20.f)); ImGui::Separator(); m_imgui->text(_L("Texture layers")); + if (mv != nullptr) { + // Add-layer affordance as an icon beside the heading. It is deliberately *not* right-aligned + // against the window edge: this panel uses ImGuiWindowFlags_AlwaysAutoResize, and positioning + // an item at GetWindowContentRegionMax().x - w feeds the window's own width back into its + // auto-fit, growing it by one item-spacing every frame -- which, with the panel docked and + // anchored by its right edge, walked it left off-screen on hover. A plain SameLine can't do that. + const unsigned int add_icon = tool_icon_id(); + const float sz = m_imgui->scaled(1.3f); + ImGui::SameLine(); + const bool add_clicked = (add_icon != 0) ? m_imgui->image_button((ImTextureID) (intptr_t) add_icon, ImVec2(sz, sz)) + : m_imgui->button(m_desc.at("add_texture")); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Add a texture layer"), m_imgui->scaled(20.f)); + if (add_clicked) + add_texture_layer(); + } if (mv != nullptr) { std::vector ordered; @@ -303,30 +2702,470 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ordered.push_back(&l); std::sort(ordered.begin(), ordered.end(), [](const auto *a, const auto *b) { return a->slot < b->slot; }); - for (TextureDisplacementLayer *layer : ordered) { + // Removing a layer erases it from mv->texture_displacement_layers, which shifts every later + // element down and leaves `ordered` - and `layer` itself - pointing at the wrong element + // (or past the end). Removing mid-loop would therefore keep rendering this row's remaining + // widgets against freed/shifted memory. Defer it to after the loop instead. + int slot_to_remove = -1; + + // The layer stack lives in its own scrolling, tinted region (#10, #12): with eight layers' + // worth of controls the panel otherwise runs off the bottom of the screen, and there was + // nothing to tell "settings that belong to this layer" apart from "settings that belong to + // the tool". + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(1.f, 1.f, 1.f, 0.04f)); + ImGui::BeginChild("##texture_layers", ImVec2(0.f, m_imgui->scaled(20.f)), true); + + for (size_t li = 0; li < ordered.size(); ++li) { + TextureDisplacementLayer *layer = ordered[li]; ImGui::PushID(layer->slot); const bool is_active = layer->slot == m_active_layer_slot; - if (ImGui::RadioButton("##active", is_active)) + + // Tint the whole active layer's block, not just its header (#: "background color for the + // selected layer ... whole plate"). The block's height isn't known until it is laid out, so + // draw the block into a foreground channel and the backing rectangle into a background one, + // then merge - the standard ImGui "rect behind a group" trick. + ImDrawList *dl = ImGui::GetWindowDrawList(); + const ImVec2 block_min = ImGui::GetCursorScreenPos(); + const float block_rx = ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x; + if (is_active) { + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + } + + // Clicking the header - or anywhere in the layer's block, see the group below - makes + // it the active layer (#11, #12). A radio button was doing this before, which worked but + // gave no sense of which block of controls belonged to which layer. + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.f, 0.68f, 0.58f, 0.55f)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.f, 0.68f, 0.58f, 0.35f)); + const std::string header = layer->name.empty() ? Slic3r::format(_u8L("Layer %1%"), li + 1) : layer->name; + if (ImGui::Selectable(header.c_str(), is_active, 0, ImVec2(ImGui::GetContentRegionAvail().x - m_imgui->scaled(3.f), 0.f))) set_active_layer(layer->slot); - ImGui::SameLine(); - ImGui::Text("%s", layer->name.empty() ? "texture" : layer->name.c_str()); + ImGui::PopStyleColor(2); ImGui::SameLine(); if (m_imgui->button(m_desc.at("remove_layer"))) - remove_texture_layer(layer->slot); + slot_to_remove = layer->slot; - ImGui::PushItemWidth(m_imgui->scaled(7.f)); - m_imgui->slider_float(_u8L("Depth (mm)"), &layer->depth_mm, 0.02f, 5.f, "%.2f"); - m_imgui->slider_float(_u8L("Tile size (mm)"), &layer->tiling_scale, 0.5f, 100.f, "%.1f"); - m_imgui->slider_float(_u8L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f"); + // Everything below is this layer's own; the group lets a click anywhere inside it select + // the layer, which is what makes the block feel like one object rather than loose widgets. + ImGui::BeginGroup(); + render_texture_picker(*layer); + + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + // Depth and tile size are logarithmic: see ImGuiLogSlider. + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Depth (mm)"), &layer->depth_mm, 0.01f, 10.f, "%.3f", ImGuiLogSlider); + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Tile size (mm)"), &layer->tiling_scale, 0.2f, 200.f, "%.2f", ImGuiLogSlider); + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Rotation"), &layer->rotation_deg, 0.f, 360.f, "%.0f"); + // Midlevel (#19): 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. + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Midlevel"), &layer->midlevel, 0.f, 10.f, "%.2f"); ImGui::PopItemWidth(); - ImGui::Checkbox(_u8L("Invert").c_str(), &layer->invert); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("The grey level that stays put. At 0 the texture can only push the surface " + "outwards. Raise it and anything darker cuts inwards instead, so one height " + "map both embosses and engraves -0.5 makes mid-grey neutral.\n\n" + "Cutting inwards can fold the surface through itself where normals converge: " + "inside a sharp concave corner, or through a thin wall. Keep Depth small " + "relative to the feature you are cutting into."), + m_imgui->scaled(20.f)); + if (layer->midlevel > 0.f && layer->depth_mm > 1.f) + m_imgui->warning_text(_L("Deep inward displacement may self-intersect.")); + + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Smoothing"), &layer->smoothing, 0.f, 1.f, "%.2f"); + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Blurs the height texture before it displaces the surface, rounding hard edges " + "and removing speckle without needing a softer source image. Affects the preview " + "and the bake alike."), + m_imgui->scaled(20.f)); + + // Edge smoothing: fade the displacement to flat toward the boundary of the painted area. + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Edge smoothing").c_str(), &layer->edge_smoothing); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Fade the relief to flat toward the edge of the painted area, so it blends into " + "the surrounding surface. A small amount only softens a thin band at the very " + "edge; the maximum flattens the whole painted face."), + m_imgui->scaled(20.f)); + if (layer->edge_smoothing) { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Edge amount"), &layer->edge_smoothing_amount, + 0.02f, 1.f, "%.2f"); + ImGui::PopItemWidth(); + } + + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Invert").c_str(), &layer->invert); + + // The lowest painted layer has nothing underneath it to combine with - it *is* the + // base - so a blend mode would be meaningless (and Multiply/Divide against an implicit + // zero would annihilate it). build_texture_displacement() forces the first layer to + // reach a given vertex to behave additively regardless; say so rather than offering a + // control that silently does nothing. + if (li == 0) { + ImGui::TextDisabled("%s", _u8L("Base layer").c_str()); + } else { + m_imgui->text(_u8L("Blend")); + ImGui::SameLine(); + const std::string blend_add = _u8L("Add"); + const std::string blend_subtract = _u8L("Subtract"); + const std::string blend_multiply = _u8L("Multiply"); + 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() }; + int blend_mode = static_cast(layer->blend_mode); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (scoped_combo("##blend_mode", &blend_mode, blend_items, IM_ARRAYSIZE(blend_items))) { + layer->blend_mode = static_cast(blend_mode); + m_preview_params_dirty = true; + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("How this layer combines with the layers below it, wherever they overlap. " + "Add and Subtract pile relief on or carve it away. Multiply and Divide " + "scale the relief underneath, which makes this layer act as a mask over " + "it - for those two, Depth is a gain, and a depth of 1 mm on a white " + "part of the texture leaves the layers below unchanged."), + m_imgui->scaled(20.f)); + } + + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Tile").c_str(), &layer->tile_enabled); + if (layer->tile_enabled) { + ImGui::SameLine(); + const std::string tile_method_repeat = _u8L("Repeat"); + const std::string tile_method_mirrored = _u8L("Mirrored repeat"); + const char *tile_method_items[] = { tile_method_repeat.c_str(), tile_method_mirrored.c_str() }; + int tile_method = static_cast(layer->tile_method); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (scoped_combo("##tile_method", &tile_method, tile_method_items, IM_ARRAYSIZE(tile_method_items))) { + layer->tile_method = static_cast(tile_method); + m_preview_params_dirty = true; + } + ImGui::PopItemWidth(); + } + + { + m_imgui->text(_u8L("Projection")); + ImGui::SameLine(); + const std::string projection_triplanar = _u8L("Triplanar (blended)"); + const std::string projection_cylindrical = _u8L("Cylindrical"); + const std::string projection_spherical = _u8L("Spherical"); + const std::string projection_lscm = _u8L("Unwrap (LSCM)"); + const std::string projection_view = _u8L("From view"); + const char *projection_items[] = { projection_triplanar.c_str(), projection_cylindrical.c_str(), + projection_spherical.c_str(), projection_lscm.c_str(), + projection_view.c_str() }; + int projection_method = static_cast(layer->projection_method); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (scoped_combo("##projection_method", &projection_method, projection_items, IM_ARRAYSIZE(projection_items))) { + const auto new_method = static_cast(projection_method); + // Capture the current view the moment "From view" is chosen, so it does something + // sensible immediately rather than projecting from a stale/default direction. + if (new_method == TextureProjectionMethod::ViewProjected && + layer->projection_method != TextureProjectionMethod::ViewProjected) + capture_view_projection(*layer); + layer->projection_method = new_method; + m_preview_params_dirty = true; + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(layer->projection_method == TextureProjectionMethod::LSCM ? + _u8L("Flattens the painted area and maps the texture onto it with as little " + "stretching as possible. The area is cut into pieces at its sharp edges " + "first (see Seam angle), so each piece can lie flat on its own.") : + _u8L("Triplanar projects the texture from all three axes at once and blends " + "between them, so a patch wrapping around a sharp edge has no seam. " + "Cylindrical and Spherical wrap the texture around the painted area's " + "own centre, for round shapes."), + m_imgui->scaled(20.f)); + + if (layer->projection_method == TextureProjectionMethod::LSCM) { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_preview_params_dirty |= m_imgui->slider_float(_u8L("Seam angle"), &layer->lscm_seam_angle_deg, + 5.f, 90.f, "%.0f"); + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Edges sharper than this are cut, and the pieces either side of them are " + "flattened separately. Lower it to cut more: each piece then lies flat " + "with less stretching, at the cost of the texture not running continuously " + "across the cut. Raise it to keep more of the area in one piece. Corners " + "of a box are 90 degrees, so the default cuts them apart; a smoothly " + "rounded surface stays whole."), + m_imgui->scaled(20.f)); + + if (ImGui::Checkbox(_u8L("Connect islands").c_str(), &layer->auto_connect_islands)) { + // Apply (or, when turned off, just stop re-applying) right away rather than + // waiting for the next re-unwrap. + if (layer->auto_connect_islands && !m_uv_editor_unwrap.empty()) { + std::vector net = compute_connected_net(m_uv_editor_unwrap); + if (net.size() == size_t(m_uv_editor_unwrap.chart_count)) { + if (layer->islands.size() < net.size()) + layer->islands.resize(net.size()); + for (size_t i = 0; i < net.size(); ++i) + layer->islands[i] = net[i]; + } + } + m_preview_params_dirty = true; + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Lay the unwrap out as a connected net: pieces that share an edge are " + "unfolded next to each other (a cube becomes a joined net rather than six " + "loose squares). They stay separate islands, so you can still move any of " + "them by hand afterwards."), + m_imgui->scaled(20.f)); + + if (is_active) { + // Explicit unwrap (#: "Add unwrap button so it does not recompute on every + // change"). The LSCM solve runs only when this is pressed -- painting, the seam + // angle slider and seam marking no longer trigger it -- and the pane opens right + // afterwards. Re-press it to fold in any edits made since. + if (m_imgui->button(_u8L("Unwrap"))) { + m_uv_unwrap_pending = true; + m_uv_apply_connected_net = true; // a genuine re-unwrap may relayout the islands + m_show_uv_editor = true; + update_uv_editor(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Flatten the painted area into UV islands and open the UV editor. The " + "unwrap is computed only when you press this, not on every edit -- so " + "paint, change the seam angle or mark seams first, then press Unwrap to " + "see the result. Islands can then be moved, rotated and scaled."), + m_imgui->scaled(20.f)); + + // Select mode for the UV pane: whole islands, single vertices, or single edges. + // Vertex/Edge are free-form UV editing and feed the per-vertex overrides that the + // bake honours; Island is the move/rotate/scale-with-grouping behaviour. + if (!m_uv_editor_unwrap.empty()) { + m_imgui->text(_u8L("Select:")); + ImGui::SameLine(); + int mode = m_uv_select_mode; + ImGui::RadioButton(_u8L("Island").c_str(), &mode, 0); + ImGui::SameLine(); + ImGui::RadioButton(_u8L("Vertex").c_str(), &mode, 1); + ImGui::SameLine(); + ImGui::RadioButton(_u8L("Edge").c_str(), &mode, 2); + if (mode != m_uv_select_mode) { + m_uv_select_mode = mode; + if (UVEditorCanvas *c = wxGetApp().plater()->get_uv_editor_canvas()) + c->set_select_mode(static_cast(mode)); + } + if (!layer->lscm_uv_overrides.empty()) { + ImGui::SameLine(); + if (m_imgui->button(_u8L("Clear UV edits"))) { + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Clear texture UV edits"), + UndoRedo::SnapshotType::GizmoAction); + layer->lscm_uv_overrides.clear(); + m_uv_unwrap_pending = true; // re-solve so the pane drops the edited coords + update_uv_editor(); + rebuild_preview(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Discard all manual vertex/edge moves and return the unwrap to its " + "automatic shape."), + m_imgui->scaled(20.f)); + } + } + + // Manual seam marking (#9): a click mode that toggles mesh edges as seams. + bool seam_mode = m_seam_edit_mode; + if (ImGui::Checkbox(_u8L("Mark seams").c_str(), &seam_mode)) { + m_seam_edit_mode = seam_mode; + if (seam_mode) + m_adjust_texture_mode = false; // the two click modes are mutually exclusive + else { + m_seam_hover_edge = { -1, -1 }; // drop the hover highlight when leaving the mode + m_seam_hover_vertex = -1; + m_seam_hover_glmodel.reset(); + m_seam_path_anchor = -1; + m_seam_anchor_glmodel.reset(); + } + m_parent.set_as_dirty(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Click edges on the model to cut the unwrap along them, like marking a " + "seam in Blender. The edge under the cursor is highlighted yellow; click " + "to mark it red. Click a marked (red) edge again to unmark it. Hold Ctrl " + "and drag to rotate the view. Painting is paused while this is on."), + m_imgui->scaled(20.f)); + if (m_seam_edit_mode) { + // Shortest-path mode, for dense meshes: click two points, seam the whole path. + ImGui::SameLine(); + bool path_mode = m_seam_path_mode; + if (ImGui::Checkbox(_u8L("Path").c_str(), &path_mode)) { + m_seam_path_mode = path_mode; + m_seam_path_anchor = -1; + m_seam_hover_edge = { -1, -1 }; // hover target type changes with the mode + m_seam_hover_vertex = -1; + m_seam_hover_glmodel.reset(); + m_seam_anchor_glmodel.reset(); + m_parent.set_as_dirty(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Instead of clicking every edge, click a start point and then an end " + "point: the whole shortest path between them is seamed at once (green " + "marks the start). Each click extends the seam from the last point. " + "Best for dense meshes."), + m_imgui->scaled(20.f)); + } + if (!layer->lscm_seam_edges.empty()) { + ImGui::SameLine(); + if (m_imgui->button(_u8L("Clear seams"))) { + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Clear texture seams"), + UndoRedo::SnapshotType::GizmoAction); + layer->lscm_seam_edges.clear(); + rebuild_preview(); + } + } + + // What the UV editor is actually showing. Cheap, and the only way to tell an + // unwrap that produced nothing apart from one merely framed off-screen. The + // island tools (snap, average scale, cut) and the gesture hints now live in the + // UV pane itself - its toolbar and status line - rather than here (#18). + if (m_uv_editor_unwrap.empty()) + m_imgui->text(_u8L("Press Unwrap to flatten the painted area.")); + else + m_imgui->text(Slic3r::format(_u8L("Unwrap: %1% islands, %2% faces, %3% verts."), + m_uv_editor_unwrap.chart_count, m_uv_editor_unwrap.indices.size(), + m_uv_editor_unwrap.uvs.size())); + } + } + + if (layer->projection_method == TextureProjectionMethod::ViewProjected) { + // Re-capture the projector from wherever the camera is now (#6): orbit the model, + // press this, and the texture is re-laid from the new angle. + if (m_imgui->button(_u8L("Capture current view"))) { + capture_view_projection(*layer); + m_preview_params_dirty = true; + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Projects the texture straight onto the painted area from the direction " + "you are currently looking, like a slide projector. Faces angled away from " + "that direction will stretch - turn the model to where you want the " + "texture crisp, then capture."), + m_imgui->scaled(20.f)); + } + } + + if (is_active) { + bool adjust_on = m_adjust_texture_mode; + if (ImGui::Checkbox(_u8L("Adjust placement").c_str(), &adjust_on)) { + if (adjust_on) { + update_model_object(); // flush any pending strokes before anchoring + if (update_adjust_anchor()) + m_adjust_texture_mode = true; + else + show_error(nullptr, _u8L("Paint something with this layer first.")); + } else { + m_adjust_texture_mode = false; + m_adjust_drag_handle = AdjustHandle::None; + } + } + } + ImGui::EndGroup(); + // A click that lands on the layer's body (not on a widget, which consumes its own click) + // selects it too, so the whole block reads as one clickable object (#12). + if (!is_active && ImGui::IsItemClicked()) + set_active_layer(layer->slot); + + if (is_active) { + const float pad = m_imgui->scaled(0.25f); + const ImVec2 rmin(block_min.x - pad, block_min.y - pad); + const ImVec2 rmax(block_rx, ImGui::GetCursorScreenPos().y); + dl->ChannelsSetCurrent(0); + dl->AddRectFilled(rmin, rmax, ImGui::GetColorU32(ImVec4(0.0f, 0.59f, 0.53f, 0.22f)), m_imgui->scaled(0.2f)); + dl->ChannelsMerge(); + } + ImGui::PopID(); ImGui::Separator(); } + + ImGui::EndChild(); + ImGui::PopStyleColor(); + + if (slot_to_remove >= 0) + remove_texture_layer(slot_to_remove); // deferred: see slot_to_remove's declaration } - if (m_imgui->button(m_desc.at("add_texture"))) - add_texture_layer(); + // ImGui sliders report "changed" continuously on every frame while being dragged, not just + // once on release - rebuilding the preview (a real CPU mesh recompute) on every one of those + // frames is what made dragging these sliders feel slow. Only rebuild once the mouse button + // that's driving the drag is released, i.e. once per edit instead of dozens of times per drag. + if (m_preview_params_dirty && (m_auto_update || !ImGui::IsMouseDown(ImGuiMouseButton_Left))) { + rebuild_preview(); + m_preview_params_dirty = false; + } + // (The "Add layer" button now lives next to the "Texture layers" heading, as an icon.) + + ImGui::Separator(); + m_imgui->text(_u8L("Not enough vertices for fine detail?")); + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 1, 5)) { + m_subdivide_count = std::clamp(m_subdivide_count, 1, 5); + if (m_subdivide_editing) + rebuild_subdivide_preview(); + m_parent.set_as_dirty(); + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("How many times to split every triangle into four. Each step roughly quadruples the " + "triangle count, so there are enough vertices for the height texture to displace."), + m_imgui->scaled(20.f)); + + if (!m_subdivide_editing) { + if (m_imgui->button(_u8L("Preview subdivision"))) { + m_subdivide_editing = true; + rebuild_subdivide_preview(); + m_parent.set_as_dirty(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Shows the subdivided mesh as a wireframe without changing the model. Press Apply to " + "commit it, or Done to leave the model as it is."), + m_imgui->scaled(20.f)); + } else { + if (m_imgui->button(_u8L("Apply"))) { + subdivide_model(); // commits m_subdivide_count passes (takes its own snapshot) + rebuild_subdivide_preview(); // re-preview against the now-denser mesh + m_parent.set_as_dirty(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Replaces the model's geometry with the subdivided mesh and clears any not-yet-baked " + "paint on it (already-baked bumps are unaffected)."), + m_imgui->scaled(20.f)); + ImGui::SameLine(); + if (m_imgui->button(_u8L("Done"))) { + m_subdivide_editing = false; + m_subdivide_preview_count = -1; + m_subdivide_preview_glmodel.reset(); + m_parent.set_as_dirty(); + } + } + + // Remesh: even out uneven triangle sizes (CGAL isotropic remeshing). GPU Delaunay isn't practical + // here, but this delivers the same goal - a consistent triangle size across the whole model. + m_imgui->text(_u8L("Uneven triangle sizes?")); + if (m_remesh_target_edge_mm <= 0.f && mv != nullptr) { + // Seed the target with the model's current mean edge length, so the default is a sensible + // "make everything about the size it already averages". + const indexed_triangle_set &its = mv->mesh().its; + double sum = 0.0; size_t cnt = 0; + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) { + sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm(); + ++cnt; + } + m_remesh_target_edge_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)), 0.1f, 20.f) : 1.f; + } + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_imgui->slider_float(_u8L("Target edge (mm)"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f", ImGuiLogSlider); + ImGui::PopItemWidth(); + m_imgui->disabled_begin(mv == nullptr); + if (m_imgui->button(_u8L("Remesh"))) + remesh_model(); + m_imgui->disabled_end(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big " + "ones and merging the small ones - so displacement has an even density to work with. " + "Replaces the geometry and clears any not-yet-baked paint (already-baked bumps are kept)."), + m_imgui->scaled(20.f)); ImGui::Separator(); @@ -352,7 +3191,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->disabled_end(); ImGui::Separator(); - if (m_imgui->button(_L("Done"))) + if (m_imgui->button(_L("Close"))) m_parent.reset_all_gizmos(); GizmoImguiEnd(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index ef9c3f25b2..f81c6bc08a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -3,7 +3,15 @@ #include "GLGizmoPainterBase.hpp" #include "libslic3r/TextureDisplacement.hpp" +#include "slic3r/GUI/GLModel.hpp" +#include "slic3r/GUI/GLTexture.hpp" #include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/TextureLibrary.hpp" + +#include +#include +#include +#include namespace Slic3r::GUI { @@ -22,6 +30,10 @@ public: void render_painter_gizmo() override; + // Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/ + // rotation handles instead of painting); otherwise forwards to the normal painting handling. + bool on_mouse(const wxMouseEvent &mouse_event) override; + protected: void on_render_input_window(float x, float y, float bottom_limit) override; std::string on_get_name() const override; @@ -54,6 +66,173 @@ private: void set_active_layer(int slot); // flushes the previous layer's edits, then reloads selectors void bake(); + // Marks every facet of every model-part volume as painted for the currently active layer -- + // "whole model" as an alternative to brushing/clicking every triangle by hand. + void select_whole_model(); + + // Uniformly subdivides the volume's mesh (see libslic3r::subdivide_mesh_uniform()) so a + // low-poly input model has enough vertices to actually show texture-displacement detail. + // A real, committed geometry change (like Bake), so it needs its own snapshot; unlike Bake it + // has no target region, so any not-yet-baked paint on the volume is dropped rather than + // remapped (texture-displacement paint has no remap-across-topology-change support yet). + void subdivide_model(); + + // Returns a cached GPU thumbnail of layer's texture (decoding + uploading it the first time it + // is requested, or whenever its image_data changes), or nullptr if it has no usable texture. + GLTexture *get_layer_thumbnail(const TextureDisplacementLayer &layer); + + // A texture from the picker's library (see slic3r/GUI/TextureLibrary.hpp), read and uploaded + // once and then kept for the gizmo's lifetime. The decoded bytes are held alongside the GPU + // thumbnail so that picking the texture can hand the layer this very same image_data buffer -- + // which both avoids re-reading the file and lets decode_height_texture()'s own cache (keyed by + // exactly this pointer) hit immediately on the first bake/preview. + struct LibraryTexture + { + std::shared_ptr> image_data; + std::unique_ptr thumbnail; + }; + const LibraryTexture *get_library_texture(const std::string &path); + + // The layer's texture chooser: a drop-down whose closed state and every one of whose entries + // shows a large preview image on the left and the texture's name on the right, plus an adjacent + // button that imports an image file from disk into the user texture folder. Shipped and + // user-imported textures are listed under separate headings. + void render_texture_picker(TextureDisplacementLayer &layer); + void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry); + void import_custom_texture(TextureDisplacementLayer &layer); + // Draws a picker row (image left, name right) on top of a full-width Selectable, and leaves the + // cursor below it. Shared by the drop-down's closed state and its individual entries so the two + // cannot drift apart. Returns true when the row is clicked. + bool texture_row(const char *id, const std::string &name, GLTexture *thumbnail, bool selected, float width); + float texture_row_height() const; + + // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active + // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane + // (a "pan" -- drag anywhere on it for free 2D movement), plus two arrows along the patch's + // own U/V axes that constrain the drag to just that one axis for precise nudging. Anchored to + // the centroid/average-normal of the active layer's current paint patch (see + // libslic3r::compute_layer_paint_anchor()), so nothing is drawn if it has nothing painted yet. + // + // NOTE: the drag direction/sign below is this session's best-effort reasoning about which way + // the texture should appear to move as the handle is dragged -- it could not be visually + // confirmed while writing it (no way to render/see pixels in this environment), so it may + // need a one-line sign flip once actually tested. + bool update_adjust_anchor(); // recomputes m_adjust_anchor_pos/normal; false if nothing painted + bool on_mouse_adjust_texture(const wxMouseEvent &mouse_event); + void render_adjust_texture_gizmo(); + // Mesh-local tangent-plane basis at m_adjust_anchor_normal, matching project_planar()'s + // dominant-axis convention so dragging on-canvas maps consistently onto offset. + void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const; + + // The plane a drag is measured against: the paint patch's anchor, lifted clear of the surface. + // Deliberately *fixed* -- independent of the layer's offset -- so that moving the handle cannot + // move the plane the handle's own motion is derived from, which would be a feedback loop. + Vec3f adjust_plane_point() const; + + // Where the handle is actually drawn, in mesh-local coordinates. This is NOT just the patch's + // centroid: the handle *represents the texture's placement*, so it has to travel as `offset` + // changes. Pinning it to the centroid is why dragging it looked broken -- the texture slid but + // the handle stayed put. Undoing apply_uv_transform()'s scale and rotation turns the layer's + // offset back into a displacement in mm within the patch's tangent plane, which is what gets + // added to the anchor here. That is exactly consistent with the drag arithmetic in + // on_mouse_adjust_texture(): the handle then tracks the cursor 1:1, and sits back on the anchor + // precisely when offset is zero. + Vec3f adjust_handle_center(const TextureDisplacementLayer &layer) const; + + // The layer painted by the active slot, or nullptr if that slot has no layer yet. + TextureDisplacementLayer *active_layer(); + const TextureDisplacementLayer *active_layer() const; + + // Recomputes m_preview_glmodel from the volume's current (unbaked) paint state, using the same + // build_texture_displacement() algorithm as Bake. Called whenever the paint mask changes + // (stroke end, layer switch, undo/redo reload, post-bake refresh) rather than every frame -- + // this is real mesh work (PNG sampling, vertex welding), not something to redo per paint stroke + // drag sample or idle repaint. With several painted layers this can be slow, so the actual + // computation runs in a background TextureDisplacementPreviewJob; this function only queues + // it and returns immediately, and m_preview_glmodel is updated later when it completes. + void rebuild_preview(); + void render_preview_mesh(); + + // Alternate, GPU-only preview: perturbs shading normals from the active layer's height texture + // (a classic bump map) instead of actually moving vertices, using the + // resources/shaders/*/texture_displacement_bump.* shader. Faster than the true-displacement + // preview (no CPU meshing at all -- just a per-vertex paint-weight buffer built at the same + // cadence as rebuild_preview()) but only shows the *active* layer, and any bump is a shading + // illusion, not real geometry -- "Bake" always produces the true, exact result either way. + void rebuild_bump_preview_mesh(); + void render_bump_preview_mesh(); + + // Feeds the active layer's painted patch + LSCM unwrap (if it's using that projection method) + // into Plater's docked UV-editor pane and shows it, or hides the pane if the active layer + // isn't using LSCM (or nothing is painted). Called whenever something that could change what + // the pane should show happens: paint changes, layer switch, projection method change, bake, + // and on shutdown (to hide it). + void update_uv_editor(); + + // Applies one island edit reported by the UV editor's drag/rotate gestures to the active layer. + // Deltas are incremental (see UVEditorCanvas::IslandEditFn); `finished` ends the gesture, which + // is when -- and only when -- the 3D preview is rebuilt, since doing that per mouse-move would + // queue a mesh recompute for every pixel of a drag. + void on_island_edited(int island, const Vec2f &offset_delta, float rotation_delta, float scale_factor, bool finished); + // Applies a committed vertex/edge edit from the UV editor's Vertex/Edge modes: each entry is an + // unwrapped-vertex index and its new raw-unwrap coordinate. Maps the unwrapped index to a mesh + // vertex and stores a per-vertex UV override on the layer (see lscm_uv_overrides), then rebuilds the + // preview so the baked geometry follows. + void on_uv_vertex_edited(const std::vector> &edits); + // UV-editor sub-element select mode, mirrored into the canvas: 0 = Island, 1 = Vertex, 2 = Edge. + int m_uv_select_mode = 0; + // One affine per island (columns: x basis, y basis, translation), mapping the unwrap's raw mm + // coordinates to texture UVs -- the same type as UVEditorCanvas::IslandTransform, spelled out + // here so this header needn't drag in wxGLCanvas/glad. Cheap to recompute (it is per *island*, + // not per vertex), which is what lets an island drag update the pane without re-uploading a + // single vertex. + std::vector> uv_editor_island_transforms(const TextureDisplacementLayer &layer); + // Handles a toolbar command forwarded from the UV pane that needs the layer data the canvas + // doesn't hold (average island scale, cut island). Takes the command as an int (a cast of + // UVEditorCanvas::Command) so this header needn't pull in glad/wxGLCanvas via the canvas header. + void on_uv_command(int cmd); + // Splits one unwrap chart in two by marking the mesh edges that straddle the plane through its + // 3D centroid, perpendicular to its longest axis, as seams (#17). The re-unwrap then separates it. + void cut_island(TextureDisplacementLayer &layer, int chart); + + // Captures the current camera's right/up axes into the layer's projector (#6), transformed into + // the volume's local space so the projection is stable as the object is later moved/rotated. + void capture_view_projection(TextureDisplacementLayer &layer); + + // Manual seam marking (#9): a mode where clicking the model toggles the nearest mesh edge in the + // active layer's lscm_seam_edges, so the unwrap can be cut exactly where the user wants -- the + // Blender "mark seam" workflow. Painting is suppressed while it is on. + bool m_seam_edit_mode = false; + GLModel m_seam_glmodel; // the current seam edges, highlighted on the mesh + bool on_mouse_seam(const wxMouseEvent &mouse_event); + void toggle_seam_at(const Vec2d &mouse_pos); + void rebuild_seam_overlay(); + void render_seam_overlay(); + // The mesh edge nearest the mouse, in the volume's own vertex indices, or {-1,-1} if the ray misses. + // Factored out of toggle_seam_at() so the same pick can drive a live hover highlight (below) that + // shows which edge a click would toggle -- the "I don't know how it works" feedback the user hit. + std::pair seam_edge_at(const Vec2d &mouse_pos) const; + std::pair m_seam_hover_edge{ -1, -1 }; + // The vertex a click would pick in shortest-path mode, so the target is visible on hover the same + // way the edge is in normal mode. -1 when nothing is under the cursor (or not in path mode). + int m_seam_hover_vertex = -1; + GLModel m_seam_hover_glmodel; + void rebuild_seam_hover_overlay(); + + // Shortest-path seam marking, for dense meshes where clicking every single triangle edge is + // tedious: in this sub-mode a click picks the nearest vertex, and the next click marks every edge + // on the shortest surface path between the two as a seam - so a whole seam line is drawn with two + // clicks. The end vertex becomes the next start, so a multi-segment seam chains click by click. + bool m_seam_path_mode = false; + int m_seam_path_anchor = -1; // mesh vertex the path starts from, or -1 + GLModel m_seam_anchor_glmodel; // the anchor's incident edges, highlighted + int seam_vertex_at(const Vec2d &mouse_pos) const; // nearest mesh vertex under the cursor + void mark_seam_path(int v_from, int v_to); // seam every edge on the shortest path + void rebuild_seam_anchor_overlay(); + // Set while an island gesture is in flight, so the undo snapshot is taken once at the start of + // the drag (capturing the state *before* it) rather than on every motion event. + bool m_island_drag_active = false; + // Which of the up to TEXTURE_DISPLACEMENT_MAX_LAYERS paint masks the brush currently writes // into. Always a valid slot index (0 by default) so the base class's per-volume selector // machinery always has something to work with, even before any texture has been added -- @@ -62,7 +241,214 @@ private: int m_active_layer_slot = 0; bool m_bake_in_progress = false; + // When set, the true-displacement geometry is rebuilt on every parameter change (live), instead of + // only once the slider being dragged is released. On by default so painting/added textures show + // straight away without needing to nudge a slider first. + bool m_auto_update = true; + + // Subdivision is now count-based (split the whole mesh 1..5 times) rather than a target edge + // length, and is previewed as a wireframe before it is committed: nothing is written to the model + // until "Apply". While previewing, the would-be subdivided mesh is drawn as a wireframe overlay so + // the added density is visible; "Done" ends the preview without touching the model. The normal + // "Show mesh wireframe" toggle is left alone, so a wireframe the user already had on stays on. + int m_subdivide_count = 1; + bool m_subdivide_editing = false; + int m_subdivide_preview_count = -1; // the count m_subdivide_preview_glmodel was built for + GLModel m_subdivide_preview_glmodel; + void rebuild_subdivide_preview(); + void render_subdivide_preview(); + + // Isotropic remeshing (CGAL) to even out wildly varying triangle sizes so displacement has a + // consistent density to work with. Target edge length in mm; 0 means "not yet initialised", filled + // with the mesh's mean edge length the first time the control is shown. Like subdivide, it replaces + // the geometry and drops not-yet-baked paint (no remap across a topology change). + float m_remesh_target_edge_mm = 0.f; + void remesh_model(); + + // Live, pre-bake preview of the true displaced geometry (built by the same algorithm Bake + // uses). Empty/uninitialized whenever nothing is painted yet, in which case the gizmo falls + // back to the standard paint-mask overlay like every other painting gizmo. + GLModel m_preview_glmodel; + // Set while a layer parameter slider has changed since the last rebuild_preview() call but the + // mouse button driving the drag hasn't been released yet -- see on_render_input_window(). + bool m_preview_params_dirty = false; + + // See rebuild_bump_preview_mesh()/render_bump_preview_mesh(). + bool m_use_bump_preview = false; + // Set from the UV editor's per-move island edits instead of rebuilding the (potentially large) bump + // mesh synchronously inside that mouse handler -- doing the rebuild there stalled both the UV pane + // and the 3D view. The rebuild is instead coalesced to once per 3D frame (render_painter_gizmo). + bool m_bump_preview_dirty = false; + GLModel m_bump_preview_glmodel; + // Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader + // should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh(). + bool m_bump_preview_uses_vertex_uv = false; + + // GPU island drag: while an island is dragged in the UV editor, the bump mesh is baked once (with + // the dragged island's vertices flagged, v_normal.y = 1) and then moved purely through the shader's + // island_delta uniform -- one uniform update per mouse move, no rebuild -- so it tracks the cursor + // as smoothly as Adjust placement. m_bump_active_chart is the dragged island (or -1); + // m_bump_active_vertex flags its base vertices; m_bump_baked_active_xf is that island's placement + // baked into the current mesh, against which the live delta is measured; m_bump_island_delta is the + // resulting final-uv-space affine handed to the shader (identity except mid-drag). + int m_bump_active_chart = -1; + std::vector m_bump_active_vertex; + Eigen::Matrix m_bump_baked_active_xf = Eigen::Matrix::Identity(); + Eigen::Matrix m_bump_island_delta = Eigen::Matrix::Identity(); + void compute_bump_active_vertices(const std::vector &charts); + + // The set of islands the current UV-editor drag moves together: the pane's multi-selection unioned + // with each selected island's join group (see build_island_move_set()). Populated at drag start and + // cleared when it finishes. A move applies the same offset to every island in it; rotate/scale act + // only on the primary. Empty when no move drag is in flight. + std::vector m_island_move_set; + // All islands that must move with `primary`: the pane's multi-selection plus, for each of those, the + // charts sharing its join group in `layer`. Always contains `primary`. + std::vector build_island_move_set(const TextureDisplacementLayer &layer, int primary) const; + // The join-group id of chart `c`: its explicit entry in `groups`, or `c` itself (its own singleton) + // when unset. Two charts move together iff this matches. + static int island_group_of(const std::vector &groups, int c); + // Merges chart `b`'s join group into chart `a`'s (materialising `groups` to `chart_count` first). + static void join_island_groups(std::vector &groups, int a, int b, int chart_count); + // Final per-vertex texture uv for the projections the shader can't reconstruct itself -- LSCM (an + // unwrap) and ViewProjected (a projector plane the shader doesn't know). One entry per patch/base + // vertex, already through apply_uv_transform(). Empty for Triplanar/Cylindrical/Spherical, which + // the shader projects on its own. Shared by the bump preview and the UV-check overlay. + std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, + const TextureDisplacementLayer &layer) const; + + // UV-check overlay drawn over the painted patch to sanity-check the unwrap (#13/#14). Built by + // rebuild_uvcheck_mesh(), drawn by render_uvcheck_mesh() with the "texture_displacement_uvcheck" + // shader. Checker works for any projection; Distortion needs the per-vertex LSCM uv. + enum class UVCheckMode { None, Checker, Distortion }; + UVCheckMode m_uv_check_mode = UVCheckMode::None; + GLModel m_uvcheck_glmodel; + bool m_uvcheck_uses_vertex_uv = false; + void rebuild_uvcheck_mesh(); + void render_uvcheck_mesh(); + + // The UV editor pane is opened only on the user's explicit request (this toggle in the panel), + // never automatically just because a patch exists -- auto-popping it whenever there was "a + // selection to process" is exactly what the user asked to stop. update_uv_editor() keeps the pane + // hidden unless this is set. Reset on gizmo shutdown so reopening the gizmo doesn't reopen the pane. + bool m_show_uv_editor = false; + // The unwrap is expensive, so it is recomputed only when the user explicitly asks for it (the + // "Unwrap" button), not on every paint stroke or slider nudge. This is set by that button and + // consumed by the next update_uv_editor() call, which is the only path that re-solves the unwrap; + // every other call merely refreshes the cheap per-island affine transforms over the existing one. + bool m_uv_unwrap_pending = false; + // Set alongside m_uv_unwrap_pending only by the Unwrap button, so the connected-net auto-layout runs + // on a genuine re-unwrap but not on a refresh re-solve (a vertex-edit commit or undo), which must + // leave island placements untouched. + bool m_uv_apply_connected_net = false; + // Signature of the per-vertex UV overrides last reflected in the pane. When it changes without the + // user pressing Unwrap -- a vertex/edge edit committing, or an undo/redo reverting one -- the pane + // is re-solved so its geometry follows, even though a plain edit otherwise never re-solves (#Feat2). + size_t m_uv_overrides_sig = 0; + // What the UV pane's background currently holds, so update_uv_editor() only re-uploads it when the + // choice actually changes (the height texture is large; re-sending it every stroke would be waste). + enum class UVBackground { None, Height, Checker }; + UVBackground m_uv_editor_bg = UVBackground::None; + float m_uv_editor_bg_smoothing = -1.f; // smoothing the height backdrop was uploaded at + // Per-chart distortion heatmap colour for the UV pane (#7/#14), computed once when the unwrap is + // re-solved (relative stretch doesn't change when islands are merely moved), fed to the canvas only + // while the Distortion check mode is on. Empty otherwise. + std::vector m_uv_editor_distortion_colors; + void compute_uv_editor_distortion_colors(const indexed_triangle_set &patch); + + // Plain triangle-edge overlay on the mesh (#8), toggled independently of the check modes. + bool m_wireframe_overlay = false; + GLModel m_wireframe_overlay_glmodel; + size_t m_wireframe_overlay_vcount = 0; // topology signature, so it rebuilds only on a real change + void rebuild_wireframe_overlay(); // from the base mesh (bump/paint mode) + void build_wireframe_from_its(const indexed_triangle_set &its); // from an explicit mesh, no early-out + void refresh_wireframe(); // pick base vs displaced source for the current view + void render_wireframe_overlay(); + // The displaced preview geometry the last preview job produced, kept so the wireframe overlay can be + // drawn on the raised surface actually shown in the true-displacement view (#: "wireframe in real mode"). + indexed_triangle_set m_preview_its; + // Bumped on every rebuild_preview() call; a background TextureDisplacementPreviewJob's result + // is only applied if this hasn't moved on since the job was queued (see rebuild_preview()), + // so a burst of edits can't have an earlier, now-stale job clobber a later one's result. + uint64_t m_preview_generation = 0; + + // Per-slot GPU thumbnail cache for the layer list panel, keyed by the image_data pointer that + // was current the last time each thumbnail was built (see get_layer_thumbnail()). + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnails; + std::array m_thumbnail_source{}; + // The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it (and the + // fast/bump preview, which samples this texture, actually shows the blur). + std::array m_thumbnail_smoothing{}; + + // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). + std::map m_library_textures; + + // Everything the *unwrap* depends on. update_uv_editor() runs from rebuild_preview(), i.e. on + // every stroke end and every slider release -- but depth/tiling/rotation/offset/blend change + // none of this, so re-extracting the patch and re-solving on those edits would be pure waste. + // Held as the real values rather than a hash: TriangleSplittingData has an exact operator==, so + // there is no reason to accept a hash's (however unlikely) chance of showing a stale unwrap. + struct UVEditorState + { + int slot = -1; + const void *image_data = nullptr; + float seam_angle = -1.f; + float padding = -2.f; + TriangleSelector::TriangleSplittingData facets; + // Manual/auto seam edges also change the unwrap, so a change here must force a re-solve just + // like the facets do (marking a seam leaves the paint mask untouched). + std::vector> seam_edges; + + bool operator==(const UVEditorState &other) const + { + return slot == other.slot && image_data == other.image_data && seam_angle == other.seam_angle && + padding == other.padding && facets == other.facets && seam_edges == other.seam_edges; + } + }; + UVEditorState m_uv_editor_state; + // Bounds of the UVs last handed to the pane, purely so the panel can show where the unwrap + // actually landed -- it is packed in mm and then divided by the tile size, so it is easy for it + // to end up far outside the texture's first tile without any of that being visible. + Vec2f m_uv_editor_bbox_min = Vec2f::Zero(); + Vec2f m_uv_editor_bbox_max = Vec2f::Zero(); + // The unwrap m_uv_editor_state produced, kept so that changing tiling/rotation/offset only costs + // re-running apply_uv_transform() over it, not another extraction and solve. + PatchUnwrap m_uv_editor_unwrap; + + // When set, the panel is a free-floating window the user can drag anywhere (with a title bar to + // grab), instead of being pinned to the right of the gizmo toolbar. Persisted across gizmo + // open/close within a session, so the choice sticks while working. + bool m_undocked = false; + + // See the "Adjust Texture" block of private methods above. + bool m_adjust_texture_mode = false; + bool m_adjust_anchor_valid = false; + Vec3f m_adjust_anchor_pos = Vec3f::Zero(); // mesh-local + Vec3f m_adjust_anchor_normal = Vec3f::UnitZ(); // mesh-local + + // Pan: free drag anywhere on the flat panel, moves offset along both axes. AxisU/AxisV: drag + // the corresponding arrow, moves offset along only that one axis. + enum class AdjustHandle { None, Pan, AxisU, AxisV }; + AdjustHandle m_adjust_drag_handle = AdjustHandle::None; + Vec2f m_adjust_drag_start_offset = Vec2f::Zero(); + // Anchor-relative planar position (see project_planar()) of the point under the mouse at the + // moment the current drag started; every subsequent frame's delta is measured against this, + // rather than accumulated frame-to-frame, to avoid drift. + Vec2f m_adjust_drag_start_planar = Vec2f::Zero(); + + // Lazily-built unit quad (the pan panel) and unit line-with-arrowhead (reused, rotated, for + // both the U and V axis arrows), transformed into place at render time. + GLModel m_adjust_panel_glmodel; + GLModel m_adjust_arrow_glmodel; + std::map m_desc; + + // The tool's SVG (toolbar_texture_displacement.svg) uploaded once as a GL texture, so it can be + // used as an ImGui image button in the panel (currently the "add layer" affordance next to the + // Texture layers heading). Lazily loaded on first use, when a GL context is guaranteed current. + GLTexture m_tool_icon; + bool m_tool_icon_tried = false; + unsigned int tool_icon_id(); // 0 if the icon could not be loaded }; } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 9803fad2cb..0977e388b5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -217,9 +217,8 @@ bool GLGizmosManager::init() m_gizmos.emplace_back(new GLGizmoSeam(m_parent, m_is_dark ? "toolbar_seam_dark.svg" : "toolbar_seam.svg", EType::Seam)); m_gizmos.emplace_back(new GLGizmoFuzzySkin(m_parent, m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg", EType::FuzzySkin)); m_gizmos.emplace_back(new GLGizmoMmuSegmentation(m_parent, m_is_dark ? "mmu_segmentation_dark.svg" : "mmu_segmentation.svg", EType::MmSegmentation)); - // TODO: placeholder icon reused from the fuzzy-skin gizmo; swap in a dedicated - // toolbar_texture_displacement(_dark).svg once one exists. - m_gizmos.emplace_back(new GLGizmoTextureDisplacement(m_parent, m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg", EType::TextureDisplacement)); + // One shared icon (no dedicated dark variant yet); it recolours acceptably in both themes. + m_gizmos.emplace_back(new GLGizmoTextureDisplacement(m_parent, "toolbar_texture_displacement.svg", EType::TextureDisplacement)); m_gizmos.emplace_back(new GLGizmoEmboss(m_parent, m_is_dark ? "toolbar_text_dark.svg" : "toolbar_text.svg", EType::Emboss)); m_gizmos.emplace_back(new GLGizmoSVG(m_parent)); m_gizmos.emplace_back(new GLGizmoMeasure(m_parent, m_is_dark ? "toolbar_measure_dark.svg" : "toolbar_measure.svg", EType::Measure)); diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index d4d08aa84f..94083dacee 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -2554,7 +2554,11 @@ void ImGuiWrapper::push_toolbar_style(const float scale) ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(238 / 255.0f, 238 / 255.0f, 238 / 255.0f, 1.00f)); // 10 ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(238 / 255.0f, 238 / 255.0f, 238 / 255.0f, 0.00f)); // 11 ImGui::PushStyleColor(ImGuiCol_TextSelectedBg, COL_GREEN_LIGHT); // 12 - ImGui::PushStyleColor(ImGuiCol_CheckMark, ImVec4(1.00f, 1.00f, 1.00f, 1.00f));//13 + // The checkbox/radio frame behind this is drawn fully transparent (see FrameBg above, + // alpha 0), showing the light window background through it -- a white check mark there is + // invisible. Dark mode doesn't have this problem (its window background is dark), so only + // this branch needs a check mark color with real contrast against a light background. + ImGui::PushStyleColor(ImGuiCol_CheckMark, ImVec4(0.f, 156 / 255.f, 136 / 255.f, 1.00f));//13 ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, ImVec4(0.42f, 0.42f, 0.42f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(0.93f, 0.93f, 0.93f, 1.00f)); ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(0.93f, 0.93f, 0.93f, 1.00f)); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp new file mode 100644 index 0000000000..e4a7ca616c --- /dev/null +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -0,0 +1,29 @@ +#include "TextureDisplacementPreviewJob.hpp" + +#include "slic3r/GUI/I18N.hpp" + +namespace Slic3r::GUI { + +TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, + std::function on_finished) + : m_input(std::move(input)), m_generation(generation), m_on_finished(std::move(on_finished)) +{ +} + +void TextureDisplacementPreviewJob::process(Ctl &ctl) +{ + ctl.update_status(0, _u8L("Computing texture displacement preview")); + + // Only ever touches m_input (captured by value before this job was queued) and local state -- + // never the live Model -- so this is safe to run concurrently with the UI thread. + m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data); +} + +void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) +{ + if (canceled || eptr || !m_on_finished) + return; + m_on_finished(std::move(m_result), m_generation); +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp new file mode 100644 index 0000000000..d024fc2548 --- /dev/null +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -0,0 +1,52 @@ +#ifndef slic3r_TextureDisplacementPreviewJob_hpp_ +#define slic3r_TextureDisplacementPreviewJob_hpp_ + +#include +#include +#include + +#include "libslic3r/TextureDisplacement.hpp" +#include "libslic3r/TriangleMesh.hpp" + +#include "Job.hpp" + +namespace Slic3r::GUI { + +// Everything process() needs, captured by value on the main thread when the job is queued -- +// mirrors TextureDisplacementBakeInput, but a preview never writes back to the Model. +struct TextureDisplacementPreviewInput +{ + indexed_triangle_set base_mesh; + std::vector layers; + TextureDisplacementFacetsData facets_data; +}; + +// Computes the true (unbaked) displaced-mesh preview in the background. With several painted +// layers this is real, non-trivial CPU work (PNG sampling, per-layer vertex welding), which used +// to run synchronously on every paint stroke and parameter tweak and made editing feel slow with +// more than one or two layers. Unlike Bake, this never touches the live Model -- a preview is +// purely informational, there is nothing to commit. +class TextureDisplacementPreviewJob : public Job +{ +public: + // `generation` is an opaque token the caller controls (typically an incrementing counter): + // on_finished should only actually be applied by the caller if it still matches the caller's + // current generation when the job completes, so that a burst of edits queuing several of + // these jobs in a row can't have an earlier, now-stale result clobber a later one that + // finishes first. + TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, + std::function on_finished); + + void process(Ctl &ctl) override; + void finalize(bool canceled, std::exception_ptr &eptr) override; + +private: + TextureDisplacementPreviewInput m_input; + uint64_t m_generation; + indexed_triangle_set m_result; + std::function m_on_finished; +}; + +} // namespace Slic3r::GUI + +#endif // slic3r_TextureDisplacementPreviewJob_hpp_ diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 26b9217b3f..8deede98ac 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -90,6 +90,7 @@ #include "Selection.hpp" #include "GLToolbar.hpp" #include "GUI_Preview.hpp" +#include "UVEditorCanvas.hpp" #include "3DBed.hpp" #include "PartPlate.hpp" #include "Camera.hpp" @@ -4468,6 +4469,13 @@ struct Plater::priv GLToolbar collapse_toolbar; Preview *preview; AssembleView* assemble_view { nullptr }; + // Docked/resizable 2D pane showing GLGizmoTextureDisplacement's LSCM unwrap of a painted + // patch; a sibling AUI pane alongside "sidebar"/"main", not part of the view3D/preview/ + // assemble_view sizer -- see its registration below and Plater::get_uv_editor_canvas(). The + // pane hosts the panel (toolbar + canvas + status line); uv_editor_canvas is its inner canvas, + // cached so the gizmo can reach it directly. + UVEditorPanel* uv_editor_panel { nullptr }; + UVEditorCanvas* uv_editor_canvas { nullptr }; bool first_enter_assemble{ true }; std::unique_ptr notification_manager; @@ -5133,6 +5141,20 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) .BottomDockable(false) .BestSize(wxSize(39 * wxGetApp().em_unit(), 90 * wxGetApp().em_unit()))); + // UV editor pane for GLGizmoTextureDisplacement's LSCM unwrap preview -- a resizable/dockable + // sibling of "sidebar"/"main" like everything else registered on this same AUI manager, not a + // change to the view3D/preview/assemble_view sizer above. Hidden by default: only relevant + // while that gizmo is active with a layer using the "Unwrap (LSCM)" projection method (see + // Plater::show_uv_editor()), so it stays out of the way of everyone else's window layout. + uv_editor_panel = new UVEditorPanel(q); + uv_editor_canvas = uv_editor_panel->canvas(); + m_aui_mgr.AddPane(uv_editor_panel, wxAuiPaneInfo() + .Name("uv_editor") + .Caption(_L("UV Editor")) + .Right() + .Hide() + .BestSize(wxSize(40 * wxGetApp().em_unit(), 40 * wxGetApp().em_unit()))); + auto* panel_sizer = new wxBoxSizer(wxHORIZONTAL); panel_sizer->Add(view3D, 1, wxEXPAND | wxALL, 0); panel_sizer->Add(preview, 1, wxEXPAND | wxALL, 0); @@ -5162,6 +5184,13 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) BOOST_LOG_TRIVIAL(info) << "Removed floating AUI state from saved window layout for Wayland"; } + // The UV editor is a transient, gizmo-driven pane (see show_uv_editor()); a saved layout + // from a session that happened to close with it open would otherwise restore it visible on + // startup, with nothing painted in it. Force it hidden here so it only ever appears when the + // texture-displacement gizmo asks for it. + if (wxAuiPaneInfo &uv_pane = m_aui_mgr.GetPane("uv_editor"); uv_pane.IsOk()) + uv_pane.Hide(); + sidebar_layout.is_collapsed = !sidebar.IsShown(); } @@ -17149,6 +17178,33 @@ GLCanvas3D* Plater::get_assmeble_canvas3D() return nullptr; } +UVEditorCanvas* Plater::get_uv_editor_canvas() +{ + return p->uv_editor_canvas; +} + +void Plater::show_uv_editor(bool show) +{ + if (p->uv_editor_panel == nullptr) + return; + const wxAuiPaneInfo &pane = p->m_aui_mgr.GetPane(p->uv_editor_panel); + if (!pane.IsOk() || pane.IsShown() == show) + return; + + // Deferred, because GLGizmoTextureDisplacement calls this from its ImGui panel -- that is, from + // the middle of the 3D canvas's GL frame. Showing an AUI pane re-lays out the window and + // delivers the resulting size/paint events synchronously, and the UV canvas painting itself + // makes its own surface current in the app's *shared* GL context, which mid-frame is the one + // the 3D canvas is drawing into. Doing the layout once the frame is over avoids that entirely. + CallAfter([this, show]() { + wxAuiPaneInfo &deferred_pane = p->m_aui_mgr.GetPane(p->uv_editor_panel); + if (!deferred_pane.IsOk() || deferred_pane.IsShown() == show) + return; + deferred_pane.Show(show); + p->m_aui_mgr.Update(); + }); +} + GLCanvas3D* Plater::get_current_canvas3D(bool exclude_preview) { return p->get_current_canvas3D(exclude_preview); diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index c70c5ca7c1..474a6512a7 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -611,6 +611,13 @@ public: GLCanvas3D* get_assmeble_canvas3D(); wxWindow* get_select_machine_dialog(); + // Docked UV-editor pane used by GLGizmoTextureDisplacement's LSCM projection preview (see + // UVEditorCanvas.hpp). Returns nullptr only before the main window is fully constructed. + class UVEditorCanvas* get_uv_editor_canvas(); + // Shows or hides the UV-editor AUI pane, updating its docked layout accordingly. Safe to call + // repeatedly (e.g. every time the gizmo's active layer/projection method changes). + void show_uv_editor(bool show); + void arrange(); void orient(); void find_new_position(const ModelInstancePtrs &instances); diff --git a/src/slic3r/GUI/TextureLibrary.cpp b/src/slic3r/GUI/TextureLibrary.cpp new file mode 100644 index 0000000000..349d82a050 --- /dev/null +++ b/src/slic3r/GUI/TextureLibrary.cpp @@ -0,0 +1,208 @@ +#include "TextureLibrary.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include "libslic3r/PNGReadWrite.hpp" +#include "libslic3r/Utils.hpp" + +#include "slic3r/GUI/GUI.hpp" +#include "slic3r/GUI/I18N.hpp" + +namespace Slic3r::GUI { + +namespace { + +// Extensions the picker will list. The shipped folder only ever contains .png; the rest are here +// so a user who drops a .jpg straight into their own folder still sees it (load_texture_image_data() +// converts anything it can open). +bool is_image_file(const boost::filesystem::path &path) +{ + std::string ext = path.extension().string(); + boost::algorithm::to_lower(ext); + return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp"; +} + +void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector &out) +{ + boost::system::error_code ec; + if (!boost::filesystem::is_directory(dir, ec)) + return; + + const size_t first = out.size(); + for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) { + if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path())) + continue; + out.push_back({ it->path().stem().string(), it->path().string(), is_user }); + } + std::sort(out.begin() + first, out.end(), + [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; }); +} + +// Slic3r::png only writes PNGs to a file, so the encode round-trips through a temp file rather than +// staying in memory. It happens once per import / per texture pick, not per frame, so the I/O is +// not worth avoiding with a second PNG encoder. +bool encode_gray_png_bytes(const wxImage &image, std::vector &out, std::string &error) +{ + const wxImage gray = image.ConvertToGreyscale(); + const int w = gray.GetWidth(); + const int h = gray.GetHeight(); + if (w <= 0 || h <= 0) { + error = _u8L("The selected image is empty."); + return false; + } + + // wxImage always stores 3 bytes per pixel; after ConvertToGreyscale the three are equal. + std::vector pixels(size_t(w) * size_t(h)); + const unsigned char *rgb = gray.GetData(); + for (size_t i = 0; i < pixels.size(); ++i) + pixels[i] = rgb[i * 3]; + + const boost::filesystem::path tmp = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("orca_texdisp_%%%%%%%%.png"); + if (!Slic3r::png::write_gray_to_file(tmp.string(), size_t(w), size_t(h), pixels)) { + error = _u8L("Failed to prepare the texture for use."); + return false; + } + + { + std::ifstream ifs(tmp.string(), std::ios::binary); + out.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp, ec); + + if (out.empty()) { + error = _u8L("Failed to prepare the texture for use."); + return false; + } + return true; +} + +std::vector read_file_bytes(const std::string &path) +{ + std::ifstream ifs(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); +} + +// True if these bytes are already the 8-bit grayscale PNG libslic3r can decode, i.e. can be stored +// on a layer as-is. Mirrors exactly what decode_height_texture() accepts. +bool is_supported_height_map(const std::vector &bytes) +{ + if (bytes.empty()) + return false; + const png::ReadBuf rbuf{ bytes.data(), bytes.size() }; + if (!png::is_png(rbuf)) + return false; + png::ImageGreyscale img; + return png::decode_png(rbuf, img) && img.cols > 0 && img.rows > 0; +} + +std::vector g_library; +bool g_library_scanned = false; + +} // namespace + +std::string user_texture_dir() +{ + const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement"; + boost::system::error_code ec; + boost::filesystem::create_directories(dir, ec); + if (ec) { + BOOST_LOG_TRIVIAL(error) << "Could not create the user texture directory " << dir.string() << ": " << ec.message(); + return {}; + } + return dir.string(); +} + +const std::vector &texture_library(bool force_rescan) +{ + if (g_library_scanned && !force_rescan) + return g_library; + + g_library.clear(); + scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", false, g_library); + const std::string user_dir = user_texture_dir(); + if (!user_dir.empty()) + scan_dir(boost::filesystem::path(user_dir), true, g_library); + + g_library_scanned = true; + return g_library; +} + +std::optional import_texture_to_library(const std::string &source_path, std::string &error) +{ + const std::string user_dir = user_texture_dir(); + if (user_dir.empty()) { + error = _u8L("Could not create the folder for imported textures."); + return std::nullopt; + } + + wxImage image; + if (!image.LoadFile(from_u8(source_path)) || !image.IsOk()) { + error = _u8L("Could not load the selected image."); + return std::nullopt; + } + + std::vector bytes; + if (!encode_gray_png_bytes(image, bytes, error)) + return std::nullopt; + + // Never overwrite an existing texture (the user's or, if they picked the same name twice, their + // own earlier import) -- uniquify instead. + const std::string stem = boost::filesystem::path(source_path).stem().string(); + boost::filesystem::path dest = boost::filesystem::path(user_dir) / (stem + ".png"); + for (int i = 2; boost::filesystem::exists(dest); ++i) + dest = boost::filesystem::path(user_dir) / (stem + " (" + std::to_string(i) + ").png"); + + { + boost::nowide::ofstream ofs(dest.string(), std::ios::binary); + ofs.write(reinterpret_cast(bytes.data()), std::streamsize(bytes.size())); + if (!ofs.good()) { + error = _u8L("Failed to save the imported texture."); + return std::nullopt; + } + } + + texture_library(true); // pick the new file up + const std::string dest_str = dest.string(); + for (const TextureLibraryEntry &e : g_library) + if (e.path == dest_str) + return e; + + error = _u8L("Failed to save the imported texture."); + return std::nullopt; +} + +std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error) +{ + std::vector bytes = read_file_bytes(path); + if (bytes.empty()) { + error = _u8L("Could not read the texture file."); + return nullptr; + } + if (is_supported_height_map(bytes)) + return std::make_shared>(std::move(bytes)); + + // Not an 8-bit grayscale PNG (a colour image somebody copied into the folder by hand, say): + // convert it the same way an import would, but leave the file on disk alone. + wxImage image; + if (!image.LoadFile(from_u8(path)) || !image.IsOk()) { + error = _u8L("Could not load the selected image."); + return nullptr; + } + std::vector converted; + if (!encode_gray_png_bytes(image, converted, error)) + return nullptr; + return std::make_shared>(std::move(converted)); +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureLibrary.hpp b/src/slic3r/GUI/TextureLibrary.hpp new file mode 100644 index 0000000000..acef78d4d9 --- /dev/null +++ b/src/slic3r/GUI/TextureLibrary.hpp @@ -0,0 +1,50 @@ +#ifndef slic3r_TextureLibrary_hpp_ +#define slic3r_TextureLibrary_hpp_ + +#include +#include +#include +#include + +namespace Slic3r::GUI { + +// One selectable height-map texture in the texture-displacement gizmo's texture picker. +struct TextureLibraryEntry +{ + std::string name; // display name (the file's stem, e.g. "Wood Grain") + std::string path; // absolute path on disk + bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer +}; + +// Every available height-map texture: the ones shipped in resources/textures/displacement first, +// then the user's own from /textures/displacement, each group sorted by name. +// +// The two live in separate directories deliberately: an app update replaces the resources tree +// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or +// delete. `is_user` is what the picker uses to show them under separate headings. +// +// Scanned once and cached. Pass force_rescan after an import, or to pick up a file the user dropped +// into either folder by hand while the app was running. +const std::vector &texture_library(bool force_rescan = false); + +// /textures/displacement, created if it does not exist yet. Empty string on failure. +std::string user_texture_dir(); + +// Reads any image format wxWidgets can open, converts it to the 8-bit grayscale PNG that +// libslic3r's decode_height_texture() understands, and saves it into user_texture_dir() (uniquified +// if that name is taken). The conversion has to happen here rather than in libslic3r, which has no +// image toolkit and so only ever handles the one already-normalized format. +// +// Returns the newly imported entry, or nullopt with `error` set. `source_path` is only read. +std::optional import_texture_to_library(const std::string &source_path, std::string &error); + +// Encoded bytes of `path`, ready to hand to TextureDisplacementLayer::image_data. Files already in +// the supported 8-bit grayscale PNG form (everything in the two library folders, by construction) +// are passed through verbatim; anything else -- e.g. a colour PNG the user copied into the folder +// by hand -- is converted on the fly, so a valid image never silently produces a blank layer. +// Returns nullptr with `error` set if the file cannot be read or decoded at all. +std::shared_ptr> load_texture_image_data(const std::string &path, std::string &error); + +} // namespace Slic3r::GUI + +#endif // slic3r_TextureLibrary_hpp_ diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp new file mode 100644 index 0000000000..ab7d023f8b --- /dev/null +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -0,0 +1,1314 @@ +#include "UVEditorCanvas.hpp" + +#include +#include +#include +#include + +#include + +#include "3DScene.hpp" +#include "GLShader.hpp" +#include "GUI_App.hpp" +#include "I18N.hpp" +#include "OpenGLManager.hpp" +#include "Plater.hpp" +#include "wxExtensions.hpp" +#include "libslic3r/AppConfig.hpp" + +namespace Slic3r::GUI { + +namespace { +// Roughly Blender's UV/Image editor palette, which is what this pane is measured against. The UV_ +// prefix is not decoration: COLOR_BACKGROUND (and several other COLOR_*) are Win32 system-colour +// macros from WinUser.h, and an unprefixed name here expands to an integer literal mid-declaration. +const ColorRGBA UV_COLOR_BG = { 0.16f, 0.16f, 0.16f, 1.f }; +const ColorRGBA UV_COLOR_GRID = { 1.f, 1.f, 1.f, 0.10f }; +const ColorRGBA UV_COLOR_TILE_OUTLINE = { 1.f, 1.f, 1.f, 0.45f }; +const ColorRGBA UV_COLOR_WIRE = { 0.85f, 0.85f, 0.85f, 0.35f }; // interior edges, unselected +const ColorRGBA UV_COLOR_BOUNDARY = { 1.f, 0.35f, 0.15f, 0.85f }; // island outline == a seam, so Blender's seam red +// Island fills (#4): unselected a light green kept translucent so the texture/checker/wireframe under +// it still reads (the user asked for both these fills *and* to see the check overlay through them, so +// the alphas stay low); the selected one switches to the app accent teal #009688 and is marked mainly +// by its bold boundary and brighter wire rather than a heavy fill. +const ColorRGBA UV_COLOR_FILL = { 0.55f, 0.85f, 0.45f, 0.40f }; // unselected: light green +const ColorRGBA UV_COLOR_SEL_FILL = { 0.0f, 0.588f, 0.533f, 0.20f }; // selected: #009688 teal +const ColorRGBA UV_COLOR_SEL_WIRE = { 0.75f, 1.f, 0.7f, 0.55f }; +const ColorRGBA UV_COLOR_SEL_BOUNDARY = { 0.2f, 1.f, 0.55f, 1.f }; // bold light-green edge (#7) +const ColorRGBA UV_COLOR_DIAL = { 1.f, 0.85f, 0.2f, 0.9f }; // rotation protractor (#11) + +constexpr float SNAP_PIXELS = 28.f; // how close a boundary vertex has to come before it sticks (#2) + +// The pixel format this canvas is created with has to match the one the app's single shared +// wxGLContext was created against (that of View3D's canvas, from OpenGLManager::create_wxglcanvas()), +// so this mirrors that attribute list *including its multisampling*: WGL requires the HDC passed to +// wglMakeCurrent() to have the same pixel format as the one the context was created with, and a +// differing sample count is a differing pixel format. Getting only the non-multisample half of this +// right still leaves SetCurrent() failing, which is silent - the canvas then just shows whatever +// was last in its backbuffer, i.e. nothing. +std::vector gl_attrib_list() +{ + int antialiasing_samples = 4; + if (const AppConfig *app_config = wxGetApp().app_config; app_config != nullptr) { + const std::string value = app_config->get(SETTING_OPENGL_AA_SAMPLES); + if (value == "0" || value == "2" || value == "4" || value == "8" || value == "16") + antialiasing_samples = ::atoi(value.c_str()); + } + // OpenGLManager's own auto-detection has already run by now (View3D is created before this + // canvas), so this only reads its verdict rather than re-detecting. + if (!OpenGLManager::can_multisample()) + antialiasing_samples = 0; + + return { + WX_GL_RGBA, + WX_GL_DOUBLEBUFFER, + WX_GL_MIN_RED, 8, + WX_GL_MIN_GREEN, 8, + WX_GL_MIN_BLUE, 8, + WX_GL_MIN_ALPHA, 8, + WX_GL_DEPTH_SIZE, 24, + WX_GL_STENCIL_SIZE, 8, + WX_GL_SAMPLE_BUFFERS, antialiasing_samples > 0 ? GL_TRUE : GL_FALSE, + WX_GL_SAMPLES, antialiasing_samples, + 0 + }; +} + +// Wide lines are only *required* to be supported in a compatibility profile; a core-profile driver is +// allowed to reject anything but 1.0 with GL_INVALID_VALUE. In practice every desktop driver we care +// about honours it, so ask and swallow the error rather than giving up thickness everywhere. +void set_line_width(float width) +{ + ::glLineWidth(width); + ::glGetError(); +} + +uint64_t undirected_edge_key(int a, int b) +{ + if (a > b) + std::swap(a, b); + return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); +} + +// Signed-area test, so it works whichever way round the triangle is wound. +bool point_in_triangle(const Vec2f &p, const Vec2f &a, const Vec2f &b, const Vec2f &c) +{ + const auto cross = [](const Vec2f &u, const Vec2f &v) { return u.x() * v.y() - u.y() * v.x(); }; + const float d0 = cross(b - a, p - a); + const float d1 = cross(c - b, p - b); + const float d2 = cross(a - c, p - c); + const bool has_neg = (d0 < 0.f) || (d1 < 0.f) || (d2 < 0.f); + const bool has_pos = (d0 > 0.f) || (d1 > 0.f) || (d2 > 0.f); + return !(has_neg && has_pos); +} + +// Shortest signed difference between two angles, so a rotation gesture crossing +/-pi doesn't jump. +float angle_delta(float from, float to) +{ + float d = to - from; + while (d > float(M_PI)) + d -= 2.f * float(M_PI); + while (d < -float(M_PI)) + d += 2.f * float(M_PI); + return d; +} + +// A 2D affine as the 4x4 the "flat" shader's view_model_matrix wants. +Transform3d to_transform3d(const UVEditorCanvas::IslandTransform &m) +{ + Transform3d t = Transform3d::Identity(); + t(0, 0) = m(0, 0); t(0, 1) = m(0, 1); t(0, 3) = m(0, 2); + t(1, 0) = m(1, 0); t(1, 1) = m(1, 1); t(1, 3) = m(1, 2); + return t; +} +} // namespace + +UVEditorCanvas::UVEditorCanvas(wxWindow *parent) + : wxGLCanvas(parent, wxID_ANY, gl_attrib_list().data(), wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS) +{ + // The GL canvas paints its entire surface, so background erasing is unnecessary (and would + // otherwise race with our own rendering) - same setup as OpenGLManager::create_wxglcanvas(). + SetBackgroundStyle(wxBG_STYLE_PAINT); + + // Shares the app's one real GL context (see class comment) rather than creating an + // independent one - this is the same call View3D/Preview/AssembleView make in + // GUI_Preview.cpp, and is what lets this canvas reuse wxGetApp().get_shader(...) and GLModel. + m_context = wxGetApp().init_glcontext(*this); + + Bind(wxEVT_PAINT, &UVEditorCanvas::on_paint, this); + Bind(wxEVT_SIZE, &UVEditorCanvas::on_size, this); + Bind(wxEVT_LEFT_DOWN, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_LEFT_UP, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_DOWN, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_UP, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_DOWN, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_UP, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_MOTION, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_MOUSEWHEEL, &UVEditorCanvas::on_mouse, this); + Bind(wxEVT_KEY_DOWN, &UVEditorCanvas::on_key, this); + Bind(wxEVT_ERASE_BACKGROUND, &UVEditorCanvas::on_erase_background, this); +} + +// NOTE (applies to set_background_texture() too): these are called from the texture-displacement +// gizmo's ImGui panel, i.e. from *inside* the main 3D canvas's own render pass. They must therefore +// only ever mark state dirty and schedule a repaint - rendering inline from here would make this +// canvas's GL surface current in the middle of the 3D canvas's frame (and did: it was also called +// while this pane was still hidden, where wxGLCanvas::SetCurrent() refuses to switch at all and the +// GL calls that followed simply landed on the 3D canvas instead). +void UVEditorCanvas::set_islands(Islands islands) +{ + // Only frame the view when a patch first appears, not on every stroke that extends one - the + // latter would keep yanking the view out from under a user who has panned or zoomed. + m_needs_fit |= m_islands.indices.empty(); + // Re-uploads that keep the same island/vertex count are just a refresh (e.g. a committed vertex edit + // re-applied), not a re-segmentation, so the selection and the picked sub-element stay valid and are + // worth keeping. A change in either count means the charts were renumbered and both are meaningless. + const bool same_structure = m_islands.island_count == islands.island_count && + m_islands.uvs.size() == islands.uvs.size(); + m_islands = std::move(islands); + if (m_selected_island >= m_islands.island_count) + m_selected_island = -1; + if (!same_structure) { + // A fresh unwrap renumbers charts, so any previous multi-selection is meaningless: drop it, then + // keep the primary (if still valid) as a single-island selection so the highlight is consistent. + m_selection.clear(); + if (m_selected_island >= 0) + m_selection.push_back(m_selected_island); + // Vertex/edge picks index into the old unwrap; drop them too. + m_active_vertex = -1; + m_active_edge = { -1, -1 }; + } + + // Boundary vertices, bucketed per island, for snapping. + m_island_boundary_verts.assign(size_t(std::max(m_islands.island_count, 0)), {}); + std::vector seen(m_islands.uvs.size(), false); + for (const auto &[a, b] : m_islands.boundary_edges) + for (const int v : { a, b }) { + if (v < 0 || size_t(v) >= m_islands.uvs.size() || seen[size_t(v)]) + continue; + seen[size_t(v)] = true; + const int island = m_islands.vertex_island[size_t(v)]; + if (island >= 0 && size_t(island) < m_island_boundary_verts.size()) + m_island_boundary_verts[size_t(island)].push_back(v); + } + + m_mesh_dirty = true; + m_background_quad_dirty = true; // the backdrop is sized to the unwrap, so it moved too + Refresh(); +} + +void UVEditorCanvas::set_island_transforms(std::vector transforms) +{ + m_transforms = std::move(transforms); + m_background_quad_dirty = true; // content_bounds() depends on where the islands ended up + Refresh(); +} + +void UVEditorCanvas::set_island_fill_colors(std::vector colors) +{ + m_island_fill_colors = std::move(colors); + Refresh(); +} + +void UVEditorCanvas::set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored) +{ + m_tiling_scale = (std::abs(tiling_scale) > 1e-6f) ? tiling_scale : 1.f; + m_rotation_deg = rotation_deg; + m_tile_enabled = tile_enabled; + m_tile_mirrored = tile_mirrored; + m_background_quad_dirty = true; +} + +void UVEditorCanvas::set_background_texture(const std::vector &grayscale_pixels, int width, int height) +{ + if (width <= 0 || height <= 0 || grayscale_pixels.size() != size_t(width) * size_t(height)) { + m_background_width = m_background_height = 0; + m_background_pixels.clear(); + } else { + m_background_width = width; + m_background_height = height; + m_background_pixels.assign(size_t(width) * size_t(height) * 4, 255); + for (size_t i = 0; i < grayscale_pixels.size(); ++i) { + const unsigned char g = grayscale_pixels[i]; + m_background_pixels[i * 4 + 0] = g; + m_background_pixels[i * 4 + 1] = g; + m_background_pixels[i * 4 + 2] = g; + m_background_pixels[i * 4 + 3] = 255; + } + } + m_background_dirty = true; + Refresh(); +} + +void UVEditorCanvas::reset_view() +{ + m_needs_fit = true; + Refresh(); +} + +void UVEditorCanvas::run_command(Command cmd) +{ + switch (cmd) { + case Command::FrameAll: reset_view(); return; + case Command::ToggleSnap: m_snap_enabled = !m_snap_enabled; update_status(); return; + // The rest need the layer data the canvas doesn't hold; hand them to the gizmo. + case Command::AverageScale: + case Command::CutSelectedIsland: + case Command::ProjectFromView: + case Command::JoinSelected: + case Command::UnjoinSelected: + if (m_on_command) + m_on_command(cmd); + return; + } +} + +void UVEditorCanvas::update_status() +{ + if (!m_on_status) + return; + + wxString msg; + switch (m_gesture) { + case Gesture::MoveIsland: msg = _L("Moving island | release to drop, Esc to cancel"); break; + case Gesture::RotateIsland: + case Gesture::RotateIslandModal: + msg = wxString::Format(_L("Rotating island: %d° | Shift = snap 15°, click to confirm, Esc to cancel"), + int(std::lround(m_rot_display_deg))); + break; + case Gesture::ScaleIslandModal: msg = _L("Scaling island | click to confirm, Esc to cancel"); break; + case Gesture::MoveVertex: msg = _L("Moving vertex | release to drop"); break; + case Gesture::MoveEdge: msg = _L("Moving edge | release to drop"); break; + case Gesture::Pan: msg = _L("Panning"); break; + case Gesture::None: + default: + if (m_select_mode == SelectMode::Vertex) + msg = _L("Vertex mode: drag a vertex to reshape the unwrap | wheel = zoom, middle-drag = pan, Home = frame"); + else if (m_select_mode == SelectMode::Edge) + msg = _L("Edge mode: drag an island edge to reshape the unwrap | wheel = zoom, middle-drag = pan, Home = frame"); + else if (m_selection.size() > 1) + msg = wxString::Format(_L("%d islands selected | drag = move together, Shift/Ctrl click = add/remove, R/S = rotate/scale primary"), + int(m_selection.size())); + else if (m_selected_island >= 0) + msg = wxString::Format(_L("Island %d selected | drag = move, R = rotate, S = scale, Shift/Ctrl click = multi-select, Home = frame"), + m_selected_island + 1); + else + msg = _L("Click an island to select | Shift/Ctrl click = multi-select, wheel = zoom, middle-drag = pan, Home = frame all"); + if (m_snap_enabled) + msg += _L(" | snap ON"); + break; + } + m_on_status(msg); +} + +Vec2f UVEditorCanvas::island_uv(size_t vertex) const +{ + const Vec2f &raw = m_islands.uvs[vertex]; + const int island = m_islands.vertex_island[vertex]; + if (island < 0 || size_t(island) >= m_transforms.size()) + return raw; + const IslandTransform &m = m_transforms[size_t(island)]; + return m.block<2, 2>(0, 0) * raw + m.col(2); +} + +void UVEditorCanvas::content_bounds(Vec2f &min_uv, Vec2f &max_uv) const +{ + // Always include the texture's first tile, so an unwrap that happens to be tiny, or absent, + // still leaves something sensibly framed on screen. + min_uv = Vec2f(0.f, 0.f); + max_uv = Vec2f(1.f, 1.f); + for (size_t i = 0; i < m_islands.uvs.size(); ++i) { + const Vec2f uv = island_uv(i); + min_uv = min_uv.cwiseMin(uv); + max_uv = max_uv.cwiseMax(uv); + } +} + +void UVEditorCanvas::fit_view_to_content() +{ + Vec2f min_uv, max_uv; + content_bounds(min_uv, max_uv); + + m_pan = 0.5f * (min_uv + max_uv); + m_zoom = std::max(0.5f * (max_uv - min_uv).maxCoeff() * 1.1f, 0.05f); + m_needs_fit = false; +} + +void UVEditorCanvas::view_half_extents(float &half_w, float &half_h) const +{ + const wxSize size = GetSize(); + const float w = float(std::max(1, size.GetWidth())); + const float h = float(std::max(1, size.GetHeight())); + + // m_zoom is the half-extent visible across the *shorter* edge; the longer edge shows + // proportionally more. Both axes have to be driven off the same uniform scale, or the content + // comes out squashed on one of the two pane orientations. + const float aspect = w / h; + half_w = m_zoom * std::max(aspect, 1.f); + half_h = m_zoom * std::max(1.f / aspect, 1.f); +} + +Vec2f UVEditorCanvas::screen_to_uv(const wxPoint &px) const +{ + const wxSize size = GetSize(); + const float w = float(std::max(1, size.GetWidth())); + const float h = float(std::max(1, size.GetHeight())); + + float half_w, half_h; + view_half_extents(half_w, half_h); + + // Inverse of the projection in render(): x maps straight through, y is negated there so that v + // runs down the screen - which means screen-down and v-increasing agree, and this is a plain + // scale on both axes. + return Vec2f(m_pan.x() + (2.f * float(px.x) / w - 1.f) * half_w, + m_pan.y() + (2.f * float(px.y) / h - 1.f) * half_h); +} + +int UVEditorCanvas::island_at(const Vec2f &uv) const +{ + int found = -1; + for (const Vec3i32 &tri : m_islands.indices) { + if (tri.minCoeff() < 0 || size_t(tri.maxCoeff()) >= m_islands.uvs.size()) + continue; + if (!point_in_triangle(uv, island_uv(size_t(tri[0])), island_uv(size_t(tri[1])), island_uv(size_t(tri[2])))) + continue; + + const int island = m_islands.vertex_island[size_t(tri[0])]; + // Islands are allowed to overlap, so a point can be inside several. Keep whichever is already + // selected - otherwise a drag of a partly-covered island would be stolen mid-gesture by the + // one on top of it. + if (is_selected(island)) + return island; + found = island; + } + return found; +} + +void UVEditorCanvas::set_select_mode(SelectMode mode) +{ + if (m_select_mode == mode) + return; + m_select_mode = mode; + m_active_vertex = -1; + m_active_edge = { -1, -1 }; + m_gesture = Gesture::None; + update_status(); + Refresh(); +} + +int UVEditorCanvas::vertex_at(const Vec2f &uv) const +{ + // Screen-space threshold, so the pick feels the same at every zoom. + const wxSize size = GetSize(); + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + const float threshold = SNAP_PIXELS * uv_per_px; + + int best = -1; + float best_d = threshold * threshold; + for (size_t i = 0; i < m_islands.uvs.size(); ++i) { + const float d = (island_uv(i) - uv).squaredNorm(); + if (d < best_d) { + best_d = d; + best = int(i); + } + } + return best; +} + +std::pair UVEditorCanvas::edge_at(const Vec2f &uv) const +{ + const wxSize size = GetSize(); + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + const float threshold = SNAP_PIXELS * uv_per_px; + + // Point-to-segment distance in texture-UV space, against the island outlines (the edges the pane + // exists to show). Interior edges are left alone: the boundary is what a user reshapes. + const auto seg_dist_sq = [](const Vec2f &p, const Vec2f &a, const Vec2f &b) { + const Vec2f ab = b - a; + const float l2 = ab.squaredNorm(); + const float t = (l2 > 1e-12f) ? std::clamp((p - a).dot(ab) / l2, 0.f, 1.f) : 0.f; + return (p - (a + ab * t)).squaredNorm(); + }; + std::pair best{ -1, -1 }; + float best_d = threshold * threshold; + for (const auto &[a, b] : m_islands.boundary_edges) { + if (a < 0 || b < 0 || size_t(a) >= m_islands.uvs.size() || size_t(b) >= m_islands.uvs.size()) + continue; + const float d = seg_dist_sq(uv, island_uv(size_t(a)), island_uv(size_t(b))); + if (d < best_d) { + best_d = d; + best = { a, b }; + } + } + return best; +} + +void UVEditorCanvas::move_vertex_raw(int v, const Vec2f &delta_uv) +{ + if (v < 0 || size_t(v) >= m_islands.uvs.size()) + return; + const int island = m_islands.vertex_island[size_t(v)]; + // The gesture happens in texture-UV space; the stored coordinate is the raw unwrap. Undo the + // island's own linear map (which includes the layer's tiling scale + rotation) to get there. + Eigen::Matrix2f lin = Eigen::Matrix2f::Identity(); + if (island >= 0 && size_t(island) < m_transforms.size()) + lin = m_transforms[size_t(island)].block<2, 2>(0, 0); + const float det = lin.determinant(); + const Vec2f raw_delta = (std::abs(det) > 1e-12f) ? Vec2f(lin.inverse() * delta_uv) : delta_uv; + m_islands.uvs[size_t(v)] += raw_delta; + m_mesh_dirty = true; // the edited raw uv is redrawn from rebuild_island_models() next frame +} + +float UVEditorCanvas::island_rotation_deg(int island) const +{ + // The rotation baked into the island's affine, in the same y-down UV convention the gesture uses. + // First column is scale*(cos, sin); its angle is the island's on-screen orientation. + if (island < 0 || size_t(island) >= m_transforms.size()) + return 0.f; + const IslandTransform &m = m_transforms[size_t(island)]; + return std::atan2(m(1, 0), m(0, 0)) * 180.f / float(M_PI); +} + +Vec2f UVEditorCanvas::island_centroid(int island) const +{ + Vec2f sum = Vec2f::Zero(); + int count = 0; + for (size_t i = 0; i < m_islands.uvs.size(); ++i) + if (m_islands.vertex_island[i] == island) { + sum += island_uv(i); + ++count; + } + return (count > 0) ? Vec2f(sum / float(count)) : Vec2f::Zero(); +} + +Vec2f UVEditorCanvas::uv_delta_to_unwrap(const Vec2f &delta_uv) const +{ + // apply_uv_transform() maps unwrap -> uv as uv = R(unwrap / tiling) + offset, so the inverse of + // a *delta* (the translation drops out) is unwrap = tiling * R^-1(delta_uv). + const float rad = m_rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + return Vec2f(delta_uv.x() * cs + delta_uv.y() * sn, -delta_uv.x() * sn + delta_uv.y() * cs) * m_tiling_scale; +} + +Vec2f UVEditorCanvas::snap_correction(int island) const +{ + if (!m_snap_enabled || island < 0 || size_t(island) >= m_island_boundary_verts.size()) + return Vec2f::Zero(); + + // A screen-space threshold, so the magnet feels the same at every zoom level. + const wxSize size = GetSize(); + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + const float threshold = SNAP_PIXELS * uv_per_px; + + float best_dist_sq = threshold * threshold; + Vec2f best = Vec2f::Zero(); + for (const int mine : m_island_boundary_verts[size_t(island)]) { + const Vec2f a = island_uv(size_t(mine)); + for (size_t other = 0; other < m_island_boundary_verts.size(); ++other) { + if (int(other) == island) + continue; + for (const int theirs : m_island_boundary_verts[other]) { + const Vec2f d = island_uv(size_t(theirs)) - a; + const float dist_sq = d.squaredNorm(); + if (dist_sq < best_dist_sq) { + best_dist_sq = dist_sq; + best = d; + } + } + } + } + return best; +} + +void UVEditorCanvas::end_gesture() +{ + const bool was_editing = m_gesture == Gesture::MoveIsland || m_gesture == Gesture::RotateIsland || + m_gesture == Gesture::RotateIslandModal || m_gesture == Gesture::ScaleIslandModal; + + // Stick to a neighbour at the end of a move, rather than magnetically fighting the cursor + // throughout it - a snap that keeps re-applying mid-drag is very hard to pull *out* of. + if (m_gesture == Gesture::MoveIsland && m_on_island_edit) { + const Vec2f correction = snap_correction(m_selected_island); + if (!correction.isZero()) + m_on_island_edit(m_selected_island, uv_delta_to_unwrap(correction), 0.f, 1.f, false); + } + + if (was_editing && m_on_island_edit) + m_on_island_edit(m_selected_island, Vec2f::Zero(), 0.f, 1.f, /* finished */ true); + + // Commit a vertex/edge edit once, on release: hand the owner the affected unwrapped vertices and + // their new raw-unwrap coordinates so it can store the overrides and re-solve the bake preview. + if ((m_gesture == Gesture::MoveVertex || m_gesture == Gesture::MoveEdge) && m_vertex_edit_moved && + m_on_vertex_edit) { + std::vector> edits; + const auto add = [&](int v) { + if (v >= 0 && size_t(v) < m_islands.uvs.size()) + edits.emplace_back(v, m_islands.uvs[size_t(v)]); + }; + if (m_gesture == Gesture::MoveVertex) + add(m_active_vertex); + else { + add(m_active_edge.first); + add(m_active_edge.second); + } + if (!edits.empty()) + m_on_vertex_edit(edits); + } + + m_gesture = Gesture::None; + m_rot_raw_deg = 0.f; + m_rot_applied_deg = 0.f; + m_modal_scale_accum = 1.f; + if (HasCapture()) + ReleaseMouse(); +} + +void UVEditorCanvas::on_key(wxKeyEvent &evt) +{ + const int key = evt.GetKeyCode(); + + // Undo/redo while the pane has focus. Island edits already take a Plater snapshot per gesture (see + // GLGizmoTextureDisplacement::on_island_edited), so this just drives the same global history; the + // gizmo re-pushes the restored island transforms into the canvas on the reload that follows. + if (evt.ControlDown() && (key == 'Z' || key == 'z')) { + if (evt.ShiftDown()) wxGetApp().plater()->redo(); + else wxGetApp().plater()->undo(); + return; + } + if (evt.ControlDown() && (key == 'Y' || key == 'y')) { + wxGetApp().plater()->redo(); + return; + } + + if (m_gesture == Gesture::RotateIslandModal || m_gesture == Gesture::ScaleIslandModal) { + if (key == WXK_ESCAPE) { + // Put the island back exactly where the modal gesture found it, then finish. + if (m_on_island_edit) { + if (m_gesture == Gesture::RotateIslandModal && m_rot_applied_deg != 0.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), -m_rot_applied_deg, 1.f, false); + if (m_gesture == Gesture::ScaleIslandModal && m_modal_scale_accum != 1.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), 0.f, 1.f / m_modal_scale_accum, false); + } + end_gesture(); + Refresh(); + return; + } + if (key == WXK_RETURN || key == WXK_NUMPAD_ENTER) { + end_gesture(); + Refresh(); + return; + } + } + + // Frame everything. The unwrap is packed in mm and then divided by the layer's tile size, so it + // can easily sit tens of tiles away from the texture's first one - panning back by hand from + // there is hopeless, and without this there would be no way to reach reset_view() at all. + if (key == WXK_HOME || key == 'F' || key == 'f') { + reset_view(); + return; + } + + // Blender's modal transforms: R / S, then the transform follows the mouse until it is confirmed + // with a click or Enter, or abandoned with Esc. + if (m_selected_island >= 0 && m_select_mode == SelectMode::Island && m_gesture == Gesture::None && + (key == 'R' || key == 'r' || key == 'S' || key == 's')) { + const Vec2f uv = screen_to_uv(ScreenToClient(wxGetMousePosition())); + const Vec2f centre = island_centroid(m_selected_island); + const Vec2f rel = uv - centre; + if (key == 'R' || key == 'r') { + m_gesture = Gesture::RotateIslandModal; + m_rot_raw_deg = 0.f; + m_rot_applied_deg = 0.f; + m_rot_base_deg = island_rotation_deg(m_selected_island); + m_rot_display_deg = m_rot_base_deg; + m_gesture_last_angle = std::atan2(rel.y(), rel.x()); + } else { + m_gesture = Gesture::ScaleIslandModal; + m_modal_scale_accum = 1.f; + m_gesture_last_dist = std::max(rel.norm(), 1e-6f); + } + return; + } + + evt.Skip(); +} + +void UVEditorCanvas::on_mouse(wxMouseEvent &evt) +{ + const wxEventType type = evt.GetEventType(); + const wxPoint pos = evt.GetPosition(); + + // Key events (R/S/Home) only arrive if this canvas has focus, and clicking it is the natural way + // to ask for it - the pane is not in the tab order. + if (type == wxEVT_LEFT_DOWN || type == wxEVT_RIGHT_DOWN || type == wxEVT_MIDDLE_DOWN) + SetFocus(); + + // A modal R/S is confirmed by any click, exactly as in Blender. + if ((m_gesture == Gesture::RotateIslandModal || m_gesture == Gesture::ScaleIslandModal) && + (type == wxEVT_LEFT_DOWN || type == wxEVT_RIGHT_DOWN)) { + end_gesture(); + Refresh(); + return; + } + + if (type == wxEVT_LEFT_DOWN) { + const Vec2f uv = screen_to_uv(pos); + m_drag_last_px = pos; + m_gesture_last_uv = uv; + if (m_select_mode == SelectMode::Vertex) { + m_active_vertex = vertex_at(uv); + m_active_edge = { -1, -1 }; + m_vertex_edit_moved = false; + m_gesture = (m_active_vertex >= 0) ? Gesture::MoveVertex : Gesture::Pan; + } else if (m_select_mode == SelectMode::Edge) { + m_active_edge = edge_at(uv); + m_active_vertex = -1; + m_vertex_edit_moved = false; + m_gesture = (m_active_edge.first >= 0) ? Gesture::MoveEdge : Gesture::Pan; + } else { + const int hit = island_at(uv); + if (hit >= 0) { + if (evt.ShiftDown()) { + // Shift adds to the selection (and makes the clicked one the new primary). + if (!is_selected(hit)) + m_selection.push_back(hit); + m_selected_island = hit; + } else if (evt.ControlDown()) { + // Ctrl toggles: clicking a selected island removes it (the requested "deselect"), + // clicking an unselected one adds it. + if (auto it = std::find(m_selection.begin(), m_selection.end(), hit); it != m_selection.end()) { + m_selection.erase(it); + m_selected_island = m_selection.empty() ? -1 : m_selection.back(); + } else { + m_selection.push_back(hit); + m_selected_island = hit; + } + } else { + // Plain click: keep the whole selection if the clicked island is already part of it (so + // a drag moves the group), otherwise collapse to just this one. + if (!is_selected(hit)) + m_selection.assign(1, hit); + m_selected_island = hit; + } + } else if (!evt.ShiftDown() && !evt.ControlDown()) { + // Clicking empty space with no modifier clears the selection and pans. + m_selection.clear(); + m_selected_island = -1; + } + m_gesture = (m_selected_island >= 0) ? Gesture::MoveIsland : Gesture::Pan; + } + CaptureMouse(); + Refresh(); + } else if (type == wxEVT_RIGHT_DOWN && m_selected_island >= 0 && m_select_mode == SelectMode::Island) { + const Vec2f rel = screen_to_uv(pos) - island_centroid(m_selected_island); + m_gesture = Gesture::RotateIsland; + m_rot_raw_deg = 0.f; + m_rot_applied_deg = 0.f; + m_rot_base_deg = island_rotation_deg(m_selected_island); + m_rot_display_deg = m_rot_base_deg; + m_gesture_last_angle = std::atan2(rel.y(), rel.x()); + CaptureMouse(); + } else if (type == wxEVT_MIDDLE_DOWN) { + m_gesture = Gesture::Pan; + m_drag_last_px = pos; + CaptureMouse(); + } else if (type == wxEVT_LEFT_UP || type == wxEVT_RIGHT_UP || type == wxEVT_MIDDLE_UP) { + if (m_gesture != Gesture::RotateIslandModal && m_gesture != Gesture::ScaleIslandModal) { + end_gesture(); + Refresh(); + } + } else if (type == wxEVT_MOTION) { + switch (m_gesture) { + case Gesture::Pan: { + const wxSize size = GetSize(); + const float scale = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + // Both axes point the same way on screen as in UV space (v runs down), so dragging the + // content along with the cursor is a subtraction on both. + m_pan.x() -= float(pos.x - m_drag_last_px.x) * scale; + m_pan.y() -= float(pos.y - m_drag_last_px.y) * scale; + m_drag_last_px = pos; + Refresh(); + break; + } + case Gesture::MoveIsland: { + const Vec2f uv = screen_to_uv(pos); + if (m_on_island_edit) + m_on_island_edit(m_selected_island, uv_delta_to_unwrap(uv - m_gesture_last_uv), 0.f, 1.f, false); + m_gesture_last_uv = uv; + Refresh(); + break; + } + case Gesture::MoveVertex: { + const Vec2f uv = screen_to_uv(pos); + move_vertex_raw(m_active_vertex, uv - m_gesture_last_uv); + m_gesture_last_uv = uv; + m_vertex_edit_moved = true; + Refresh(); + break; + } + case Gesture::MoveEdge: { + const Vec2f uv = screen_to_uv(pos); + const Vec2f delta = uv - m_gesture_last_uv; + move_vertex_raw(m_active_edge.first, delta); + move_vertex_raw(m_active_edge.second, delta); + m_gesture_last_uv = uv; + m_vertex_edit_moved = true; + Refresh(); + break; + } + case Gesture::RotateIsland: + case Gesture::RotateIslandModal: { + const Vec2f rel = screen_to_uv(pos) - island_centroid(m_selected_island); + const float angle = std::atan2(rel.y(), rel.x()); + // Accumulate the raw mouse rotation incrementally so it survives crossing +/-180 degrees. + m_rot_raw_deg += angle_delta(m_gesture_last_angle, angle) * 180.f / float(M_PI); + m_gesture_last_angle = angle; + + // With Shift, quantise to *global* 15-degree marks (0/15/30...), i.e. snap the island's + // absolute on-screen orientation, not 15 degrees relative to wherever it started (#10) -- + // snapping the target rather than each delta is what keeps it from juddering on a step. + constexpr float STEP = 15.f; + const float absolute = m_rot_base_deg + m_rot_raw_deg; + const float target_abs = evt.ShiftDown() ? std::round(absolute / STEP) * STEP : absolute; + const float deg = target_abs - (m_rot_base_deg + m_rot_applied_deg); + if (m_on_island_edit && deg != 0.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), deg, 1.f, false); + m_rot_applied_deg = target_abs - m_rot_base_deg; + m_rot_display_deg = target_abs; + Refresh(); + break; + } + case Gesture::ScaleIslandModal: { + const Vec2f rel = screen_to_uv(pos) - island_centroid(m_selected_island); + const float dist = std::max(rel.norm(), 1e-6f); + const float factor = dist / m_gesture_last_dist; + if (m_on_island_edit && factor != 1.f) + m_on_island_edit(m_selected_island, Vec2f::Zero(), 0.f, factor, false); + m_modal_scale_accum *= factor; + m_gesture_last_dist = dist; + Refresh(); + break; + } + default: break; + } + } else if (type == wxEVT_MOUSEWHEEL) { + // Zoom about the cursor, not the view centre - otherwise zooming in on something off to the + // side walks it straight out of the frame. + const Vec2f before = screen_to_uv(pos); + const float factor = std::pow(0.9f, float(evt.GetWheelRotation()) / float(evt.GetWheelDelta())); + m_zoom = std::clamp(m_zoom * factor, 0.001f, 5000.f); + const Vec2f after = screen_to_uv(pos); + m_pan += before - after; + Refresh(); + } +} + +void UVEditorCanvas::rebuild_island_models() +{ + m_mesh_dirty = false; + m_island_wireframe.clear(); + m_island_boundary.clear(); + m_island_fill.clear(); + + const int islands = std::max(m_islands.island_count, 0); + if (islands == 0 || m_islands.uvs.empty() || m_islands.indices.empty()) + return; + + m_island_wireframe.resize(size_t(islands)); + m_island_boundary.resize(size_t(islands)); + m_island_fill.resize(size_t(islands)); + + const int vertex_count = int(m_islands.uvs.size()); + + // Charts have disjoint vertex sets (compute_patch_unwrap() duplicates seam vertices per chart), + // so every vertex belongs to exactly one island and this partition is clean. + std::unordered_map is_boundary; + is_boundary.reserve(m_islands.boundary_edges.size() * 2); + for (const auto &[a, b] : m_islands.boundary_edges) + is_boundary[undirected_edge_key(a, b)] = true; + + // Named, not size_t(islands) inline: the latter is a most-vexing-parse and declares a function + // (a single identifier in the parens reads as a parameter name), which is what every + // "subscript requires array or pointer type" error on these vectors was. + const size_t n_islands = size_t(islands); + + // Global vertex index -> index within its own island's buffers. + std::vector local(m_islands.uvs.size(), -1); + std::vector> island_verts(n_islands); + for (size_t i = 0; i < m_islands.uvs.size(); ++i) { + const int island = m_islands.vertex_island[i]; + if (island < 0 || island >= islands) + continue; + local[i] = int(island_verts[size_t(island)].size()); + island_verts[size_t(island)].push_back(m_islands.uvs[i]); + } + + std::vector wire(n_islands), outline(n_islands), fill(n_islands); + for (int c = 0; c < islands; ++c) { + for (GLModel::Geometry *g : { &wire[size_t(c)], &outline[size_t(c)] }) + g->format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + fill[size_t(c)].format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + for (GLModel::Geometry *g : { &wire[size_t(c)], &outline[size_t(c)], &fill[size_t(c)] }) { + g->reserve_vertices(island_verts[size_t(c)].size()); + for (const Vec2f &uv : island_verts[size_t(c)]) + g->add_vertex(Vec3f(uv.x(), uv.y(), 0.f)); + } + } + + for (const Vec3i32 &tri : m_islands.indices) { + if (tri.minCoeff() < 0 || tri.maxCoeff() >= vertex_count) + continue; + const int island = m_islands.vertex_island[size_t(tri[0])]; + if (island < 0 || island >= islands) + continue; + + fill[size_t(island)].add_triangle(unsigned(local[size_t(tri[0])]), unsigned(local[size_t(tri[1])]), + unsigned(local[size_t(tri[2])])); + // Interior edges only: the island outline is drawn separately, brighter and on top, so drawing + // it here as well would just dim it by blending against itself. + for (int i = 0; i < 3; ++i) { + const int a = tri[i], b = tri[(i + 1) % 3]; + if (!is_boundary.count(undirected_edge_key(a, b))) + wire[size_t(island)].add_line(unsigned(local[size_t(a)]), unsigned(local[size_t(b)])); + } + } + + for (const auto &[a, b] : m_islands.boundary_edges) { + if (a < 0 || b < 0 || a >= vertex_count || b >= vertex_count) + continue; + const int island = m_islands.vertex_island[size_t(a)]; + if (island < 0 || island >= islands) + continue; + outline[size_t(island)].add_line(unsigned(local[size_t(a)]), unsigned(local[size_t(b)])); + } + + for (int c = 0; c < islands; ++c) { + if (!wire[size_t(c)].is_empty()) + m_island_wireframe[size_t(c)].init_from(std::move(wire[size_t(c)])); + if (!outline[size_t(c)].is_empty()) + m_island_boundary[size_t(c)].init_from(std::move(outline[size_t(c)])); + if (!fill[size_t(c)].is_empty()) + m_island_fill[size_t(c)].init_from(std::move(fill[size_t(c)])); + } +} + +void UVEditorCanvas::rebuild_grid() +{ + // One line per UV unit (i.e. per texture tile), plus a subdivision when zoomed in far enough that + // whole tiles would be too coarse to read. + const float span = 2.f * m_zoom; + float step = 1.f; + while (step > 0.01f && span / step > 40.f) + step *= 2.f; + while (span / step < 4.f) + step *= 0.5f; + + // Cover exactly the currently-visible view rectangle (plus a one-step margin), recomputed on every + // render. The old version only rebuilt when `step` changed, so panning at a fixed zoom left the grid + // frozen at wherever it was last built - which is the "not in whole area / not always rendered while + // moving" the user saw. The grid is only a few dozen lines, so rebuilding it per frame is cheap. + float half_w, half_h; + view_half_extents(half_w, half_h); + const Vec2f lo = m_pan - Vec2f(half_w, half_h) - Vec2f(step, step); + const Vec2f hi = m_pan + Vec2f(half_w, half_h) + Vec2f(step, step); + m_grid_step = step; + + const int first_x = int(std::floor(lo.x() / step)), last_x = int(std::ceil(hi.x() / step)); + const int first_y = int(std::floor(lo.y() / step)), last_y = int(std::ceil(hi.y() / step)); + if (last_x - first_x > 4000 || last_y - first_y > 4000) + return; // degenerate zoom; not worth drawing a grid nobody can see + + GLModel::Geometry grid; + grid.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + unsigned index = 0; + const auto line = [&](const Vec2f &a, const Vec2f &b) { + grid.add_vertex(Vec3f(a.x(), a.y(), 0.f)); + grid.add_vertex(Vec3f(b.x(), b.y(), 0.f)); + grid.add_line(index, index + 1); + index += 2; + }; + for (int i = first_x; i <= last_x; ++i) + line(Vec2f(float(i) * step, lo.y()), Vec2f(float(i) * step, hi.y())); + for (int i = first_y; i <= last_y; ++i) + line(Vec2f(lo.x(), float(i) * step), Vec2f(hi.x(), float(i) * step)); + + m_grid_glmodel.reset(); + if (!grid.is_empty()) + m_grid_glmodel.init_from(std::move(grid)); +} + +void UVEditorCanvas::rebuild_rotation_dial() +{ + m_dial_glmodel.reset(); + const bool rotating = (m_gesture == Gesture::RotateIsland || m_gesture == Gesture::RotateIslandModal); + if (!rotating || m_selected_island < 0) + return; + + const wxSize size = GetSize(); + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + const float radius = 90.f * uv_per_px; // ~constant on-screen size regardless of zoom + const Vec2f centre = island_centroid(m_selected_island); + + GLModel::Geometry dial; + dial.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + unsigned index = 0; + const auto line = [&](const Vec2f &a, const Vec2f &b) { + dial.add_vertex(Vec3f(a.x(), a.y(), 0.f)); + dial.add_vertex(Vec3f(b.x(), b.y(), 0.f)); + dial.add_line(index, index + 1); + index += 2; + }; + const auto on_ring = [&](float deg, float r) { + const float a = deg * float(M_PI) / 180.f; + return centre + r * Vec2f(std::cos(a), std::sin(a)); + }; + + // The ring itself. + constexpr int SEG = 72; + for (int i = 0; i < SEG; ++i) + line(on_ring(float(i) * 360.f / SEG, radius), on_ring(float(i + 1) * 360.f / SEG, radius)); + // A tick every 15 degrees (the snap marks), longer on the cardinals. + for (int d = 0; d < 360; d += 15) { + const bool cardinal = (d % 90) == 0; + line(on_ring(float(d), radius * (cardinal ? 0.80f : 0.90f)), on_ring(float(d), radius * (cardinal ? 1.08f : 1.0f))); + } + // The needle at the island's current absolute angle. + line(centre, on_ring(m_rot_display_deg, radius * 1.12f)); + + if (!dial.is_empty()) + m_dial_glmodel.init_from(std::move(dial)); +} + +void UVEditorCanvas::rebuild_background_quad() +{ + m_background_glmodel.reset(); + m_background_quad_dirty = false; + if (m_background_width <= 0 || m_background_height <= 0) + return; + + Vec2f lo, hi; + content_bounds(lo, hi); + if (m_tile_enabled) { + // Snap out to whole tiles. Only cosmetic, but it keeps the backdrop's edge on a tile + // boundary instead of slicing a brick in half at an arbitrary place. + lo = Vec2f(std::floor(lo.x()), std::floor(lo.y())); + hi = Vec2f(std::ceil(hi.x()), std::ceil(hi.y())); + } else { + // Tiling off: the sampler reads 0 outside the first tile and nothing else exists, so the + // backdrop is exactly that one tile (GL_CLAMP_TO_BORDER in render() gives it the same + // black surround, rather than smearing the edge texels outward). + lo = Vec2f(0.f, 0.f); + hi = Vec2f(1.f, 1.f); + } + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3T2 }; + init_data.reserve_vertices(4); + init_data.reserve_indices(6); + // Texcoord == position: the height sampling maps one whole texture onto each unit square of uv + // (see DecodedHeightTexture::sample()), so uv *is* the texture coordinate. That also puts the + // texture's first pixel row at v = 0, exactly where sample() reads it. + init_data.add_vertex(Vec3f(lo.x(), lo.y(), 0.f), Vec2f(lo.x(), lo.y())); + init_data.add_vertex(Vec3f(hi.x(), lo.y(), 0.f), Vec2f(hi.x(), lo.y())); + init_data.add_vertex(Vec3f(hi.x(), hi.y(), 0.f), Vec2f(hi.x(), hi.y())); + init_data.add_vertex(Vec3f(lo.x(), hi.y(), 0.f), Vec2f(lo.x(), hi.y())); + init_data.add_triangle(0, 1, 2); + init_data.add_triangle(0, 2, 3); + m_background_glmodel.init_from(std::move(init_data)); +} + +void UVEditorCanvas::rebuild_background_texture() +{ + m_background_texture.reset(); + m_background_dirty = false; + if (m_background_width > 0 && m_background_height > 0) + m_background_texture.load_from_raw_data(m_background_pixels, (unsigned int) m_background_width, + (unsigned int) m_background_height, false); + // The quad's extent doesn't depend on the pixels, but it does have to exist alongside them. + m_background_quad_dirty = true; +} + +void UVEditorCanvas::on_paint(wxPaintEvent & /*evt*/) +{ + wxPaintDC dc(this); + render(); +} + +void UVEditorCanvas::on_size(wxSizeEvent &evt) +{ + evt.Skip(); + // Refresh() alone only *schedules* a repaint, which Windows can coalesce/delay until a live + // resize drag ends, leaving stale wrong-aspect-ratio content on screen throughout the drag. + // Update() flushes it immediately - still through the normal paint path (unlike calling + // render() directly), which matters because this canvas shares the app's one GL context with + // the 3D view and must not hijack it outside its own paint. + Refresh(); + Update(); +} + +void UVEditorCanvas::render() +{ + if (m_context == nullptr || !IsShownOnScreen()) + return; + + // wxGLCanvas::SetCurrent() genuinely fails (returns false) on a canvas that isn't shown on + // screen, and can also fail on a pixel-format mismatch with the context - and every GL call + // made afterwards would then run against whichever context *is* current, which here is the main + // 3D canvas mid-frame. Bail instead of corrupting it. + if (!SetCurrent(*m_context)) + return; + + if (m_mesh_dirty) + rebuild_island_models(); + if (m_background_dirty) + rebuild_background_texture(); + if (m_background_quad_dirty) + rebuild_background_quad(); + if (m_needs_fit) + fit_view_to_content(); + rebuild_grid(); + rebuild_rotation_dial(); + + const wxSize size = GetSize(); + glsafe(::glViewport(0, 0, size.GetWidth(), size.GetHeight())); + glsafe(::glClearColor(UV_COLOR_BG.r(), UV_COLOR_BG.g(), UV_COLOR_BG.b(), 1.f)); + glsafe(::glClear(GL_COLOR_BUFFER_BIT)); + glsafe(::glDisable(GL_DEPTH_TEST)); + glsafe(::glEnable(GL_BLEND)); + glsafe(::glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)); + + // Orthographic, centered on m_pan. Y is negated so v runs down the screen, which is what puts + // the texture's first pixel row (v = 0, see rebuild_background_quad()) at the top rather than + // upside down. + float half_w, half_h; + view_half_extents(half_w, half_h); + + Transform3d projection_matrix = Transform3d::Identity(); + projection_matrix(0, 0) = 1.0 / std::max(double(half_w), 1e-6); + projection_matrix(1, 1) = -1.0 / std::max(double(half_h), 1e-6); + Transform3d view_matrix = Transform3d::Identity(); + view_matrix.translate(Vec3d(-double(m_pan.x()), -double(m_pan.y()), 0.0)); + + const auto draw = [&](GLModel &model, const ColorRGBA &color, const Transform3d &model_matrix) { + if (!model.is_initialized()) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + shader->start_using(); + shader->set_uniform("view_model_matrix", view_matrix * model_matrix); + shader->set_uniform("projection_matrix", projection_matrix); + model.set_color(color); + model.render(); + shader->stop_using(); + }; + const Transform3d identity = Transform3d::Identity(); + + if (m_background_glmodel.is_initialized()) { + if (GLShaderProgram *shader = wxGetApp().get_shader("flat_texture")) { + shader->start_using(); + shader->set_uniform("view_model_matrix", view_matrix); + shader->set_uniform("projection_matrix", projection_matrix); + glsafe(::glActiveTexture(GL_TEXTURE0)); + glsafe(::glBindTexture(GL_TEXTURE_2D, m_background_texture.get_id())); + // Repeat exactly the way DecodedHeightTexture::sample() does, so the backdrop under an + // island really is the texels that island will sample. With tiling off, sample() returns + // 0 outside the first tile - a black border, not a smeared edge, which is what + // CLAMP_TO_BORDER reproduces (GLTexture's own default of GL_REPEAT would not). + const GLint wrap = m_tile_enabled ? (m_tile_mirrored ? GL_MIRRORED_REPEAT : GL_REPEAT) : GL_CLAMP_TO_BORDER; + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap)); + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap)); + if (!m_tile_enabled) { + constexpr GLfloat border[4] = { 0.f, 0.f, 0.f, 1.f }; + glsafe(::glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border)); + } + m_background_glmodel.render(); + glsafe(::glBindTexture(GL_TEXTURE_2D, 0)); + shader->stop_using(); + } + } + + set_line_width(1.f); + draw(m_grid_glmodel, UV_COLOR_GRID, identity); + + // The texture's first tile. Always drawn, even with nothing painted, so the pane always has a + // fixed landmark: the unwrap is packed in mm and divided by the tile size, so it is routinely + // many tiles away from here, and without this there is no way to tell "the islands are somewhere + // else" apart from "there are no islands". + if (!m_tile_outline_glmodel.is_initialized()) { + GLModel::Geometry tile; + tile.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + tile.reserve_vertices(4); + tile.reserve_indices(8); + tile.add_vertex(Vec3f(0.f, 0.f, 0.f)); + tile.add_vertex(Vec3f(1.f, 0.f, 0.f)); + tile.add_vertex(Vec3f(1.f, 1.f, 0.f)); + tile.add_vertex(Vec3f(0.f, 1.f, 0.f)); + for (unsigned i = 0; i < 4; ++i) + tile.add_line(i, (i + 1) % 4); + m_tile_outline_glmodel.init_from(std::move(tile)); + } + set_line_width(2.f); + draw(m_tile_outline_glmodel, UV_COLOR_TILE_OUTLINE, identity); + + const auto island_matrix = [this, &identity](int c) { + return (size_t(c) < m_transforms.size()) ? to_transform3d(m_transforms[size_t(c)]) : identity; + }; + + // Island fills first, as a translucent wash under the wires: every island gets a faint one so it + // reads as a solid patch rather than a wire cage, and the selected one gets the accent teal (#7). + // When a distortion heatmap has been supplied (set_island_fill_colors), unselected islands take + // their heatmap colour instead of the default wash; the selected one still wins its highlight. + for (int c = 0; c < int(m_island_fill.size()); ++c) { + ColorRGBA fill = UV_COLOR_FILL; + if (size_t(c) < m_island_fill_colors.size()) + fill = m_island_fill_colors[size_t(c)]; + draw(m_island_fill[size_t(c)], is_selected(c) ? UV_COLOR_SEL_FILL : fill, island_matrix(c)); + } + + set_line_width(1.f); + for (int c = 0; c < int(m_island_wireframe.size()); ++c) + draw(m_island_wireframe[size_t(c)], is_selected(c) ? UV_COLOR_SEL_WIRE : UV_COLOR_WIRE, island_matrix(c)); + + // The island outlines - i.e. exactly the edges the seam angle cut the patch along. Drawn last + // and brightest, because "where does one island end and the next begin" is the single thing this + // pane exists to answer. The selected one gets a bold edge (#7). + for (int c = 0; c < int(m_island_boundary.size()); ++c) { + const bool selected = is_selected(c); + set_line_width(selected ? 3.f : 1.5f); + draw(m_island_boundary[size_t(c)], selected ? UV_COLOR_SEL_BOUNDARY : UV_COLOR_BOUNDARY, island_matrix(c)); + } + set_line_width(1.f); + + // The rotation protractor, on top of everything while a rotation gesture is live (#11). + if (m_dial_glmodel.is_initialized()) { + set_line_width(2.f); + draw(m_dial_glmodel, UV_COLOR_DIAL, identity); + set_line_width(1.f); + } + + // Vertex/Edge mode handles: a small square drawn over the picked vertex (or each endpoint of the + // picked edge), so it is obvious which sub-element a drag will move. The already-drawn boundary + // sits between an edge's two markers, so the pair reads as "this edge". + if (m_select_mode != SelectMode::Island && (m_active_vertex >= 0 || m_active_edge.first >= 0)) { + if (!m_vertex_marker_glmodel.is_initialized()) { + GLModel::Geometry q; + q.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + q.reserve_vertices(4); + q.reserve_indices(6); + q.add_vertex(Vec3f(-0.5f, -0.5f, 0.f)); + q.add_vertex(Vec3f(0.5f, -0.5f, 0.f)); + q.add_vertex(Vec3f(0.5f, 0.5f, 0.f)); + q.add_vertex(Vec3f(-0.5f, 0.5f, 0.f)); + q.add_triangle(0, 1, 2); + q.add_triangle(0, 2, 3); + m_vertex_marker_glmodel.init_from(std::move(q)); + } + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + const float marker = uv_per_px * 10.f; // ~10 px square, constant on screen at any zoom + const auto draw_marker = [&](int v) { + if (v < 0 || size_t(v) >= m_islands.uvs.size()) + return; + const Vec2f p = island_uv(size_t(v)); + Transform3d m = Transform3d::Identity(); + m.translate(Vec3d(double(p.x()), double(p.y()), 0.0)); + m.scale(double(marker)); + draw(m_vertex_marker_glmodel, UV_COLOR_SEL_BOUNDARY, m); + }; + if (m_active_vertex >= 0) + draw_marker(m_active_vertex); + if (m_active_edge.first >= 0) { + draw_marker(m_active_edge.first); + draw_marker(m_active_edge.second); + } + } + + // The GL context is shared with the 3D view; leave the bits we touched as we found them. + glsafe(::glDisable(GL_BLEND)); + glsafe(::glEnable(GL_DEPTH_TEST)); + + SwapBuffers(); + + // Cheap, and render() runs on every gesture change (each Refresh), so the status line stays + // current without threading update_status() through every mouse/key transition. The panel + // guards against relayout when the text is unchanged. + update_status(); +} + +// ---------------------------------------------------------------------------------------------- +// UVEditorPanel +// ---------------------------------------------------------------------------------------------- + +namespace { +enum : int { + ID_UV_FRAME = wxID_HIGHEST + 4200, + ID_UV_SNAP, + ID_UV_AVG_SCALE, + ID_UV_CUT, + ID_UV_PROJECT, + ID_UV_JOIN, + ID_UV_UNJOIN, +}; +} // namespace + +UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) +{ + // Icon + label buttons: the tool icon (toolbar_texture_displacement.svg) is reused on all four as a + // placeholder until dedicated per-tool art exists, and the label is kept alongside it so the + // buttons stay tellable apart while they share one picture. + const wxBitmap icon = create_scaled_bitmap("toolbar_texture_displacement", this, 16); + auto *bar = new wxBoxSizer(wxHORIZONTAL); + const auto add_button = [&](int id, const wxString &label, const wxString &tip) { + auto *b = new wxButton(this, id, label, wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); + b->SetBitmap(icon); + b->SetToolTip(tip); + bar->Add(b, 0, wxALL, 2); + b->Bind(wxEVT_BUTTON, &UVEditorPanel::on_tool, this); + return b; + }; + add_button(ID_UV_FRAME, _L("Frame"), _L("Frame all islands (Home)")); + m_snap_button = new wxToggleButton(this, ID_UV_SNAP, _L("Snap"), wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); + m_snap_button->SetBitmap(icon); + m_snap_button->SetToolTip(_L("Snap islands together when dragging")); + bar->Add(m_snap_button, 0, wxALL, 2); + m_snap_button->Bind(wxEVT_TOGGLEBUTTON, &UVEditorPanel::on_tool, this); + add_button(ID_UV_AVG_SCALE, _L("Avg scale"), _L("Give every island the same texel density")); + add_button(ID_UV_CUT, _L("Cut"), _L("Split the selected island across its long axis")); + add_button(ID_UV_JOIN, _L("Join"), _L("Unfold the selected island onto its nearest neighbour along their shared edge")); + add_button(ID_UV_UNJOIN, _L("Unjoin"), _L("Send the selected island back to its own packed position")); + + m_canvas = new UVEditorCanvas(this); + m_canvas->set_status_callback([this](const wxString &text) { + if (m_status != nullptr && m_status->GetLabel() != text) { + m_status->SetLabel(text); + m_status->Refresh(); + } + }); + + m_status = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_status->SetForegroundColour(wxColour(180, 180, 180)); + + auto *sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(bar, 0, wxEXPAND); + sizer->Add(m_canvas, 1, wxEXPAND); + sizer->Add(m_status, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 3); + SetSizer(sizer); +} + +void UVEditorPanel::on_tool(wxCommandEvent &evt) +{ + switch (evt.GetId()) { + case ID_UV_FRAME: m_canvas->run_command(UVEditorCanvas::Command::FrameAll); break; + case ID_UV_SNAP: m_canvas->run_command(UVEditorCanvas::Command::ToggleSnap); break; + case ID_UV_AVG_SCALE: m_canvas->run_command(UVEditorCanvas::Command::AverageScale); break; + case ID_UV_CUT: m_canvas->run_command(UVEditorCanvas::Command::CutSelectedIsland); break; + case ID_UV_JOIN: m_canvas->run_command(UVEditorCanvas::Command::JoinSelected); break; + case ID_UV_UNJOIN: m_canvas->run_command(UVEditorCanvas::Command::UnjoinSelected); break; + default: evt.Skip(); return; + } + // Keep the toggle button's visual state in step with the canvas, which owns the flag. + if (m_snap_button != nullptr) + m_snap_button->SetValue(m_canvas->snap_enabled()); +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp new file mode 100644 index 0000000000..87d85d6e2c --- /dev/null +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -0,0 +1,316 @@ +#ifndef slic3r_UVEditorCanvas_hpp_ +#define slic3r_UVEditorCanvas_hpp_ + +#include +#include +#include +#include + +// Must come before wx/glcanvas.h in any translation unit that includes this header: glcanvas.h +// pulls in the platform's real GL/gl.h, and glad/gl.h errors out if that happens first (it wants +// to be the one to define the standard include guards GL/gl.h itself defines). +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Point.hpp" +#include "GLModel.hpp" +#include "GLTexture.hpp" + +namespace Slic3r::GUI { + +// Standalone 2D viewer/editor for a flattened (UV-unwrapped) mesh patch -- shows the result of +// GLGizmoTextureDisplacement's LSCM projection method as its own resizable pane (see Plater's +// "uv_editor" AUI pane) rather than folding 2D UV-space rendering into the main 3D viewport. +// +// Islands can be laid out by hand, roughly the way Blender's UV editor works: click one to select +// it, drag to move it, right-drag or press R to rotate it, S to scale it, both about its own centre. +// Islands are free to overlap -- nothing re-packs them behind the user's back. The texture underneath +// is always drawn upright and axis-aligned, and it is the islands that move over it, which is what +// makes "rotate this island" a meaningful gesture rather than just spinning the whole texture. +// +// **Geometry is uploaded in the unwrap's own (raw, mm) coordinates, once**, and each island is drawn +// through its own affine matrix passed as a shader uniform. That matters: a patch can easily run to +// a million triangles, and the earlier design -- which pre-transformed every UV on the CPU and +// re-uploaded the whole wireframe on every mouse-move event -- made a drag cost a couple of hundred +// milliseconds per frame. Moving an island now touches a 2x3 matrix and nothing else. +// +// Uses the app's single shared wxGLContext (via wxGetApp().init_glcontext(), the same call +// View3D/Preview/AssembleView each make in GUI_Preview.cpp) rather than an independent context of +// its own, specifically so it can reuse the app's already-registered "flat"/"flat_texture" +// shaders and GLModel as-is -- GLModel::render() looks up its shader via a GUI_App-wide "current +// shader", which only means anything for canvases sharing the app's one real GL context. +class UVEditorCanvas : public wxGLCanvas +{ +public: + explicit UVEditorCanvas(wxWindow *parent); + + // Maps one island's raw unwrap coordinate to a texture UV: the island's own hand placement and + // then the layer's tiling/rotation/offset, composed into a single affine (columns: x basis, + // y basis, translation). + using IslandTransform = Eigen::Matrix; + + // The unwrap to display, in the unwrap's own mm coordinates -- *not* texture UVs. Changing this + // is the expensive path (it rebuilds every vertex buffer), so it must only be called when the + // unwrap itself changes, never merely because an island moved. Pass an empty `indices` to show + // nothing. + struct Islands + { + std::vector uvs; + std::vector indices; + std::vector vertex_island; // per uv + std::vector> boundary_edges; // island outlines, indices into uvs + int island_count = 0; + }; + void set_islands(Islands islands); + + // The cheap path: one transform per island. Safe to call on every mouse-move of a drag. + void set_island_transforms(std::vector transforms); + + // One fill colour per island, overriding the default light-green wash -- used to paint the UV + // distortion heatmap over the islands when the gizmo's "Distortion" check mode is on (#7/#14). + // Pass empty to go back to the default wash. Cheap: it never touches a vertex buffer. + void set_island_fill_colors(std::vector colors); + + // The layer's own tiling scale and rotation. Needed to map a gesture, which happens in texture-UV + // space, back into the unwrap's mm space -- which is where a TextureIsland's offset actually + // lives (see apply_uv_transform()). The tile settings come along because the background has to + // repeat exactly the way the height sampler does, or the pane would stop showing what gets baked. + void set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored); + + // Same 8-bit grayscale pixels build_texture_displacement()'s height sampling uses, shown + // beneath the wireframe (expanded to RGBA on upload) so the unwrap can be checked against the + // texture it will actually sample. Pass width/height <= 0 to clear it. + void set_background_texture(const std::vector &grayscale_pixels, int width, int height); + + // Snap a dragged island's boundary to a neighbouring island's when they come close (#2). Off is + // the honest default for overlap-friendly layouts; the pane toolbar toggles it. + void set_snap_enabled(bool enabled) { m_snap_enabled = enabled; } + bool snap_enabled() const { return m_snap_enabled; } + + // High-level actions the pane toolbar triggers. The canvas handles the view-only ones (framing, + // the snap toggle) itself and forwards the rest to whoever owns the island data (the gizmo), via + // the command callback -- the canvas has the selection and the view, the gizmo has the layer. + enum class Command { FrameAll, ToggleSnap, AverageScale, CutSelectedIsland, ProjectFromView, JoinSelected, UnjoinSelected }; + void run_command(Command cmd); + using CommandFn = std::function; + void set_command_callback(CommandFn fn) { m_on_command = std::move(fn); } + + // Called whenever the one-line status/hint text changes (current gesture + the shortcuts that + // apply right now), so the pane can show it Blender-style along the bottom. + using StatusFn = std::function; + void set_status_callback(StatusFn fn) { m_on_status = std::move(fn); } + + // Reports an island edit as it happens. The deltas are *incremental* (one mouse event's worth) + // and already converted into the units a TextureIsland stores -- unwrap mm, degrees, and a scale + // *factor* to multiply the island's existing scale by. They are incremental on purpose: the owner + // applies them and hands back fresh transforms, and if the gesture tracked geometry rather than + // raw mouse motion that round trip would feed back into itself. `finished` marks the end of a + // gesture, so the owner can rebuild the 3D preview once rather than on every motion event. + using IslandEditFn = + std::function; + void set_island_edit_callback(IslandEditFn fn) { m_on_island_edit = std::move(fn); } + + // What a click grabs: a whole island (move/rotate/scale, groups move together), a single vertex, or + // a single edge (both its endpoints). Vertex/Edge are free-form UV editing -- they move the actual + // unwrap coordinates, which the owner then folds into the layer's per-vertex UV overrides so the + // change is baked, not just shown (see set_vertex_edit_callback). + enum class SelectMode { Island, Vertex, Edge }; + void set_select_mode(SelectMode mode); + SelectMode select_mode() const { return m_select_mode; } + + // Reports a committed vertex/edge edit: the list of (unwrapped-vertex index, its new raw-unwrap + // coordinate in mm). Fired once, on mouse release, since it re-solves the displacement preview; the + // pane shows the edit live from its own geometry in the meantime. The owner maps the unwrapped index + // to a mesh vertex (via the unwrap's source_vertex) and stores the override. + using UVVertexEditFn = std::function> &edits)>; + void set_vertex_edit_callback(UVVertexEditFn fn) { m_on_vertex_edit = std::move(fn); } + + // The primary (last-clicked) island, still the pivot for rotate/scale and the target of the + // single-island toolbar commands (Cut/Join/Unjoin). -1 if nothing is selected. + int selected_island() const { return m_selected_island; } + // The full multi-selection (Shift adds, Ctrl toggles). Always contains m_selected_island when it is + // >= 0. The gizmo reads this to decide which islands a drag moves together, unioned with each + // selected island's join group. + const std::vector &selected_islands() const { return m_selection; } + void reset_view(); + +private: + void on_paint(wxPaintEvent &evt); + void on_size(wxSizeEvent &evt); + void on_mouse(wxMouseEvent &evt); + void on_key(wxKeyEvent &evt); + void on_erase_background(wxEraseEvent &evt) {} // required to avoid flicker on MSW, deliberately a no-op + + void render(); + void rebuild_island_models(); + void rebuild_background_texture(); + void rebuild_background_quad(); + void rebuild_grid(); + // The UV region worth looking at: every island, plus always at least the texture's first tile, so + // there is something sensibly framed even before anything is painted. + void content_bounds(Vec2f &min_uv, Vec2f &max_uv) const; + // Frames content_bounds(). Bound to Home, and run once each time an unwrap first appears. + void fit_view_to_content(); + + // Half-extents of the visible UV region. Split out because both rendering and every mouse + // gesture need them, and they have to agree exactly or picking lands in the wrong place. + void view_half_extents(float &half_w, float &half_h) const; + Vec2f screen_to_uv(const wxPoint &px) const; + // Raw unwrap coordinate -> texture UV, through the island's own transform. + Vec2f island_uv(size_t vertex) const; + // The island under `uv`, or -1. Prefers the current selection when islands overlap, so that + // dragging one that sits under another doesn't hand the drag to its neighbour halfway through. + int island_at(const Vec2f &uv) const; + // Nearest unwrapped vertex to `uv` within a screen-space threshold, or -1 (Vertex mode picking). + int vertex_at(const Vec2f &uv) const; + // Nearest island-boundary edge to `uv` within a screen-space threshold, as its two unwrapped-vertex + // indices, or {-1,-1} (Edge mode picking). + std::pair edge_at(const Vec2f &uv) const; + // Moves one unwrapped vertex by a texture-UV delta, converting it back into the vertex's own raw + // unwrap space through its island's inverse transform, and marks the mesh dirty so it redraws. + void move_vertex_raw(int unwrapped_vertex, const Vec2f &delta_uv); + Vec2f island_centroid(int island) const; + // Converts a delta in texture-UV space into the unwrap's mm space, undoing the layer's scale and + // rotation -- the inverse of what apply_uv_transform() did on the way in. + Vec2f uv_delta_to_unwrap(const Vec2f &delta_uv) const; + // The correction that would bring the selected island's nearest boundary vertex onto a boundary + // vertex of some *other* island, in texture-UV space. Zero if nothing is within reach (#2). + Vec2f snap_correction(int island) const; + void end_gesture(); + // Rebuilds the status line from the current gesture/selection and pushes it to m_on_status. + void update_status(); + + wxGLContext *m_context = nullptr; // owned by OpenGLManager/GUI_App, not by this canvas + + Islands m_islands; + std::vector m_transforms; + // Per-island fill colour override (distortion heatmap); empty means use the default wash (#7). + std::vector m_island_fill_colors; + // Boundary vertices per island, for snapping -- a patch's boundary is a tiny fraction of it, and + // rescanning the whole uv array on every snap test would not be. + std::vector> m_island_boundary_verts; + + bool m_mesh_dirty = true; + // One set of models per island, so an island can be drawn through its own transform. Built once + // per unwrap, never on a drag. + std::vector m_island_wireframe; // interior edges + std::vector m_island_boundary; // outline + std::vector m_island_fill; // filled, for the selected island's wash + + GLModel m_tile_outline_glmodel; // the texture's first tile, [0,1]^2 -- the "you are here" + GLModel m_grid_glmodel; + float m_grid_step = 0.f; // the UV step m_grid_glmodel was built for; 0 = not built + + float m_tiling_scale = 1.f; + float m_rotation_deg = 0.f; + bool m_tile_enabled = true; + bool m_tile_mirrored = false; + bool m_snap_enabled = false; + + std::vector m_background_pixels; // RGBA, expanded from the grayscale input + int m_background_width = 0, m_background_height = 0; + bool m_background_dirty = false; // the pixels need (re)uploading + bool m_background_quad_dirty = true; // only the quad's extent changed + GLTexture m_background_texture; + // Covers content_bounds(), not just [0,1]: the unwrap is packed in mm and then divided by the + // layer's tile size, so it routinely spans many tiles, and a single-unit-square backdrop would + // leave most of the islands sitting over bare background. Texcoord == position, so the GL wrap + // mode repeats it exactly the way DecodedHeightTexture::sample() does. + GLModel m_background_glmodel; + + // 2D pan/zoom. m_pan is the UV-space point at the center of the view, m_zoom half the UV-space + // extent visible across the shorter screen edge. v runs *down* the screen, matching both the + // texture's own row order and every other UV editor's convention. + Vec2f m_pan = Vec2f(0.5f, 0.5f); + float m_zoom = 0.75f; + bool m_needs_fit = true; // fit the view to the next unwrap that arrives + + enum class Gesture + { + None, + Pan, + MoveIsland, + RotateIsland, // right-drag: rotation tracks the mouse, ends when the button is released + RotateIslandModal, // 'R': rotation tracks the mouse until a click confirms or Esc cancels + ScaleIslandModal, // 'S': likewise, distance from the centre drives the scale + MoveVertex, // Vertex mode: drag one unwrapped vertex + MoveEdge, // Edge mode: drag both endpoints of one boundary edge + }; + Gesture m_gesture = Gesture::None; + + SelectMode m_select_mode = SelectMode::Island; + // The sub-element being edited in Vertex/Edge mode (unwrapped-vertex indices), or -1/{-1,-1}. + int m_active_vertex = -1; + std::pair m_active_edge{ -1, -1 }; + // Set once a Vertex/Edge drag actually moves, so a bare click (select without drag) doesn't commit a + // no-op edit and take an undo snapshot for nothing. + bool m_vertex_edit_moved = false; + // Lazily-built small filled square, drawn at an edited/hovered vertex as a handle. + GLModel m_vertex_marker_glmodel; + int m_selected_island = -1; + // The full multi-selection; m_selected_island is its primary (last-clicked) member. Kept as a small + // vector rather than a set because it is tiny and iteration order (primary last) is convenient. + std::vector m_selection; + bool is_selected(int island) const + { + return std::find(m_selection.begin(), m_selection.end(), island) != m_selection.end(); + } + wxPoint m_drag_last_px; + Vec2f m_gesture_last_uv = Vec2f::Zero(); + float m_gesture_last_angle = 0.f; + float m_gesture_last_dist = 0.f; + // Rotation is tracked as two running totals over the gesture: the raw mouse rotation, and how much + // has actually been applied. With Shift held the applied total is quantised to 15-degree steps + // (Blender-style angle snapping), so the two diverge -- and driving the applied total off the raw + // one, rather than snapping each incremental delta, is what makes the snap stable instead of + // juddering. The raw/applied split also survives crossing +/-180 degrees, which a single wrapped + // angle would not. m_rot_applied doubles as the modal-rotate undo amount for Esc. + float m_rot_raw_deg = 0.f; + float m_rot_applied_deg = 0.f; + // The island's absolute on-screen rotation when the gesture began (decoded from its transform), so + // Shift can snap to *global* 15-degree marks (0/15/30...) rather than 15 degrees relative to + // wherever the island happened to start (#10). Also drives the angle read-out and the dial. + float m_rot_base_deg = 0.f; + float m_rot_display_deg = 0.f; // current absolute angle, for the status line and dial needle + float m_modal_scale_accum = 1.f; // so Esc can undo exactly what the modal scale applied, as a factor + + // A protractor drawn around the island while it rotates: a ring, a tick every 15 degrees, and a + // needle at the current angle, so the rotation is legible (#11). Rebuilt each frame during a + // rotation gesture (cheap: a few hundred short lines) and left empty otherwise. + GLModel m_dial_glmodel; + void rebuild_rotation_dial(); + // The island's current absolute rotation in degrees, decoded from its transform's first column. + float island_rotation_deg(int island) const; + + IslandEditFn m_on_island_edit; + UVVertexEditFn m_on_vertex_edit; + CommandFn m_on_command; + StatusFn m_on_status; +}; + +// Hosts a UVEditorCanvas together with a small icon toolbar (frame, snap, average scale, cut, +// project-from-view) and a Blender-style status line along the bottom that names the current gesture +// and the shortcuts in play (#18). This is what actually goes into Plater's "uv_editor" AUI pane; +// the gizmo still talks to the inner canvas, reached via canvas(). +class UVEditorPanel : public wxPanel +{ +public: + explicit UVEditorPanel(wxWindow *parent); + UVEditorCanvas *canvas() { return m_canvas; } + +private: + void on_tool(wxCommandEvent &evt); + + UVEditorCanvas *m_canvas = nullptr; + wxToggleButton *m_snap_button = nullptr; + wxStaticText *m_status = nullptr; +}; + +} // namespace Slic3r::GUI + +#endif // slic3r_UVEditorCanvas_hpp_