From 91ab04e8230edd5f087c46576710bd4caf0fc9cb Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Mon, 12 Oct 2026 08:04:35 +0200 Subject: [PATCH] Redesign the texture placement gizmo and give the tile two extents The on-canvas control for a texture layer's placement was a translucent orange quad with two hairline GL arrows. It moved the texture and nothing else, had no hover state, took no notice of the theme, and sized itself from the volume's bounding box, which left it tiny on small parts and huge on large ones. Rotation and tile size were panel-only numbers. The gizmo is now one tile of the texture lying in the paint patch's tangent plane, so it draws the three values it edits: where it sits is the offset, how it is turned is the rotation, how big it is is the tile extents. It carries four chevron arms for an axis-constrained move (the silhouette of the panel's own Adjust placement icon), a six-dot move grip taken from the drag icon, a corner grip on each corner for the extents, and a ring on a short stem for the rotation. Colours come from ImGuiWrapper's COL_ORCA and the icon grey rather than raw orange and yellow. It is drawn through ImGui's foreground draw list instead of the flat shader, which is what allows antialiased sub-pixel strokes, rounded joins, a contrast halo under every stroke and a numeric readout by the cursor. That halo matters because a volume stays the same neutral grey whatever the theme, so the accent alone cannot carry contrast; the halo strength and the handle fill do. Handle sizes and hit radii are in screen pixels, so the control is equally usable at any zoom or model size, and a tile that projects below 44px is drawn at that floor and dashed to say it is not to scale. Picking is a real point-in-quad test rather than a circle through the corners, so a move can no longer start from empty space beside the tile. Rotating or resizing changes where a given offset places the tile, so every drag recomputes the offset from the centre it means to keep. That is what makes rotate and scale act in place instead of swinging the tile around the anchor, and it leaves one place that writes offset. Tile size gains a second extent. tiling_scale_v holds the v extent in mm and anything <= 0 means "same as u", which is both the default and what every layer written before this meant, so existing layers and older undo snapshots still read back as the square tiles they were. The new layer_tile_mm() states that fallback once and every consumer goes through it: the bake, both preview shaders, the UV pane and the gizmo. The panel shows one slider until the lock is opened, and the lock state is the data rather than a separate flag that could fall out of step with it. The extents now divide after the rotation in apply_uv_transform() and in all four shaders. Dividing two different extents before it would shear the tile rather than rotate it; with a square tile both orders are algebraically identical, so no existing layer changes. The inverse in UVEditorCanvas::uv_delta_to_unwrap() and the island matrix in uv_editor_island_transforms() follow the same reordering, and the texel size used for refinement and step cutting now takes the finer extent. --- .../110/texture_displacement_shaded.fs | 27 +- .../110/texture_displacement_uvcheck.fs | 15 +- .../140/texture_displacement_shaded.fs | 31 +- .../140/texture_displacement_uvcheck.fs | 15 +- src/libslic3r/TextureDisplacement.cpp | 47 +- src/libslic3r/TextureDisplacement.hpp | 30 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 878 +++++++++++++----- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 116 ++- src/slic3r/GUI/UVEditorCanvas.cpp | 19 +- src/slic3r/GUI/UVEditorCanvas.hpp | 13 +- 10 files changed, 886 insertions(+), 305 deletions(-) diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 0b9192749c..0470be25ee 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -45,6 +45,10 @@ uniform sampler2D height_tex; uniform vec2 height_tex_texel; uniform float depth_mm; uniform float tiling_scale; +// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile +// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the +// CPU side, so an unset uniform behaves exactly as this shader always did. +uniform float tiling_scale_v; // Height map width / height. Scales the v axis so a non-square image keeps its proportions // instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform(). uniform float tex_aspect; @@ -150,13 +154,22 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ } } +// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies. +vec2 tile_mm() +{ + float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0; + return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); - planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle + // instead of shearing. With a square tile the two orders agree exactly. + r /= tile_mm(); // After the rotation, so the rotation stays a rotation rather than becoming a shear. r.y *= tex_aspect; return r + uv_offset; @@ -278,7 +291,7 @@ void main() // a texel there is no parallax to find and the march would be pure cost - which is the common // case of looking straight down at a surface. float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4); - if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) { + if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) { // A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the // undisplaced surface. Start at the top of the shell, where the ray is outside the surface // by construction, and step inward; the crossing is what this pixel actually sees. @@ -310,14 +323,14 @@ void main() float hU = texture2D(height_tex, uv + vec2(0.0, height_tex_texel.y)).r; vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y)); - float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; - float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0); + vec2 tile = tile_mm(); + float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0); float cs = cos(rotation_rad); float sn = sin(rotation_rad); - // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v - // component of the gradient carries the extra factor before being rotated back into t/b. - vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); + // One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of + // the uv-space gradient carries its own factor before being rotated back into t/b. + vec2 g = vec2(dh_duv.x / tile.x, dh_duv.y * tex_aspect / tile.y); // ...and back out of raw-projection units into millimetres along t / b; see the 140 variant. vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm; diff --git a/resources/shaders/110/texture_displacement_uvcheck.fs b/resources/shaders/110/texture_displacement_uvcheck.fs index f9e998adc2..be08b27559 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -16,6 +16,9 @@ uniform bool volume_mirrored; uniform int mode; uniform float checker_freq; uniform float tiling_scale; +// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see +// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did. +uniform float tiling_scale_v; uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; @@ -67,13 +70,23 @@ vec2 projection_raw(vec3 p, vec3 n) return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); } + +// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies. +vec2 tile_mm() +{ + float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0; + return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); - planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle + // instead of shearing. With a square tile the two orders agree exactly. + r /= tile_mm(); r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear return r + uv_offset; } diff --git a/resources/shaders/140/texture_displacement_shaded.fs b/resources/shaders/140/texture_displacement_shaded.fs index 911a919aaf..8904274f8d 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -104,6 +104,10 @@ uniform sampler2D height_tex; uniform vec2 height_tex_texel; // (1/width, 1/height) of height_tex uniform float depth_mm; uniform float tiling_scale; +// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - the square tile +// every layer had before the two axes could differ - which is also what layer_tile_mm() does on the +// CPU side, so an unset uniform behaves exactly as this shader always did. +uniform float tiling_scale_v; // Height map width / height. Scales the v axis so a non-square image keeps its proportions // instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform(). uniform float tex_aspect; @@ -216,13 +220,22 @@ void projection_axes(vec3 p, vec3 n, out vec3 t, out vec3 b, out vec2 units_per_ } } +// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies. +vec2 tile_mm() +{ + float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0; + return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); - planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle + // instead of shearing. With a square tile the two orders agree exactly. + r /= tile_mm(); // After the rotation, so the rotation stays a rotation rather than becoming a shear. r.y *= tex_aspect; return r + uv_offset; @@ -359,7 +372,7 @@ void main() // a texel there is no parallax to find and the march would be pure cost - which is the common // case of looking straight down at a surface. float sweep = length(view_dir - triangle_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4); - if (v_dot_n > 0.05 && sweep > 0.5 * tiling_scale * height_tex_texel.x) { + if (v_dot_n > 0.05 && sweep > 0.5 * min(tile_mm().x, tile_mm().y) * height_tex_texel.x) { // A point at ray parameter s (model_pos + view_dir * s) sits at height s * v_dot_n above the // undisplaced surface. Start at the top of the shell, where the ray is outside the surface // by construction, and step inward; the crossing is what this pixel actually sees. @@ -393,17 +406,17 @@ void main() // Central difference, per uv unit (not per texel). vec2 dh_duv = vec2((hR - hL) / (2.0 * height_tex_texel.x), (hU - hD) / (2.0 * height_tex_texel.y)); - // uv -> mm is 1/tiling_scale for the triplanar projection, so this turns the uv-space - // gradient into a real surface slope. - float inv_tiling = (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; - float amplitude = (invert ? -1.0 : 1.0) * depth_mm * inv_tiling * clamp(weight, 0.0, 1.0); + // uv -> mm is the reciprocal of the tile extents, applied per component below, which is what + // turns the uv-space gradient into a real surface slope. + vec2 tile = tile_mm(); + float amplitude = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0); // uv was rotated by project_uv() while t/b are the unrotated model axes, so rotate the // gradient back into the axes' frame. float cs = cos(rotation_rad); float sn = sin(rotation_rad); - // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v - // component of the gradient carries the extra factor before being rotated back into t/b. - vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); + // One uv unit is tile.x mm along u but tile.y / tex_aspect mm along v, so each component of + // the uv-space gradient carries its own factor before being rotated back into t/b. + vec2 g = vec2(dh_duv.x / tile.x, dh_duv.y * tex_aspect / tile.y); // ...and back out of raw-projection units into millimetres of travel along t / b, which is a // no-op except for the spherical longitude (see projection_axes()). After the inverse rotation, // because units_per_mm is expressed in the t/b frame rather than in uv. diff --git a/resources/shaders/140/texture_displacement_uvcheck.fs b/resources/shaders/140/texture_displacement_uvcheck.fs index e6fc8036da..8fac374962 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -25,6 +25,9 @@ uniform bool volume_mirrored; uniform int mode; // 0 checker, 1 distortion uniform float checker_freq; // checker squares per uv unit (one uv unit == one texture tile) uniform float tiling_scale; +// Tile extent along v, in mm. Zero or negative means "the same as tiling_scale" - see +// layer_tile_mm() on the CPU side, so an unset uniform behaves exactly as this shader always did. +uniform float tiling_scale_v; uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; @@ -78,13 +81,23 @@ vec2 projection_raw(vec3 p, vec3 n) return (an.x >= an.y && an.x >= an.z) ? p.yz : ((an.y >= an.x && an.y >= an.z) ? p.xz : p.xy); } + +// Tile extents in mm, u then v, with the v fallback layer_tile_mm() applies. +vec2 tile_mm() +{ + float su = (tiling_scale > 1e-6) ? tiling_scale : 1.0; + return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); - planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // Extents divide after the rotation, so a tile whose two sides differ stays a rotated rectangle + // instead of shearing. With a square tile the two orders agree exactly. + r /= tile_mm(); r.y *= tex_aspect; // after the rotation, so the rotation stays a rotation rather than a shear return r + uv_offset; } diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ec9d9a2e11..d0b0d06cbb 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -470,7 +470,10 @@ V2Resolution recommend_v2_resolution(const indexed_triangle_set const DecodedHeightTexture &tex = decode_height_texture(layer); if (tex.width <= 0) continue; - const float texel = layer.tiling_scale / float(tex.width); + // Texels are square in both axes (the aspect correction is what keeps them so), so the finer + // of the two tile extents sets the finest texel the refinement has to resolve. + const Vec2f tile = layer_tile_mm(layer); + const float texel = std::min(tile.x(), tile.y()) / float(tex.width); if (out.texel_mm <= 0.f || texel < out.texel_mm) { out.texel_mm = texel; out.pixels_per_edge = analyze_texture_detail(layer).pixels_per_edge; @@ -1678,12 +1681,14 @@ namespace { // of them was most of the per-sample arithmetic. struct UVTransform { - float scale, cs, sn, aspect; + float inv_u, inv_v, cs, sn, aspect; Vec2f offset; UVTransform(const TextureDisplacementLayer &layer, float aspect_) { - scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; + const Vec2f tile = layer_tile_mm(layer); + inv_u = 1.f / tile.x(); + inv_v = 1.f / tile.y(); const float rad = layer.rotation_deg * float(M_PI) / 180.f; cs = std::cos(rad); sn = std::sin(rad); @@ -1692,9 +1697,11 @@ struct UVTransform } Vec2f operator()(const Vec2f &planar) const { - const Vec2f scaled = planar * scale; - Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); - // See apply_uv_transform() for why the aspect correction follows the rotation. + // See apply_uv_transform() for why both the tile extents and the aspect correction follow + // the rotation rather than preceding it. + Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs); + rotated.x() *= inv_u; + rotated.y() *= inv_v; if (aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; return rotated + offset; @@ -1714,24 +1721,26 @@ inline Vec3f triplanar_weights(const Vec3f &normal) Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect) { - const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; - const Vec2f scaled = planar * scale; - const float rad = layer.rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad); const float sn = std::sin(rad); - Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); + Vec2f rotated(planar.x() * cs - planar.y() * sn, planar.x() * sn + planar.y() * cs); + + // The tile extents. Dividing here rather than before the rotation is what makes a non-square + // tile a rotated *rectangle* on the surface: scaling the two axes of an already-rotated + // coordinate by different amounts would be a shear, so the rotation has to come first and the + // extents divide in the tile's own frame. With a square tile the two orders agree exactly, + // R being linear - which is why this is not a change in behaviour for any existing layer. + const Vec2f tile = layer_tile_mm(layer); + rotated.x() /= tile.x(); + rotated.y() /= tile.y(); // Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its - // proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out - // half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is - // `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so - // dividing v by that extent is the same as multiplying it by width / height. A square texture has - // aspect 1 and is untouched, which is why this changes nothing for the shipped library. - // - // Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a - // shear, and doing it the other way round would make "Rotation" skew the pattern instead of - // turning it. + // proportions, so a 2:1 image is squeezed into one tile and every feature in it comes out half + // as wide as it should be. The tile is `layer_tile_mm().y() * height / width` mm along v, which + // is exactly what keeps texels square - so dividing v by that extent is the same as multiplying + // it by width / height. A square texture has aspect 1 and is untouched, which is why this + // changes nothing for the shipped library. if (aspect > 0.f && aspect != 1.f) rotated.y() *= aspect; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d61739c7d8..ad02e0d071 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -177,7 +177,11 @@ struct TextureDisplacementLayer std::shared_ptr> image_data; float depth_mm = 0.4f; // maximum displacement along the surface normal, in mm - float tiling_scale = 12.5f; // size of one texture tile, in mm + float tiling_scale = 12.5f; // size of one texture tile along u, in mm + // Size of one tile along v, in mm. Anything <= 0 means "whatever tiling_scale is", which is both + // the default and what a layer created before the tile could be non-square meant - so an older + // undo snapshot still comes back as the square tile it was. + float tiling_scale_v = -1.f; float rotation_deg = 0.f; Vec2f offset = Vec2f::Zero(); bool invert = false; @@ -302,7 +306,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, view_project_projective, view_project_matrix, color_enabled); + lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, tiling_scale_v); } template void load(Archive &ar) { @@ -314,7 +318,7 @@ struct TextureDisplacementLayer tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel, island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective, - view_project_matrix, color_enabled); + view_project_matrix, color_enabled, tiling_scale_v); 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); @@ -323,6 +327,16 @@ struct TextureDisplacementLayer }; +// The layer's tile extents in mm, u first. The v extent falls back to the u one whenever +// tiling_scale_v is unset, so "unset means square" is written down once and every consumer - the +// bake, the preview shaders, the UV pane and the placement gizmo - agrees on what a layer with no +// v extent means. +inline Vec2f layer_tile_mm(const TextureDisplacementLayer &layer) +{ + const float su = (layer.tiling_scale > 1e-6f) ? layer.tiling_scale : 1.f; + return Vec2f(su, (layer.tiling_scale_v > 1e-6f) ? layer.tiling_scale_v : su); +} + // Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next // to texture_displacement_layers and consumed by build_texture_displacement(). struct TextureDisplacementOptions @@ -538,9 +552,13 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // projection method, without going through project_texture_displacement_uv()'s own dispatch // (which only knows how to compute the *analytic* methods from a single vertex + normal). // `aspect` is the height map's width / height. It scales the v axis so a non-square image is not -// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is -// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the -// square case and leaves the coordinate exactly as it always was. +// squeezed into a square tile: the tile is `layer_tile_mm().y() * height / width` mm along v, which +// keeps texels square. 1 (the default) is the square case and leaves the coordinate exactly as it +// always was. +// +// The tile extents divide *after* the rotation, not before. With one extent that made no difference +// (R is linear, so R(p / s) == R(p) / s), but with two it is what keeps a non-square tile a rotated +// rectangle on the surface instead of a sheared parallelogram. Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f); // Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 96af821505..2b1ebb54ac 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -554,7 +554,7 @@ void GLGizmoTextureDisplacement::on_shutdown() m_island_move_set.clear(); m_adjust_texture_mode = false; m_seam_edit_mode = false; - m_adjust_drag_handle = AdjustHandle::None; + end_adjust_texture_mode(); m_adjust_anchor_valid = false; // The pane is request-only: closing the gizmo drops the request, so reopening it later doesn't // silently reopen the pane too. @@ -748,9 +748,9 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() m_c->object_clipper()->render_cut(); m_c->instances_hider()->render_cut(); - if (m_adjust_texture_mode) - render_adjust_texture_gizmo(); - else if (!m_seam_edit_mode) // the brush cursor is meaningless while marking seams + // The placement gizmo is drawn through ImGui's foreground draw list, which only exists inside + // the gizmo's ImGui frame - see the end of on_render_input_window(). Here there is only the brush. + if (!m_adjust_texture_mode && !m_seam_edit_mode) // the brush cursor is meaningless while marking seams render_cursor(); glsafe(::glDisable(GL_BLEND)); @@ -1564,6 +1564,7 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height()))); shader->set_uniform("depth_mm", layer->depth_mm); shader->set_uniform("tiling_scale", layer->tiling_scale); + shader->set_uniform("tiling_scale_v", layer_tile_mm(*layer).y()); // Read off the uploaded texture rather than the decoded one: they are the same image, and this is // the aspect the sampler will actually see. shader->set_uniform("tex_aspect", float(tex->get_width()) / float(tex->get_height())); @@ -2113,6 +2114,7 @@ void GLGizmoTextureDisplacement::render_uvcheck_mesh() shader->set_uniform("mode", m_uv_check_mode == UVCheckMode::Distortion ? 1 : 0); shader->set_uniform("checker_freq", 4.f); // squares per texture tile shader->set_uniform("tiling_scale", layer->tiling_scale); + shader->set_uniform("tiling_scale_v", layer_tile_mm(*layer).y()); shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f); shader->set_uniform("uv_offset", layer->offset); shader->set_uniform("use_vertex_uv", m_uvcheck_uses_vertex_uv); @@ -2477,8 +2479,8 @@ void GLGizmoTextureDisplacement::update_uv_editor() } } // The backdrop's extent and repeat follow the tile settings, with or without an unwrap. - uv_canvas->set_uv_transform(layer->tiling_scale, layer->rotation_deg, layer->tile_enabled, - layer->tile_method == TextureTileMethod::MirroredRepeat); + uv_canvas->set_uv_transform(layer->tiling_scale, layer_tile_mm(*layer).y(), layer->rotation_deg, + layer->tile_enabled, layer->tile_method == TextureTileMethod::MirroredRepeat); if (m_uv_editor_unwrap.empty()) { // Nothing unwrapped yet: the pane opens anyway, because Unwrap itself lives in it. Its canvas shows the @@ -2654,6 +2656,7 @@ void GLGizmoTextureDisplacement::push_uv_pane_state() st.has_layer = true; st.layer_name = from_u8(layer->name.empty() ? Slic3r::format(_u8L("Layer %1%"), layer->slot + 1) : layer->name); st.tile_mm = layer->tiling_scale; + st.tile_mm_v = layer_tile_mm(*layer).y(); st.seam_angle_deg = layer->lscm_seam_angle_deg; st.connect_islands = layer->auto_connect_islands; st.has_seams = !layer->lscm_seam_edges.empty(); @@ -2848,8 +2851,10 @@ void GLGizmoTextureDisplacement::run_uv_command(int cmd, float value) m_uv_select_mode = std::clamp(int(std::lround(value)), 0, 2); // the canvas has already switched } else if (cmd == int(Command::SetMarkSeams)) { m_seam_edit_mode = value != 0.f; - if (m_seam_edit_mode) + if (m_seam_edit_mode) { m_adjust_texture_mode = false; // the two click modes are mutually exclusive + end_adjust_texture_mode(); + } else { m_seam_hover_edge = { -1, -1 }; // drop the hover highlight when leaving the mode m_seam_hover_vertex = -1; @@ -3095,14 +3100,16 @@ void GLGizmoTextureDisplacement::cut_island(TextureDisplacementLayer &layer, int std::vector> GLGizmoTextureDisplacement::uv_editor_island_transforms(const TextureDisplacementLayer &layer) { - const float uv_scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; - const float rad = layer.rotation_deg * float(M_PI) / 180.f; - const float cs = std::cos(rad) * uv_scale; - const float sn = std::sin(rad) * uv_scale; + // S^-1 R, with S the tile extents - the same order apply_uv_transform() applies them in, so a + // non-square tile scales each row rather than the whole matrix. + const Vec2f tile = layer_tile_mm(layer); + const float rad = layer.rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad); + const float sn = std::sin(rad); Eigen::Matrix2f uv_linear; - uv_linear << cs, -sn, - sn, cs; + uv_linear << cs / tile.x(), -sn / tile.x(), + sn / tile.y(), cs / tile.y(); m_uv_editor_bbox_min = Vec2f(std::numeric_limits::max(), std::numeric_limits::max()); m_uv_editor_bbox_max = Vec2f(std::numeric_limits::lowest(), std::numeric_limits::lowest()); @@ -3269,31 +3276,56 @@ Vec3f GLGizmoTextureDisplacement::adjust_plane_point() const return m_adjust_anchor_pos + m_adjust_anchor_normal * lift; } +void GLGizmoTextureDisplacement::end_adjust_texture_mode() +{ + m_adjust_drag_kind = AdjustHandle::None; + m_adjust_drag_index = -1; + m_adjust_hover_kind = AdjustHandle::None; + m_adjust_hover_index = -1; + m_adjust_readout.clear(); +} + +Vec2f GLGizmoTextureDisplacement::adjust_handle_planar(const TextureDisplacementLayer &layer, float aspect) +{ + // apply_uv_transform() maps a planar mm coordinate p to uv = S^-1 R(p) + offset, with S the + // tile extents and v additionally carrying the image's aspect. The tile's centre is the p whose + // uv is zero, so that one equation inverts directly: R(p) = -S offset (with the aspect undone + // on v), and p is R^-1 of it. Which is also why the centre lands exactly on the anchor when the + // offset is zero, whatever the rotation or the tile size. + const Vec2f tile = layer_tile_mm(layer); + const float a = (aspect > 0.f) ? aspect : 1.f; + const float X = -layer.offset.x() * tile.x(); + const float Y = -layer.offset.y() * tile.y() / a; + const float rad = layer.rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + return Vec2f(cs * X + sn * Y, -sn * X + cs * Y); +} + +Vec2f GLGizmoTextureDisplacement::adjust_offset_for_planar(const Vec2f &planar, float rot_deg, + const Vec2f &tile_mm, float aspect) +{ + const float a = (aspect > 0.f) ? aspect : 1.f; + const float rad = rot_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + const float X = planar.x() * cs - planar.y() * sn; + const float Y = planar.x() * sn + planar.y() * cs; + const float su = (tile_mm.x() > 1e-6f) ? tile_mm.x() : 1.f; + const float sv = (tile_mm.y() > 1e-6f) ? tile_mm.y() : su; + return Vec2f(-X / su, -Y * a / sv); +} + Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacementLayer &layer) const { - // apply_uv_transform() maps a planar mm coordinate p to uv = R(p / tiling_scale) + offset, and - // on_mouse_adjust_texture() drives offset by offset = offset_start - R(delta / tiling_scale). - // Inverting that, the handle's displacement from the anchor is - R^-1(offset) * tiling_scale - - // which, substituted into the drag equation, moves the handle by exactly `delta`. So the handle - // follows the cursor precisely, and is back on the anchor exactly when offset is zero. - const float rad = layer.rotation_deg * float(M_PI) / 180.f; - const float cs = std::cos(rad), sn = std::sin(rad); - // Undo the non-square v scaling first - it is the last thing apply_uv_transform() does, so it is - // the first thing to come off on the way back. - const float aspect = layer_texture_aspect(layer); - const Vec2f o(layer.offset.x(), (aspect > 0.f) ? layer.offset.y() / aspect : layer.offset.y()); - const Vec2f unrotated(o.x() * cs + o.y() * sn, -o.x() * sn + o.y() * cs); - const Vec2f planar = -unrotated * layer.tiling_scale; - Vec3f u_axis, v_axis; adjust_tangent_basis(u_axis, v_axis); + const Vec2f planar = adjust_handle_planar(layer, layer_texture_aspect(layer)); return adjust_plane_point() + u_axis * planar.x() + v_axis * planar.y(); } void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const { - // Mirrors project_planar()'s own dominant-axis choice exactly, so the ring's "0 degrees" and - // the offset handle's plane always agree with what project_texture_displacement_uv() does. + // Mirrors project_planar()'s own dominant-axis choice exactly, so the tile's "0 degrees" and + // the drag arithmetic always agree with what project_texture_displacement_uv() does. const Vec3f n = m_adjust_anchor_normal.cwiseAbs(); if (n.x() >= n.y() && n.x() >= n.z()) { u_axis = Vec3f::UnitY(); @@ -3307,236 +3339,586 @@ void GLGizmoTextureDisplacement::adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_ax } } -void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() +namespace { +// The placement gizmo's fixed screen-space dimensions, in pixels. Nothing here scales with the +// volume or the zoom: the handles have to stay the same size to stay grabbable, and only the tile +// frame - the one part that stands for a measurement - is allowed to grow and shrink. +constexpr double ADJUST_MIN_TILE_PX = 44.0; // the smallest the frame is ever drawn +constexpr double ADJUST_ARM_PX = 32.0; // how far an axis arm reaches past the frame's edge +constexpr double ADJUST_ROT_STEM_PX = 30.0; // from the near corner out to the rotate ring +constexpr double ADJUST_ROT_R_PX = 7.6; // the rotate ring's radius +constexpr double ADJUST_GRIP_HALF_PX = 4.6; // half the side of a corner scale grip +// Hit radii, in the order adjust_pick() tests them. Deliberately larger than the marks they belong +// to: the drawn handle says where to aim, the radius decides what counts as hitting it. +constexpr double ADJUST_PICK_ROT_PX = 12.0; +constexpr double ADJUST_PICK_GRIP_PX = 11.0; +constexpr double ADJUST_PICK_TIP_PX = 11.0; +constexpr double ADJUST_PICK_SHAFT_PX = 8.0; + +double point_to_segment_px(const Vec2d &p, const Vec2d &a, const Vec2d &b) +{ + const Vec2d ab = b - a; + const double len2 = ab.squaredNorm(); + const double t = (len2 > 1e-9) ? std::clamp((p - a).dot(ab) / len2, 0.0, 1.0) : 0.0; + return (p - (a + ab * t)).norm(); +} + +// Point in a convex quad, vertices in order: every edge's cross product shares a sign for a point +// inside. A degenerate edge (the frame seen exactly edge-on) contributes no sign and is skipped. +bool point_in_quad(const Vec2d &p, const std::array &q) +{ + int sign = 0; + for (int i = 0; i < 4; ++i) { + const Vec2d &a = q[size_t(i)], &b = q[size_t((i + 1) % 4)]; + const double cr = (b.x() - a.x()) * (p.y() - a.y()) - (b.y() - a.y()) * (p.x() - a.x()); + const int s = (cr > 0.0) ? 1 : ((cr < 0.0) ? -1 : 0); + if (s == 0) + continue; + if (sign == 0) + sign = s; + else if (s != sign) + return false; + } + return true; +} +} // namespace + +bool GLGizmoTextureDisplacement::adjust_basis(Transform3d &trafo, Vec3f &u_axis, Vec3f &v_axis, Vec3f &base) const { if (!m_adjust_anchor_valid) - return; + return false; + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || active_layer() == nullptr) + return false; + const Selection &selection = m_parent.get_selection(); + const int idx = selection.get_instance_idx(); + if (idx < 0 || size_t(idx) >= mo->instances.size()) + return false; + trafo = mo->instances[size_t(idx)]->get_transformation().get_matrix() * mv->get_matrix(); + adjust_tangent_basis(u_axis, v_axis); + base = adjust_plane_point(); + return true; +} - const ModelObject *mo = m_c->selection_info()->model_object(); - const ModelVolume *mv = texture_volume(); +bool GLGizmoTextureDisplacement::adjust_quad_for(const Vec2f ¢er_planar, float rot_deg, + const Vec2f &tile_mm, std::array &out) const +{ + Transform3d trafo; + Vec3f u_axis, v_axis, base; + if (!adjust_basis(trafo, u_axis, v_axis, base)) + return false; const TextureDisplacementLayer *layer = active_layer(); - if (mo == nullptr || mv == nullptr || layer == nullptr) - return; - - const Selection &selection = m_parent.get_selection(); - const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); - - // Handle sizes scale with the volume so they stay usable on both tiny and huge models. - const float bbox_size = float(mv->mesh().bounding_box().size().norm()); - const float panel_half = bbox_size * 0.04f + 1.f; - const float arrow_length = panel_half * 2.2f; - - // Tracks the layer's offset, so the handle actually travels with the texture as it is dragged. - const Vec3f handle_center_local = adjust_handle_center(*layer); - - GLShaderProgram *shader = wxGetApp().get_shader("flat"); - if (shader == nullptr) - return; - - glsafe(::glDisable(GL_DEPTH_TEST)); - glsafe(::glEnable(GL_BLEND)); - shader->start_using(); + const float aspect = layer_texture_aspect(*layer); + const float rad = rot_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + const Vec3f e_u = u_axis * cs - v_axis * sn; + const Vec3f e_v = u_axis * sn + v_axis * cs; + const Vec3f c = base + u_axis * center_planar.x() + v_axis * center_planar.y(); + const Vec3f du = e_u * (0.5f * tile_mm.x()); + const Vec3f dv = e_v * (0.5f * tile_mm.y() / ((aspect > 0.f) ? aspect : 1.f)); const Camera &camera = wxGetApp().plater()->get_camera(); - - Vec3f u_axis, v_axis; - adjust_tangent_basis(u_axis, v_axis); - Transform3d plane_transform = Transform3d::Identity(); - plane_transform.linear().col(0) = u_axis.cast(); - plane_transform.linear().col(1) = v_axis.cast(); - plane_transform.linear().col(2) = m_adjust_anchor_normal.cast(); - plane_transform.translation() = handle_center_local.cast(); - - // Pan panel: a flat square lying in the patch's own tangent plane. Dragging anywhere on it - // moves the texture freely along both axes at once. - if (!m_adjust_panel_glmodel.is_initialized()) { - GLModel::Geometry init_data; - init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; - init_data.reserve_vertices(4); - init_data.reserve_indices(6); - init_data.add_vertex(Vec3f(-1.f, -1.f, 0.f)); - init_data.add_vertex(Vec3f(1.f, -1.f, 0.f)); - init_data.add_vertex(Vec3f(1.f, 1.f, 0.f)); - init_data.add_vertex(Vec3f(-1.f, 1.f, 0.f)); - init_data.add_triangle(0, 1, 2); - init_data.add_triangle(0, 2, 3); - m_adjust_panel_glmodel.init_from(std::move(init_data)); + const Vec3f pts[4] = { c + du + dv, c + du - dv, c - du - dv, c - du + dv }; + for (int i = 0; i < 4; ++i) { + const Point p = CameraUtils::project(camera, trafo * pts[i].cast()); + out[size_t(i)] = Vec2d(double(p.x()), double(p.y())); } - Transform3d view_model_matrix = camera.get_view_matrix() * trafo_matrix * plane_transform * - Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(panel_half, panel_half, panel_half)); - shader->set_uniform("view_model_matrix", view_model_matrix); - shader->set_uniform("projection_matrix", camera.get_projection_matrix()); - ColorRGBA panel_color = m_adjust_drag_handle == AdjustHandle::Pan ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE(); - panel_color.a(0.45f); - m_adjust_panel_glmodel.set_color(panel_color); - m_adjust_panel_glmodel.render(); + return true; +} - // Axis arrows: a shaft plus a small V-shaped arrowhead, both along local +X. Reused for both - // the U and V axes below by swapping which world direction local +X is transformed to. - if (!m_adjust_arrow_glmodel.is_initialized()) { - GLModel::Geometry init_data; - init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; - init_data.reserve_vertices(4); - init_data.reserve_indices(6); - init_data.add_vertex(Vec3f(0.f, 0.f, 0.f)); - init_data.add_vertex(Vec3f(1.f, 0.f, 0.f)); - init_data.add_vertex(Vec3f(0.82f, 0.08f, 0.f)); - init_data.add_vertex(Vec3f(0.82f, -0.08f, 0.f)); - init_data.add_line(0, 1); - init_data.add_line(1, 2); - init_data.add_line(1, 3); - m_adjust_arrow_glmodel.init_from(std::move(init_data)); - } -#if !SLIC3R_OPENGL_ES - if (!OpenGLManager::get_gl_info().is_core_profile()) - glsafe(::glLineWidth(2.0f)); -#endif // !SLIC3R_OPENGL_ES +GLGizmoTextureDisplacement::AdjustFrame GLGizmoTextureDisplacement::adjust_frame() const +{ + AdjustFrame f; + Transform3d trafo; + Vec3f u_axis, v_axis, base; + if (!adjust_basis(trafo, u_axis, v_axis, base)) + return f; + const TextureDisplacementLayer *layer = active_layer(); - auto render_arrow = [&](const Vec3f &axis, const Vec3f &other_axis, bool is_active) { - Transform3d arrow_transform = Transform3d::Identity(); - arrow_transform.linear().col(0) = axis.cast(); - arrow_transform.linear().col(1) = other_axis.cast(); - arrow_transform.linear().col(2) = m_adjust_anchor_normal.cast(); - arrow_transform.translation() = handle_center_local.cast(); + // The tile's own edge directions. apply_uv_transform() rotates the planar coordinate by + // +rotation_deg before dividing by the extents, so the tile's u edge lies along R^-1 of the uv + // u axis, which is (cos, -sin) in the planar basis - and the pattern on the surface therefore + // appears to turn the other way, which is what "Rotation" has always meant here. + const float rad = layer->rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + f.e_u = u_axis * cs - v_axis * sn; + f.e_v = u_axis * sn + v_axis * cs; - const Transform3d vmm = camera.get_view_matrix() * trafo_matrix * arrow_transform * - Geometry::assemble_transform(Vec3d::Zero(), Vec3d::Zero(), Vec3d(arrow_length, arrow_length, arrow_length)); - shader->set_uniform("view_model_matrix", vmm); - shader->set_uniform("projection_matrix", camera.get_projection_matrix()); - m_adjust_arrow_glmodel.set_color(is_active ? ColorRGBA::YELLOW() : ColorRGBA::ORANGE()); - m_adjust_arrow_glmodel.render(); + const float aspect = layer_texture_aspect(*layer); + f.tile_mm = layer_tile_mm(*layer); + // One uv unit along v is tile.y / aspect millimetres, the aspect correction living in uv space + // (see apply_uv_transform()), so that - not tile.y itself - is the edge to draw. + f.half_planar = Vec2f(0.5f * f.tile_mm.x(), 0.5f * f.tile_mm.y() / ((aspect > 0.f) ? aspect : 1.f)); + f.center_planar = adjust_handle_planar(*layer, aspect); + + const Vec3f centre = base + u_axis * f.center_planar.x() + v_axis * f.center_planar.y(); + const Vec3f du = f.e_u * f.half_planar.x(); + const Vec3f dv = f.e_v * f.half_planar.y(); + + const Camera &camera = wxGetApp().plater()->get_camera(); + const auto to_screen = [&](const Vec3f &local) { + const Point p = CameraUtils::project(camera, trafo * local.cast()); + return Vec2d(double(p.x()), double(p.y())); }; - render_arrow(u_axis, v_axis, m_adjust_drag_handle == AdjustHandle::AxisU); - render_arrow(v_axis, u_axis, m_adjust_drag_handle == AdjustHandle::AxisV); - shader->stop_using(); - glsafe(::glDisable(GL_BLEND)); - glsafe(::glEnable(GL_DEPTH_TEST)); + f.center = to_screen(centre); + f.corner[0] = to_screen(centre + du + dv); + f.corner[1] = to_screen(centre + du - dv); + f.corner[2] = to_screen(centre - du - dv); + f.corner[3] = to_screen(centre - du + dv); + f.edge_mid[0] = to_screen(centre + du); + f.edge_mid[1] = to_screen(centre - du); + f.edge_mid[2] = to_screen(centre + dv); + f.edge_mid[3] = to_screen(centre - dv); + + // A tile can project to a handful of pixels - a fine texture, or a zoomed-out view - and a + // control that small cannot be aimed at. Below the floor the frame is pushed out from its centre + // to exactly that size and drawn dashed instead of solid; the readout keeps reporting the true + // millimetres, so the drawing can never be mistaken for the measurement. + double reach = 0.0; + for (const Vec2d &c : f.corner) + reach = std::max(reach, (c - f.center).norm()); + if (reach > 1e-6 && 2.0 * reach < ADJUST_MIN_TILE_PX) { + const double k = 0.5 * ADJUST_MIN_TILE_PX / reach; + for (Vec2d &c : f.corner) + c = f.center + (c - f.center) * k; + for (Vec2d &m : f.edge_mid) + m = f.center + (m - f.center) * k; + f.clamped = true; + } + + // Arms are measured in screen pixels off the projected edge, not in millimetres along the + // surface, so they keep their length however the tile is foreshortened. + static const Vec2d fallback_dir[4] = { { 1.0, 0.0 }, { -1.0, 0.0 }, { 0.0, 1.0 }, { 0.0, -1.0 } }; + for (int i = 0; i < 4; ++i) { + const Vec2d out = f.edge_mid[size_t(i)] - f.center; + const double len = out.norm(); + // An edge seen exactly edge-on projects to nothing and has no outward direction to read off + // it; the screen axes keep the arms spread out rather than piled on the centre. + f.arm_dir[size_t(i)] = (len > 1e-6) ? Vec2d(out / len) : fallback_dir[i]; + f.arm_tip[size_t(i)] = f.edge_mid[size_t(i)] + f.arm_dir[size_t(i)] * ADJUST_ARM_PX; + } + + const Vec2d rot_out = f.corner[0] - f.center; + const double rot_len = rot_out.norm(); + f.rotate_at = f.corner[0] + ((rot_len > 1e-6) ? Vec2d(rot_out / rot_len) : Vec2d(0.0, 1.0)) * ADJUST_ROT_STEM_PX; + + f.valid = true; + return f; +} + +GLGizmoTextureDisplacement::AdjustHandle +GLGizmoTextureDisplacement::adjust_pick(const Vec2d &mouse, const AdjustFrame &f, int &index) const +{ + index = -1; + if (!f.valid) + return AdjustHandle::None; + + const auto within = [&mouse](const Vec2d &p, double r) { return (mouse - p).squaredNorm() <= r * r; }; + + // Outermost first. The rotate ring is the only handle off the frame, so it is the cheapest to + // rule out; the corners and the arms come before the interior so that a grip stays grabbable + // even when the frame is foreshortened to a sliver and every handle lands on the move area. + if (within(f.rotate_at, ADJUST_PICK_ROT_PX)) + return AdjustHandle::Rotate; + for (int i = 0; i < 4; ++i) + if (within(f.corner[size_t(i)], ADJUST_PICK_GRIP_PX)) { + index = i; + return AdjustHandle::Scale; + } + for (int i = 0; i < 4; ++i) + if (within(f.arm_tip[size_t(i)], ADJUST_PICK_TIP_PX)) { + index = i; + return AdjustHandle::Axis; + } + for (int i = 0; i < 4; ++i) + if (point_to_segment_px(mouse, f.edge_mid[size_t(i)], f.arm_tip[size_t(i)]) <= ADJUST_PICK_SHAFT_PX) { + index = i; + return AdjustHandle::Axis; + } + // A real polygon test, not a circle through the corners: the old hit test let a move start from + // empty space beside the tile, and refused one near a corner the circle did not reach. + if (point_in_quad(mouse, f.corner)) + return AdjustHandle::Move; + return AdjustHandle::None; +} + +bool GLGizmoTextureDisplacement::adjust_planar_under(const Vec2d &mouse, Vec2f &planar) const +{ + Transform3d trafo; + Vec3f u_axis, v_axis, base; + if (!adjust_basis(trafo, u_axis, v_axis, base)) + return false; + const Camera &camera = wxGetApp().plater()->get_camera(); + Vec3d world_hit; + if (!ray_plane_hit(camera, mouse, trafo, base, m_adjust_anchor_normal, world_hit)) + return false; + const Vec3f local = (trafo.inverse() * world_hit).cast(); + // project_planar() is absolute, and everything here is relative to the anchor, so the base's own + // planar coordinate is the origin. Taking it from the very point the frame is built on is what + // keeps the hit, the frame and the offset arithmetic in one coordinate system. + planar = project_planar(local, m_adjust_anchor_normal) - project_planar(base, m_adjust_anchor_normal); + return true; +} + +void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() +{ + const AdjustFrame f = adjust_frame(); + if (!f.valid) + return; + + ImDrawList *dl = ImGui::GetForegroundDrawList(); + + // Orca's own palette: COL_ORCA for the accent, the icon grey for structure, both straight out of + // ImGuiWrapper and the panel's own SVGs. The accent lifts in the dark theme, but it is the halo + // and the handle fill that really carry the theme - a volume stays the same neutral grey however + // the UI is themed, so the accent alone could not. + const bool dark = m_is_dark_mode; + const ImU32 accent = dark ? IM_COL32(38, 166, 154, 255) : IM_COL32(0, 150, 136, 255); + const ImU32 hot = dark ? IM_COL32(77, 182, 172, 255) : IM_COL32(38, 166, 154, 255); + const ImU32 held = dark ? IM_COL32(128, 203, 196, 255) : IM_COL32(0, 103, 91, 255); + const ImU32 structc = dark ? IM_COL32(182, 182, 182, 255) : IM_COL32(148, 148, 148, 255); + const ImU32 dimc = dark ? IM_COL32(182, 182, 182, 115) : IM_COL32(148, 148, 148, 128); + const ImU32 halo = dark ? IM_COL32(0, 0, 0, 133) : IM_COL32(0, 0, 0, 82); + const ImU32 fill = dark ? IM_COL32(38, 166, 154, 31) : IM_COL32(0, 150, 136, 20); + const ImU32 fill_hot = dark ? IM_COL32(38, 166, 154, 51) : IM_COL32(0, 150, 136, 41); + const ImU32 handle = dark ? IM_COL32(45, 45, 49, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 ghostc = dark ? IM_COL32(182, 182, 182, 153) : IM_COL32(100, 100, 106, 153); + + const bool dragging = m_adjust_drag_kind != AdjustHandle::None; + const AdjustHandle act = dragging ? m_adjust_drag_kind : m_adjust_hover_kind; + const int act_idx = dragging ? m_adjust_drag_index : m_adjust_hover_index; + const ImU32 act_col = dragging ? held : hot; + // While a drag runs, everything that is not being dragged drops to grey: during a rotation the + // frame has to read as the thing being turned, not as another thing to grab. + const ImU32 rest_col = dragging ? dimc : structc; + const bool move_live = act == AdjustHandle::Move; + const ImU32 frame_col = move_live ? act_col : accent; + const float frame_w = move_live ? 2.4f : 1.8f; + + const auto P = [](const Vec2d &v) { return ImVec2(float(v.x()), float(v.y())); }; + // Every stroke goes down twice: the halo first, a pixel wider on each side, then the stroke + // itself. That is the whole reason the gizmo stays readable over a pale model, a dark one and a + // painted one alike, and it is why this is drawn through ImGui rather than the flat shader. + const auto stroke = [&](const ImVec2 *pts, int n, bool closed, ImU32 col, float w) { + const ImDrawFlags fl = closed ? ImDrawFlags_Closed : ImDrawFlags(0); + dl->AddPolyline(pts, n, halo, fl, w + 2.4f); + dl->AddPolyline(pts, n, col, fl, w); + }; + const auto dashed = [&](const Vec2d &a, const Vec2d &b, ImU32 col, double dash, double gap) { + const double len = (b - a).norm(); + if (len < 1e-6) + return; + const Vec2d dir = (b - a) / len; + for (double t = 0.0; t < len; t += dash + gap) + dl->AddLine(P(a + dir * t), P(a + dir * std::min(t + dash, len)), col, 1.3f); + }; + + // ---- drag feedback, under the gizmo itself -------------------------------------------------- + if (dragging) { + // Where the tile was when the drag began, so there is something to judge the move against. + std::array ghost; + if (adjust_quad_for(m_adjust_drag_start_center, m_adjust_drag_start_rot, m_adjust_drag_start_tile, ghost)) + for (int i = 0; i < 4; ++i) + dashed(ghost[size_t(i)], ghost[size_t((i + 1) % 4)], ghostc, 5.0, 5.0); + + const ImU32 guide = dark ? IM_COL32(38, 166, 154, 153) : IM_COL32(0, 150, 136, 166); + if (act == AdjustHandle::Axis && act_idx >= 0) { + // The axis the move is being held to, drawn right across the view so it is obvious which + // one won when two arms were close together on screen. + const Vec2d d = f.arm_dir[size_t(act_idx)]; + dashed(f.center - d * 1200.0, f.center + d * 1200.0, guide, 6.0, 6.0); + } else if (act == AdjustHandle::Scale && act_idx >= 0) { + const Vec2d out = f.corner[size_t(act_idx)] - f.center; + const double len = out.norm(); + if (len > 1e-6) { + const Vec2d d = out / len; + dashed(f.center - d * 1200.0, f.center + d * 1200.0, guide, 6.0, 6.0); + } + } else if (act == AdjustHandle::Rotate) { + // The orbit, and the angle swept since the drag began, measured in the surface's own + // plane - so a circle on the model, an ellipse on screen. + Transform3d trafo; + Vec3f u_axis, v_axis, base; + if (adjust_basis(trafo, u_axis, v_axis, base)) { + const Camera &camera = wxGetApp().plater()->get_camera(); + const double r = std::max(double(f.half_planar.norm()), 1e-4); + const auto on_orbit = [&](double ang) { + const Vec3f local = base + u_axis * float(f.center_planar.x() + r * std::cos(ang)) + + v_axis * float(f.center_planar.y() + r * std::sin(ang)); + const Point p = CameraUtils::project(camera, trafo * local.cast()); + return Vec2d(double(p.x()), double(p.y())); + }; + constexpr int N = 72; + Vec2d prev = on_orbit(0.0); + for (int i = 1; i <= N; ++i) { + const Vec2d cur = on_orbit(double(i) * 2.0 * M_PI / double(N)); + dashed(prev, cur, guide, 4.0, 6.0); + prev = cur; + } + // The swept arc, solid on top of the dashed orbit - the start angle is where the + // grab was, so the arc reads as "this is how far you have turned it". + // rotation_deg runs the opposite way to the cursor's sweep (see the drag), so where + // the cursor is now is the start angle plus however far the value has gone back. + const double a0 = double(m_adjust_drag_start_angle); + const double a1 = a0 + double(m_adjust_drag_start_rot - active_layer()->rotation_deg) * M_PI / 180.0; + const int steps = std::max(2, int(std::ceil(std::abs(a1 - a0) / (2.0 * M_PI / double(N))))); + Vec2d sp = on_orbit(a0); + for (int i = 1; i <= steps; ++i) { + const Vec2d cur = on_orbit(a0 + (a1 - a0) * double(i) / double(steps)); + dl->AddLine(P(sp), P(cur), act_col, 3.0f); + sp = cur; + } + } + } + } + + // ---- the tile ------------------------------------------------------------------------------ + const ImVec2 quad[4] = { P(f.corner[0]), P(f.corner[1]), P(f.corner[2]), P(f.corner[3]) }; + dl->AddConvexPolyFilled(quad, 4, move_live ? fill_hot : fill); + if (f.clamped) + // Dashed says "not to scale": the tile is really smaller than this. + for (int i = 0; i < 4; ++i) + dashed(f.corner[size_t(i)], f.corner[size_t((i + 1) % 4)], frame_col, 5.0, 5.0); + else + stroke(quad, 4, true, frame_col, frame_w); + + // ---- axis arms ----------------------------------------------------------------------------- + for (int i = 0; i < 4; ++i) { + const bool live = act == AdjustHandle::Axis && act_idx == i; + const ImU32 col = live ? act_col : rest_col; + const Vec2d &tip = f.arm_tip[size_t(i)]; + if (live) + dl->AddCircleFilled(P(tip), 6.4f, col, 16); + const ImVec2 shaft[2] = { P(f.edge_mid[size_t(i)]), P(tip) }; + stroke(shaft, 2, false, col, live ? 1.8f : 1.6f); + // The chevron is the Adjust placement icon's own mark. Idle it is the only thing there, so + // the gizmo stays light; the filled dot behind it appears on hover, when a target is wanted. + const Vec2d d = f.arm_dir[size_t(i)], n(-d.y(), d.x()); + const ImVec2 chev[3] = { P(tip - d * 8.0 + n * 5.0), P(tip), P(tip - d * 8.0 - n * 5.0) }; + stroke(chev, 3, false, live ? handle : col, live ? 1.8f : 1.6f); + } + + // ---- move grip: the panel's own drag icon, six dots, lying in the tile's plane -------------- + { + const Vec2d ou = f.edge_mid[0] - f.center, ov = f.edge_mid[2] - f.center; + const double lu = ou.norm(), lv = ov.norm(); + const Vec2d eu = (lu > 1e-6) ? Vec2d(ou / lu) : Vec2d(1.0, 0.0); + const Vec2d ev = (lv > 1e-6) ? Vec2d(ov / lv) : Vec2d(0.0, 1.0); + const ImU32 col = move_live ? act_col : rest_col; + for (int r = -1; r <= 1; ++r) + for (int c = -1; c <= 1; c += 2) { + const Vec2d p = f.center + eu * (double(c) * 2.9) + ev * (double(r) * 5.4); + dl->AddCircleFilled(P(p), 2.6f, halo, 10); + dl->AddCircleFilled(P(p), 1.5f, col, 10); + } + } + + // ---- corner scale grips -------------------------------------------------------------------- + for (int i = 0; i < 4; ++i) { + const bool live = act == AdjustHandle::Scale && act_idx == i; + const Vec2d &c = f.corner[size_t(i)]; + const double h = ADJUST_GRIP_HALF_PX; + const ImVec2 sq[4] = { ImVec2(float(c.x() - h), float(c.y() - h)), ImVec2(float(c.x() + h), float(c.y() - h)), + ImVec2(float(c.x() + h), float(c.y() + h)), ImVec2(float(c.x() - h), float(c.y() + h)) }; + dl->AddConvexPolyFilled(sq, 4, live ? act_col : handle); + stroke(sq, 4, true, live ? act_col : frame_col, 1.6f); + } + + // ---- rotate handle ------------------------------------------------------------------------- + { + const bool live = act == AdjustHandle::Rotate; + const ImU32 col = live ? act_col : rest_col; + const ImVec2 stem[2] = { P(f.corner[0]), P(f.rotate_at) }; + stroke(stem, 2, false, col, live ? 1.8f : 1.6f); + const ImVec2 at = P(f.rotate_at); + dl->AddCircleFilled(at, float(ADJUST_ROT_R_PX) + 1.2f, halo, 20); + dl->AddCircleFilled(at, float(ADJUST_ROT_R_PX), live ? act_col : handle, 20); + dl->AddCircle(at, float(ADJUST_ROT_R_PX), live ? act_col : frame_col, 20, 1.6f); + // The same curved arrow Orca's rotate gizmo uses, so the handle says what it does without a + // tooltip: a part ring with an arrowhead on its leading end. + const ImU32 gcol = live ? handle : rest_col; + dl->PathClear(); + dl->PathArcTo(at, 4.2f, float(M_PI) * 1.11f, float(M_PI) * 1.89f, 12); + dl->PathStroke(gcol, 0, 1.5f); + const ImVec2 head[3] = { ImVec2(at.x + 1.11f, at.y - 3.38f), ImVec2(at.x + 3.95f, at.y - 1.44f), + ImVec2(at.x + 4.87f, at.y - 4.75f) }; + dl->AddPolyline(head, 3, gcol, 0, 1.5f); + } + + // ---- the readout, by the cursor, only while a drag is live ---------------------------------- + if (!m_adjust_readout.empty() && ImGui::IsMousePosValid()) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + const ImVec2 ts = ImGui::CalcTextSize(m_adjust_readout.c_str()); + const ImVec2 at(mouse.x + 16.f, mouse.y - ts.y - 20.f); + dl->AddRectFilled(ImVec2(at.x - 7.f, at.y - 5.f), ImVec2(at.x + ts.x + 7.f, at.y + ts.y + 5.f), + IM_COL32(26, 26, 29, 230), 5.f); + dl->AddText(at, IM_COL32(255, 255, 255, 255), m_adjust_readout.c_str()); + } } bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mouse_event) { - if (!m_adjust_anchor_valid) - return false; - - ModelVolume *mv = texture_volume(); - ModelObject *mo = m_c->selection_info()->model_object(); TextureDisplacementLayer *layer = active_layer(); - if (mv == nullptr || mo == nullptr || layer == nullptr) + if (!m_adjust_anchor_valid || layer == nullptr) return false; - const Selection &selection = m_parent.get_selection(); - const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); - const Camera &camera = wxGetApp().plater()->get_camera(); + const Vec2d mouse(mouse_event.GetX(), mouse_event.GetY()); - const float bbox_size = float(mv->mesh().bounding_box().size().norm()); - const float panel_half = bbox_size * 0.04f + 1.f; - const float arrow_length = panel_half * 2.2f; - // Where the handle is drawn (moves with the layer's offset) vs. the plane the drag is measured - // against (fixed at the anchor). Keeping them apart is what stops the handle's own motion from - // feeding back into the delta that produced it. - const Vec3f handle_center_local = adjust_handle_center(*layer); - const Vec3f drag_plane_local = adjust_plane_point(); - const Vec3d handle_center_world = trafo_matrix * handle_center_local.cast(); - const Vec2d mouse_pos(mouse_event.GetX(), mouse_event.GetY()); - - Vec3f u_axis, v_axis; - adjust_tangent_basis(u_axis, v_axis); - - const Point handle_screen = CameraUtils::project(camera, handle_center_world); - const Vec2d handle_screen_d(double(handle_screen.x()), double(handle_screen.y())); - - // Point-to-segment distance in screen space, for the arrow shafts. - auto dist_to_segment_px = [](const Vec2d &p, const Vec2d &a, const Vec2d &b) { - const Vec2d ab = b - a; - const double len2 = ab.squaredNorm(); - const double t = (len2 > 1e-9) ? std::clamp((p - a).dot(ab) / len2, 0.0, 1.0) : 0.0; - return (p - (a + ab * t)).norm(); - }; - - if (mouse_event.LeftDown()) { - const Vec3d u_tip_world = trafo_matrix * (handle_center_local + u_axis * arrow_length).cast(); - const Vec3d v_tip_world = trafo_matrix * (handle_center_local + v_axis * arrow_length).cast(); - const Point u_tip_screen = CameraUtils::project(camera, u_tip_world); - const Point v_tip_screen = CameraUtils::project(camera, v_tip_world); - const Vec2d u_tip_screen_d(double(u_tip_screen.x()), double(u_tip_screen.y())); - const Vec2d v_tip_screen_d(double(v_tip_screen.x()), double(v_tip_screen.y())); - - // Panel screen-space "radius", approximated from one corner (a loose circle around the - // square is close enough for hit-testing purposes). - const Vec3d panel_corner_world = trafo_matrix * (handle_center_local + (u_axis + v_axis) * panel_half).cast(); - const Point panel_corner_screen = CameraUtils::project(camera, panel_corner_world); - const double panel_screen_radius = (Vec2d(double(panel_corner_screen.x()), double(panel_corner_screen.y())) - handle_screen_d).norm(); - - constexpr double pick_tolerance_px = 8.0; - const double dist_to_u = dist_to_segment_px(mouse_pos, handle_screen_d, u_tip_screen_d); - const double dist_to_v = dist_to_segment_px(mouse_pos, handle_screen_d, v_tip_screen_d); - const double dist_to_panel = (handle_screen_d - mouse_pos).norm(); - - // Arrows take priority over the panel (their tips extend past it), then the panel covers - // the broader central area. - if (dist_to_u <= pick_tolerance_px && dist_to_u <= dist_to_v) - m_adjust_drag_handle = AdjustHandle::AxisU; - else if (dist_to_v <= pick_tolerance_px) - m_adjust_drag_handle = AdjustHandle::AxisV; - else if (dist_to_panel <= panel_screen_radius) - m_adjust_drag_handle = AdjustHandle::Pan; - else { - m_adjust_drag_handle = AdjustHandle::None; - return false; - } - - m_adjust_drag_start_offset = layer->offset; - - Vec3d world_hit; - if (ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit)) { - const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast(); - m_adjust_drag_start_planar = project_planar(local_hit, m_adjust_anchor_normal); - } + if (mouse_event.LeftUp() && m_adjust_drag_kind != AdjustHandle::None) { + m_adjust_drag_kind = AdjustHandle::None; + m_adjust_drag_index = -1; + m_adjust_readout.clear(); + // The live drag only marked the preview's parameters stale; the real mesh is rebuilt once, + // when the gesture ends, exactly as a paint stroke is flushed on mouse-up. + rebuild_preview(); + m_preview_params_dirty = false; + m_parent.set_as_dirty(); return true; } - if (mouse_event.Dragging() && m_adjust_drag_handle != AdjustHandle::None) { - Vec3d world_hit; - if (!ray_plane_hit(camera, mouse_pos, trafo_matrix, drag_plane_local, m_adjust_anchor_normal, world_hit)) + if (mouse_event.Dragging() && m_adjust_drag_kind != AdjustHandle::None) { + Vec2f hit; + if (!adjust_planar_under(mouse, hit)) return true; - const Vec3f local_hit = (trafo_matrix.inverse() * world_hit).cast(); - const Vec2f current_planar = project_planar(local_hit, m_adjust_anchor_normal); - Vec2f delta_planar = current_planar - m_adjust_drag_start_planar; - // project_planar()'s (x, y) axes are exactly u_axis/v_axis (see adjust_tangent_basis()), - // so zeroing one component constrains the drag to only the other axis. - if (m_adjust_drag_handle == AdjustHandle::AxisU) - delta_planar.y() = 0.f; - else if (m_adjust_drag_handle == AdjustHandle::AxisV) - delta_planar.x() = 0.f; + // Numbers are formatted first and substituted as strings: boost::format, which + // Slic3r::format wraps, will not take a printf width together with positional arguments. + const auto num = [](float v, int dp) { + char b[32]; + std::snprintf(b, sizeof(b), "%.*f", dp, double(v)); + return std::string(b); + }; + const auto signed_mm = [](float v) { + char b[32]; + std::snprintf(b, sizeof(b), "%+.2f", double(v)); + return std::string(b); + }; - const float scale = (layer->tiling_scale > 1e-6f) ? (1.f / layer->tiling_scale) : 1.f; - const Vec2f delta_scaled = delta_planar * scale; - const float rad = layer->rotation_deg * float(M_PI) / 180.f; - const float cs = std::cos(rad), sn = std::sin(rad); - Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs); - // ...and the same v scaling apply_uv_transform() applies for a non-square texture, so the - // handle keeps tracking the cursor exactly instead of drifting on the v axis. - delta_rotated.y() *= layer_texture_aspect(*layer); - // Increasing `offset` shifts which texel is sampled at a fixed world position, which - // visually slides the pattern the *opposite* way - subtracting is this session's - // best-effort reasoning about the direction that feels like "dragging the texture", - // unverified against an actual render (see header comment). - layer->offset = m_adjust_drag_start_offset - delta_rotated; + const float aspect = layer_texture_aspect(*layer); + const float rad0 = m_adjust_drag_start_rot * float(M_PI) / 180.f; + const float cs0 = std::cos(rad0), sn0 = std::sin(rad0); + // The tile's own axes in planar space: orthonormal, and the directions the frame's edges run + // along (see adjust_frame()). Projecting onto these is not the same as zeroing one planar + // component once the tile is rotated, which is what the old axis drag got wrong. + const Vec2f e_u(cs0, -sn0), e_v(sn0, cs0); + + Vec2f centre = m_adjust_drag_start_center; + float rot = m_adjust_drag_start_rot; + Vec2f tile = m_adjust_drag_start_tile; + + switch (m_adjust_drag_kind) { + case AdjustHandle::Move: { + centre = m_adjust_drag_start_center + (hit - m_adjust_drag_start_grab); + m_adjust_readout = Slic3r::format(_u8L("U %1% V %2% mm"), signed_mm(centre.x()), + signed_mm(centre.y())); + break; + } + case AdjustHandle::Axis: { + const Vec2f axis = (m_adjust_drag_index < 2) ? e_u : e_v; + const Vec2f delta = hit - m_adjust_drag_start_grab; + centre = m_adjust_drag_start_center + axis * delta.dot(axis); + // The position along the axis, not the distance travelled, so this reads the same way + // the free move's readout does. + m_adjust_readout = Slic3r::format(_u8L("%1% %2% mm"), (m_adjust_drag_index < 2) ? "U" : "V", + signed_mm(centre.dot(axis))); + break; + } + case AdjustHandle::Rotate: { + const Vec2f rel = hit - centre; + if (rel.squaredNorm() < 1e-8f) + return true; + const float ang = std::atan2(rel.y(), rel.x()); + // The tile's u edge sits at planar angle -rotation_deg, so the stored value runs the + // other way from the cursor's own sweep. Getting this backwards is the difference + // between a handle that follows the pointer and one that runs away from it. + rot = m_adjust_drag_start_rot - (ang - m_adjust_drag_start_angle) * 180.f / float(M_PI); + float shown = std::fmod(rot, 360.f); + if (shown > 180.f) + shown -= 360.f; + if (shown <= -180.f) + shown += 360.f; + m_adjust_readout = num(shown, 1) + "°"; + break; + } + case AdjustHandle::Scale: { + const Vec2f rel = hit - centre; + const bool locked = layer->tiling_scale_v <= 0.f; + if (locked || mouse_event.ShiftDown()) { + // One extent, or the lock asked for: the tile follows how much further out the grab + // point is than where it started, which is the only reading that cannot change the + // tile's proportions. + const float r0 = (m_adjust_drag_start_grab - m_adjust_drag_start_center).norm(); + const float k = (r0 > 1e-4f) ? (rel.norm() / r0) : 1.f; + tile = m_adjust_drag_start_tile * k; + } else { + // The grabbed corner follows the cursor about the fixed centre, so its coordinates + // in the tile's own frame *are* the half extents. The v extent is stored before the + // aspect correction, hence the multiply back on the way in. + const float a = (aspect > 0.f) ? aspect : 1.f; + tile = Vec2f(2.f * std::abs(rel.dot(e_u)), 2.f * std::abs(rel.dot(e_v)) * a); + } + // The same bounds the panel's sliders clamp to, so a drag cannot reach a value the + // control beside it refuses to show. + tile = Vec2f(std::clamp(tile.x(), 0.2f, 200.f), std::clamp(tile.y(), 0.2f, 200.f)); + m_adjust_readout = (layer->tiling_scale_v > 0.f) ? + Slic3r::format(_u8L("Tile %1% x %2% mm"), num(tile.x(), 2), num(tile.y(), 2)) : + Slic3r::format(_u8L("Tile %1% mm"), num(tile.x(), 2)); + break; + } + default: break; + } + + layer->rotation_deg = rot; + layer->tiling_scale = tile.x(); + // A locked tile stays locked: a scale drag must not quietly give the v extent a value of its + // own behind the panel's lock. + if (layer->tiling_scale_v > 0.f) + layer->tiling_scale_v = tile.y(); + // Last, and for every kind of drag alike: the offset is whatever puts the tile's centre + // where this gesture wants it, given the rotation and the extents it has just settled. That + // is what makes a rotate or a scale act in place instead of swinging the tile around the + // anchor, and it is why there is only one place that writes `offset`. + layer->offset = adjust_offset_for_planar(centre, rot, layer_tile_mm(*layer), aspect); m_preview_params_dirty = true; m_parent.set_as_dirty(); return true; } - if (mouse_event.LeftUp() && m_adjust_drag_handle != AdjustHandle::None) { - m_adjust_drag_handle = AdjustHandle::None; - rebuild_preview(); - m_preview_params_dirty = false; + const AdjustFrame f = adjust_frame(); + int idx = -1; + const AdjustHandle k = adjust_pick(mouse, f, idx); + + if (mouse_event.LeftDown()) { + if (k == AdjustHandle::None) + return false; // not ours: falls through so the view can still be orbited in this mode + Vec2f grab; + if (!adjust_planar_under(mouse, grab)) + return false; + m_adjust_drag_kind = k; + m_adjust_drag_index = idx; + m_adjust_hover_kind = k; + m_adjust_hover_index = idx; + m_adjust_drag_start_center = f.center_planar; + m_adjust_drag_start_grab = grab; + m_adjust_drag_start_rot = layer->rotation_deg; + m_adjust_drag_start_tile = f.tile_mm; + const Vec2f rel = grab - f.center_planar; + m_adjust_drag_start_angle = std::atan2(rel.y(), rel.x()); + m_adjust_readout.clear(); + m_parent.set_as_dirty(); return true; } + // Hover. Only ever one handle, and only redrawn when it actually changes - the pick runs on + // every motion event, and this mode is live for as long as the user is placing a texture. + if (k != m_adjust_hover_kind || idx != m_adjust_hover_index) { + m_adjust_hover_kind = k; + m_adjust_hover_index = idx; + m_parent.set_as_dirty(); + } return false; } @@ -4476,7 +4858,8 @@ const V2Resolution &GLGizmoTextureDisplacement::v2_recommendation(const ModelVol // panel asks for this every frame. std::string key = std::to_string(mv.id().id) + ":" + std::to_string(mv.mesh().facets_count()); for (const TextureDisplacementLayer &l : mv.texture_displacement_layers) - key += Slic3r::format("|%1%:%2%:%3%:%4%", l.slot, reinterpret_cast(l.image_data.get()), l.tiling_scale, l.depth_mm); + key += Slic3r::format("|%1%:%2%:%3%:%4%:%5%", l.slot, reinterpret_cast(l.image_data.get()), + l.tiling_scale, layer_tile_mm(l).y(), l.depth_mm); const Transform3d trafo = texture_displacement_volume_to_world(mv); for (int i = 0; i < 12; ++i) key += Slic3r::format(",%1%", trafo.matrix()(i / 4, i % 4)); @@ -4972,8 +5355,10 @@ TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh( if (layer.empty()) continue; const DecodedHeightTexture &tex = decode_height_texture(layer); - if (tex.width > 0 && layer.tiling_scale > 0.f) - texel_mm = std::min(texel_mm, layer.tiling_scale / float(tex.width)); + if (tex.width > 0 && layer.tiling_scale > 0.f) { + const Vec2f tile = layer_tile_mm(layer); + texel_mm = std::min(texel_mm, std::min(tile.x(), tile.y()) / float(tex.width)); + } } const bool cut_steps = texel_mm < std::numeric_limits::max() && texture_has_steps_to_cut(mesh.its, region, sampler, 2.f * texel_mm, 2.f * texel_mm); @@ -6454,10 +6839,39 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad); hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in " "millimetres. Black does not move it at all, unless Midlevel says otherwise.")); - m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad); + // One tile can be a rectangle, so the extent along v is its own control - but only once it + // has been unlocked, since a square tile is what almost every texture wants and two + // sliders for one number would be noise. The lock state *is* the data: a v extent of <= 0 + // means "same as u" (see layer_tile_mm()), so there is no separate panel flag to keep in + // step with it, and nothing to go stale across a layer switch or an undo. + const bool tile_split = layer.tiling_scale_v > 0.f; + m_preview_params_dirty |= float_row("##tile_size", tile_split ? _L("Tile width") : _L("Tile size"), + &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad); hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern " "more often and makes its detail finer; with Tile off, this is the size of the " "single copy.")); + if (tile_split) { + m_preview_params_dirty |= float_row("##tile_size_v", _L("Tile height"), &layer.tiling_scale_v, + 0.2f, 200.f, "%.2f mm", true, card_pad); + hover_tip(_u8L("How tall one copy of the texture is on the model. Different from Tile width, " + "the pattern is stretched along one axis of the surface - which is what a " + "woodgrain or a brushed finish wants.")); + } + { + const std::string text = tile_split ? _u8L("Lock to a square tile") : + _u8L("Separate width and height"); + const ImVec2 ts = ImGui::CalcTextSize(text.c_str()); + const ImVec2 lp = ImGui::GetCursorScreenPos(); + if (ImGui::InvisibleButton("##tile_lock", ts)) { + // Unlocking seeds v from u, so the toggle on its own never changes what is baked. + layer.tiling_scale_v = tile_split ? -1.f : layer.tiling_scale; + m_preview_params_dirty = true; + } + const ImU32 lc = ImGui::GetColorU32(col_link); + dl->AddText(lp, lc, text.c_str()); + if (ImGui::IsItemHovered()) + dl->AddLine(ImVec2(lp.x, lp.y + ts.y), ImVec2(lp.x + ts.x, lp.y + ts.y), lc); + } m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad); hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an " "edge of the model.")); @@ -6764,7 +7178,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float show_error(nullptr, _u8L("Paint something with this layer first.")); } else { m_adjust_texture_mode = false; - m_adjust_drag_handle = AdjustHandle::None; + end_adjust_texture_mode(); } } } @@ -6782,7 +7196,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Placement is one of the settings being hidden, so it stops with them. if (expanded && m_adjust_texture_mode) { m_adjust_texture_mode = false; - m_adjust_drag_handle = AdjustHandle::None; + end_adjust_texture_mode(); } } const ImU32 c = ImGui::GetColorU32(col_link); @@ -7470,6 +7884,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // 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(); + // Same reason, same frame: the placement gizmo's halo, sub-pixel strokes and readout all come + // from this draw list. + if (m_adjust_texture_mode) + render_adjust_texture_gizmo(); } } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 48e0ab72f6..77dedab3f2 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -405,20 +405,84 @@ private: // Panel state only, not saved with the project. std::array m_layer_expanded{}; - // "Adjust Texture" mode: instead of painting, dragging an on-canvas handle changes the active - // layer's offset. The handle is a flat panel lying in the paint patch's own tangent plane - // (a "pan" - drag anywhere on it for free 2D movement), plus two arrows along the patch's - // own U/V axes that constrain the drag to just that one axis for precise nudging. Anchored to - // the centroid/average-normal of the active layer's current paint patch (see - // libslic3r::compute_layer_paint_anchor()), so nothing is drawn if it has nothing painted yet. + // "Adjust Texture" mode: instead of painting, the active layer's placement is a control lying + // on the model. The gizmo is one tile of the texture in the paint patch's own tangent plane: + // where it sits is the layer's offset, how it is turned is rotation_deg, how big it is is the + // tile extents - so each handle is one of the values it edits rather than a stand-in for it. + // Anchored to the centroid/average-normal of the active layer's paint patch (see + // libslic3r::compute_layer_paint_anchor()), so nothing is drawn until something is painted. // - // NOTE: the drag direction/sign below is this session's best-effort reasoning about which way - // the texture should appear to move as the handle is dragged - it could not be visually - // confirmed while writing it (no way to render/see pixels in this environment), so it may - // need a one-line sign flip once actually tested. + // Drawn through ImGui's foreground draw list rather than the "flat" shader, which is what gives + // it antialiased sub-pixel strokes, rounded joins, a contrast halo and the numeric readout. The + // cost is that render_adjust_texture_gizmo() has to be called from inside the gizmo's ImGui + // frame (see on_render_input_window()), not from on_render() with the rest of the GL drawing. + enum class AdjustHandle { None, Move, Axis, Scale, Rotate }; + + // The gizmo's geometry for one frame, in screen pixels. Worked out once and shared by the + // drawing and the hit test, so the two cannot disagree about where a handle is. Everything + // except the frame itself is a fixed number of pixels, which is what keeps the control equally + // usable on a 5 mm part and a 500 mm one - the old handle scaled with the volume's bounding box + // and so was unusable at both ends. + struct AdjustFrame + { + bool valid = false; + Vec2d center = Vec2d::Zero(); + // +u+v, +u-v, -u-v, -u+v. A scale grip sits on each. + std::array corner{}; + // +u, -u, +v, -v: where each axis arm leaves the frame, and where its chevron ends. + std::array edge_mid{}; + std::array arm_tip{}; + std::array arm_dir{}; // unit, pointing outward + Vec2d rotate_at = Vec2d::Zero(); + // The tile projects to less than ADJUST_MIN_TILE_PX across, so the frame was drawn at that + // floor instead. It is dashed to say so, and the readout still gives the true millimetres. + bool clamped = false; + // What the frame was built from, in mesh-local mm: the tile's own (rotated) edge directions + // and its extents. The drag arithmetic reuses these rather than recomputing them, so it + // cannot end up working against a different tile than the one on screen. + Vec3f e_u = Vec3f::UnitX(), e_v = Vec3f::UnitY(); + Vec2f tile_mm = Vec2f::Ones(); // (u, v) as stored on the layer + Vec2f half_planar = Vec2f::Ones(); // half extents in planar mm, v already aspect-corrected + Vec2f center_planar = Vec2f::Zero(); + }; + bool update_adjust_anchor(); // recomputes m_adjust_anchor_pos/normal; false if nothing painted + // Drops every scrap of live gizmo state. Called from each of the several ways out of the mode - + // the panel's toggle, collapsing the card, switching to seam marking, closing the gizmo - so a + // stale hover highlight or a half-finished drag cannot survive into the next time it is opened. + void end_adjust_texture_mode(); bool on_mouse_adjust_texture(const wxMouseEvent &mouse_event); void render_adjust_texture_gizmo(); + // This frame's gizmo geometry, or an invalid frame when there is nothing to draw. + AdjustFrame adjust_frame() const; + // Which handle is under `mouse`, with `index` set to the arm or corner it belongs to. Tested + // outermost first; see the hit-area board of the design for the order and the radii. + AdjustHandle adjust_pick(const Vec2d &mouse, const AdjustFrame &f, int &index) const; + + // Where the tile's centre sits, in planar mm relative to the anchor, for a layer's placement. + // Not the patch centroid: the gizmo *is* the texture's placement, so it has to travel as the + // offset changes - pinning it to the centroid is what made the old handle look broken, the + // texture sliding while the handle stayed put. + static Vec2f adjust_handle_planar(const TextureDisplacementLayer &layer, float aspect); + // The exact inverse: the offset that puts the tile's centre at `planar`. Rotating or resizing + // the tile changes where a given offset places it, so every drag recomputes the offset from the + // centre it wants to keep - which is what makes rotate and scale act *in place* rather than + // swinging the tile around the anchor. + static Vec2f adjust_offset_for_planar(const Vec2f &planar, float rot_deg, const Vec2f &tile_mm, float aspect); + // The mesh-local-to-world transform of the selected instance, the (unrotated) tangent basis and + // the lifted anchor the whole gizmo is built on. False when there is nothing to build on - which + // is the single place that decides the gizmo is not drawable, so the drawing, the hit test and + // the drag cannot disagree about it. + bool adjust_basis(Transform3d &trafo, Vec3f &u_axis, Vec3f &v_axis, Vec3f &base) const; + // Screen-space quad of a tile with the given placement. Used for the pre-drag ghost, which is + // drawn from the captured start placement rather than from the layer, the layer having already + // been moved by then. + bool adjust_quad_for(const Vec2f ¢er_planar, float rot_deg, const Vec2f &tile_mm, + std::array &out) const; + // Where the cursor meets the drag plane, in planar mm relative to the anchor. The plane is + // deliberately the *fixed* anchor plane rather than the tile's current position: measuring a + // drag against a plane the drag itself moves is a feedback loop. + bool adjust_planar_under(const Vec2d &mouse, Vec2f &planar) const; // 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. @@ -993,20 +1057,26 @@ private: Vec3f m_adjust_anchor_pos = Vec3f::Zero(); // mesh-local Vec3f m_adjust_anchor_normal = Vec3f::UnitZ(); // mesh-local - // Pan: free drag anywhere on the flat panel, moves offset along both axes. AxisU/AxisV: drag - // the corresponding arrow, moves offset along only that one axis. - enum class AdjustHandle { None, Pan, AxisU, AxisV }; - AdjustHandle m_adjust_drag_handle = AdjustHandle::None; - Vec2f m_adjust_drag_start_offset = Vec2f::Zero(); - // Anchor-relative planar position (see project_planar()) of the point under the mouse at the - // moment the current drag started; every subsequent frame's delta is measured against this, - // rather than accumulated frame-to-frame, to avoid drift. - Vec2f m_adjust_drag_start_planar = Vec2f::Zero(); + // What the cursor is over, and what is being dragged. `index` picks the arm or the corner + // (0..3, in AdjustFrame's order); it is -1 for Move and Rotate, which have only one of each. + // Only ever one of each is set, so two handles can never both look live. + AdjustHandle m_adjust_hover_kind = AdjustHandle::None; + int m_adjust_hover_index = -1; + AdjustHandle m_adjust_drag_kind = AdjustHandle::None; + int m_adjust_drag_index = -1; - // Lazily-built unit quad (the pan panel) and unit line-with-arrowhead (reused, rotated, for - // both the U and V axis arrows), transformed into place at render time. - GLModel m_adjust_panel_glmodel; - GLModel m_adjust_arrow_glmodel; + // Everything the live drag is measured against, captured once on mouse-down. Every frame's + // delta is taken from these rather than accumulated frame to frame, so a long drag cannot + // drift away from the cursor. + Vec2f m_adjust_drag_start_center = Vec2f::Zero(); // tile centre, planar mm + Vec2f m_adjust_drag_start_grab = Vec2f::Zero(); // the grabbed point, planar mm + float m_adjust_drag_start_rot = 0.f; + Vec2f m_adjust_drag_start_tile = Vec2f::Ones(); // (u, v) extents as stored + float m_adjust_drag_start_angle = 0.f; // planar angle of the grab, radians + + // The live value to put in the readout pill, built by the drag and drawn by the renderer in the + // same frame. Empty when no drag is running, which is also when no pill is drawn. + std::string m_adjust_readout; std::map m_desc; diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index 2432026689..0c6ecd825c 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -296,9 +296,11 @@ void UVEditorCanvas::set_island_fill_colors(std::vector colors) Refresh(); } -void UVEditorCanvas::set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored) +void UVEditorCanvas::set_uv_transform(float tiling_scale, float tiling_scale_v, float rotation_deg, + bool tile_enabled, bool tile_mirrored) { m_tiling_scale = (std::abs(tiling_scale) > 1e-6f) ? tiling_scale : 1.f; + m_tiling_scale_v = (tiling_scale_v > 1e-6f) ? tiling_scale_v : m_tiling_scale; m_rotation_deg = rotation_deg; m_tile_enabled = tile_enabled; m_tile_mirrored = tile_mirrored; @@ -654,11 +656,15 @@ Vec2f UVEditorCanvas::island_centroid(int island) const Vec2f UVEditorCanvas::uv_delta_to_unwrap(const Vec2f &delta_uv) const { - // apply_uv_transform() maps unwrap -> uv as uv = R(unwrap / tiling) + offset, so the inverse of - // a *delta* (the translation drops out) is unwrap = tiling * R^-1(delta_uv). + // apply_uv_transform() maps unwrap -> uv as uv = S^-1 R(unwrap) + offset, with S the tile + // extents, so the inverse of a *delta* (the translation drops out) is unwrap = R^-1(S delta_uv). + // The extents therefore multiply *before* the inverse rotation, which is the mirror image of the + // forward order; with a square tile the two are interchangeable, with a rectangular one they are + // not. const float rad = m_rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad), sn = std::sin(rad); - return Vec2f(delta_uv.x() * cs + delta_uv.y() * sn, -delta_uv.x() * sn + delta_uv.y() * cs) * m_tiling_scale; + const Vec2f scaled(delta_uv.x() * m_tiling_scale, delta_uv.y() * m_tiling_scale_v); + return Vec2f(scaled.x() * cs + scaled.y() * sn, -scaled.x() * sn + scaled.y() * cs); } Vec2f UVEditorCanvas::snap_correction(int island) const @@ -2153,7 +2159,10 @@ UVEditorPanel::UVEditorPanel(wxWindow *parent) : wxPanel(parent, wxID_ANY) void UVEditorPanel::apply_state(const UVEditorCanvas::PaneState &s) { const wxString name = s.has_layer ? s.layer_name : _L("No layer mapped with Unwrap"); - const wxString tile = s.has_layer ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) : wxString(); + // A square tile reads as one number; only a genuinely rectangular one is worth two. + const wxString tile = !s.has_layer ? wxString() : + std::abs(s.tile_mm_v - s.tile_mm) < 0.005f ? wxString::Format(_L("%.1f mm tile"), s.tile_mm) : + wxString::Format(_L("%.1f x %.1f mm tile"), s.tile_mm, s.tile_mm_v); const bool relayout = m_layer_name->GetLabel() != name || m_tile->GetLabel() != tile; m_layer_name->SetLabel(name); m_tile->SetLabel(tile); diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 2fc1055cb8..1b06e875ba 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -91,7 +91,10 @@ public: // space, back into the unwrap's mm space - which is where a TextureIsland's offset actually // lives (see apply_uv_transform()). The tile settings come along because the background has to // repeat exactly the way the height sampler does, or the pane would stop showing what gets baked. - void set_uv_transform(float tiling_scale, float rotation_deg, bool tile_enabled, bool tile_mirrored); + // `tiling_scale_v` is the tile's extent along v; pass <= 0 for a square tile, matching + // layer_tile_mm(). + void set_uv_transform(float tiling_scale, float tiling_scale_v, float rotation_deg, bool tile_enabled, + bool tile_mirrored); // Same 8-bit grayscale pixels build_texture_displacement()'s height sampling uses, shown // beneath the wireframe (expanded to RGBA on upload) so the unwrap can be checked against the @@ -127,7 +130,8 @@ public: { bool has_layer = false; // an active layer mapped with Unwrap wxString layer_name; - float tile_mm = 0.f; + float tile_mm = 0.f; // tile extent along u + float tile_mm_v = 0.f; // along v; equal to tile_mm for a square tile std::vector thumbnail_rgb; // thumbnail_px square, 3 bytes per pixel; empty for none int thumbnail_px = 0; float seam_angle_deg = 40.f; @@ -290,8 +294,9 @@ private: GLModel m_grid_glmodel; float m_grid_step = 0.f; // the UV step m_grid_glmodel was built for; 0 = not built - float m_tiling_scale = 1.f; - float m_rotation_deg = 0.f; + float m_tiling_scale = 1.f; + float m_tiling_scale_v = 1.f; // already resolved: never the "unset" sentinel, see set_uv_transform() + float m_rotation_deg = 0.f; bool m_tile_enabled = true; bool m_tile_mirrored = false; bool m_snap_enabled = false;