diff --git a/resources/shaders/110/texture_displacement_shaded.fs b/resources/shaders/110/texture_displacement_shaded.fs index 0470be25ee..41292a30ef 100644 --- a/resources/shaders/110/texture_displacement_shaded.fs +++ b/resources/shaders/110/texture_displacement_shaded.fs @@ -49,6 +49,10 @@ uniform float tiling_scale; // 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; +// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an +// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before. +uniform bool mirror_u; +uniform bool mirror_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; @@ -161,6 +165,12 @@ vec2 tile_mm() return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); } +// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side. +vec2 mirror_sign() +{ + return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); @@ -168,8 +178,9 @@ vec2 project_uv(vec3 p, vec3 n) 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(); + // instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on + // the same division, in the tile's own frame. + r *= mirror_sign() / tile_mm(); // After the rotation, so the rotation stays a rotation rather than becoming a shear. r.y *= tex_aspect; return r + uv_offset; @@ -330,7 +341,10 @@ void main() float sn = sin(rotation_rad); // 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); + // Each component carries its own extent and its own mirror sign, both of which project_uv() + // applied on the way in; without the sign a mirrored layer would light as though it were not. + vec2 ms = mirror_sign(); + vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / 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 be08b27559..d9dd3d1389 100644 --- a/resources/shaders/110/texture_displacement_uvcheck.fs +++ b/resources/shaders/110/texture_displacement_uvcheck.fs @@ -19,6 +19,9 @@ 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; +// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour. +uniform bool mirror_u; +uniform bool mirror_v; uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; @@ -78,6 +81,12 @@ vec2 tile_mm() return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); } +// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side. +vec2 mirror_sign() +{ + return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); @@ -85,8 +94,9 @@ vec2 project_uv(vec3 p, vec3 n) 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(); + // instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on + // the same division, in the tile's own frame. + r *= mirror_sign() / 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 8904274f8d..863889b8f2 100644 --- a/resources/shaders/140/texture_displacement_shaded.fs +++ b/resources/shaders/140/texture_displacement_shaded.fs @@ -108,6 +108,10 @@ uniform float tiling_scale; // 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; +// Mirror the texture along one axis of the tile. Both default to false, which GLSL guarantees for an +// uninitialised bool uniform, so a shader whose caller has not set them behaves exactly as before. +uniform bool mirror_u; +uniform bool mirror_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; @@ -227,6 +231,12 @@ vec2 tile_mm() return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); } +// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side. +vec2 mirror_sign() +{ + return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); @@ -234,8 +244,9 @@ vec2 project_uv(vec3 p, vec3 n) 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(); + // instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on + // the same division, in the tile's own frame. + r *= mirror_sign() / tile_mm(); // After the rotation, so the rotation stays a rotation rather than becoming a shear. r.y *= tex_aspect; return r + uv_offset; @@ -416,7 +427,10 @@ void main() float sn = sin(rotation_rad); // 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); + // Each component carries its own extent and its own mirror sign, both of which project_uv() + // applied on the way in; without the sign a mirrored layer would light as though it were not. + vec2 ms = mirror_sign(); + vec2 g = vec2(dh_duv.x * ms.x / tile.x, dh_duv.y * tex_aspect * ms.y / 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 8fac374962..aa42b1ad50 100644 --- a/resources/shaders/140/texture_displacement_uvcheck.fs +++ b/resources/shaders/140/texture_displacement_uvcheck.fs @@ -28,6 +28,9 @@ 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; +// Mirror along one tile axis; false by default, so an unset uniform is the old behaviour. +uniform bool mirror_u; +uniform bool mirror_v; uniform vec3 tex_anchor; // the volume's origin in world space uniform float rotation_rad; uniform vec2 uv_offset; @@ -89,6 +92,12 @@ vec2 tile_mm() return vec2(su, (tiling_scale_v > 1e-6) ? tiling_scale_v : su); } +// +1 or -1 per axis, matching layer_mirror_sign() on the CPU side. +vec2 mirror_sign() +{ + return vec2(mirror_u ? -1.0 : 1.0, mirror_v ? -1.0 : 1.0); +} + vec2 project_uv(vec3 p, vec3 n) { vec2 planar = projection_raw(p, n); @@ -96,8 +105,9 @@ vec2 project_uv(vec3 p, vec3 n) 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(); + // instead of shearing. With a square tile the two orders agree exactly. The mirror is a sign on + // the same division, in the tile's own frame. + r *= mirror_sign() / 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 d0b0d06cbb..a0ec96d23f 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1687,8 +1687,11 @@ struct UVTransform UVTransform(const TextureDisplacementLayer &layer, float aspect_) { const Vec2f tile = layer_tile_mm(layer); - inv_u = 1.f / tile.x(); - inv_v = 1.f / tile.y(); + const Vec2f sign = layer_mirror_sign(layer); + // The mirror folds into the reciprocal: it is a negation of the same coordinate, so there is + // nothing to do per sample beyond carrying the sign here. + inv_u = sign.x() / tile.x(); + inv_v = sign.y() / tile.y(); const float rad = layer.rotation_deg * float(M_PI) / 180.f; cs = std::cos(rad); sn = std::sin(rad); @@ -1731,9 +1734,15 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la // 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. + // + // The mirror rides along as a sign on the same division. It is in the tile's own frame too, so + // it mirrors along the tile's axes rather than the world's, and because the offset is what + // positions the tile, mirroring about the tile's middle is a matter of the offset the gizmo + // writes alongside it, not of anything here. const Vec2f tile = layer_tile_mm(layer); - rotated.x() /= tile.x(); - rotated.y() /= tile.y(); + const Vec2f sign = layer_mirror_sign(layer); + rotated.x() *= sign.x() / tile.x(); + rotated.y() *= sign.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 one tile and every feature in it comes out half diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index ad02e0d071..ca22076fad 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -182,6 +182,12 @@ struct TextureDisplacementLayer // 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; + // Mirror the texture along one axis of the tile. Set by dragging a width or height handle back + // through the tile's middle, which is how a side handle behaves in an image editor: the extent + // cannot go negative, so the sign lives here instead. Both default to off, which is what every + // layer written before this meant. + bool mirror_u = false; + bool mirror_v = false; float rotation_deg = 0.f; Vec2f offset = Vec2f::Zero(); bool invert = false; @@ -306,7 +312,8 @@ 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, tiling_scale_v); + lscm_uv_overrides, view_project_projective, view_project_matrix, color_enabled, tiling_scale_v, + mirror_u, mirror_v); } template void load(Archive &ar) { @@ -318,7 +325,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, tiling_scale_v); + view_project_matrix, color_enabled, tiling_scale_v, mirror_u, mirror_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); @@ -337,6 +344,15 @@ inline Vec2f layer_tile_mm(const TextureDisplacementLayer &layer) return Vec2f(su, (layer.tiling_scale_v > 1e-6f) ? layer.tiling_scale_v : su); } +// +1 or -1 per axis, for the layer's mirroring. A separate factor from the extents rather than a +// signed extent: the extents are a size the panel shows and the refinement measures texels against, +// and letting one of them go negative would mean every one of those places had to remember to take +// the magnitude. +inline Vec2f layer_mirror_sign(const TextureDisplacementLayer &layer) +{ + return Vec2f(layer.mirror_u ? -1.f : 1.f, layer.mirror_v ? -1.f : 1.f); +} + // 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 diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 2b1ebb54ac..4237301880 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1565,6 +1565,8 @@ void GLGizmoTextureDisplacement::render_shaded_preview_mesh() 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()); + shader->set_uniform("mirror_u", layer->mirror_u); + shader->set_uniform("mirror_v", layer->mirror_v); // 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())); @@ -2115,6 +2117,8 @@ void GLGizmoTextureDisplacement::render_uvcheck_mesh() 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("mirror_u", layer->mirror_u); + shader->set_uniform("mirror_v", layer->mirror_v); 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); @@ -2479,8 +2483,9 @@ 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_tile_mm(*layer).y(), 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_mirror_sign(*layer), + 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 @@ -3103,13 +3108,17 @@ GLGizmoTextureDisplacement::uv_editor_island_transforms(const TextureDisplacemen // 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 Vec2f sign = layer_mirror_sign(layer); const float rad = layer.rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad); const float sn = std::sin(rad); + // M S^-1 R, the mirror being a sign on each row of S^-1 - the same composition, in the same + // order, as apply_uv_transform(). + const float iu = sign.x() / tile.x(), iv = sign.y() / tile.y(); Eigen::Matrix2f uv_linear; - uv_linear << cs / tile.x(), -sn / tile.x(), - sn / tile.y(), cs / tile.y(); + uv_linear << cs * iu, -sn * iu, + sn * iv, cs * iv; 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()); @@ -3285,24 +3294,34 @@ void GLGizmoTextureDisplacement::end_adjust_texture_mode() m_adjust_readout.clear(); } +// One tile of the texture is uv [0,1] in both axes, so the middle of the image is uv (0.5, 0.5) - +// not uv 0, which is one of its *corners*. Everything about the gizmo's position hangs off this: the +// frame is centred here, so it bounds one whole tile rather than straddling the four that meet at +// the image's corner, and rotating and scaling hold this point still, so they turn and resize the +// image about its middle. +static const Vec2f ADJUST_TILE_UV_CENTER(0.5f, 0.5f); + 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. + // tile extents and v additionally carrying the image's aspect. Asking which p lands on a given + // uv inverts that one equation directly: R(p) = S (uv - offset), with the aspect undone on v, + // and p is R^-1 of it. + // A negation is its own inverse, so the mirror's sign appears here exactly as it does going + // forward - no special case, just the same factor on the same axis. const Vec2f tile = layer_tile_mm(layer); + const Vec2f sign = layer_mirror_sign(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 X = (ADJUST_TILE_UV_CENTER.x() - layer.offset.x()) * tile.x() * sign.x(); + const float Y = (ADJUST_TILE_UV_CENTER.y() - layer.offset.y()) * tile.y() / a * sign.y(); 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 Vec2f &tile_mm, const Vec2f &mirror_sign, + float aspect) { const float a = (aspect > 0.f) ? aspect : 1.f; const float rad = rot_deg * float(M_PI) / 180.f; @@ -3311,7 +3330,11 @@ Vec2f GLGizmoTextureDisplacement::adjust_offset_for_planar(const Vec2f &planar, 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); + // The exact inverse of adjust_handle_planar(), uv centre and mirror included: solving + // uv = M S^-1 R(planar) + offset for the offset that puts the image's middle on `planar`. Because + // the offset is solved for the centre the gesture means to keep, mirroring one axis mirrors the + // image about the tile's own middle rather than sliding it somewhere else. + return ADJUST_TILE_UV_CENTER - Vec2f(mirror_sign.x() * X / su, mirror_sign.y() * Y * a / sv); } Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacementLayer &layer) const @@ -3343,17 +3366,30 @@ 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 +// How large the handles are drawn, against the sizes the gizmo was first laid out at. The whole set +// scales together, and has to: bigger grips with the old minimum frame would bury the move area +// under the four corner zones on a small tile, and bigger marks with the old hit radii would be +// something to aim at and miss. Stroke widths are deliberately *not* in here - they are line +// weights in Orca's icon language, not dimensions of the handles. +constexpr double ADJUST_UI_SCALE = 2.0; +constexpr double ADJUST_MIN_TILE_PX = 44.0 * ADJUST_UI_SCALE; // the smallest the frame is ever drawn +constexpr double ADJUST_BAR_OFF_PX = 8.0 * ADJUST_UI_SCALE; // edge to the bar that resizes it +constexpr double ADJUST_BAR_HALF_PX = 11.0 * ADJUST_UI_SCALE; // half the bar's length, along the edge +// The axis arrow sits beyond the bar on the same ray. The gap is what keeps the two from fighting +// over clicks: it is wider than the bar's own hit radius, so by the time the arrow's shaft begins +// the bar's zone has all but ended, and the few pixels where they do meet go to the bar. +constexpr double ADJUST_ARM_GAP_PX = 23.0 * ADJUST_UI_SCALE; // edge to the start of the arrow +constexpr double ADJUST_ARM_PX = 32.0 * ADJUST_UI_SCALE; // the arrow's own length +constexpr double ADJUST_ROT_STEM_PX = 30.0 * ADJUST_UI_SCALE; // near corner out to the rotate ring +constexpr double ADJUST_ROT_R_PX = 7.6 * ADJUST_UI_SCALE; // the rotate ring's radius +constexpr double ADJUST_GRIP_HALF_PX = 4.6 * ADJUST_UI_SCALE; // half the side of a corner 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; +constexpr double ADJUST_PICK_ROT_PX = 12.0 * ADJUST_UI_SCALE; +constexpr double ADJUST_PICK_GRIP_PX = 11.0 * ADJUST_UI_SCALE; +constexpr double ADJUST_PICK_BAR_PX = 9.0 * ADJUST_UI_SCALE; // around the bar's own segment +constexpr double ADJUST_PICK_TIP_PX = 11.0 * ADJUST_UI_SCALE; // around the arrow's chevron +constexpr double ADJUST_PICK_SHAFT_PX = 8.0 * ADJUST_UI_SCALE; // around the arrow's shaft double point_to_segment_px(const Vec2d &p, const Vec2d &a, const Vec2d &b) { @@ -3488,16 +3524,33 @@ GLGizmoTextureDisplacement::AdjustFrame GLGizmoTextureDisplacement::adjust_frame 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. + // The bars are measured in screen pixels off the projected edge, not in millimetres along the + // surface, so they keep their size however the tile is foreshortened. Each sits just outside its + // own edge and runs parallel to it, which is what says "this edge moves" rather than "this + // whole thing moves". static const Vec2d fallback_dir[4] = { { 1.0, 0.0 }, { -1.0, 0.0 }, { 0.0, 1.0 }, { 0.0, -1.0 } }; + // The two corners each edge runs between, in the corner order +u+v, +u-v, -u-v, -u+v. + static const int edge_ends[4][2] = { { 0, 1 }, { 2, 3 }, { 3, 0 }, { 1, 2 } }; 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. + // it; the screen axes keep the bars 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 along = f.corner[size_t(edge_ends[i][0])] - f.corner[size_t(edge_ends[i][1])]; + const double along_len = along.norm(); + // Degenerate the same way: fall back to the perpendicular of the outward direction so the + // bar still has a length to be picked along. + const Vec2d dir = (along_len > 1e-6) ? Vec2d(along / along_len) : + Vec2d(-f.arm_dir[size_t(i)].y(), f.arm_dir[size_t(i)].x()); + // Never longer than the edge it belongs to, or a small tile would wear bars sticking out + // past its own corners and into the neighbouring edge's. + const double half = std::min(ADJUST_BAR_HALF_PX, std::max(0.35 * along_len, 3.0)); + const Vec2d at = f.edge_mid[size_t(i)] + f.arm_dir[size_t(i)] * ADJUST_BAR_OFF_PX; + f.bar_a[size_t(i)] = at - dir * half; + f.bar_b[size_t(i)] = at + dir * half; + f.arm_base[size_t(i)] = f.edge_mid[size_t(i)] + f.arm_dir[size_t(i)] * ADJUST_ARM_GAP_PX; + f.arm_tip[size_t(i)] = f.arm_base[size_t(i)] + f.arm_dir[size_t(i)] * ADJUST_ARM_PX; } const Vec2d rot_out = f.corner[0] - f.center; @@ -3525,17 +3578,26 @@ GLGizmoTextureDisplacement::adjust_pick(const Vec2d &mouse, const AdjustFrame &f for (int i = 0; i < 4; ++i) if (within(f.corner[size_t(i)], ADJUST_PICK_GRIP_PX)) { index = i; - return AdjustHandle::Scale; + return AdjustHandle::CornerScale; } + // The arrow's chevron is the furthest thing out on its ray, so nothing else can claim it. for (int i = 0; i < 4; ++i) if (within(f.arm_tip[size_t(i)], ADJUST_PICK_TIP_PX)) { index = i; - return AdjustHandle::Axis; + return AdjustHandle::AxisMove; + } + // Corners before edges: a corner sits at the end of two edges, and grabbing it should take both + // extents rather than whichever edge happened to be tested first. And the bar before the + // arrow's shaft, which is what gives the bar the handful of pixels where their zones meet. + for (int i = 0; i < 4; ++i) + if (point_to_segment_px(mouse, f.bar_a[size_t(i)], f.bar_b[size_t(i)]) <= ADJUST_PICK_BAR_PX) { + index = i; + return AdjustHandle::EdgeScale; } 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) { + if (point_to_segment_px(mouse, f.arm_base[size_t(i)], f.arm_tip[size_t(i)]) <= ADJUST_PICK_SHAFT_PX) { index = i; - return AdjustHandle::Axis; + return AdjustHandle::AxisMove; } // 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. @@ -3597,6 +3659,11 @@ void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() const ImU32 frame_col = move_live ? act_col : accent; const float frame_w = move_live ? 2.4f : 1.8f; + // The marks drawn inline below are in the same units as the constants above, so they take the + // same factor; without it the chevrons, the grip dots and the ring's glyph would stay at their + // original size inside handles twice as large. + const float S = float(ADJUST_UI_SCALE); + 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 @@ -3624,12 +3691,12 @@ void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() 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. + if ((act == AdjustHandle::AxisMove || act == AdjustHandle::EdgeScale) && act_idx >= 0) { + // The axis the extent is read along, drawn right across the view so it is obvious which + // of the two the bar under the cursor controls. 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) { + } else if (act == AdjustHandle::CornerScale && act_idx >= 0) { const Vec2d out = f.corner[size_t(act_idx)] - f.center; const double len = out.norm(); if (len > 1e-6) { @@ -3684,22 +3751,41 @@ void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() else stroke(quad, 4, true, frame_col, frame_w); - // ---- axis arms ----------------------------------------------------------------------------- + // ---- axis arrows: a move held to one of the tile's own axes -------------------------------- for (int i = 0; i < 4; ++i) { - const bool live = act == AdjustHandle::Axis && act_idx == i; + const bool live = act == AdjustHandle::AxisMove && 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) }; + dl->AddCircleFilled(P(tip), 6.4f * S, col, 16); + const ImVec2 shaft[2] = { P(f.arm_base[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. + // The chevron is the Adjust placement icon's own mark, and pointing away from the tile is + // what separates it from the bar behind it: one says "move it this way", the other "this + // edge moves". The filled dot behind the chevron 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) }; + const ImVec2 chev[3] = { P(tip - d * (8.0 * ADJUST_UI_SCALE) + n * (5.0 * ADJUST_UI_SCALE)), P(tip), + P(tip - d * (8.0 * ADJUST_UI_SCALE) - n * (5.0 * ADJUST_UI_SCALE)) }; stroke(chev, 3, false, live ? handle : col, live ? 1.8f : 1.6f); } + // ---- edge bars: one tile extent each ------------------------------------------------------- + for (int i = 0; i < 4; ++i) { + const bool live = act == AdjustHandle::EdgeScale && act_idx == i; + const ImU32 col = live ? act_col : (dragging ? dimc : accent); + // A capsule lying just outside its edge, parallel to it: a bar across the axis it resizes, + // which is the usual way of saying "this side moves" and leaves the corners to mean both. + const ImVec2 bar[2] = { P(f.bar_a[size_t(i)]), P(f.bar_b[size_t(i)]) }; + dl->AddLine(bar[0], bar[1], halo, (live ? 5.4f : 4.2f) + 2.4f); + dl->AddLine(bar[0], bar[1], col, live ? 5.4f : 4.2f); + // A tick from the bar back to its edge while it is live, so which edge is moving is not left + // to be inferred from a bar floating beside the frame. + if (live) { + const ImVec2 tick[2] = { P(f.edge_mid[size_t(i)]), P(f.edge_mid[size_t(i)] + f.arm_dir[size_t(i)] * ADJUST_BAR_OFF_PX) }; + stroke(tick, 2, false, col, 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; @@ -3709,15 +3795,16 @@ void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() 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); + const Vec2d p = f.center + eu * (double(c) * 2.9 * ADJUST_UI_SCALE) + + ev * (double(r) * 5.4 * ADJUST_UI_SCALE); + dl->AddCircleFilled(P(p), 2.6f * S, halo, 10); + dl->AddCircleFilled(P(p), 1.5f * S, col, 10); } } // ---- corner scale grips -------------------------------------------------------------------- for (int i = 0; i < 4; ++i) { - const bool live = act == AdjustHandle::Scale && act_idx == i; + const bool live = act == AdjustHandle::CornerScale && 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)), @@ -3733,17 +3820,17 @@ void GLGizmoTextureDisplacement::render_adjust_texture_gizmo() 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) + 1.2f * S, 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->PathArcTo(at, 4.2f * S, float(M_PI) * 1.11f, float(M_PI) * 1.89f, 20); 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) }; + const ImVec2 head[3] = { ImVec2(at.x + 1.11f * S, at.y - 3.38f * S), ImVec2(at.x + 3.95f * S, at.y - 1.44f * S), + ImVec2(at.x + 4.87f * S, at.y - 4.75f * S) }; dl->AddPolyline(head, 3, gcol, 0, 1.5f); } @@ -3804,27 +3891,75 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou // 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; + Vec2f centre = m_adjust_drag_start_center; + float rot = m_adjust_drag_start_rot; + Vec2f tile = m_adjust_drag_start_tile; + bool mirror_u = m_adjust_drag_start_mirror_u; + bool mirror_v = m_adjust_drag_start_mirror_v; + // Which side of the centre each handle sits on, by index: arms and bars run +u, -u, +v, -v, + // and corners +u+v, +u-v, -u-v, -u+v. A handle dragged to the far side of the centre has + // turned the tile inside out on that axis, which is a mirror. + static const float side_u[4] = { 1.f, -1.f, 0.f, 0.f }; + static const float side_v[4] = { 0.f, 0.f, 1.f, -1.f }; + static const float corn_u[4] = { 1.f, 1.f, -1.f, -1.f }; + static const float corn_v[4] = { 1.f, -1.f, -1.f, 1.f }; + const size_t di = size_t(std::clamp(m_adjust_drag_index, 0, 3)); 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())); + const Vec2f delta = hit - m_adjust_drag_start_grab; + if (mouse_event.ShiftDown()) { + // Shift holds the move to one of the tile's own axes - whichever the gesture is + // already going along. Projecting onto that axis is not the same as zeroing a planar + // component once the tile is rotated, which is what makes this worth spelling out. + const bool along_u = std::abs(delta.dot(e_u)) >= std::abs(delta.dot(e_v)); + const Vec2f axis = along_u ? e_u : e_v; + centre = m_adjust_drag_start_center + axis * delta.dot(axis); + m_adjust_readout = Slic3r::format(_u8L("%1% %2% mm"), along_u ? "U" : "V", + signed_mm(centre.dot(axis))); + } else { + centre = m_adjust_drag_start_center + delta; + m_adjust_readout = Slic3r::format(_u8L("U %1% V %2% mm"), signed_mm(centre.x()), + signed_mm(centre.y())); + } break; } - case AdjustHandle::Axis: { + case AdjustHandle::AxisMove: { + // Along the tile's own axis. Projecting onto it is not the same as zeroing one planar + // component once the tile is rotated, which is what the first version of this got wrong. 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::EdgeScale: { + // One extent, straight off the cursor: the grabbed edge follows it and the opposite one + // mirrors, so the tile resizes about its centre and the other axis is left alone. This is + // the whole point of the edge bars - a corner can only ever give you both at once. + // + // Keep dragging past the middle and the extent does not stick at its floor: the handle + // has crossed to the far side, so the tile is inside out on that axis and the texture + // mirrors, the way a side handle behaves in an image editor. The extent itself stays a + // magnitude - the sign is the mirror flag. + const Vec2f rel = (hit - m_adjust_drag_grab_bias) - centre; + const bool along_u = m_adjust_drag_index < 2; + const float a = (aspect > 0.f) ? aspect : 1.f; + if (along_u) { + const float along = rel.dot(e_u); + tile.x() = 2.f * std::abs(along); + mirror_u = m_adjust_drag_start_mirror_u != (along * side_u[di] < 0.f); + } else { + const float along = rel.dot(e_v); + tile.y() = 2.f * std::abs(along) * a; + mirror_v = m_adjust_drag_start_mirror_v != (along * side_v[di] < 0.f); + } + const std::string flipped = (along_u ? mirror_u : mirror_v) ? _u8L(", mirrored") : std::string(); + m_adjust_readout = (along_u ? Slic3r::format(_u8L("Tile width %1% mm"), num(tile.x(), 2)) : + Slic3r::format(_u8L("Tile height %1% mm"), num(tile.y(), 2))) + flipped; + break; + } case AdjustHandle::Rotate: { const Vec2f rel = hit - centre; if (rel.squaredNorm() < 1e-8f) @@ -3842,22 +3977,26 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou m_adjust_readout = num(shown, 1) + "°"; break; } - case AdjustHandle::Scale: { - const Vec2f rel = hit - centre; + case AdjustHandle::CornerScale: { + const Vec2f rel = (hit - m_adjust_drag_grab_bias) - 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(); + // A square tile, or Shift asking for the proportions to hold: the tile follows how + // much further out the grab point is than where it started, which is the only + // reading that cannot change the ratio between the two extents. + const float r0 = (m_adjust_drag_start_grab - m_adjust_drag_grab_bias - 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); + const float a = (aspect > 0.f) ? aspect : 1.f; + const float along_u = rel.dot(e_u), along_v = rel.dot(e_v); + tile = Vec2f(2.f * std::abs(along_u), 2.f * std::abs(along_v) * a); + // Dragged through the middle on either axis, same as an edge bar. + mirror_u = m_adjust_drag_start_mirror_u != (along_u * corn_u[di] < 0.f); + mirror_v = m_adjust_drag_start_mirror_v != (along_v * corn_v[di] < 0.f); } // The same bounds the panel's sliders clamp to, so a drag cannot reach a value the // control beside it refuses to show. @@ -3872,15 +4011,23 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou 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) + // Dragging one edge is a request for one extent, which a square tile cannot honour - the two + // are the same number - so the edge bars unlock it. The panel's link flips to "Lock to a + // square tile" on the next frame, which is where that becomes visible. + if (m_adjust_drag_kind == AdjustHandle::EdgeScale) layer->tiling_scale_v = tile.y(); + // Otherwise a locked tile stays locked: a corner drag must not quietly give the v extent a + // value of its own behind the panel's lock. + else if (layer->tiling_scale_v > 0.f) + layer->tiling_scale_v = tile.y(); + layer->mirror_u = mirror_u; + layer->mirror_v = mirror_v; // 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); + layer->offset = adjust_offset_for_planar(centre, rot, layer_tile_mm(*layer), layer_mirror_sign(*layer), + aspect); m_preview_params_dirty = true; m_parent.set_as_dirty(); @@ -3907,6 +4054,28 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou 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_drag_start_mirror_u = layer->mirror_u; + m_adjust_drag_start_mirror_v = layer->mirror_v; + // Where the handle actually is, as against where the click landed inside its hit radius. The + // scale drags read the extents straight off the cursor, so without this the tile would jump + // by that difference the instant the button went down - and the radii are deliberately + // generous, so the jump would be plainly visible. + m_adjust_drag_grab_bias = Vec2f::Zero(); + if (k == AdjustHandle::EdgeScale || k == AdjustHandle::CornerScale) { + const float rad = layer->rotation_deg * float(M_PI) / 180.f; + const float cs = std::cos(rad), sn = std::sin(rad); + const Vec2f e_u(cs, -sn), e_v(sn, cs); + // Corner order, and edge order, both as adjust_frame() builds them. + static const float cu[4] = { 1.f, 1.f, -1.f, -1.f }; + static const float cv[4] = { 1.f, -1.f, -1.f, 1.f }; + static const float eu[4] = { 1.f, -1.f, 0.f, 0.f }; + static const float ev[4] = { 0.f, 0.f, 1.f, -1.f }; + const size_t j = size_t(std::clamp(idx, 0, 3)); + const float su = (k == AdjustHandle::CornerScale) ? cu[j] : eu[j]; + const float sv = (k == AdjustHandle::CornerScale) ? cv[j] : ev[j]; + const Vec2f at = f.center_planar + e_u * (su * f.half_planar.x()) + e_v * (sv * f.half_planar.y()); + m_adjust_drag_grab_bias = grab - at; + } m_adjust_readout.clear(); m_parent.set_as_dirty(); return true; @@ -4062,6 +4231,9 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() "texture_displacement_adjust.svg", "canvas_drag.svg", "texture_displacement_move_up.svg", "texture_displacement_move_down.svg", "texture_displacement_drag.svg", "texture_displacement_select_all.svg", "texture_displacement_erase_all.svg", + // The tile's square lock. Two glyphs rather than one icon's two variants, the states being + // different shapes; both already carry the accent and the icon grey this panel uses. + "lock_closed.svg", "lock_open.svg", }; std::vector paths; paths.reserve(names.size()); @@ -6845,33 +7017,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // 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; + // The slider gives up the width the lock needs, so the two sit on one row. 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); + &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, + card_pad + icon_sm + gap_s); 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.")); + ImGui::SameLine(0.f, gap_s); + if (icon_toggle(843, tile_split ? "lock_open.svg" : "lock_closed.svg", !tile_split, icon_sm, + _L("Square tile"), + tile_split ? _L("Width and height are set separately. Click to lock the tile " + "square again, taking the height from the width.") : + _L("The tile is square: one size sets both axes. Click to unlock " + "and set the height on its own."))) { + // Unlocking seeds v from u, so the lock on its own never changes what is baked. + layer.tiling_scale_v = tile_split ? -1.f : layer.tiling_scale; + m_preview_params_dirty = true; + } if (tile_split) { + // Indented under the width by the same amount, so the lock reads as governing the pair + // rather than belonging to the row above it. 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); + 0.2f, 200.f, "%.2f mm", true, card_pad + icon_sm + gap_s); 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.")); @@ -7169,7 +7341,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(0.f, gap_s); if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"), - _L("Adjust placement - drag the handle on the model to move the texture"))) { + _L("Adjust placement - drag the tile on the model to move the texture, " + "its edges to set the tile's width and height, a corner for both, " + "or the ring to rotate it. Shift holds a move to one axis, and holds " + "a corner's proportions."))) { if (!m_adjust_texture_mode) { update_model_object(); // flush any pending strokes before anchoring if (update_adjust_anchor()) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 77dedab3f2..b7154d929c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -416,7 +416,14 @@ private: // 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 }; + // Move is the whole interior, free in both axes. AxisMove is the four arrows, each holding a + // move to one of the tile's axes. EdgeScale is the four edge bars, one tile extent each, and + // CornerScale the four corner grips, which take both extents at once. + // + // The arrows and the bars share the same four rays out of the tile, so they are kept apart by + // distance rather than direction: the bar hugs its edge, then a clear gap, then the arrow well + // outside it. adjust_pick() leans on that - see the order it tests them in. + enum class AdjustHandle { None, Move, AxisMove, EdgeScale, CornerScale, 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 @@ -429,10 +436,17 @@ private: 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. + // +u, -u, +v, -v: the middle of each edge, and the two ends of the bar that sits just + // outside it. The bar's ends are kept rather than recomputed so the drawing and the hit test + // cannot end up with it in two different places. std::array edge_mid{}; + std::array bar_a{}; + std::array bar_b{}; + // The axis arrow further out on the same ray: where its shaft starts and where its chevron + // ends. Kept, like the bar's ends, so the drawing and the hit test cannot disagree. + std::array arm_base{}; std::array arm_tip{}; - std::array arm_dir{}; // unit, pointing outward + std::array arm_dir{}; // unit, pointing outward from the centre 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. @@ -468,7 +482,8 @@ private: // 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); + static Vec2f adjust_offset_for_planar(const Vec2f &planar, float rot_deg, const Vec2f &tile_mm, + const Vec2f &mirror_sign, 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 @@ -1070,9 +1085,18 @@ private: // 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 + // How far the grab landed from the handle's own position, in planar mm. A scale drag reads the + // extents straight off the cursor, so without subtracting this the tile would jump by however + // far inside the generous hit radius the click happened to fall. + Vec2f m_adjust_drag_grab_bias = Vec2f::Zero(); 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 mirroring the layer had when the drag began. A width or height handle dragged back through + // the tile's middle toggles its axis against this, rather than against whatever the last frame + // left behind, so dragging back and forth across the middle cannot ratchet the flag. + bool m_adjust_drag_start_mirror_u = false; + bool m_adjust_drag_start_mirror_v = false; // 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. diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index 0c6ecd825c..56f38d9aa6 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -296,11 +296,12 @@ void UVEditorCanvas::set_island_fill_colors(std::vector colors) Refresh(); } -void UVEditorCanvas::set_uv_transform(float tiling_scale, float tiling_scale_v, float rotation_deg, - bool tile_enabled, bool tile_mirrored) +void UVEditorCanvas::set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign, + 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_mirror_sign = mirror_sign; m_rotation_deg = rotation_deg; m_tile_enabled = tile_enabled; m_tile_mirrored = tile_mirrored; @@ -663,7 +664,10 @@ Vec2f UVEditorCanvas::uv_delta_to_unwrap(const Vec2f &delta_uv) const // not. const float rad = m_rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad), sn = std::sin(rad); - const Vec2f scaled(delta_uv.x() * m_tiling_scale, delta_uv.y() * m_tiling_scale_v); + // The mirror is its own sign on the same axis, and a negation is its own inverse, so it comes + // back out here exactly as it went in. + const Vec2f scaled(delta_uv.x() * m_tiling_scale * m_mirror_sign.x(), + delta_uv.y() * m_tiling_scale_v * m_mirror_sign.y()); return Vec2f(scaled.x() * cs + scaled.y() * sn, -scaled.x() * sn + scaled.y() * cs); } diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 1b06e875ba..10aaf4fa6e 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -93,8 +93,8 @@ public: // repeat exactly the way the height sampler does, or the pane would stop showing what gets baked. // `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); + void set_uv_transform(float tiling_scale, float tiling_scale_v, const Vec2f &mirror_sign, + 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 @@ -296,6 +296,7 @@ private: float m_tiling_scale = 1.f; float m_tiling_scale_v = 1.f; // already resolved: never the "unset" sentinel, see set_uv_transform() + Vec2f m_mirror_sign = Vec2f::Ones(); // +1 or -1 per axis, see layer_mirror_sign() float m_rotation_deg = 0.f; bool m_tile_enabled = true; bool m_tile_mirrored = false;