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.