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.
This commit is contained in:
ExPikaPaka
2026-10-12 08:04:35 +02:00
parent d007134001
commit 91ab04e823
10 changed files with 886 additions and 305 deletions
@@ -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;
@@ -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;
}
@@ -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.
@@ -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;
}
+28 -19
View File
@@ -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;
+24 -6
View File
@@ -177,7 +177,11 @@ struct TextureDisplacementLayer
std::shared_ptr<std::vector<unsigned char>> 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<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(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<class Archive> 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<std::vector<unsigned char>>(blob.begin(), blob.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(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
File diff suppressed because it is too large Load Diff
@@ -405,20 +405,84 @@ private:
// Panel state only, not saved with the project.
std::array<bool, TEXTURE_DISPLACEMENT_MAX_LAYERS> 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<Vec2d, 4> corner{};
// +u, -u, +v, -v: where each axis arm leaves the frame, and where its chevron ends.
std::array<Vec2d, 4> edge_mid{};
std::array<Vec2d, 4> arm_tip{};
std::array<Vec2d, 4> 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 &center_planar, float rot_deg, const Vec2f &tile_mm,
std::array<Vec2d, 4> &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<std::string, wxString> m_desc;
+14 -5
View File
@@ -296,9 +296,11 @@ void UVEditorCanvas::set_island_fill_colors(std::vector<ColorRGBA> 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);
+9 -4
View File
@@ -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<unsigned char> 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;