#include "UVEditorCanvas.hpp" #include #include #include #include #include #include "libslic3r/Point.hpp" #include #include #include #include #include "slic3r/GUI/GLModel.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "3DScene.hpp" #include "BitmapCache.hpp" #include "GLShader.hpp" #include "GUI_App.hpp" #include "I18N.hpp" #include "OpenGLManager.hpp" #include "Plater.hpp" #include "wxExtensions.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/SpinInput.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 }; // The texture outside every island is washed towards the background, so the islands read as the lit areas // whatever the texture looks like - a white brick texture no longer swallows a light outline. const ColorRGBA UV_COLOR_OUTSIDE = { 0.16f, 0.16f, 0.16f, 0.72f }; const ColorRGBA UV_COLOR_GRID = { 1.f, 1.f, 1.f, 0.08f }; // Every stroke that must stay legible is drawn twice: a wider near-black halo, then the colour on top, so it // holds up on light and dark texels alike. const ColorRGBA UV_COLOR_HALO = { 0.06f, 0.06f, 0.07f, 1.f }; const ColorRGBA UV_COLOR_TILE_OUTLINE = { 0.70f, 0.70f, 0.72f, 1.f }; const ColorRGBA UV_COLOR_WIRE = { 1.f, 1.f, 1.f, 0.30f }; // interior edges, unselected const ColorRGBA UV_COLOR_WIRE_SHADOW = { 0.f, 0.f, 0.f, 0.35f }; // 1 px offset under the wire const ColorRGBA UV_COLOR_BOUNDARY = { 0.93f, 0.93f, 0.94f, 1.f }; const ColorRGBA UV_COLOR_HOVER = { 0.72f, 0.98f, 0.93f, 1.f }; // what a click would grab const ColorRGBA UV_COLOR_HOVER_FILL = { 1.f, 1.f, 1.f, 0.12f }; // No wash on unselected islands: the texture inside them is exactly what gets baked, so it shows unaltered. const ColorRGBA UV_COLOR_FILL = { 1.f, 1.f, 1.f, 0.f }; const ColorRGBA UV_COLOR_SEL_FILL = { 0.15f, 0.85f, 0.75f, 0.24f }; const ColorRGBA UV_COLOR_SEL_WIRE = { 0.55f, 1.f, 0.92f, 0.55f }; const ColorRGBA UV_COLOR_SEL_BOUNDARY = { 0.16f, 0.90f, 0.78f, 1.f }; // the app teal, brightened to read on texture 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) // wxWidgets reports this canvas' size in *logical* points, while the GL drawable behind it is sized // in device pixels. On the backends where those differ under HiDPI (the same pair GLCanvas3D guards // its RetinaHelper with) a viewport built straight from GetSize() covers only the bottom-left // 1/scale of the drawable, which is exactly where the whole editor ended up drawn, shrunken. // Mouse coordinates arrive in logical points, so only the viewport needs converting - every other // GetSize() use here is compared against event coordinates and must stay logical. wxSize gl_drawable_size(const wxWindow *win, const wxSize &logical_size) { #if defined(__APPLE__) || defined(__WXGTK3__) const double scale = (win != nullptr) ? win->GetContentScaleFactor() : 1.0; if (scale > 0.0) return wxSize(std::max(1, int(std::lround(logical_size.GetWidth() * scale))), std::max(1, int(std::lround(logical_size.GetHeight() * scale)))); #else (void) win; #endif return wxSize(std::max(1, logical_size.GetWidth()), std::max(1, logical_size.GetHeight())); } // 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_LEAVE_WINDOW, &UVEditorCanvas::on_leave, 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 }; m_sel_vertices.clear(); m_sel_edges.clear(); m_hover_island = m_hover_vertex = -1; m_hover_edge = { -1, -1 }; } // Boundary vertices and edges, bucketed per island: the vertices for snapping, the edges for the outline. const size_t n_islands = size_t(std::max(m_islands.island_count, 0)); m_island_boundary_verts.assign(n_islands, {}); m_island_boundary_edges.assign(n_islands, {}); std::vector seen(m_islands.uvs.size(), false); 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 int island = m_islands.vertex_island[size_t(a)]; if (island < 0 || size_t(island) >= n_islands) continue; m_island_boundary_edges[size_t(island)].emplace_back(a, b); for (const int v : { a, b }) if (!seen[size_t(v)]) { seen[size_t(v)] = true; m_island_boundary_verts[size_t(island)].push_back(v); } } // Triangles and raw bounds per island, for picking. m_island_tris.assign(n_islands, {}); m_island_raw_bounds.assign(n_islands, { Vec2f::Constant(std::numeric_limits::max()), Vec2f::Constant(std::numeric_limits::lowest()) }); for (size_t t = 0; t < m_islands.indices.size(); ++t) { const Vec3i32 &tri = m_islands.indices[t]; if (tri.minCoeff() < 0 || size_t(tri.maxCoeff()) >= m_islands.uvs.size()) continue; const int island = m_islands.vertex_island[size_t(tri[0])]; if (island < 0 || size_t(island) >= n_islands) continue; m_island_tris[size_t(island)].push_back(int(t)); auto &[lo, hi] = m_island_raw_bounds[size_t(island)]; for (int k = 0; k < 3; ++k) { lo = lo.cwiseMin(m_islands.uvs[size_t(tri[k])]); hi = hi.cwiseMax(m_islands.uvs[size_t(tri[k])]); } } 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, float value) { switch (cmd) { case Command::FrameAll: reset_view(); return; case Command::ToggleSnap: m_snap_enabled = !m_snap_enabled; update_status(); return; case Command::SetSelectMode: // Switched here straight away so the strip and the canvas agree at once; the gizmo is still told, // because it is what keeps the mode when the canvas is next refreshed from the layer. set_select_mode(static_cast(std::clamp(int(value), 0, 2))); break; default: break; } // Everything else needs the layer data the canvas doesn't hold; hand it to the gizmo. if (m_on_command) m_on_command(cmd, value); } void UVEditorCanvas::set_pane_state(PaneState state) { m_pane_state = std::move(state); update_status(); if (m_on_pane_state) m_on_pane_state(m_pane_state); } 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 (!has_islands()) msg = m_pane_state.has_layer ? _L("Paint the area on the model, then press Unwrap") : _L("Pick Unwrap as a texture layer's mapping to edit its UVs here"); else if (m_select_mode == SelectMode::Vertex) msg = m_sel_vertices.size() > 1 ? wxString::Format(_L("%d vertices selected | drag = move together, Shift/Ctrl click = add/remove"), int(m_sel_vertices.size())) : _L("Vertex mode: drag a vertex to reshape | Shift/Ctrl click = multi-select, wheel = zoom, Home = frame"); else if (m_select_mode == SelectMode::Edge) msg = m_sel_edges.size() > 1 ? wxString::Format(_L("%d edges selected | drag = move together, Shift/Ctrl click = add/remove"), int(m_sel_edges.size())) : _L("Edge mode: drag an island edge to reshape | Shift/Ctrl click = multi-select, wheel = zoom, 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::framed_bounds(Vec2f &min_uv, Vec2f &max_uv) const { content_bounds(min_uv, max_uv); // With tiling on, the backdrop is snapped out to whole tiles, so it is bigger than the raw // bounds - and by a different amount on each side. Framing the raw bounds therefore left that // backdrop visibly off-centre: hanging past one edge of the pane with dead space against the // other. Frame what is drawn instead. (Tiling off draws only the first tile, which the bounds // already contain, so there is nothing to snap.) if (m_tile_enabled) { min_uv = Vec2f(std::floor(min_uv.x()), std::floor(min_uv.y())); max_uv = Vec2f(std::ceil(max_uv.x()), std::ceil(max_uv.y())); } } void UVEditorCanvas::fit_view_to_content() { Vec2f min_uv, max_uv; framed_bounds(min_uv, max_uv); const wxSize size = GetSize(); const float aspect = float(std::max(1, size.GetWidth())) / float(std::max(1, size.GetHeight())); const Vec2f half = 0.5f * (max_uv - min_uv); m_pan = 0.5f * (min_uv + max_uv); // m_zoom is the half-extent shown across the *shorter* pane edge (see view_half_extents()), so // each axis' required half-extent has to be converted back into that unit before the larger of // the two is taken. Sizing off the bigger axis alone, as this did, ignores the pane's shape and // zooms out further than either axis needs on anything but a square pane. The 1.1 leaves a // margin so the outermost island edge is not flush against the frame. m_zoom = std::max(1.1f * std::max(half.x() / std::max(aspect, 1.f), half.y() * std::min(aspect, 1.f)), 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 (int island = 0; island < int(m_island_tris.size()); ++island) { // The raw bounds through the island's affine: the box of the four transformed corners contains the island. const auto &[lo, hi] = m_island_raw_bounds[size_t(island)]; if (lo.x() > hi.x()) continue; const IslandTransform m = size_t(island) < m_transforms.size() ? m_transforms[size_t(island)] : IslandTransform(IslandTransform::Identity()); Vec2f bmin = Vec2f::Constant(std::numeric_limits::max()), bmax = -bmin; for (const Vec2f &corner : { lo, hi, Vec2f(lo.x(), hi.y()), Vec2f(hi.x(), lo.y()) }) { const Vec2f p = m.block<2, 2>(0, 0) * corner + m.col(2); bmin = bmin.cwiseMin(p); bmax = bmax.cwiseMax(p); } if (uv.x() < bmin.x() || uv.y() < bmin.y() || uv.x() > bmax.x() || uv.y() > bmax.y()) continue; for (const int t : m_island_tris[size_t(island)]) { const Vec3i32 &tri = m_islands.indices[size_t(t)]; if (!point_in_triangle(uv, island_uv(size_t(tri[0])), island_uv(size_t(tri[1])), island_uv(size_t(tri[2])))) continue; // 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; break; } } return found; } void UVEditorCanvas::update_hover(const wxPoint &pos) { int island = -1, vertex = -1; std::pair edge{ -1, -1 }; if (has_islands()) { const Vec2f uv = screen_to_uv(pos); switch (m_select_mode) { case SelectMode::Island: island = island_at(uv); break; case SelectMode::Vertex: vertex = vertex_at(uv); break; case SelectMode::Edge: edge = edge_at(uv); break; } } if (island != m_hover_island || vertex != m_hover_vertex || edge != m_hover_edge) { m_hover_island = island; m_hover_vertex = vertex; m_hover_edge = edge; Refresh(); } } 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_hover_island = m_hover_vertex = -1; m_hover_edge = { -1, -1 }; m_sel_vertices.clear(); m_sel_edges.clear(); 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; if (island >= 0 && size_t(island) < m_island_raw_bounds.size()) { // keep the pick box around the moved vertex auto &[lo, hi] = m_island_raw_bounds[size_t(island)]; lo = lo.cwiseMin(m_islands.uvs[size_t(v)]); hi = hi.cwiseMax(m_islands.uvs[size_t(v)]); } 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; } std::vector UVEditorCanvas::selected_edge_endpoints() const { std::vector verts; for (const auto &[a, b] : m_sel_edges) for (const int v : { a, b }) if (v >= 0 && std::find(verts.begin(), verts.end(), v) == verts.end()) verts.push_back(v); return verts; } 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)]); }; // Commit every element of the multi-selection, not just the primary, so a group drag stores all // the moved vertices' overrides. Falls back to the primary if the selection is somehow empty. if (m_gesture == Gesture::MoveVertex) { if (m_sel_vertices.empty()) add(m_active_vertex); else for (const int v : m_sel_vertices) add(v); } else { const std::vector endpoints = selected_edge_endpoints(); if (endpoints.empty()) { add(m_active_edge.first); add(m_active_edge.second); } else for (const int v : endpoints) add(v); } 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(); // Track the pointer so the +/- add-remove hint can be drawn next to it in Vertex/Edge mode. m_cursor_px = pos; m_cursor_inside = true; if (type == wxEVT_MOTION && m_gesture == Gesture::None) { update_hover(pos); if (m_select_mode != SelectMode::Island) Refresh(); // animate the hint (and its +/- flip) as the pointer/modifiers move } // 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) { const int hit = vertex_at(uv); m_active_edge = { -1, -1 }; m_vertex_edit_moved = false; // Same Shift-adds / Ctrl-toggles / plain-replaces rules as island selection, so a group of // vertices can be picked and dragged together. if (hit >= 0) { if (evt.ShiftDown()) { if (!is_vertex_selected(hit)) m_sel_vertices.push_back(hit); } else if (evt.ControlDown()) { if (auto it = std::find(m_sel_vertices.begin(), m_sel_vertices.end(), hit); it != m_sel_vertices.end()) m_sel_vertices.erase(it); else m_sel_vertices.push_back(hit); } else if (!is_vertex_selected(hit)) { m_sel_vertices.assign(1, hit); } } else if (!evt.ShiftDown() && !evt.ControlDown()) { m_sel_vertices.clear(); } // Primary = the clicked vertex only if it is (still) selected; a Ctrl-deselect just toggles. m_active_vertex = (hit >= 0 && is_vertex_selected(hit)) ? hit : -1; m_gesture = (m_active_vertex >= 0) ? Gesture::MoveVertex : Gesture::Pan; } else if (m_select_mode == SelectMode::Edge) { const std::pair hit = edge_at(uv); m_active_vertex = -1; m_vertex_edit_moved = false; if (hit.first >= 0) { if (evt.ShiftDown()) { if (!is_edge_selected(hit)) m_sel_edges.push_back(hit); } else if (evt.ControlDown()) { if (auto it = std::find(m_sel_edges.begin(), m_sel_edges.end(), hit); it != m_sel_edges.end()) m_sel_edges.erase(it); else m_sel_edges.push_back(hit); } else if (!is_edge_selected(hit)) { m_sel_edges.assign(1, hit); } } else if (!evt.ShiftDown() && !evt.ControlDown()) { m_sel_edges.clear(); } m_active_edge = (hit.first >= 0 && is_edge_selected(hit)) ? hit : std::pair{ -1, -1 }; 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); const Vec2f delta = uv - m_gesture_last_uv; // Move the whole selection by the same uv-space delta (each vertex converts it through its // own island transform in move_vertex_raw). Falls back to the primary if none is selected. if (m_sel_vertices.empty()) move_vertex_raw(m_active_vertex, delta); else for (const int v : m_sel_vertices) move_vertex_raw(v, delta); 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 every unique endpoint of every selected edge once. Falls back to the primary edge. const std::vector endpoints = selected_edge_endpoints(); if (endpoints.empty()) { move_vertex_raw(m_active_edge.first, delta); move_vertex_raw(m_active_edge.second, delta); } else for (const int v : endpoints) move_vertex_raw(v, 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; if (m_gesture == Gesture::None) update_hover(pos); Refresh(); } } void UVEditorCanvas::rebuild_island_models() { m_mesh_dirty = false; m_island_wireframe.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_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), fill(n_islands); for (int c = 0; c < islands; ++c) { wire[size_t(c)].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)], &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 (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 (!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; if (m_tile_enabled) { // Whole tiles, so the backdrop's edge lands on a tile boundary instead of slicing a brick in // half. framed_bounds() applies exactly this, and the view is framed on its result - the two // must not drift apart or the backdrop stops being centred in the pane. framed_bounds(lo, hi); } 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::on_leave(wxMouseEvent &evt) { evt.Skip(); if (m_cursor_inside) { m_cursor_inside = false; // The +/- hint and the hover highlight were following the cursor; drop them now the pointer is gone. if (m_gesture == Gesture::None) { m_hover_island = m_hover_vertex = -1; m_hover_edge = { -1, -1 }; } Refresh(); } } 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; // The context is shared with the 3D view, which leaves its own state behind: GLCanvas3D turns face culling on at // init and many of its passes turn it back on, and the pane's projection flips Y, so every triangle drawn here // faces away from it. With culling left on, the texture and the islands simply vanish - which is why they showed // the first time and were gone, fully or partly, after the 3D view had drawn. A scissor rectangle left on would // clip the clear and everything else the same way. Set what this pass relies on; put the 3D view's back after. const GLboolean prev_cull = ::glIsEnabled(GL_CULL_FACE); const GLboolean prev_scissor = ::glIsEnabled(GL_SCISSOR_TEST); const GLboolean prev_stencil = ::glIsEnabled(GL_STENCIL_TEST); glsafe(::glDisable(GL_CULL_FACE)); glsafe(::glDisable(GL_SCISSOR_TEST)); glsafe(::glDisable(GL_STENCIL_TEST)); glsafe(::glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)); glsafe(::glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE)); if (m_gl_reset_pending) { m_gl_reset_pending = false; m_mesh_dirty = true; m_background_dirty = true; // re-uploads m_background_pixels, or drops the texture if there are none m_tile_outline_glmodel.reset(); m_vertex_marker_glmodel.reset(); m_dim_quad_glmodel.reset(); m_stroke_glmodel.reset(); m_stencil_bits = -1; } 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(); // logical points; the on-screen handle sizes below use it const wxSize viewport = gl_drawable_size(this, size); glsafe(::glViewport(0, 0, viewport.GetWidth(), viewport.GetHeight())); // Line widths below are authored in logical points (they are chosen against the same scale the // hit-test thresholds use), so they take the same logical -> device conversion as the viewport. const float px_scale = float(viewport.GetWidth()) / float(std::max(1, size.GetWidth())); const auto line_width = [px_scale](float w) { set_line_width(w * px_scale); }; glsafe(::glClearColor(UV_COLOR_BG.r(), UV_COLOR_BG.g(), UV_COLOR_BG.b(), 1.f)); glsafe(::glClearStencil(0)); glsafe(::glStencilMask(0xFF)); glsafe(::glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_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_raw = [&](GLModel &model, const ColorRGBA &color, const Transform3d &view_model, const Transform3d &projection) { 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_model); shader->set_uniform("projection_matrix", projection); model.set_color(color); model.render(); shader->stop_using(); }; const auto draw = [&](GLModel &model, const ColorRGBA &color, const Transform3d &model_matrix) { draw_raw(model, color, view_matrix * model_matrix, projection_matrix); }; 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(); } } const auto island_matrix = [this, &identity](int c) { return (size_t(c) < m_transforms.size()) ? to_transform3d(m_transforms[size_t(c)]) : identity; }; const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); // Strokes are built as quads `width_px` wide on screen, rebuilt every paint at the current zoom. Square caps // cover the joints of an outline; the colours passed here are opaque, so the overlapping caps don't show. using Segments = std::vector>; const auto draw_segments = [&](const Segments &segments, float width_px, const ColorRGBA &color) { if (segments.empty()) return; const float half = 0.5f * width_px * uv_per_px; GLModel::Geometry quads; quads.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; quads.reserve_vertices(segments.size() * 4); quads.reserve_indices(segments.size() * 6); unsigned base = 0; for (const auto &[a, b] : segments) { const Vec2f d = b - a; const float len = d.norm(); if (len <= 1e-12f) continue; const Vec2f t = d * (half / len); const Vec2f n(-t.y(), t.x()); for (const Vec2f &p : { Vec2f(a - t + n), Vec2f(a - t - n), Vec2f(b + t - n), Vec2f(b + t + n) }) quads.add_vertex(Vec3f(p.x(), p.y(), 0.f)); quads.add_triangle(base, base + 1, base + 2); quads.add_triangle(base, base + 2, base + 3); base += 4; } m_stroke_glmodel.reset(); if (quads.is_empty()) return; m_stroke_glmodel.init_from(std::move(quads)); draw(m_stroke_glmodel, color, identity); }; const auto draw_stroke = [&](const Segments &segments, float width_px, const ColorRGBA &color, float halo_px) { draw_segments(segments, width_px + 2.f * halo_px, UV_COLOR_HALO); draw_segments(segments, width_px, color); }; const auto edge_segments = [this](const std::vector> &edges) { Segments out; out.reserve(edges.size()); for (const auto &[a, b] : edges) if (a >= 0 && b >= 0 && size_t(a) < m_islands.uvs.size() && size_t(b) < m_islands.uvs.size()) out.emplace_back(island_uv(size_t(a)), island_uv(size_t(b))); return out; }; // Dim the texture everywhere but inside the islands: the fills go into the stencil only, then one // full-viewport quad washes the rest towards the background. Skipped without a stencil buffer, where the // quad would cover the islands too. if (m_stencil_bits < 0) { GLint bits = 0; ::glGetError(); // drop anything pending, so the check below sees only this query ::glGetIntegerv(GL_STENCIL_BITS, &bits); // compatibility profiles if (::glGetError() != GL_NO_ERROR) { bits = 0; ::glGetFramebufferAttachmentParameteriv(GL_DRAW_FRAMEBUFFER, GL_STENCIL, GL_FRAMEBUFFER_ATTACHMENT_STENCIL_SIZE, &bits); ::glGetError(); } m_stencil_bits = std::max(0, int(bits)); } if (has_islands() && !m_island_fill.empty() && m_stencil_bits > 0) { if (!m_dim_quad_glmodel.is_initialized()) { GLModel::Geometry q; q.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; q.add_vertex(Vec3f(-1.f, -1.f, 0.f)); q.add_vertex(Vec3f(1.f, -1.f, 0.f)); q.add_vertex(Vec3f(1.f, 1.f, 0.f)); q.add_vertex(Vec3f(-1.f, 1.f, 0.f)); q.add_triangle(0, 1, 2); q.add_triangle(0, 2, 3); m_dim_quad_glmodel.init_from(std::move(q)); } glsafe(::glEnable(GL_STENCIL_TEST)); glsafe(::glStencilFunc(GL_ALWAYS, 1, 0xFF)); glsafe(::glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE)); glsafe(::glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE)); for (int c = 0; c < int(m_island_fill.size()); ++c) draw(m_island_fill[size_t(c)], ColorRGBA::WHITE(), island_matrix(c)); glsafe(::glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE)); glsafe(::glStencilFunc(GL_EQUAL, 0, 0xFF)); glsafe(::glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP)); draw_raw(m_dim_quad_glmodel, UV_COLOR_OUTSIDE, identity, identity); glsafe(::glDisable(GL_STENCIL_TEST)); } 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". draw_stroke({ { Vec2f(0.f, 0.f), Vec2f(1.f, 0.f) }, { Vec2f(1.f, 0.f), Vec2f(1.f, 1.f) }, { Vec2f(1.f, 1.f), Vec2f(0.f, 1.f) }, { Vec2f(0.f, 1.f), Vec2f(0.f, 0.f) } }, 1.5f, UV_COLOR_TILE_OUTLINE, 1.f); // Island fills. Unselected islands get none, so the texture that will be baked shows unaltered; the one a // click would grab gets a light wash, the selection a teal one. A distortion heatmap (set_island_fill_colors) // replaces the default for unselected islands and is left alone by the hover wash. for (int c = 0; c < int(m_island_fill.size()); ++c) { ColorRGBA fill = size_t(c) < m_island_fill_colors.size() ? m_island_fill_colors[size_t(c)] : UV_COLOR_FILL; if (is_selected(c)) fill = UV_COLOR_SEL_FILL; else if (c == m_hover_island && m_island_fill_colors.empty()) fill = UV_COLOR_HOVER_FILL; if (fill.a() > 0.f) draw(m_island_fill[size_t(c)], fill, island_matrix(c)); } // Interior edges stay GL lines (a patch can have a million of them), each with a dark copy one pixel down // and right, so the light wire still reads on a light texture. Transform3d shadow_projection = projection_matrix; shadow_projection.pretranslate(Vec3d(2.0 / std::max(1, size.GetWidth()), -2.0 / std::max(1, size.GetHeight()), 0.0)); line_width(1.f); for (int c = 0; c < int(m_island_wireframe.size()); ++c) { draw_raw(m_island_wireframe[size_t(c)], UV_COLOR_WIRE_SHADOW, view_matrix * island_matrix(c), shadow_projection); 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 - because "where does one // island end and the next begin" is the single thing this pane exists to answer. Plain first, then the hovered // one, then the selection, so a highlighted outline is never overdrawn by its neighbour's. { std::vector> plain, hovered, selected; for (int c = 0; c < int(m_island_boundary_edges.size()); ++c) { auto &dst = is_selected(c) ? selected : (c == m_hover_island ? hovered : plain); dst.insert(dst.end(), m_island_boundary_edges[size_t(c)].begin(), m_island_boundary_edges[size_t(c)].end()); } draw_stroke(edge_segments(plain), 1.5f, UV_COLOR_BOUNDARY, 1.25f); draw_stroke(edge_segments(hovered), 2.5f, UV_COLOR_HOVER, 1.5f); draw_stroke(edge_segments(selected), 3.f, UV_COLOR_SEL_BOUNDARY, 1.75f); } // The rotation protractor, on top of everything while a rotation gesture is live (#11). if (m_dial_glmodel.is_initialized()) { line_width(2.f); draw(m_dial_glmodel, UV_COLOR_DIAL, identity); line_width(1.f); } // Vertex/Edge mode: the element under the cursor in the hover colour, and the selection as teal - a selected // edge drawn over its whole length, a vertex as a square handle, both on a dark halo. 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 auto draw_marker = [&](int v, float px, const ColorRGBA &color) { 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(px * uv_per_px)); draw(m_vertex_marker_glmodel, color, m); }; const auto draw_handle = [&](int v, const ColorRGBA &color, float px) { draw_marker(v, px + 4.f, UV_COLOR_HALO); draw_marker(v, px, color); }; if (m_select_mode == SelectMode::Vertex) { if (m_hover_vertex >= 0 && !is_vertex_selected(m_hover_vertex)) draw_handle(m_hover_vertex, UV_COLOR_HOVER, 7.f); // Every element of the multi-selection, falling back to the primary while the set is still empty. if (m_sel_vertices.empty()) draw_handle(m_active_vertex, UV_COLOR_SEL_BOUNDARY, 8.f); else for (const int v : m_sel_vertices) draw_handle(v, UV_COLOR_SEL_BOUNDARY, 8.f); } else if (m_select_mode == SelectMode::Edge) { if (m_hover_edge.first >= 0 && !is_edge_selected(m_hover_edge)) draw_stroke(edge_segments({ m_hover_edge }), 3.f, UV_COLOR_HOVER, 2.f); std::vector> edges = m_sel_edges; if (edges.empty() && m_active_edge.first >= 0) edges.push_back(m_active_edge); draw_stroke(edge_segments(edges), 4.f, UV_COLOR_SEL_BOUNDARY, 2.f); for (const auto &[a, b] : edges) { draw_handle(a, UV_COLOR_SEL_BOUNDARY, 6.f); draw_handle(b, UV_COLOR_SEL_BOUNDARY, 6.f); } } // Add/remove hint next to the cursor in Vertex/Edge mode: a green '+' when a click will add to the // selection (plain or Shift), a red '-' when Ctrl is held and a click will remove one - the UV-side // twin of the 3D paint cursor's own sign. if (m_select_mode != SelectMode::Island && m_cursor_inside) { const Vec2f centre = screen_to_uv(m_cursor_px) + Vec2f(14.f, -14.f) * uv_per_px; // up-right of the pointer const float r = 6.f * uv_per_px; const bool removing = wxGetKeyState(WXK_CONTROL); Segments sign{ { centre - Vec2f(r, 0.f), centre + Vec2f(r, 0.f) } }; // the '-' bar, shared by both signs if (!removing) sign.emplace_back(centre - Vec2f(0.f, r), centre + Vec2f(0.f, r)); // the extra stroke that makes it a '+' draw_stroke(sign, 2.5f, removing ? ColorRGBA(1.f, 0.36f, 0.30f, 1.f) : ColorRGBA(0.40f, 0.92f, 0.50f, 1.f), 1.f); } // 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)); if (prev_cull) glsafe(::glEnable(GL_CULL_FACE)); if (prev_scissor) glsafe(::glEnable(GL_SCISSOR_TEST)); if (prev_stencil) glsafe(::glEnable(GL_STENCIL_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_JOIN, ID_UV_UNJOIN, ID_UV_UNWRAP, ID_UV_MARK_SEAMS, ID_UV_SEAM_PATH, ID_UV_CLEAR_SEAMS, ID_UV_CLEAR_EDITS, ID_UV_SELECT_ISLAND, // + SelectMode ID_UV_SELECT_VERTEX, ID_UV_SELECT_EDGE, ID_UV_BG_HEIGHT, // + Background ID_UV_BG_CHECKER, ID_UV_BG_DISTORTION, ID_UV_PICK_TEXTURE, }; // An icon from resources/images, rasterised at the window's real pixel density. On GTK3 create_scaled_bitmap() // renders at the DIP size and lets GTK upscale that on a HiDPI screen, which is what made these small icons blurry; // here the SVG is rendered at device pixels instead and tagged with the scale, so it is drawn 1:1. (Windows renders // at device pixels already, and macOS through BitmapCache's own backing scale.) wxBitmap pane_icon(wxWindow *win, const std::string &name, int size_dip) { #ifdef __WXGTK3__ if (const double scale = win->GetContentScaleFactor(); scale > 1.0) { static BitmapCache cache; const unsigned px = unsigned(std::lround(size_dip * scale)); if (wxBitmap *bmp = cache.load_svg(name, 0, px, false, wxGetApp().dark_mode()); bmp != nullptr && bmp->IsOk()) return wxBitmap(bmp->ConvertToImage(), -1, scale); } #endif return create_scaled_bitmap(name, win, size_dip); } // The size a bitmap takes on screen, in the units a wxDC draws in on this platform. wxSize drawn_size(const wxBitmap &bmp) { #ifdef __WXGTK3__ return bmp.GetLogicalSize(); #else return ScalableBitmap::GetBmpSize(bmp); #endif } // `bmp` with its alpha scaled down, for a disabled control. Keeps the bitmap's scale factor, so it stays sharp. wxBitmap faded(const wxBitmap &bmp, double alpha) { wxImage image = bmp.ConvertToImage(); if (!image.HasAlpha()) image.InitAlpha(); unsigned char *a = image.GetAlpha(); for (int i = 0, n = image.GetWidth() * image.GetHeight(); i < n; ++i) a[i] = (unsigned char) std::lround(a[i] * alpha); return wxBitmap(image, -1, bmp.GetScaleFactor()); } // The pane's colours, matching the texture displacement panel's ImGui style in both themes. struct PaneColors { wxColour bg, ink, dim, rule, frame, hover; static PaneColors current() { if (wxGetApp().dark_mode()) return { wxColour(0x2d, 0x2d, 0x31), wxColour(0xef, 0xef, 0xf0), wxColour(0x90, 0x90, 0x96), wxColour(0x3d, 0x3d, 0x45), wxColour(0x36, 0x36, 0x3c), wxColour(0x49, 0x49, 0x50) }; return { wxColour(0xff, 0xff, 0xff), wxColour(0x32, 0x3a, 0x3d), wxColour(0x7c, 0x82, 0x82), wxColour(0xed, 0xed, 0xed), wxColour(0xce, 0xce, 0xce), wxColour(0xee, 0xee, 0xee) }; } }; wxColour mix(const wxColour &a, const wxColour &b, double t) { const auto ch = [t](unsigned char x, unsigned char y) { return (unsigned char) std::lround(x + (y - x) * t); }; return wxColour(ch(a.Red(), b.Red()), ch(a.Green(), b.Green()), ch(a.Blue(), b.Blue())); } } // namespace // An icon button - square, or with a label beside the icon - drawn the way the texture displacement panel draws // its own: a 1 px frame, a teal frame over a teal tint while a toggle is on, a solid teal fill for the one // accent action, and an optional amber dot for "needs attention". Drawn by hand rather than with wxButton / // wxToggleButton so the pane looks the same on every platform and in both themes. // // A click emits wxEVT_BUTTON with the button's id; for a toggle the event's int is the new state. The owner // may overwrite that state again with SetValue() - which is how radio groups and gizmo-owned flags work. class UVToolButton : public wxWindow { public: UVToolButton(wxWindow *parent, wxWindowID id, const std::string &icon, const wxString &label, const wxString &tip, bool toggle, bool accent = false, int size_dip = 26) : wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE | wxFULL_REPAINT_ON_RESIZE) , m_icon_name(icon), m_icon_dip(size_dip >= 26 ? 16 : 14), m_label(label), m_toggle(toggle), m_accent(accent) , m_size_dip(size_dip) { SetBackgroundStyle(wxBG_STYLE_PAINT); SetToolTip(tip); if (accent) { wxFont font = GetFont(); font.MakeBold(); SetFont(font); } Bind(wxEVT_PAINT, &UVToolButton::on_paint, this); Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent &) { m_hover = true; Refresh(); }); Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent &) { m_hover = false; m_pressed = false; Refresh(); }); Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent &) { if (IsEnabled()) { m_pressed = true; Refresh(); } }); Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { const bool was_pressed = m_pressed; m_pressed = false; Refresh(); if (!was_pressed || !IsEnabled() || !GetClientRect().Contains(e.GetPosition())) return; if (m_toggle) m_on = !m_on; wxCommandEvent evt(wxEVT_BUTTON, GetId()); evt.SetEventObject(this); evt.SetInt(m_on ? 1 : 0); ProcessWindowEvent(evt); }); SetMinSize(DoGetBestSize()); } void SetValue(bool on) { if (on != m_on) { m_on = on; Refresh(); } } bool GetValue() const { return m_on; } // Shows a ready-made bitmap instead of the SVG icon (the layer thumbnail). Its scale factor sets its size. void SetBitmap(const wxBitmap &bmp) { m_bitmap = bmp; Refresh(); } void SetBadge(bool badge) { if (badge != m_badge) { m_badge = badge; Refresh(); } } bool Enable(bool enable = true) override { const bool changed = wxWindow::Enable(enable); if (changed) Refresh(); return changed; } protected: wxSize DoGetBestSize() const override { const int h = FromDIP(m_size_dip); if (m_label.empty()) return wxSize(h, h); const int icon_w = m_icon_name.empty() ? 0 : FromDIP(m_icon_dip) + FromDIP(6); return wxSize(FromDIP(9) + icon_w + GetTextExtent(m_label).x + FromDIP(10), h); } private: void on_paint(wxPaintEvent &) { wxAutoBufferedPaintDC dc(this); const PaneColors c = PaneColors::current(); const wxRect r = GetClientRect(); const wxColour teal(0x00, 0x96, 0x88); const bool enabled = IsEnabled(); wxColour fill = c.bg, border = c.frame, text = c.ink; if (m_accent) { fill = (m_hover && enabled) ? wxColour(0x26, 0xa6, 0x9a) : teal; border = fill; text = *wxWHITE; } else if (m_on) { fill = mix(c.bg, teal, 0.22); border = teal; } else if (m_hover && enabled) { fill = c.hover; } if (m_pressed) fill = mix(fill, teal, 0.18); dc.SetBackground(wxBrush(c.bg)); dc.Clear(); dc.SetBrush(wxBrush(fill)); dc.SetPen(wxPen(border, 1)); dc.DrawRoundedRectangle(r, FromDIP(2)); // Rasterised on first paint rather than in the constructor: the content scale is only reliable once the // window is on screen, and it changes when the window moves to a screen with a different one. if (!m_icon_name.empty() && (!m_icon.IsOk() || m_icon_scale != GetContentScaleFactor())) { m_icon_scale = GetContentScaleFactor(); m_icon = pane_icon(this, m_icon_name, m_icon_dip); } const wxBitmap &bmp = m_bitmap.IsOk() ? m_bitmap : m_icon; int x = 0; if (bmp.IsOk()) { const wxSize bs = drawn_size(bmp); x = m_label.empty() ? (r.width - bs.x) / 2 : FromDIP(9); dc.DrawBitmap(enabled ? bmp : faded(bmp, 0.35), x, (r.height - bs.y) / 2, true); x += bs.x + FromDIP(6); } else { x = FromDIP(9); } if (!m_label.empty()) { dc.SetFont(GetFont()); dc.SetTextForeground(enabled ? text : mix(text, c.bg, 0.55)); dc.DrawText(m_label, x, (r.height - dc.GetTextExtent(m_label).y) / 2); } if (m_badge) { const int d = FromDIP(9); dc.SetPen(wxPen(c.bg, FromDIP(2))); dc.SetBrush(wxBrush(wxColour(0xe0, 0xa4, 0x4a))); dc.DrawCircle(r.width - d / 2 - 1, d / 2 + 1, d / 2); } } std::string m_icon_name; int m_icon_dip = 16; wxBitmap m_icon; // m_icon_name rasterised for m_icon_scale double m_icon_scale = 0.; wxBitmap m_bitmap; wxString m_label; bool m_toggle = false; bool m_accent = false; int m_size_dip = 26; bool m_on = false; bool m_badge = false; bool m_hover = false; bool m_pressed = false; }; UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) { const PaneColors c = PaneColors::current(); const int gap = FromDIP(6); const int pad = FromDIP(8); SetBackgroundColour(c.bg); const auto rule = [&](const wxSize &size) { auto *w = new wxWindow(this, wxID_ANY, wxDefaultPosition, size); w->SetBackgroundColour(c.rule); w->SetMinSize(size); return w; }; const auto text = [&](const wxString &label, const wxColour &colour, long style = 0) { auto *t = new wxStaticText(this, wxID_ANY, label, wxDefaultPosition, wxDefaultSize, style); t->SetForegroundColour(colour); t->SetBackgroundColour(c.bg); return t; }; // ---- header: the layer being edited, the background under the islands, and Unwrap ---- auto *header = new wxBoxSizer(wxHORIZONTAL); m_thumb = new UVToolButton(this, ID_UV_PICK_TEXTURE, std::string(), wxEmptyString, _L("Change texture - choose another one from the texture library"), false, false, 26); m_layer_name = text(wxEmptyString, c.ink, wxST_ELLIPSIZE_END); { wxFont font = m_layer_name->GetFont(); font.MakeBold(); m_layer_name->SetFont(font); } // The name opens the library too, like the texture's name in the layer card. m_layer_name->SetCursor(wxCursor(wxCURSOR_HAND)); m_layer_name->SetToolTip(m_thumb->GetToolTipText()); m_layer_name->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &) { if (m_canvas->pane_state().has_layer) m_canvas->run_command(UVEditorCanvas::Command::PickTexture); }); m_layer_name->SetMinSize(wxSize(FromDIP(30), -1)); m_tile = text(wxEmptyString, c.dim); header->Add(m_thumb, 0, wxALIGN_CENTER_VERTICAL); header->Add(m_layer_name, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); header->Add(m_tile, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); const char *const bg_icons[3] = { "texture_displacement_uv_bg_height", "texture_displacement_checker", "texture_displacement_distortion" }; const wxString bg_tips[3] = { _L("Height map - show the layer's texture under the islands"), _L("Checker - a test grid; squares stay square where the unwrap does not stretch"), _L("Distortion - colour each island by how much the unwrap stretches it") }; for (int i = 0; i < 3; ++i) { m_background[i] = new UVToolButton(this, ID_UV_BG_HEIGHT + i, bg_icons[i], wxEmptyString, bg_tips[i], true, false, 22); header->Add(m_background[i], 0, wxALIGN_CENTER_VERTICAL | wxLEFT, i == 0 ? gap : FromDIP(3)); } header->Add(rule(wxSize(1, FromDIP(18))), 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, gap); m_unwrap = new UVToolButton(this, ID_UV_UNWRAP, "texture_displacement_map_unwrap", _L("Unwrap"), wxEmptyString, false, true, 24); header->Add(m_unwrap, 0, wxALIGN_CENTER_VERTICAL); // ---- settings: what the next Unwrap will do ---- auto *settings = new wxBoxSizer(wxHORIZONTAL); auto *seam_label = text(_L("Seam angle"), c.dim); m_seam_angle = new ::SpinInput(this, wxEmptyString, wxString::FromUTF8("°"), wxDefaultPosition, wxSize(FromDIP(76), FromDIP(24)), 0, 5, 90, 40); const wxString seam_tip = _L("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. Takes effect at the next Unwrap."); seam_label->SetToolTip(seam_tip); m_seam_angle->SetToolTip(seam_tip); m_connect = new ::CheckBox(this); auto *connect_label = text(_L("Connect islands"), c.ink); const wxString connect_tip = _L("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_connect->SetToolTip(connect_tip); connect_label->SetToolTip(connect_tip); settings->Add(seam_label, 0, wxALIGN_CENTER_VERTICAL); settings->Add(m_seam_angle, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); settings->AddStretchSpacer(); settings->Add(m_connect, 0, wxALIGN_CENTER_VERTICAL); settings->Add(connect_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); // ---- tool strip ---- auto *strip = new wxBoxSizer(wxVERTICAL); const auto tool = [&](int id, const char *icon, const wxString &tip, bool toggle) { auto *b = new UVToolButton(this, id, icon, wxEmptyString, tip, toggle); strip->Add(b, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(4)); return b; }; // 11 px either side of a group rule (7 below plus the next button's own 4), against 4 px between buttons. const auto strip_rule = [&]() { auto *r = rule(wxSize(FromDIP(20), 1)); strip->Add(r, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(11)); strip->AddSpacer(FromDIP(7)); }; m_select[0] = tool(ID_UV_SELECT_ISLAND, "texture_displacement_uv_select_island", _L("Island - move, rotate and scale whole islands"), true); m_select[1] = tool(ID_UV_SELECT_VERTEX, "texture_displacement_uv_select_vertex", _L("Vertex - drag vertices to reshape; Shift/Ctrl to multi-select"), true); m_select[2] = tool(ID_UV_SELECT_EDGE, "texture_displacement_uv_select_edge", _L("Edge - drag edges to reshape; Shift/Ctrl to multi-select"), true); strip_rule(); m_mark_seams = tool(ID_UV_MARK_SEAMS, "texture_displacement_uv_seam", _L("Mark seams - click edges on the model to cut the unwrap along them. The edge under the cursor is " "highlighted yellow; click to mark it red, click a red edge again to unmark it. Painting is paused " "while this is on."), true); m_seam_path = tool(ID_UV_SEAM_PATH, "texture_displacement_uv_path", _L("Path - instead of clicking every edge, click a start point and then an end point: the whole " "shortest path between them is seamed at once. Available while marking seams."), true); m_clear_seams = tool(ID_UV_CLEAR_SEAMS, "texture_displacement_cross", _L("Clear seams - remove every seam marked on this layer"), false); strip_rule(); m_avg_scale = tool(ID_UV_AVG_SCALE, "texture_displacement_uv_avg_scale", _L("Average scale - give every island the same texel density"), false); m_cut = tool(ID_UV_CUT, "texture_displacement_uv_cut", _L("Cut - split the selected island across its long axis"), false); m_join = tool(ID_UV_JOIN, "texture_displacement_uv_join", _L("Join - unfold the selected island onto its nearest neighbour along their shared edge"), false); m_unjoin = tool(ID_UV_UNJOIN, "texture_displacement_uv_unjoin", _L("Unjoin - send the selected island back to its own packed position"), false); strip->AddStretchSpacer(); m_clear_edits = tool(ID_UV_CLEAR_EDITS, "texture_displacement_uv_clear_edits", _L("Clear UV edits - discard all manual vertex/edge moves and return the unwrap to its automatic shape"), false); m_snap = tool(ID_UV_SNAP, "texture_displacement_uv_snap", _L("Snap - stick islands together when dragging one against another"), true); m_frame = tool(ID_UV_FRAME, "texture_displacement_uv_frame", _L("Frame all islands (Home)"), false); strip->AddSpacer(FromDIP(4)); m_canvas = new UVEditorCanvas(this); auto *body = new wxBoxSizer(wxHORIZONTAL); body->Add(strip, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(4)); body->Add(rule(wxSize(1, -1)), 0, wxEXPAND); body->Add(m_canvas, 1, wxEXPAND); // ---- status line: the current gesture on the left, the unwrap summary on the right ---- m_status = text(wxEmptyString, c.dim, wxST_ELLIPSIZE_END); m_status->SetMinSize(wxSize(FromDIP(40), -1)); m_stats = text(wxEmptyString, c.dim); auto *status = new wxBoxSizer(wxHORIZONTAL); status->Add(m_status, 1, wxALIGN_CENTER_VERTICAL); status->Add(m_stats, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, gap); auto *sizer = new wxBoxSizer(wxVERTICAL); sizer->Add(header, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, pad); sizer->Add(settings, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, gap); sizer->Add(rule(wxSize(-1, 1)), 0, wxEXPAND | wxTOP, gap); sizer->Add(body, 1, wxEXPAND); sizer->Add(rule(wxSize(-1, 1)), 0, wxEXPAND); sizer->Add(status, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(5)); SetSizer(sizer); Bind(wxEVT_BUTTON, &UVEditorPanel::on_tool, this); // Both settings compare against the last state the gizmo pushed before sending anything: apply_state() // writes them back, and without the check that write would bounce straight back to the gizmo as an edit. m_seam_angle->Bind(wxEVT_SPINCTRL, [this](wxCommandEvent &) { const int value = m_seam_angle->GetValue(); if (value != int(std::lround(m_canvas->pane_state().seam_angle_deg))) m_canvas->run_command(UVEditorCanvas::Command::SetSeamAngle, float(value)); }); const auto send_connect = [this]() { if (m_connect->GetValue() != m_canvas->pane_state().connect_islands) m_canvas->run_command(UVEditorCanvas::Command::SetConnectIslands, m_connect->GetValue() ? 1.f : 0.f); }; m_connect->Bind(wxEVT_TOGGLEBUTTON, [send_connect](wxCommandEvent &e) { send_connect(); e.Skip(); }); connect_label->Bind(wxEVT_LEFT_DOWN, [this, send_connect](wxMouseEvent &) { if (!m_connect->IsEnabled()) return; m_connect->SetValue(!m_connect->GetValue()); send_connect(); }); m_canvas->set_status_callback([this](const wxString &label) { if (m_status != nullptr && m_status->GetLabel() != label) { m_status->SetLabel(label); m_status->Refresh(); refresh_selection_tools(); // a selection or mode change is what changes the status line } }); m_canvas->set_pane_state_callback([this](const UVEditorCanvas::PaneState &state) { apply_state(state); }); apply_state(m_canvas->pane_state()); // AUI shows and hides the pane through this panel. Rebuild the canvas's GPU objects when it comes back, so // the texture and islands never depend on surviving the canvas's native window being hidden. Bind(wxEVT_SHOW, [this](wxShowEvent &e) { e.Skip(); if (e.IsShown()) m_canvas->invalidate_gl(); }); } void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s) { const wxString name = s.has_layer ? s.layer_name : _L("No layer mapped with Unwrap"); const wxString tile = s.has_layer ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) : wxString(); const bool relayout = m_layer_name->GetLabel() != name || m_tile->GetLabel() != tile; m_layer_name->SetLabel(name); m_tile->SetLabel(tile); if (s.thumbnail_px > 0 && s.thumbnail_rgb.size() == size_t(s.thumbnail_px) * size_t(s.thumbnail_px) * 3) { // Scaled to device pixels and tagged with the content scale, so HiDPI screens get every pixel of it. const double scale = GetContentScaleFactor(); const int px = std::max(1, int(std::lround(FromDIP(20) * scale))); wxImage image(s.thumbnail_px, s.thumbnail_px, false); std::copy(s.thumbnail_rgb.begin(), s.thumbnail_rgb.end(), image.GetData()); m_thumb->SetBitmap(wxBitmap(image.Scale(px, px, wxIMAGE_QUALITY_HIGH), -1, scale)); } else { m_thumb->SetBitmap(wxNullBitmap); } m_thumb->Enable(s.has_layer); for (int i = 0; i < 3; ++i) { m_background[i]->SetValue(int(s.background) == i); m_background[i]->Enable(s.has_layer); } m_unwrap->Enable(s.has_layer); m_unwrap->SetBadge(s.unwrap_stale); m_unwrap->SetToolTip(s.unwrap_stale ? _L("Out of date - the paint, the seams or the seam angle changed since this unwrap was made. " "Press to unwrap again.") : _L("Flatten the painted area into UV islands. It is computed only when you press this, not on " "every edit - so paint, change the seam angle or mark seams first, then press Unwrap.")); if (m_seam_angle->GetValue() != int(std::lround(s.seam_angle_deg))) m_seam_angle->SetValue(int(std::lround(s.seam_angle_deg))); m_seam_angle->Enable(s.has_layer); m_connect->SetValue(s.connect_islands); m_connect->Enable(s.has_layer); m_mark_seams->SetValue(s.mark_seams); m_mark_seams->Enable(s.has_layer); m_seam_path->SetValue(s.seam_path); m_seam_path->Enable(s.has_layer && s.mark_seams); m_clear_seams->Enable(s.has_layer && s.has_seams); m_clear_edits->Enable(s.has_layer && s.has_uv_edits); m_stats->SetLabel(s.unwrapped ? wxString::Format(_L("%d islands, %s faces"), s.island_count, wxString(std::to_string(s.face_count))) : wxString()); refresh_selection_tools(); if (relayout) Layout(); } void UVEditorPanel::refresh_selection_tools() { const bool has_islands = m_canvas->has_islands(); const int mode = int(m_canvas->select_mode()); for (int i = 0; i < 3; ++i) { m_select[i]->SetValue(i == mode); m_select[i]->Enable(has_islands); } const bool island_picked = has_islands && m_canvas->select_mode() == UVEditorCanvas::SelectMode::Island && m_canvas->selected_island() >= 0; m_avg_scale->Enable(has_islands); m_cut->Enable(island_picked); m_join->Enable(island_picked); m_unjoin->Enable(island_picked); m_snap->Enable(has_islands); m_snap->SetValue(m_canvas->snap_enabled()); m_frame->Enable(has_islands); } void UVEditorPanel::on_tool(wxCommandEvent &evt) { using Command = UVEditorCanvas::Command; const int id = evt.GetId(); const bool on = evt.GetInt() != 0; switch (id) { case ID_UV_FRAME: m_canvas->run_command(Command::FrameAll); break; case ID_UV_SNAP: m_canvas->run_command(Command::ToggleSnap); break; case ID_UV_AVG_SCALE: m_canvas->run_command(Command::AverageScale); break; case ID_UV_CUT: m_canvas->run_command(Command::CutSelectedIsland); break; case ID_UV_JOIN: m_canvas->run_command(Command::JoinSelected); break; case ID_UV_UNJOIN: m_canvas->run_command(Command::UnjoinSelected); break; case ID_UV_UNWRAP: m_canvas->run_command(Command::Unwrap); break; case ID_UV_MARK_SEAMS: m_canvas->run_command(Command::SetMarkSeams, on ? 1.f : 0.f); break; case ID_UV_SEAM_PATH: m_canvas->run_command(Command::SetSeamPath, on ? 1.f : 0.f); break; case ID_UV_CLEAR_SEAMS: m_canvas->run_command(Command::ClearSeams); break; case ID_UV_CLEAR_EDITS: m_canvas->run_command(Command::ClearUVEdits); break; case ID_UV_PICK_TEXTURE: m_canvas->run_command(Command::PickTexture); break; case ID_UV_SELECT_ISLAND: case ID_UV_SELECT_VERTEX: case ID_UV_SELECT_EDGE: m_canvas->run_command(Command::SetSelectMode, float(id - ID_UV_SELECT_ISLAND)); break; case ID_UV_BG_HEIGHT: case ID_UV_BG_CHECKER: case ID_UV_BG_DISTORTION: // Shown straight away; the gizmo confirms it with its next state push. for (int i = 0; i < 3; ++i) m_background[i]->SetValue(i == id - ID_UV_BG_HEIGHT); m_canvas->run_command(Command::SetBackground, float(id - ID_UV_BG_HEIGHT)); break; default: evt.Skip(); return; } refresh_selection_tools(); } } // namespace Slic3r::GUI