From ab023f3f6d5c56d57567b800e05b29a25dbf1934 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 21 Jul 2026 09:44:25 +0200 Subject: [PATCH] Add texture projection frame overlay, fix remeshing and subdivision --- TEXTURE_DISPLACEMENT.md | 103 ++- resources/images/texture_displacement_add.svg | 120 ++++ .../texture_displacement_add_negative.svg | 109 ++++ .../images/texture_displacement_avg_scale.svg | 330 ++++++++++ .../images/texture_displacement_cross.svg | 121 ++++ resources/images/texture_displacement_cut.svg | 359 +++++++++++ .../images/texture_displacement_frame.svg | 242 +++++++ .../images/texture_displacement_join.svg | 387 +++++++++++ .../images/texture_displacement_snap.svg | 260 ++++++++ .../images/texture_displacement_unjoin.svg | 387 +++++++++++ .../texture_displacement_uv_select_edge.svg | 383 +++++++++++ .../texture_displacement_uv_select_island.svg | 413 ++++++++++++ .../texture_displacement_uv_select_vertex.svg | 378 +++++++++++ src/libslic3r/MeshBoolean.cpp | 48 +- src/libslic3r/MeshBoolean.hpp | 4 +- src/libslic3r/TextureDisplacement.cpp | 33 +- src/libslic3r/TextureDisplacement.hpp | 37 +- src/slic3r/CMakeLists.txt | 2 + .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 607 ++++++++++++++++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 66 +- src/slic3r/GUI/TextureProjectorFrame.cpp | 97 +++ src/slic3r/GUI/TextureProjectorFrame.hpp | 56 ++ src/slic3r/GUI/UVEditorCanvas.cpp | 210 +++++- src/slic3r/GUI/UVEditorCanvas.hpp | 26 +- 24 files changed, 4658 insertions(+), 120 deletions(-) create mode 100644 resources/images/texture_displacement_add.svg create mode 100644 resources/images/texture_displacement_add_negative.svg create mode 100644 resources/images/texture_displacement_avg_scale.svg create mode 100644 resources/images/texture_displacement_cross.svg create mode 100644 resources/images/texture_displacement_cut.svg create mode 100644 resources/images/texture_displacement_frame.svg create mode 100644 resources/images/texture_displacement_join.svg create mode 100644 resources/images/texture_displacement_snap.svg create mode 100644 resources/images/texture_displacement_unjoin.svg create mode 100644 resources/images/texture_displacement_uv_select_edge.svg create mode 100644 resources/images/texture_displacement_uv_select_island.svg create mode 100644 resources/images/texture_displacement_uv_select_vertex.svg create mode 100644 src/slic3r/GUI/TextureProjectorFrame.cpp create mode 100644 src/slic3r/GUI/TextureProjectorFrame.hpp diff --git a/TEXTURE_DISPLACEMENT.md b/TEXTURE_DISPLACEMENT.md index 02a0c357fa..6bbd5f085b 100644 --- a/TEXTURE_DISPLACEMENT.md +++ b/TEXTURE_DISPLACEMENT.md @@ -164,6 +164,21 @@ texture samples per vertex and so has no single UV that represents it. (`compute_layer_vertex_uvs()`) and drive the shader's `use_vertex_uv` path. Faces angled away from the projector smear; that is inherent to view projection, not a bug. + Two companions to this mode: + - **Projection frame overlay** (`TextureProjectorFrame`, see below) — a semi-transparent window + dragged over the 3D view whose border becomes the projection's edge. Applying it stores an exact + **projective** map in `view_project_matrix`, which supersedes the affine `right`/`up` axes above + for that layer (`view_project_projective`). + - **"Project only on visible"** (`select_visible_faces()`) — repaints the layer with exactly the + facets the camera can see, so the projected area matches the viewpoint the projector was captured + from. Two tests: a facing test (normal vs. view direction, per triangle — under perspective the + view direction varies across the model, so it is taken from the eye to each centroid), then + `MeshRaycaster::get_unobscured_idxs()` on the survivors to drop facets hidden behind other + geometry, so a concave part's far inner wall is correctly excluded. One ray query per front-facing + facet, hence click-driven (on the checkbox and on each "Capture current view"), never per frame. + It **replaces** the layer's paint rather than adding to it — "project onto what I can see" would + otherwise accumulate every angle the user had ever looked from. + ### Manual seams and island cutting `TextureDisplacementLayer::lscm_seam_edges` — undirected mesh-vertex-index edge pairs the unwrap is @@ -217,7 +232,10 @@ Algorithm: recursive 1-to-4 triangle split via edge midpoints, with a shared per (keyed by sorted vertex-index pair) so triangles sharing an edge get the *same* new vertex — capped at `max_iterations` (default 6) passes to bound worst-case triangle-count explosion. -Wired as a "Subdivide model" button in the gizmo panel — a real, committed geometry change (like +Wired as a "Subdivide steps" slider (**0–5**, where 0 means no subdivision and previews nothing) plus +Preview/Apply/Done in the gizmo panel. **Apply snaps the slider back to 0** — see bug #22: leaving it +at the count just committed made the panel immediately re-preview N more passes on top of a mesh 4^N +times denser, which is what the "subdivide lags" report was. A real, committed geometry change (like Bake), using the same `save_painting()`/`set_mesh()`/`restore_painting()` dance `GLGizmoSimplify` uses: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles, texture-displacement paint does not (no remap support yet) and is dropped rather than left pointing at now-meaningless @@ -314,6 +332,53 @@ render pixels. May need a one-line sign flip once actually tested. The rotation- direction should be reliable (it follows directly from a self-consistent 2D basis, no such ambiguity). +### Projection frame overlay (ViewProjected) + +`src/slic3r/GUI/TextureProjectorFrame.hpp/.cpp` — a semi-transparent, resizable `wxFrame` the user +drags **over the 3D view**, like a slide projector's gate. Whatever the model shows through it is what +the texture is projected onto, and the window's border becomes the hard edge of the displacement. +Press **Apply projection frame** and the gizmo reads the window's rectangle and commits it. + +The window is deliberately **dumb**: it owns no placement state and reports nothing continuously. Its +position and size *are* the placement, read on demand at Apply — which is also when the expensive +visible-facet raycast runs. So dragging it is free and nothing recomputes until asked. + +Plain 2D (`wxPaintDC`), not a `wxGLCanvas`: a second GL canvas would have to share the app's one real +`wxGLContext`, the cause of bugs #10 and #14 below. It only ever draws a bitmap and a border. + +**The projective mapping (`apply_projection_frame()`)** is the substantive part. The frame defines a +**screen-space** rectangle, but the bake samples from a **local-space** position, so the two have to be +reconciled. `view_project_right/up` can only express an *affine* projection — exact under an +orthographic camera, but wrong under perspective, where the near end of a part projects larger than the +far end and no pair of axes reproduces that. So the layer instead stores a full projective map +(`view_project_matrix`, row-major 3×4, `uv = (row0·p̃/row2·p̃, row1·p̃/row2·p̃)`), built like this: + +- `K = projection · view · (instance · volume)`, i.e. local → clip, the same product the renderer uses. + Note `Camera::get_projection_matrix()` is typed `Transform3d` (nominally affine) but its perspective + form explicitly writes a `(0, 0, −1, 0)` bottom row into the underlying 4×4, so `clip.w = −z_eye` is + genuinely carried. The build therefore multiplies **`.matrix()` products** (plain `Matrix4d`), never + `Transform3d` products, which would not compose that row correctly. +- Window coordinates follow `igl::project`'s convention (as `CameraUtils::project` does), with y + measured downward. Writing `uv = (win − rect_origin) / rect_size` makes u and v affine in + `ndc = clip.xyz / clip.w`; multiplying through by `clip.w` leaves a plain linear combination of `K`'s + rows, which is exactly the 3×4 matrix — the perspective divide survives intact. +- `w > 0` is checked rather than divided blindly. A point behind the projector has `w < 0` and divides + to a plausible-looking but **mirrored** uv — the classic way a projected decal reappears on the back + of a model. `project_uv_projective()` returns false there and the caller treats it as no height. + +The map already includes placement, so `apply_uv_transform()` is **not** applied on top of it — the +window's own position and size are the placement, and the tiling/rotation/offset sliders would shove +the result off the frame the user just aligned. A "Clear" button drops back to the affine path where +those controls mean something again. + +Apply also sets `tile_enabled = false`, so `DecodedHeightTexture::sample()` returns 0 outside `[0,1)` +and the border is a hard edge rather than the first seam of an endless repeat, and repaints the layer +via `select_visible_faces(&matrix)` — the frame's uv square clips the selection, which both matches the +paint to the border and keeps the ray queries proportional to the framed area instead of the model. + +Owned by the gizmo and **destroyed** (not just hidden) in `on_shutdown()`. Closing it only hides it, so +reopening keeps it where it was left. + ### UV Editor pane `UVEditorCanvas` (`src/slic3r/GUI/UVEditorCanvas.hpp/.cpp`) — a standalone `wxGLCanvas` rendering the @@ -487,6 +552,37 @@ These were all real, confirmed root causes (found by reading the actual code pat actual island bbox into the panel — three rounds of reasoning from screenshots had each guessed wrong, because a collapsed-to-a-point unwrap and an off-screen-framed one look identical. +19. **Isotropic remeshing produced scrambled geometry with dropped triangles** — the "remeshing kinda + does not work properly" report, and a genuine one-line root cause. `isotropic_remeshing()` edits + the `Surface_mesh` **in place**, and its edge collapses only *mark* vertices and faces as removed; + the underlying arrays keep the holes until `collect_garbage()` is called. That matters because the + shared `cgal_to_indexed_triangle_set()` numbers its output vertices by **iteration order** (which + skips removed slots) while reading each face's corner as the **raw integer value of the vertex + descriptor** (which does not). The two agree only up to the first collapse; past that every + triangle indexes the wrong vertices, and any descriptor beyond the live vertex count hits the + converter's `iv >= vsize` guard and is silently dropped *together with its triangle*. Fixed by + compacting (`cgal_mesh.collect_garbage()`) before converting. Note the pre-existing callers were + unaffected: the boolean ops build their result into a fresh mesh, so only the in-place remesher + ever handed the converter a mesh with garbage in it. +20. **Remeshing rounded off every sharp edge** — the second half of the same report. CGAL's + `isotropic_remeshing` relaxes vertices tangentially along the surface, which erodes hard features + unless they are constrained: a cube came back with wobbly, eroded edges. Fixed by detecting sharp + edges (`PMP::detect_sharp_edges()` at a user-settable dihedral angle, default 40°) plus every open + border, constraining them, and passing `protect_constraints(true)`. That option additionally + requires each constrained edge to already be shorter than 4/3 · target, hence the + `PMP::split_long_edges()` pre-pass (given the same map, so the halves inherit the constraint) — + this mirrors CGAL's own isotropic_remeshing example. Also added a guard for the case where + `Surface_mesh::add_face()` refused faces on a non-manifold input: remeshing a mesh that silently + lost faces yields a **punctured** model, so it now bails out and reports instead. +21. **Remesh falsely reporting "did not change the model"** — the GUI decided success by comparing + *vertex counts*. A remesh that redistributes triangles at roughly the current density legitimately + lands on the same count, so a perfectly good result was discarded with an error. Now compared + structurally against the input (which is what `remesh_isotropic()` hands back on failure). +22. **Subdivide re-previewing at the same count after Apply** — Apply committed N passes and then + immediately rebuilt the *preview* at N passes again, now on top of a mesh up to 4^N times denser. + That is the single most expensive thing the panel can do, and it ran on every Apply. The slider + now starts at 0 (a real "no subdivision" value that previews nothing), and Apply snaps back to it. + ## Known limitations / deferred work - **No `.3mf` serialization** for texture-displacement paint data or texture assets. A background @@ -539,7 +635,8 @@ Remaining cosmetic / known gaps: **libslic3r (core, no GUI dependency):** - `src/libslic3r/TextureDisplacement.hpp/.cpp` — data model, bake algorithm, projection methods, tiling, subdivision. See doc comments throughout, they're kept accurate and up to date. -- `src/libslic3r/MeshBoolean.hpp/.cpp` — added `parameterize_lscm()` in the `cgal` sub-namespace, +- `src/libslic3r/MeshBoolean.hpp/.cpp` — added `parameterize_lscm()` and `remesh_isotropic()` + (sharp-feature-preserving isotropic remeshing, see bugs #19–#21) in the `cgal` sub-namespace, reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already there for mesh boolean ops. New CGAL includes: `Polygon_mesh_processing/border.h`, `Polygon_mesh_processing/connected_components.h`, `Surface_mesh_parameterization/{Error_code, @@ -567,6 +664,8 @@ Remaining cosmetic / known gaps: - `src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp/.cpp` — background bake commit. - `src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp/.cpp` — background preview compute (mirrors the bake job's shape but commits nothing to the Model). +- `src/slic3r/GUI/TextureProjectorFrame.hpp/.cpp` — the semi-transparent projection-frame overlay for + ViewProjected layers (plain 2D `wxPaintDC`, no GL context — see its section above). - `src/slic3r/GUI/UVEditorCanvas.hpp/.cpp` — the 2D UV unwrap viewer widget. - `src/slic3r/GUI/Plater.hpp/.cpp` — `uv_editor_canvas` member, AUI pane registration, `get_uv_editor_canvas()`/`show_uv_editor()`. diff --git a/resources/images/texture_displacement_add.svg b/resources/images/texture_displacement_add.svg new file mode 100644 index 0000000000..b4e5da179b --- /dev/null +++ b/resources/images/texture_displacement_add.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/images/texture_displacement_add_negative.svg b/resources/images/texture_displacement_add_negative.svg new file mode 100644 index 0000000000..d7979dda4f --- /dev/null +++ b/resources/images/texture_displacement_add_negative.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/images/texture_displacement_avg_scale.svg b/resources/images/texture_displacement_avg_scale.svg new file mode 100644 index 0000000000..c94a62709c --- /dev/null +++ b/resources/images/texture_displacement_avg_scale.svg @@ -0,0 +1,330 @@ + + diff --git a/resources/images/texture_displacement_cross.svg b/resources/images/texture_displacement_cross.svg new file mode 100644 index 0000000000..a2e62b9384 --- /dev/null +++ b/resources/images/texture_displacement_cross.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/images/texture_displacement_cut.svg b/resources/images/texture_displacement_cut.svg new file mode 100644 index 0000000000..532793eb25 --- /dev/null +++ b/resources/images/texture_displacement_cut.svg @@ -0,0 +1,359 @@ + + diff --git a/resources/images/texture_displacement_frame.svg b/resources/images/texture_displacement_frame.svg new file mode 100644 index 0000000000..3bf29bcc5a --- /dev/null +++ b/resources/images/texture_displacement_frame.svg @@ -0,0 +1,242 @@ + + diff --git a/resources/images/texture_displacement_join.svg b/resources/images/texture_displacement_join.svg new file mode 100644 index 0000000000..ff5e9a907f --- /dev/null +++ b/resources/images/texture_displacement_join.svg @@ -0,0 +1,387 @@ + + diff --git a/resources/images/texture_displacement_snap.svg b/resources/images/texture_displacement_snap.svg new file mode 100644 index 0000000000..f6abea8a3f --- /dev/null +++ b/resources/images/texture_displacement_snap.svg @@ -0,0 +1,260 @@ + + diff --git a/resources/images/texture_displacement_unjoin.svg b/resources/images/texture_displacement_unjoin.svg new file mode 100644 index 0000000000..2f50d35767 --- /dev/null +++ b/resources/images/texture_displacement_unjoin.svg @@ -0,0 +1,387 @@ + + diff --git a/resources/images/texture_displacement_uv_select_edge.svg b/resources/images/texture_displacement_uv_select_edge.svg new file mode 100644 index 0000000000..dbab9fdd93 --- /dev/null +++ b/resources/images/texture_displacement_uv_select_edge.svg @@ -0,0 +1,383 @@ + + diff --git a/resources/images/texture_displacement_uv_select_island.svg b/resources/images/texture_displacement_uv_select_island.svg new file mode 100644 index 0000000000..733f58053e --- /dev/null +++ b/resources/images/texture_displacement_uv_select_island.svg @@ -0,0 +1,413 @@ + + diff --git a/resources/images/texture_displacement_uv_select_vertex.svg b/resources/images/texture_displacement_uv_select_vertex.svg new file mode 100644 index 0000000000..f66446f5d0 --- /dev/null +++ b/resources/images/texture_displacement_uv_select_vertex.svg @@ -0,0 +1,378 @@ + + diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 478e0b950c..6a143b0a37 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -29,6 +29,7 @@ // For parameterize_lscm() #include #include +#include #include #include #include @@ -259,7 +260,8 @@ indexed_triangle_set cgal_to_indexed_triangle_set(const CGALMesh &cgalmesh) // Isotropic remeshing // ///////////////////////////////////////////////////////////////////////////// -indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length, unsigned n_iterations) +indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length, + unsigned n_iterations, double sharp_angle_deg) { if (mesh.indices.empty() || target_edge_length <= 0.0) return mesh; @@ -269,14 +271,56 @@ indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double t if (cgal_mesh.is_empty() || cgal_mesh.number_of_faces() == 0) return mesh; + // Surface_mesh::add_face() refuses any face that would make the mesh non-manifold and returns a + // null descriptor instead. Remeshing a mesh that silently lost faces that way produces holes in + // the output, so bail out and let the caller report it rather than hand back a punctured model. + if (cgal_mesh.number_of_faces() != mesh.indices.size()) + return mesh; + + using edge_descriptor = boost::graph_traits<_EpicMesh>::edge_descriptor; try { + // Sharp edges and open borders are pinned before remeshing. Without that, the tangential + // relaxation pass slides vertices along the surface and rounds every hard feature off - a + // cube comes back with wobbly, eroded edges, which is the most visible way "remeshing does + // not work properly". protect_constraints() forbids splitting or collapsing them, but it + // requires each constrained edge to already be shorter than 4/3 * target, hence the split + // first (passing the map so the halves inherit the constraint). This mirrors CGAL's own + // isotropic_remeshing example. + auto ecm = cgal_mesh.add_property_map("e:is_constrained", false).first; + if (sharp_angle_deg > 0.0) + CGALProc::detect_sharp_edges(cgal_mesh, sharp_angle_deg, ecm); + for (edge_descriptor e : edges(cgal_mesh)) { + const auto h = halfedge(e, cgal_mesh); + if (is_border(h, cgal_mesh) || is_border(opposite(h, cgal_mesh), cgal_mesh)) + put(ecm, e, true); + } + + std::vector constrained; + for (edge_descriptor e : edges(cgal_mesh)) + if (get(ecm, e)) + constrained.push_back(e); + if (!constrained.empty()) + CGALProc::split_long_edges(constrained, target_edge_length, cgal_mesh, + CGALParams::edge_is_constrained_map(ecm)); + CGALProc::isotropic_remeshing(faces(cgal_mesh), target_edge_length, cgal_mesh, - CGALParams::number_of_iterations(n_iterations)); + CGALParams::number_of_iterations(n_iterations) + .edge_is_constrained_map(ecm) + .protect_constraints(true)); } catch (const std::exception &) { return mesh; // CGAL throws on some non-manifold / degenerate inputs; leave the mesh untouched } if (cgal_mesh.number_of_faces() == 0) return mesh; + + // isotropic_remeshing edits in place, and its edge collapses only *mark* vertices and faces as + // removed - the underlying arrays keep the holes until the garbage is collected. That matters + // because cgal_to_indexed_triangle_set() numbers its output vertices by iteration order (which + // skips removed slots) while reading each face's corner as the raw integer value of the vertex + // descriptor (which does not). Past the first collapse the two disagree, so every triangle + // points at the wrong vertices, and any descriptor beyond the live vertex count is dropped + // together with its triangle. Compacting first makes descriptor == iteration order again. + cgal_mesh.collect_garbage(); return cgal_to_indexed_triangle_set(cgal_mesh); } diff --git a/src/libslic3r/MeshBoolean.hpp b/src/libslic3r/MeshBoolean.hpp index 3fb49cb7c5..efaf553420 100644 --- a/src/libslic3r/MeshBoolean.hpp +++ b/src/libslic3r/MeshBoolean.hpp @@ -86,9 +86,11 @@ std::optional> parameterize_lscm(const indexed_triangle_set & // Isotropic remeshing (CGAL): rebuilds the mesh so its triangles are close to a uniform target edge // length, splitting oversized triangles and collapsing undersized ones. Used to even out a model with // wildly varying triangle sizes so texture displacement has a consistent vertex density to work with. +// Edges whose dihedral angle exceeds `sharp_angle_deg`, and any open border, are held fixed so hard +// features survive instead of being eroded by the relaxation pass; pass 0 to remesh everything. // Returns the input unchanged if remeshing fails (e.g. a non-manifold or self-intersecting input). indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length, - unsigned n_iterations = 3); + unsigned n_iterations = 3, double sharp_angle_deg = 40.0); } namespace mcut { diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 50740bc532..5de11eafa6 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -934,6 +934,19 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode) } } +bool project_uv_projective(const std::array &m, const Vec3f &position, Vec2f &uv) +{ + const float x = position.x(), y = position.y(), z = position.z(); + const float w = m[8] * x + m[9] * y + m[10] * z + m[11]; + // Strictly greater than zero: at w == 0 the point sits on the projector's plane and maps to + // infinity, and at w < 0 it is behind the projector, where dividing yields a plausible-looking + // but mirrored uv - the classic way a projected decal reappears on the back of a model. + if (!(w > 1e-6f)) + return false; + uv = Vec2f((m[0] * x + m[1] * y + m[2] * z + m[3]) / w, (m[4] * x + m[5] * y + m[6] * z + m[7]) / w); + return true; +} + float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer, const Vec3f &position, const Vec3f &normal, const Vec3f &patch_center, const Vec3f &patch_axis, const Vec2f *lscm_uv) @@ -957,6 +970,16 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp case TextureProjectionMethod::Spherical: return sample_at(project_spherical(position, patch_center)); case TextureProjectionMethod::ViewProjected: + if (layer.view_project_projective) { + // Exact projective placement written by the projection-frame overlay. Sampled directly, + // *without* apply_uv_transform(): the matrix already maps the window's border to the uv + // unit square, so the tiling/rotation/offset controls would displace it off the frame + // the user just aligned. A point behind the projector has no uv at all -> no height. + Vec2f uv; + if (!project_uv_projective(layer.view_project_matrix, position, uv)) + return 0.f; + return texture.sample(uv, layer.tile_enabled, layer.tile_method); + } // Flat projection onto the captured projector plane. Single-valued per point, so unlike // blended triplanar it is one sample, and it is what "project from view" places. return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up))); @@ -1163,9 +1186,15 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set int patch_vertex_count = 0; for (const stl_triangle_vertex_indices &tri : patch.indices) for (int i = 0; i < 3; ++i) { - average_normal += vertex_normals[tri[i]]; - patch_centroid += patch.vertices[tri[i]]; + const int vi = tri[i]; + patch_centroid += patch.vertices[size_t(vi)]; ++patch_vertex_count; + // A brush stroke that split a triangle appends new vertices past the base mesh's own + // (see the get_facets_strict() note above); vertex_normals is sized to the base mesh, + // so those split indices must be skipped here or this reads out of bounds. The main + // displacement loop below guards the same way. + if (vi < int(vertex_normals.size())) + average_normal += vertex_normals[size_t(vi)]; } average_normal = (average_normal.norm() > 1e-8f) ? Vec3f(average_normal.normalized()) : Vec3f::UnitZ(); patch_centroid = (patch_vertex_count > 0) ? Vec3f(patch_centroid / float(patch_vertex_count)) : Vec3f::Zero(); diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index f6c34e93a2..dc78be701c 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -7,6 +7,7 @@ #include #include +#include // view_project_matrix is a std::array #include #include @@ -218,6 +219,24 @@ struct TextureDisplacementLayer // projects to Vec2f(dot(pos, right), dot(pos, up)) before the usual tiling/rotation/offset. Vec3f view_project_right = Vec3f::UnitX(); Vec3f view_project_up = Vec3f::UnitY(); + + // Also ViewProjected, and takes precedence over the two axes above when set: an exact *projective* + // map from a local-space position straight to a texture uv, written by the projection-frame overlay + // (the semi-transparent window dragged over the 3D view -- its border becomes the uv unit square). + // + // Row-major 3x4, applied to the homogeneous point p~ = (x, y, z, 1): + // uv = ( row0.p~ / row2.p~ , row1.p~ / row2.p~ ) + // The perspective divide is the whole point. view_project_right/up can only express an *affine* + // projection, which matches an orthographic camera exactly but not a perspective one -- under + // perspective the near end of a part projects larger than the far end, and no pair of axes + // reproduces that. Folding the camera's full projection*view*model product into one matrix does. + // Because a point behind the projector has row2.p~ <= 0 and no meaningful uv, sampling must check + // the sign rather than divide blindly; see project_uv_projective(). + // + // Note this map already includes placement, so the usual tiling/rotation/offset transform is NOT + // applied on top of it -- the window's own position and size are the placement. + bool view_project_projective = false; + std::array view_project_matrix{}; // Only used by TextureProjectionMethod::LSCM: hand placement of the unwrap's islands, indexed by // chart id (see TextureIsland). Shorter than the chart count simply means the missing ones are // still where the automatic packing put them. @@ -255,7 +274,7 @@ struct TextureDisplacementLayer static_cast(tile_method), static_cast(projection_method), lscm_seam_angle_deg, islands, static_cast(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, - lscm_uv_overrides); + lscm_uv_overrides, view_project_projective, view_project_matrix); } template void load(Archive &ar) { @@ -266,7 +285,8 @@ struct TextureDisplacementLayer ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled, tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, - edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides); + edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, + view_project_matrix); image_data = blob.empty() ? nullptr : std::make_shared>(blob.begin(), blob.end()); tile_method = static_cast(tile_method_int); projection_method = static_cast(projection_method_int); @@ -283,8 +303,11 @@ struct DecodedHeightTexture int height = 0; bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); } - // Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is - // false, uv is clamped to the texture's edge instead of being wrapped/repeated. + // Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false, + // a uv outside [0, 1) samples as 0 -- the texture simply is not there, rather than its border + // row/column being smeared outward forever (which is what clamping the coordinate would do, and + // was a real reported bug). Callers rely on this to get a hard edge: it is how the projection + // frame's border becomes the edge of the displacement. float sample(const Vec2f &uv, bool tile_enabled = true, TextureTileMethod tile_method = TextureTileMethod::Repeat) const; }; @@ -307,6 +330,12 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // (which only knows how to compute the *analytic* methods from a single vertex + normal). Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer); +// Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a +// local-space point, writing the resulting texture uv. Returns false -- and leaves `uv` untouched -- +// when the point lies behind the projector or on its plane (w <= 0), where there is no meaningful uv +// and dividing would produce a mirrored or infinite coordinate. Callers treat that as "no height". +bool project_uv_projective(const std::array &m, const Vec3f &position, Vec2f &uv); + // Sample a layer's height texture at a mesh-local position, honouring the layer's projection // method, tiling scale, rotation, offset and tiling mode. Returns a height in [0, 1]. // diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 38bd235ff2..090ae9d609 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -474,6 +474,8 @@ set(SLIC3R_GUI_SOURCES GUI/TextLines.hpp GUI/TextureLibrary.cpp GUI/TextureLibrary.hpp + GUI/TextureProjectorFrame.cpp + GUI/TextureProjectorFrame.hpp GUI/TickCode.cpp GUI/TickCode.hpp GUI/TroubleshootDialog.cpp diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 426521f04d..4772c4e74d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -5,18 +5,22 @@ #include "libslic3r/MeshBoolean.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/format.hpp" #include "slic3r/GUI/Camera.hpp" #include "slic3r/GUI/CameraUtils.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GLToolbar.hpp" // GLToolbar::Default_Icons_Size, to match the toolbar's icon size #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/MainFrame.hpp" // wxGetApp().mainframe, as the projector window's parent #include "slic3r/GUI/MsgDialog.hpp" #include "slic3r/GUI/OpenGLManager.hpp" #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/TextureLibrary.hpp" +#include "slic3r/GUI/TextureProjectorFrame.hpp" #include "slic3r/GUI/UVEditorCanvas.hpp" #include "slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp" #include "slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp" @@ -177,6 +181,16 @@ void GLGizmoTextureDisplacement::on_shutdown() // silently reopen the pane too. m_show_uv_editor = false; wxGetApp().plater()->show_uv_editor(false); + + // Destroyed, not just hidden: unlike the UV pane (owned by Plater), this frame is owned here, and + // it holds a callback capturing `this`. Leaving it alive past the gizmo would leave that callback + // pointing at a gizmo that is no longer driving anything. + if (m_projector_frame != nullptr) { + m_projector_frame->Destroy(); + m_projector_frame = nullptr; + } + m_projector_tex_source = nullptr; // a rebuilt frame starts with no texture in it + m_projector_tex_smoothing = -1.f; } PainterGizmoType GLGizmoTextureDisplacement::get_painter_type() const @@ -331,14 +345,13 @@ bool GLGizmoTextureDisplacement::on_mouse_seam(const wxMouseEvent &mouse_event) return false; } -std::pair GLGizmoTextureDisplacement::seam_edge_at(const Vec2d &mouse_pos) const +int GLGizmoTextureDisplacement::texture_volume_raycaster_index() const { const ModelVolume *mv = texture_volume(); const ModelObject *mo = m_c->selection_info()->model_object(); if (mv == nullptr || mo == nullptr) - return { -1, -1 }; + return -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()) @@ -346,10 +359,21 @@ std::pair GLGizmoTextureDisplacement::seam_edge_at(const Vec2d &mouse_ if (v == mv) { idx = count; break; } ++count; } - const auto &raycasters = m_c->raycaster()->raycasters(); - if (idx < 0 || idx >= int(raycasters.size())) + return (idx >= 0 && idx < int(m_c->raycaster()->raycasters().size())) ? idx : -1; +} + +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 }; + const int idx = texture_volume_raycaster_index(); + if (idx < 0) + return { -1, -1 }; + const auto &raycasters = m_c->raycaster()->raycasters(); + 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(); @@ -406,16 +430,10 @@ int GLGizmoTextureDisplacement::seam_vertex_at(const Vec2d &mouse_pos) const 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())) + const int idx = texture_volume_raycaster_index(); + if (idx < 0) return -1; + const auto &raycasters = m_c->raycaster()->raycasters(); const Selection &selection = m_parent.get_selection(); const Transform3d trafo = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); @@ -690,6 +708,14 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in if (layer.projection_method == TextureProjectionMethod::ViewProjected) { std::vector uv(patch.vertices.size()); for (size_t vi = 0; vi < patch.vertices.size(); ++vi) { + if (layer.view_project_projective) { + // Matches sample_layer_height()'s projective branch, including skipping + // apply_uv_transform(). A vertex behind the projector gets a uv far outside [0,1] so + // it samples as nothing, rather than the mirrored coordinate a blind divide gives. + if (!project_uv_projective(layer.view_project_matrix, patch.vertices[vi], uv[vi])) + uv[vi] = Vec2f(-1e6f, -1e6f); + continue; + } 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); @@ -1502,6 +1528,137 @@ void GLGizmoTextureDisplacement::capture_view_projection(TextureDisplacementLaye layer.view_project_up = local_up.norm() > 1e-9 ? Vec3f(local_up.normalized().cast()) : Vec3f::UnitY(); } +void GLGizmoTextureDisplacement::show_projector(bool show) +{ + if (!show) { + if (m_projector_frame != nullptr) + m_projector_frame->Hide(); + return; + } + + if (m_projector_frame == nullptr) { + // Parented to the main frame, not to Plater: Plater is a wxPanel, and wxFRAME_FLOAT_ON_PARENT + // wants a real top-level window to float above. + m_projector_frame = new TextureProjectorFrame(wxGetApp().mainframe); + m_projector_tex_source = nullptr; // fresh window, nothing uploaded into it yet + m_projector_tex_smoothing = -1.f; + m_projector_frame->set_opacity(m_projector_opacity); + + // Opened centred over the 3D canvas, at about half its size: the frame is meant to be + // dragged onto part of the model, so starting somewhere on top of it beats the OS's default + // cascade position, which is often off over the sidebar. + if (const wxGLCanvas *cnv = m_parent.get_wxglcanvas(); cnv != nullptr) { + const wxRect area = cnv->GetScreenRect(); + const wxSize size(std::max(160, area.width / 2), std::max(160, area.height / 2)); + m_projector_frame->SetSize(wxRect(area.GetTopLeft() + wxPoint((area.width - size.x) / 2, + (area.height - size.y) / 2), + size)); + } + } + + m_projector_frame->Show(); + m_projector_frame->Raise(); + update_projector(); +} + +void GLGizmoTextureDisplacement::update_projector() +{ + if (m_projector_frame == nullptr || !m_projector_frame->IsShown()) + return; + + const TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr || layer->projection_method != TextureProjectionMethod::ViewProjected) { + m_projector_frame->set_texture({}, 0, 0); + m_projector_tex_source = nullptr; + m_projector_tex_smoothing = -1.f; + return; + } + + // Only re-upload when the pixels actually changed - this is reached from the panel's per-edit + // flush, and rebuilding the bitmap from identical bytes every time would be pure waste. + if (m_projector_tex_source != layer->image_data.get() || m_projector_tex_smoothing != layer->smoothing) { + const DecodedHeightTexture height = decode_height_texture(*layer); + if (height.empty()) + m_projector_frame->set_texture({}, 0, 0); + else + m_projector_frame->set_texture(height.pixels, height.width, height.height); + m_projector_tex_source = layer->image_data.get(); + m_projector_tex_smoothing = layer->smoothing; + } +} + +int GLGizmoTextureDisplacement::apply_projection_frame() +{ + TextureDisplacementLayer *layer = active_layer(); + const ModelVolume *mv = texture_volume(); + const ModelObject *mo = m_c->selection_info()->model_object(); + wxGLCanvas *cnv = m_parent.get_wxglcanvas(); + if (layer == nullptr || mv == nullptr || mo == nullptr || cnv == nullptr || m_projector_frame == nullptr || + !m_projector_frame->IsShown()) + return -1; + + // The frame's gate, brought from screen coordinates into the GL viewport's pixel space. Two + // conversions, both necessary: ScreenToClient() because the viewport's origin is the canvas's + // top-left, not the desktop's, and the retina scale because the viewport is sized in physical + // pixels (see GLCanvas3D::get_canvas_size()) while wx hands out logical ones. + const wxRect gate = m_projector_frame->client_rect_on_screen(); + const wxPoint tl = cnv->ScreenToClient(gate.GetTopLeft()); + const double scale = double(m_parent.get_scale()); + const double rx = double(tl.x) * scale, ry = double(tl.y) * scale; + const double rw = double(gate.width) * scale, rh = double(gate.height) * scale; + if (rw < 1.0 || rh < 1.0) + return -1; + + const Camera &camera = wxGetApp().plater()->get_camera(); + const std::array &vp = camera.get_viewport(); + const Selection &sel = m_parent.get_selection(); + const Transform3d trafo = mo->instances[sel.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + + // Local space -> clip space, the same product the renderer uses, so the mapping agrees with what + // is actually on screen rather than with an idealisation of it. + const Eigen::Matrix4d K = camera.get_projection_matrix().matrix() * camera.get_view_matrix().matrix() * trafo.matrix(); + + // Window coordinates follow igl::project's convention (as CameraUtils::project does): + // win_x = vp.x + vp.w * (ndc.x + 1) / 2, and y measured downward as vp.h - win_y_gl. + // Turning those into uv = (win - rect_origin) / rect_size gives u and v as affine functions of + // ndc.x and ndc.y, and since ndc = clip.xyz / clip.w, multiplying through by clip.w leaves a + // plain linear combination of K's rows - i.e. one 3x4 matrix carrying the perspective divide. + const double A = double(vp[2]) / (2.0 * rw); + const double B = (double(vp[0]) + double(vp[2]) * 0.5 - rx) / rw; + const double C = -double(vp[3]) / (2.0 * rh); + const double D = (double(vp[3]) * 0.5 - double(vp[1]) - ry) / rh; + + const Eigen::Vector4d row_u = A * K.row(0).transpose() + B * K.row(3).transpose(); + const Eigen::Vector4d row_v = C * K.row(1).transpose() + D * K.row(3).transpose(); + const Eigen::Vector4d row_w = K.row(3).transpose(); + + std::array m{}; + for (int i = 0; i < 4; ++i) { + m[size_t(i)] = float(row_u[i]); + m[size_t(4 + i)] = float(row_v[i]); + m[size_t(8 + i)] = float(row_w[i]); + } + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Apply texture projection frame"), + UndoRedo::SnapshotType::GizmoAction); + + layer->projection_method = TextureProjectionMethod::ViewProjected; + layer->view_project_projective = true; + layer->view_project_matrix = m; + // Off, so the height sampler returns 0 outside [0,1) and the frame's border becomes a hard edge + // of the displacement rather than the first seam of an endless repeat. + layer->tile_enabled = false; + // The affine axes are kept up to date too, so turning the projective mapping off later leaves a + // sane flat projection from this same viewpoint instead of whatever was captured long ago. + capture_view_projection(*layer); + + const int painted = select_visible_faces(&m); + m_preview_params_dirty = true; + rebuild_preview(); + m_parent.set_as_dirty(); + return painted; +} + void GLGizmoTextureDisplacement::cut_island(TextureDisplacementLayer &layer, int chart) { const ModelVolume *mv = texture_volume(); @@ -2088,22 +2245,45 @@ 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); + m_tool_icon.load_from_svg_file(resources_dir() + "/images/texture_displacement_add.svg", false, false, false, 32); } return m_tool_icon.get_id(); } -unsigned int GLGizmoTextureDisplacement::icon_id(const std::string &filename) +void GLGizmoTextureDisplacement::ensure_panel_icons() { - auto it = m_icon_cache.find(filename); - if (it == m_icon_cache.end()) { - // First request for this icon: default-construct the texture in place and load it once (with a - // GL context current, since this runs from the panel render). A failed load leaves id 0 and is - // not retried. - it = m_icon_cache.try_emplace(filename).first; - it->second.load_from_svg_file(resources_dir() + "/images/" + filename, false, false, false, 32); - } - return it->second.get_id(); + if (m_panel_icons_tried) + return; + m_panel_icons_tried = true; + + // Order is irrelevant; the map keys by file name. Loaded with color_wite_gray so each icon has both + // a monochrome ("normal", grey in the current theme) and an original-colour variant. + static const std::vector names = { + "toolbar_big_brush.svg", "toolbar_face.svg", "texture_displacement_connected_area.svg", + "texture_displacement_real_preview.svg", "texture_displacement_fast_preview.svg", + "texture_displacement_checker.svg", "texture_displacement_distortion.svg", + "texture_displacement_wireframe.svg", "texture_displacement_cross.svg", + "texture_displacement_uv_select_island.svg", "texture_displacement_uv_select_edge.svg", + "texture_displacement_uv_select_vertex.svg", + }; + std::vector paths; + paths.reserve(names.size()); + for (const std::string &n : names) + paths.push_back(resources_dir() + "/images/" + n); + + // Runs from the panel render, i.e. with a GL context current, so the upload is safe here. + // + // Rasterized at twice GLToolbar::Default_Icons_Size rather than at it: these are drawn at the + // toolbar's icon size, which is that constant scaled by DPI (toolbar_icon_scale() folds in + // em_unit), so on a 200% display the draw size reaches 80 px. Rasterizing at 40 would upscale a + // 40 px bitmap there, which is what actually reads as blurry - downscaling does not. The icon set + // is rasterized once for the gizmo's lifetime, so it cannot re-raster on a DPI change; sizing for + // the larger case and letting ImGui shrink it is the version that looks right on both. + const std::vector icons = + m_panel_icons.init(paths, ImVec2(2 * GLToolbar::Default_Icons_Size, 2 * GLToolbar::Default_Icons_Size), + IconManager::RasterType::color_wite_gray); + for (size_t i = 0; i < names.size() && i < icons.size(); ++i) + m_panel_icon_map[names[i]] = icons[i]; } void GLGizmoTextureDisplacement::add_texture_layer() @@ -2322,6 +2502,101 @@ void GLGizmoTextureDisplacement::remove_texture_layer(int slot) m_parent.set_as_dirty(); } +int GLGizmoTextureDisplacement::select_visible_faces(const std::array *uv_clip) +{ + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + const int idx = texture_volume_raycaster_index(); + if (mv == nullptr || mo == nullptr || idx < 0 || idx >= int(m_triangle_selectors.size())) + return 0; + + const indexed_triangle_set &its = mv->mesh().its; + if (its.indices.empty()) + return 0; + + const Selection &selection = m_parent.get_selection(); + const Geometry::Transformation trafo(mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * + mv->get_matrix()); + const Transform3d &to_world = trafo.get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + // Normals transform by the inverse transpose, not by the matrix itself - with a non-uniform + // scale the two differ, and using the wrong one flips the facing test on the scaled axes. + const Matrix3d normal_matrix = to_world.matrix().block<3, 3>(0, 0).inverse().transpose(); + + // Pass 1 (cheap): drop back-facing triangles. Under perspective the view direction varies across + // the model, so it is taken per triangle from the eye to the centroid; under an orthographic + // camera get_position() is still a point on the view axis, so the same expression stays correct + // in direction terms for everything actually on screen. + const Vec3d eye = camera.get_position(); + const bool ortho = camera.get_type() == Camera::EType::Ortho; + const Vec3d fwd = camera.get_dir_forward(); + + std::vector centroids; // world coords, what get_unobscured_idxs() expects + std::vector front_facing; // parallel: which facet each centroid came from + centroids.reserve(its.indices.size() / 2); + front_facing.reserve(its.indices.size() / 2); + + for (size_t f = 0; f < its.indices.size(); ++f) { + const stl_triangle_vertex_indices &tri = its.indices[f]; + const Vec3d a = its.vertices[tri[0]].cast(); + const Vec3d b = its.vertices[tri[1]].cast(); + const Vec3d c = its.vertices[tri[2]].cast(); + const Vec3d n_world = normal_matrix * (b - a).cross(c - a); + if (n_world.squaredNorm() < 1e-20) + continue; // degenerate triangle: no meaningful normal, so no meaningful facing test + const Vec3d centroid_local = (a + b + c) / 3.0; + const Vec3d centroid_world = to_world * centroid_local; + const Vec3d view_dir = ortho ? fwd : Vec3d(centroid_world - eye); + if (n_world.dot(view_dir) >= 0.0) + continue; // facing away from the camera + + // Clip to the projection frame before the raycast, not after: outside the frame the texture + // samples to nothing anyway, so those facets would only be painted to no effect - and this + // is also what keeps the ray queries proportional to the framed area instead of the model. + if (uv_clip != nullptr) { + Vec2f uv; + if (!project_uv_projective(*uv_clip, centroid_local.cast(), uv)) + continue; // behind the projector + if (uv.x() < 0.f || uv.x() > 1.f || uv.y() < 0.f || uv.y() > 1.f) + continue; + } + centroids.emplace_back(centroid_world.cast()); + front_facing.push_back(unsigned(f)); + } + if (centroids.empty()) + return 0; + + // Pass 2 (the expensive one): a real ray query per surviving centroid, so geometry in front of a + // front-facing triangle correctly hides it - the far inner wall of a cup is front-facing but not + // visible. This is why the whole thing is click-driven rather than live. + std::vector unobscured; + { + wxBusyCursor wait; + unobscured = m_c->raycaster()->raycasters()[size_t(idx)]->get_unobscured_idxs( + trafo, camera, centroids, m_c->object_clipper()->get_clipping_plane()); + } + if (unobscured.empty()) + return 0; + + Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Select visible faces for texture displacement"), + UndoRedo::SnapshotType::GizmoAction); + + // Replaces the layer's paint rather than adding to it: the checkbox means "project onto what I + // can see", so a second capture from a new angle should not leave the previous angle painted. + // TriangleSelectorGUI, not the TriangleSelector base: request_update_render_data() is declared on + // the GUI subclass, so binding to the base here would drop it. + TriangleSelectorGUI &selector = *m_triangle_selectors[size_t(idx)]; + selector.reset(); + for (unsigned i : unobscured) + selector.set_facet(int(front_facing[i]), EnforcerBlockerType::ENFORCER); + selector.request_update_render_data(); + + update_model_object(); + m_parent.set_as_dirty(); + return int(unobscured.size()); +} + void GLGizmoTextureDisplacement::select_whole_model() { ModelObject *mo = m_c->selection_info()->model_object(); @@ -2348,8 +2623,8 @@ void GLGizmoTextureDisplacement::subdivide_model() { ModelVolume *mv = texture_volume(); ModelObject *mo = m_c->selection_info()->model_object(); - if (mv == nullptr || mo == nullptr) - return; + if (mv == nullptr || mo == nullptr || m_subdivide_count < 1) + return; // 0 passes means "no subdivision" - don't take a snapshot for a no-op Plater *plater = wxGetApp().plater(); Plater::TakeSnapshot snapshot(plater, _u8L("Subdivide model for texture displacement"), UndoRedo::SnapshotType::GizmoAction); @@ -2391,14 +2666,24 @@ void GLGizmoTextureDisplacement::remesh_model() // 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; + const indexed_triangle_set &src = mv->mesh().its; + indexed_triangle_set remeshed; { wxBusyCursor wait; - remeshed = MeshBoolean::cgal::remesh_isotropic(mv->mesh().its, double(m_remesh_target_edge_mm), 3); + remeshed = MeshBoolean::cgal::remesh_isotropic(mv->mesh().its, double(m_remesh_target_edge_mm), 3, + m_remesh_keep_sharp_edges ? double(m_remesh_sharp_angle_deg) : 0.0); } - 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).")); + // remesh_isotropic() signals failure by handing the input straight back, so compare against it + // structurally. Vertex count alone is not enough: a remesh that only redistributes triangles at + // roughly the current density legitimately lands on the same count, and treating that as failure + // meant a perfectly good result got thrown away with an error message. + const bool unchanged = remeshed.indices.empty() || + (remeshed.vertices.size() == src.vertices.size() && remeshed.indices.size() == src.indices.size() && + remeshed.indices == src.indices); + if (unchanged) { + show_error(nullptr, _u8L("Remeshing did not change the model. It may be non-manifold (open edges or " + "edges shared by more than two triangles), or the target edge length may " + "already be met.")); return; } @@ -2528,6 +2813,30 @@ void GLGizmoTextureDisplacement::bake() }); } +void GLGizmoTextureDisplacement::render_paint_cursor_hint() +{ + // Only in the plain paint/select modes; seam and adjust modes have their own click semantics where + // an add/remove sign would just be noise. + if (m_seam_edit_mode || m_adjust_texture_mode) + return; + const ImGuiIO &io = ImGui::GetIO(); + // The pointer must be over the 3D view, not over this panel (or any other ImGui window). + if (io.WantCaptureMouse || !ImGui::IsMousePosValid()) + return; + + // Shift erases (see handle_snapshot_action_name()); a plain stroke adds. + const bool removing = io.KeyShift; + const ImU32 color = removing ? IM_COL32(235, 70, 60, 255) : IM_COL32(90, 210, 110, 255); + const char *glyph = removing ? "-" : "+"; + + ImDrawList *dl = ImGui::GetForegroundDrawList(); + const float fs = ImGui::GetFontSize() * 1.5f; + const ImVec2 at(io.MousePos.x + 15.f, io.MousePos.y - fs - 6.f); + // A translucent dark disc behind the glyph so it reads on any material colour. + dl->AddCircleFilled(ImVec2(at.x + fs * 0.28f, at.y + fs * 0.5f), fs * 0.62f, IM_COL32(0, 0, 0, 150)); + dl->AddText(ImGui::GetFont(), fs, at, color, glyph); +} + void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float bottom_limit) { ModelObject *mo = m_c->selection_info()->model_object(); @@ -2565,19 +2874,42 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float return changed; }; - // Icon toggle button shared by the selection-mode and view-mode rows: an SVG button with a teal - // backing and full-brightness icon when active, dimmed otherwise. Falls back to a text checkbox if - // its icon could not be loaded, so the control is never lost. - const float icon_btn_sz = m_imgui->scaled(1.5f); - const ImVec4 icon_active_bg(0.0f, 0.59f, 0.53f, 1.0f); - const auto icon_toggle = [&](int uid, unsigned int ic, bool active, const wxString &label, const wxString &tip) -> bool { - bool clicked; + // Icon toggle button shared by the selection-mode and view-mode rows, styled like the main toolbar: + // an inactive button shows the icon in the theme's normal (grey) monochrome, an active one shows it + // in its original colours. All icons are the same square size. Falls back to a text checkbox if the + // icon set could not be loaded, so the control is never lost. + ensure_panel_icons(); + // Sized to match the 3D toolbar's icons exactly, rather than to the panel's font. It is the same + // expression GLCanvas3D::_update_toolbar_icons_scale() uses, and it is valid here because ImGui's + // DisplaySize is set from the canvas's pixel size (GLCanvas3D::_resize()) - so one ImGui unit is + // one canvas pixel, the very units the toolbar is drawn in. Deriving it rather than hard-coding a + // font multiple also keeps the two in step when the toolbar auto-fit shrinks its icons to make + // them fit a narrow window, which it does by lowering the same toolbar_icon_scale() read here. + const float icon_btn_sz = GLToolbar::Default_Icons_Size * wxGetApp().toolbar_icon_scale() * m_parent.get_scale(); + // The icon SVGs carry their own border, so the ImGui button's own frame border and idle fill are + // suppressed here (FrameBorderSize 0 + transparent ImGuiCol_Button) to avoid a doubled border -- the + // hover/active fill is left in place for feedback. Applied only around these gizmo icon buttons. + const auto push_borderless_icon_style = []() { + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f)); + }; + const auto pop_borderless_icon_style = []() { + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + }; + const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, const wxString &label, + const wxString &tip) -> bool { + bool clicked = false; + const auto it = m_panel_icon_map.find(iconfile); ImGui::PushID(uid); - if (ic != 0) - clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic, ImVec2(icon_btn_sz, icon_btn_sz), ImVec2(0, 0), - ImVec2(1, 1), -1, active ? icon_active_bg : ImVec4(0, 0, 0, 0), - active ? ImVec4(1, 1, 1, 1) : ImVec4(0.65f, 0.65f, 0.65f, 1.f)); - else { + // color_wite_gray variants: [0] normal/grey, [1] original colour, [2] disabled. + if (it != m_panel_icon_map.end() && it->second.size() >= 2 && it->second[active ? 1 : 0]->is_valid()) { + const IconManager::Icon &ic = *it->second[active ? 1 : 0]; + push_borderless_icon_style(); + clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id, ImVec2(icon_btn_sz, icon_btn_sz), + ic.tl, ic.br, -1, ImVec4(0, 0, 0, 0), ImVec4(1, 1, 1, 1)); + pop_borderless_icon_style(); + } else { bool v = active; clicked = ImGui::Checkbox(label.ToUTF8().data(), &v); } @@ -2586,6 +2918,27 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->tooltip(tip, m_imgui->scaled(18.f)); return clicked; }; + // Borderless icon button (non-toggle): always the grey monochrome variant. Falls back to a plain + // text button when the icon file is absent, so the control is never lost before the art lands. + const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label, + const wxString &tip) -> bool { + bool clicked = false; + const auto it = m_panel_icon_map.find(iconfile); + ImGui::PushID(uid); + if (it != m_panel_icon_map.end() && !it->second.empty() && it->second[0]->is_valid()) { + const IconManager::Icon &ic = *it->second[0]; + push_borderless_icon_style(); + clicked = m_imgui->image_button((ImTextureID) (intptr_t) ic.tex_id, ImVec2(sz, sz), ic.tl, ic.br, -1, + ImVec4(0, 0, 0, 0), ImVec4(1, 1, 1, 1)); + pop_borderless_icon_style(); + } else { + clicked = m_imgui->button(label); + } + ImGui::PopID(); + if (!tip.empty() && ImGui::IsItemHovered()) + m_imgui->tooltip(tip, m_imgui->scaled(18.f)); + return clicked; + }; if (m_imgui->button(m_undocked ? _L("Dock panel") : _L("Undock panel"))) m_undocked = !m_undocked; @@ -2604,19 +2957,19 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float 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; ImGui::SameLine(); - if (icon_toggle(801, icon_id("toolbar_big_brush.svg"), is_brush_mode, _L("Brush"), + if (icon_toggle(801, "toolbar_big_brush.svg", is_brush_mode, _L("Brush"), _L("Brush - paint over the surface by dragging"))) { m_tool_type = ToolType::BRUSH; m_cursor_type = TriangleSelector::CursorType::CIRCLE; } ImGui::SameLine(); - if (icon_toggle(802, icon_id("toolbar_face.svg"), is_face_mode, _L("Face"), + if (icon_toggle(802, "toolbar_face.svg", is_face_mode, _L("Face"), _L("Face - click individual triangles"))) { m_tool_type = ToolType::BRUSH; m_cursor_type = TriangleSelector::CursorType::POINTER; } ImGui::SameLine(); - if (icon_toggle(803, icon_id("texture_displacement_connected_area.svg"), is_area_mode, _L("Connected area"), + if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, _L("Connected area"), _L("Connected area - flood-fill the region reachable without crossing an edge sharper than the " "angle threshold"))) { m_tool_type = ToolType::SMART_FILL; @@ -2657,21 +3010,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->text(_L("View")); ImGui::SameLine(); - if (icon_toggle(701, icon_id("texture_displacement_real_preview.svg"), cur_mode == 0, _L("Normal"), + if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, _L("Normal"), _L("Normal - the true displaced geometry (what Bake produces)"))) new_mode = 0; ImGui::SameLine(); - if (icon_toggle(702, icon_id("texture_displacement_fast_preview.svg"), cur_mode == 1, _L("Fast"), + if (icon_toggle(702, "texture_displacement_fast_preview.svg", 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, icon_id("texture_displacement_checker.svg"), cur_mode == 2, _L("Checker"), + if (icon_toggle(703, "texture_displacement_checker.svg", 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, icon_id("texture_displacement_distortion.svg"), cur_mode == 3, _L("Distortion"), + if (icon_toggle(704, "texture_displacement_distortion.svg", 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, icon_id("texture_displacement_wireframe.svg"), m_wireframe_overlay, _L("Wireframe"), + if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, _L("Wireframe"), _L("Wireframe - overlay the mesh edges; independent of the view above"))) wf_toggle = true; if (new_mode != cur_mode) { @@ -2711,8 +3064,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float 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")); + bool add_clicked; + if (add_icon != 0) { + // The add icon SVG carries its own border; suppress the ImGui frame border/idle fill. + push_borderless_icon_style(); + add_clicked = m_imgui->image_button((ImTextureID) (intptr_t) add_icon, ImVec2(sz, sz)); + pop_borderless_icon_style(); + } else { + add_clicked = 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) @@ -2765,7 +3125,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float set_active_layer(layer->slot); ImGui::PopStyleColor(2); ImGui::SameLine(); - if (m_imgui->button(m_desc.at("remove_layer"))) + if (icon_button(600 + layer->slot, "texture_displacement_cross.svg", m_imgui->scaled(1.3f), + m_desc.at("remove_layer"), _u8L("Remove this layer"))) slot_to_remove = layer->slot; // Everything below is this layer's own; the group lets a click anywhere inside it select @@ -2958,18 +3319,25 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // bake honours; Island is the move/rotate/scale-with-grouping behaviour. if (!m_uv_editor_unwrap.empty()) { m_imgui->text(_u8L("Select:")); + const auto set_uv_mode = [&](int mode) { + 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)); + } + }; ImGui::SameLine(); - int mode = m_uv_select_mode; - ImGui::RadioButton(_u8L("Island").c_str(), &mode, 0); + if (icon_toggle(810, "texture_displacement_uv_select_island.svg", m_uv_select_mode == 0, + _L("Island"), _L("Island - move, rotate and scale whole islands"))) + set_uv_mode(0); ImGui::SameLine(); - ImGui::RadioButton(_u8L("Vertex").c_str(), &mode, 1); + if (icon_toggle(811, "texture_displacement_uv_select_vertex.svg", m_uv_select_mode == 1, + _L("Vertex"), _L("Vertex - drag vertices to reshape; Shift/Ctrl to multi-select"))) + set_uv_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 (icon_toggle(812, "texture_displacement_uv_select_edge.svg", m_uv_select_mode == 2, + _L("Edge"), _L("Edge - drag edges to reshape; Shift/Ctrl to multi-select"))) + set_uv_mode(2); if (!layer->lscm_uv_overrides.empty()) { ImGui::SameLine(); if (m_imgui->button(_u8L("Clear UV edits"))) { @@ -3056,7 +3424,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // press this, and the texture is re-laid from the new angle. if (m_imgui->button(_u8L("Capture current view"))) { capture_view_projection(*layer); + // Selecting the visible faces *after* capturing means the projector axes are + // already the ones the selection was made against - the two describe the + // same viewpoint, which is the whole point of the option. + if (m_project_only_visible && select_visible_faces() == 0) + show_error(nullptr, _u8L("Nothing is visible from this angle - turn the model to face the " + "part you want to project onto.")); m_preview_params_dirty = true; + update_projector(); } if (ImGui::IsItemHovered()) m_imgui->tooltip(_u8L("Projects the texture straight onto the painted area from the direction " @@ -3064,6 +3439,66 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "that direction will stretch - turn the model to where you want the " "texture crisp, then capture."), m_imgui->scaled(20.f)); + + if (ImGui::Checkbox(_u8L("Project only on visible").c_str(), &m_project_only_visible)) { + if (m_project_only_visible && select_visible_faces() == 0) + show_error(nullptr, _u8L("Nothing is visible from this angle - turn the model to face the " + "part you want to project onto.")); + m_preview_params_dirty = true; + update_projector(); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Paints exactly the faces you can currently see - facing the camera and " + "not hidden behind anything else - and projects onto those. This replaces " + "the layer's painted area, and is re-applied each time you capture the " + "view."), + m_imgui->scaled(20.f)); + + ImGui::Separator(); + bool projector_open = m_projector_frame != nullptr && m_projector_frame->IsShown(); + if (ImGui::Checkbox(_u8L("Projection frame").c_str(), &projector_open)) + show_projector(projector_open); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Opens a see-through window you drag over the model. Whatever shows " + "through it is what the texture is projected onto, and the window's " + "border becomes the edge of the projection. Move and resize it to frame " + "the area you want, then press Apply."), + m_imgui->scaled(20.f)); + + if (projector_open) { + ImGui::PushItemWidth(m_imgui->scaled(6.f)); + if (ImGui::SliderInt(_u8L("Opacity").c_str(), &m_projector_opacity, 20, 255)) + m_projector_frame->set_opacity(m_projector_opacity); + ImGui::PopItemWidth(); + + if (m_imgui->button(_u8L("Apply projection frame"))) { + const int painted = apply_projection_frame(); + if (painted == 0) + show_error(nullptr, _u8L("Nothing of the model is inside the frame - move it over the " + "part you want to project onto.")); + else if (painted < 0) + show_error(nullptr, _u8L("The frame could not be applied. Make sure it overlaps the " + "3D view.")); + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Projects the texture through the frame from the direction you are " + "looking now, and paints the visible faces inside it. This replaces " + "the layer's painted area. The result is fixed to the model, so you " + "can orbit freely afterwards."), + m_imgui->scaled(20.f)); + } + + if (layer->view_project_projective) { + m_imgui->text(_u8L("Placed by projection frame.")); + ImGui::SameLine(); + if (m_imgui->button(_u8L("Clear"))) { + // Back to the plain axis projection, where tiling/rotation/offset mean + // something again - the matrix path deliberately ignores them. + layer->view_project_projective = false; + layer->tile_enabled = true; + m_preview_params_dirty = true; + } + } } } @@ -3114,6 +3549,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // 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(); + // Same edits (tile size, rotation, offset, a new texture) are what the projector window + // draws, so it refreshes on the same one-per-edit cadence. No-op while it is closed. + update_projector(); m_preview_params_dirty = false; } // (The "Add layer" button now lives next to the "Texture layers" heading, as an icon.) @@ -3121,16 +3559,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float 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 (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) { + m_subdivide_count = std::clamp(m_subdivide_count, 0, 5); if (m_subdivide_editing) - rebuild_subdivide_preview(); + rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one 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."), + "triangle count, so there are enough vertices for the height texture to displace. " + "0 means no subdivision."), m_imgui->scaled(20.f)); if (!m_subdivide_editing) { @@ -3144,11 +3583,18 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "commit it, or Done to leave the model as it is."), m_imgui->scaled(20.f)); } else { + m_imgui->disabled_begin(m_subdivide_count < 1); 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 + subdivide_model(); // commits m_subdivide_count passes (takes its own snapshot) + // Back to 0 rather than staying at the count just applied: the mesh is now up to 4^N + // times denser, so re-previewing the same N passes on top of it is both pointless (the + // density asked for is already committed) and by far the slowest thing this panel does. + // rebuild_subdivide_preview() at 0 just drops the wireframe. + m_subdivide_count = 0; + rebuild_subdivide_preview(); m_parent.set_as_dirty(); } + m_imgui->disabled_end(); 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)."), @@ -3180,6 +3626,23 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float 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(); + + ImGui::Checkbox(_u8L("Keep sharp edges").c_str(), &m_remesh_keep_sharp_edges); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it " + "the remesher slides vertices along the surface and rounds every crisp edge off - " + "a cube comes back with wobbly edges."), + m_imgui->scaled(20.f)); + if (m_remesh_keep_sharp_edges) { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + m_imgui->slider_float(_u8L("Sharp edge angle"), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f deg"); + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Edges bent by more than this count as hard features and are kept. Lower keeps " + "more detail but leaves more of the mesh untouched; higher remeshes more freely."), + m_imgui->scaled(20.f)); + } + m_imgui->disabled_begin(mv == nullptr); if (m_imgui->button(_u8L("Remesh"))) remesh_model(); @@ -3219,6 +3682,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float GizmoImguiEnd(); ImGuiWrapper::pop_toolbar_style(); + + // Drawn last, over everything, via the foreground draw list: the +/- add-remove sign next to the + // 3D cursor. Still inside the gizmo's ImGui frame here, which is what render_paint_cursor_hint() needs. + render_paint_cursor_hint(); } } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index bce96e89b9..f3b98d89fd 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -6,6 +6,7 @@ #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLTexture.hpp" #include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/IconManager.hpp" #include "slic3r/GUI/TextureLibrary.hpp" #include @@ -15,6 +16,8 @@ namespace Slic3r::GUI { +class TextureProjectorFrame; + // Paint-style gizmo that assigns one or more texture-displacement "layers" (see // libslic3r/TextureDisplacement.hpp) to painted areas of a model, and can bake the result into // real mesh geometry. See the project plan for the overall architecture; in short: @@ -70,6 +73,44 @@ private: // "whole model" as an alternative to brushing/clicking every triangle by hand. void select_whole_model(); + // The mesh raycasters are built one per model-part volume, in that order; this is the texture + // volume's slot among them, or -1 if it has none (no selection, or the lists disagree). + int texture_volume_raycaster_index() const; + + // Paints exactly the facets currently visible from the camera onto the active layer, replacing + // whatever that layer had painted. "Visible" is two tests: the facet faces the camera, and its + // centroid is not hidden behind other geometry (a real raycast, so a concave part's far inner + // wall is correctly excluded). When `uv_clip` is given (the projection frame's matrix), facets + // whose centroid falls outside the frame's uv unit square are skipped first - which both clips + // the selection to the frame and spares the raycast for everything outside it. Costs one ray + // query per surviving facet, so it is a one-shot action, never a per-frame one. Returns the + // number of facets selected. + int select_visible_faces(const std::array *uv_clip = nullptr); + + // When set, "Capture current view" also re-selects the visible faces, so the viewpoint the + // projector was captured from and the area it projects onto stay the same. Independent of the + // projection frame below: this takes every visible facet, the frame clips to its rectangle. + // Off by default, because turning it on replaces whatever the layer had painted. + bool m_project_only_visible = false; + + // The projection-frame overlay for a ViewProjected layer: a semi-transparent window dragged over + // the 3D view whose border becomes the projection's edge. Created lazily and owned here; hidden + // rather than destroyed when closed, so reopening keeps it where the user left it. + TextureProjectorFrame *m_projector_frame = nullptr; + int m_projector_opacity = 140; + // What the overlay's texture was last built from, so repeated updates don't rebuild the bitmap + // from unchanged pixels. Same shape as the m_thumbnail_source/m_thumbnail_smoothing pair above. + const void *m_projector_tex_source = nullptr; + float m_projector_tex_smoothing = -1.f; + void show_projector(bool show); + // Pushes the active layer's texture into the overlay. Cheap, and a no-op while it is hidden. + void update_projector(); + // Reads the overlay's rectangle and commits it as the layer's projection: builds the exact + // projective local->uv matrix from the camera and that rectangle, turns tiling off so the border + // is a hard edge, and repaints the layer with the visible facets inside the frame. Returns the + // number of facets selected, or -1 if the frame could not be used at all. + int apply_projection_frame(); + // 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 @@ -120,6 +161,10 @@ private: 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(); + // Draws a small '+'/'-' next to the mouse over the 3D view while painting/selecting, so it is + // obvious whether the next stroke adds paint (default) or erases it (Shift). Uses ImGui's + // foreground draw list, so it must be called from inside the gizmo's ImGui frame. + void render_paint_cursor_hint(); // 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; @@ -251,6 +296,10 @@ private: // 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. + // 0 is a real value meaning "no subdivision": it previews nothing and Apply is a no-op. Apply + // snaps the slider back to it, because each pass quadruples the triangle count - leaving the + // count where it was would immediately re-preview N more passes on top of the mesh that was just + // committed, i.e. the most expensive thing the panel can do, on every Apply. 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 @@ -263,6 +312,10 @@ private: // 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; + // Dihedral angle above which an edge counts as a hard feature and is held fixed by the remesher. + // Off by default would round every sharp edge off, so this is on; 0 disables the protection. + float m_remesh_sharp_angle_deg = 40.f; + bool m_remesh_keep_sharp_edges = true; void remesh_model(); // Live, pre-bake preview of the true displaced geometry (built by the same algorithm Bake @@ -450,11 +503,14 @@ private: bool m_tool_icon_tried = false; unsigned int tool_icon_id(); // 0 if the icon could not be loaded - // The panel's icon-button rows (selection mode, view mode) each need their own small SVG. Loaded and - // uploaded once on first use and cached by file name (under resources/images/). Returns 0 if an icon - // could not be loaded, in which case the button falls back to a text control. - std::map m_icon_cache; - unsigned int icon_id(const std::string &filename); + // Icons for the panel's selection-mode and view-mode button rows. Loaded through IconManager with + // the same colour/monochrome variants the main toolbar uses, so an inactive button shows the icon in + // the theme's normal (grey) foreground colour and an active one shows it in its original colours -- + // matching the toolbar's selected/unselected look. Uploaded once on first panel render. + IconManager m_panel_icons; + std::map m_panel_icon_map; // file name -> [normal, colour, disabled] + bool m_panel_icons_tried = false; + void ensure_panel_icons(); }; } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureProjectorFrame.cpp b/src/slic3r/GUI/TextureProjectorFrame.cpp new file mode 100644 index 0000000000..cfa29967ad --- /dev/null +++ b/src/slic3r/GUI/TextureProjectorFrame.cpp @@ -0,0 +1,97 @@ +#include "TextureProjectorFrame.hpp" + +#include + +#include +#include + +#include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" + +namespace Slic3r { namespace GUI { + +TextureProjectorFrame::TextureProjectorFrame(wxWindow *parent) + : wxFrame(parent, wxID_ANY, _L("Projection frame - drag over the model, then Apply"), wxDefaultPosition, + wxSize(360, 360), + // Caption and resize border so moving and sizing are the native gestures the user + // already knows - "align it by moving the window" only works if the window moves the + // ordinary way. FLOAT_ON_PARENT keeps it above the 3D view without the antisocial + // always-on-top-of-everything behaviour of wxSTAY_ON_TOP. + wxCAPTION | wxRESIZE_BORDER | wxCLOSE_BOX | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT) +{ + SetBackgroundStyle(wxBG_STYLE_PAINT); + Bind(wxEVT_PAINT, &TextureProjectorFrame::on_paint, this); + // A resize changes the gate, so the texture has to be re-stretched under it. + Bind(wxEVT_SIZE, [this](wxSizeEvent &evt) { Refresh(); evt.Skip(); }); + SetTransparent(wxByte(m_alpha)); + + // Hide rather than destroy: the gizmo owns this window's lifetime, and reopening should keep the + // frame exactly where it was left - its position is the placement. + Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent &evt) { + if (evt.CanVeto()) { + evt.Veto(); + Hide(); + } else + evt.Skip(); + }); +} + +void TextureProjectorFrame::set_texture(const std::vector &gray, int width, int height) +{ + if (width <= 0 || height <= 0 || gray.size() < size_t(width) * size_t(height)) { + m_bitmap = wxBitmap(); + Refresh(); + return; + } + + wxImage img(width, height); + unsigned char *dst = img.GetData(); + for (size_t i = 0, n = size_t(width) * size_t(height); i < n; ++i) { + const unsigned char v = gray[i]; + dst[i * 3 + 0] = v; + dst[i * 3 + 1] = v; + dst[i * 3 + 2] = v; + } + m_bitmap = wxBitmap(img); + Refresh(); +} + +void TextureProjectorFrame::set_opacity(int alpha) +{ + m_alpha = std::clamp(alpha, 20, 255); + SetTransparent(wxByte(m_alpha)); + Refresh(); +} + +wxRect TextureProjectorFrame::client_rect_on_screen() const +{ + const wxSize sz = GetClientSize(); + return wxRect(ClientToScreen(wxPoint(0, 0)), sz); +} + +void TextureProjectorFrame::on_paint(wxPaintEvent &) +{ + wxPaintDC dc(this); + const wxSize sz = GetClientSize(); + if (sz.x <= 0 || sz.y <= 0) + return; + + dc.SetBackground(wxBrush(wxColour(20, 20, 20))); + dc.Clear(); + + if (m_bitmap.IsOk()) { + // Stretched to fill the client area rather than kept at its own aspect: the gate maps to the + // uv unit square whatever its shape, so a non-square window genuinely does project a + // stretched texture. Showing it any other way would misrepresent the bake. + wxImage scaled = m_bitmap.ConvertToImage().Scale(sz.x, sz.y, wxIMAGE_QUALITY_NORMAL); + dc.DrawBitmap(wxBitmap(scaled), 0, 0, false); + } + + // The border is the projection's hard edge, so it is drawn explicitly - with the window + // translucent, the native frame alone reads poorly against a busy 3D scene. + dc.SetPen(wxPen(wxColour(0, 200, 180), 2)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(0, 0, sz.x, sz.y); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureProjectorFrame.hpp b/src/slic3r/GUI/TextureProjectorFrame.hpp new file mode 100644 index 0000000000..784062680d --- /dev/null +++ b/src/slic3r/GUI/TextureProjectorFrame.hpp @@ -0,0 +1,56 @@ +#ifndef slic3r_TextureProjectorFrame_hpp_ +#define slic3r_TextureProjectorFrame_hpp_ + +// The projection-frame overlay for TextureProjectionMethod::ViewProjected. +// +// A semi-transparent, resizable window that the user drags over the 3D view like a slide projector's +// gate: whatever the model shows through this window is what the texture is projected onto, and the +// window's border is the hard edge of the projection. "Apply" then reads the window's client +// rectangle, converts it into the 3D canvas's own pixel space, and builds an exact projective map +// from it (see GLGizmoTextureDisplacement::apply_projection_frame()). +// +// The window itself is deliberately dumb - it owns no placement state and reports nothing +// continuously. Its position and size *are* the placement, and they are read on demand at Apply, +// which is also when the (expensive) visible-facet selection runs. Moving the window is therefore +// free, and nothing recomputes until the user asks for it. +// +// Plain 2D (wxGraphicsContext), not a wxGLCanvas: a second GL canvas would have to share the app's +// one real wxGLContext, the cause of bugs #10 and #14 in TEXTURE_DISPLACEMENT.md. A paint-DC window +// has no such failure mode, and this one only ever draws a bitmap and a border. + +#include + +#include +#include + +namespace Slic3r { namespace GUI { + +class TextureProjectorFrame : public wxFrame +{ +public: + explicit TextureProjectorFrame(wxWindow *parent); + + // The same 8-bit grayscale pixels build_texture_displacement() samples, so what is aligned here + // is what gets baked. Pass width/height <= 0 to clear it. + void set_texture(const std::vector &grayscale_pixels, int width, int height); + + // Whole-window opacity, 0..255. Low enough to see the model through it, high enough to judge + // where the texture lands - the useful range is roughly 60..200. + void set_opacity(int alpha); + int opacity() const { return m_alpha; } + + // The client area (the gate itself, excluding caption and borders) in screen coordinates. This + // is what the projection is built from, so it deliberately excludes the window decorations - + // the user aligns what they see, which is the client area. + wxRect client_rect_on_screen() const; + +private: + wxBitmap m_bitmap; + int m_alpha = 140; + + void on_paint(wxPaintEvent &); +}; + +}} // namespace Slic3r::GUI + +#endif // slic3r_TextureProjectorFrame_hpp_ diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index ab7d023f8b..95281acb8b 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -145,6 +145,7 @@ UVEditorCanvas::UVEditorCanvas(wxWindow *parent) 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); } @@ -177,6 +178,8 @@ void UVEditorCanvas::set_islands(Islands islands) // 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(); } // Boundary vertices, bucketed per island, for snapping. @@ -283,9 +286,15 @@ void UVEditorCanvas::update_status() 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"); + 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 = _L("Edge mode: drag an island edge to reshape the unwrap | wheel = zoom, middle-drag = pan, Home = frame"); + 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())); @@ -391,6 +400,8 @@ void UVEditorCanvas::set_select_mode(SelectMode mode) m_select_mode = mode; m_active_vertex = -1; m_active_edge = { -1, -1 }; + m_sel_vertices.clear(); + m_sel_edges.clear(); m_gesture = Gesture::None; update_status(); Refresh(); @@ -520,6 +531,16 @@ Vec2f UVEditorCanvas::snap_correction(int island) const 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 || @@ -545,11 +566,22 @@ void UVEditorCanvas::end_gesture() 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); + // 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); @@ -638,6 +670,12 @@ 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_select_mode != SelectMode::Island && m_gesture == Gesture::None) + 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) @@ -656,15 +694,50 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) m_drag_last_px = pos; m_gesture_last_uv = uv; if (m_select_mode == SelectMode::Vertex) { - m_active_vertex = vertex_at(uv); + const int hit = vertex_at(uv); m_active_edge = { -1, -1 }; m_vertex_edit_moved = false; - m_gesture = (m_active_vertex >= 0) ? Gesture::MoveVertex : Gesture::Pan; + // 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) { - m_active_edge = edge_at(uv); + const std::pair hit = edge_at(uv); m_active_vertex = -1; m_vertex_edit_moved = false; - m_gesture = (m_active_edge.first >= 0) ? Gesture::MoveEdge : Gesture::Pan; + 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) { @@ -739,8 +812,15 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) break; } case Gesture::MoveVertex: { - const Vec2f uv = screen_to_uv(pos); - move_vertex_raw(m_active_vertex, uv - m_gesture_last_uv); + 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(); @@ -749,8 +829,14 @@ void UVEditorCanvas::on_mouse(wxMouseEvent &evt) 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); + // 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(); @@ -1046,6 +1132,16 @@ void UVEditorCanvas::on_size(wxSizeEvent &evt) Update(); } +void UVEditorCanvas::on_leave(wxMouseEvent &evt) +{ + evt.Skip(); + if (m_cursor_inside) { + m_cursor_inside = false; + if (m_select_mode != SelectMode::Island) + Refresh(); // the +/- hint was following the cursor; drop it now the pointer is gone + } +} + void UVEditorCanvas::render() { if (m_context == nullptr || !IsShownOnScreen()) @@ -1190,7 +1286,8 @@ void UVEditorCanvas::render() // 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_select_mode != SelectMode::Island && + (!m_sel_vertices.empty() || !m_sel_edges.empty() || 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 }; @@ -1215,14 +1312,57 @@ void UVEditorCanvas::render() 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); + // Mark every element of the multi-selection (falling back to the primary when nothing is in the + // set yet), so a Shift/Ctrl group is all visibly highlighted. + if (m_select_mode == SelectMode::Vertex) { + if (m_sel_vertices.empty()) + draw_marker(m_active_vertex); + else + for (const int v : m_sel_vertices) + draw_marker(v); + } else { + if (m_sel_edges.empty()) { + draw_marker(m_active_edge.first); + draw_marker(m_active_edge.second); + } else + for (const auto &[a, b] : m_sel_edges) { + draw_marker(a); + draw_marker(b); + } } } + // 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. Rebuilt each frame at the pointer, sized in pixels. + if (m_select_mode != SelectMode::Island && m_cursor_inside) { + const float uv_per_px = 2.f * m_zoom / float(std::max(1, std::min(size.GetWidth(), size.GetHeight()))); + 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); + + GLModel::Geometry sign; + sign.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; + unsigned idx = 0; + const auto seg = [&](const Vec2f &a, const Vec2f &b) { + sign.add_vertex(Vec3f(a.x(), a.y(), 0.f)); + sign.add_vertex(Vec3f(b.x(), b.y(), 0.f)); + sign.add_line(idx, idx + 1); + idx += 2; + }; + seg(centre - Vec2f(r, 0.f), centre + Vec2f(r, 0.f)); // the '-' bar, shared by both signs + if (!removing) + seg(centre - Vec2f(0.f, r), centre + Vec2f(0.f, r)); // the extra stroke that makes it a '+' + + m_cursor_sign_glmodel.reset(); + if (!sign.is_empty()) + m_cursor_sign_glmodel.init_from(std::move(sign)); + set_line_width(3.f); + draw(m_cursor_sign_glmodel, removing ? ColorRGBA(1.f, 0.30f, 0.25f, 0.95f) : ColorRGBA(0.35f, 0.90f, 0.45f, 0.95f), + identity); + set_line_width(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)); @@ -1253,29 +1393,33 @@ enum : int { 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); + // Icon + label buttons: each has its own dedicated SVG (see the map below), with the label kept + // alongside it. A missing SVG simply leaves the button showing only its label -- never bitmap-less + // garbage -- so the bar stays usable before the art lands. auto *bar = new wxBoxSizer(wxHORIZONTAL); - const auto add_button = [&](int id, const wxString &label, const wxString &tip) { + const auto set_icon = [this](wxAnyButton *b, const std::string &iconname) { + const wxBitmap bmp = create_scaled_bitmap(iconname, this, 16); + if (bmp.IsOk()) + b->SetBitmap(bmp); + }; + const auto add_button = [&](int id, const std::string &iconname, const wxString &label, const wxString &tip) { auto *b = new wxButton(this, id, label, wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); - b->SetBitmap(icon); + set_icon(b, iconname); 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)")); + add_button(ID_UV_FRAME, "texture_displacement_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); + set_icon(m_snap_button, "texture_displacement_snap"); 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")); + add_button(ID_UV_AVG_SCALE, "texture_displacement_avg_scale", _L("Avg scale"), _L("Give every island the same texel density")); + add_button(ID_UV_CUT, "texture_displacement_cut", _L("Cut"), _L("Split the selected island across its long axis")); + add_button(ID_UV_JOIN, "texture_displacement_join", _L("Join"), _L("Unfold the selected island onto its nearest neighbour along their shared edge")); + add_button(ID_UV_UNJOIN, "texture_displacement_join", _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) { diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 87d85d6e2c..3e1047eeb9 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -144,6 +144,7 @@ private: void on_size(wxSizeEvent &evt); void on_mouse(wxMouseEvent &evt); void on_key(wxKeyEvent &evt); + void on_leave(wxMouseEvent &evt); // drops the +/- cursor hint when the pointer leaves the canvas void on_erase_background(wxEraseEvent &evt) {} // required to avoid flicker on MSW, deliberately a no-op void render(); @@ -244,14 +245,37 @@ private: 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}. + // The sub-element being edited in Vertex/Edge mode (unwrapped-vertex indices), or -1/{-1,-1}. This + // is the *primary* (last-picked) element of the multi-selection below. int m_active_vertex = -1; std::pair m_active_edge{ -1, -1 }; + // Multi-selection for Vertex/Edge modes, mirroring the island selection: plain click replaces, Shift + // adds, Ctrl toggles, and a drag moves the whole set together. m_active_vertex/m_active_edge stay the + // primary. Kept as small vectors (tiny, and order doesn't matter here). + std::vector m_sel_vertices; + std::vector> m_sel_edges; + bool is_vertex_selected(int v) const + { + return std::find(m_sel_vertices.begin(), m_sel_vertices.end(), v) != m_sel_vertices.end(); + } + bool is_edge_selected(const std::pair &e) const + { + return std::find(m_sel_edges.begin(), m_sel_edges.end(), e) != m_sel_edges.end(); + } + // Unique unwrapped-vertex endpoints of every selected edge (an endpoint shared by two selected edges + // is returned once, so a drag doesn't move it twice). + std::vector selected_edge_endpoints() const; + // Last known mouse position over the canvas, and whether the pointer is currently inside it. Used to + // draw the +/- add/remove sign next to the cursor in Vertex/Edge mode. + wxPoint m_cursor_px{ 0, 0 }; + bool m_cursor_inside = false; // 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; + // Rebuilt each frame at the cursor while a +/- add-remove hint is shown (Vertex/Edge mode). + GLModel m_cursor_sign_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.