Compare commits

...
Author SHA1 Message Date
ExPikaPaka fbaed8a512 Project the texture in the model's own orientation, and stop the preview tiling
The bake frame took its orientation from the world, so turning a part on the
plate slid the decoration across its surface. Turning a part is a print-time
decision - clearance, supports, warping - and it should not be a design one.
The frame now takes its orientation from the model and keeps world scale, so a
tile is still tiling_scale printed millimetres whatever the instance's scale.

The rotation is separated out by polar decomposition rather than by assuming
the linear part is a rotation times a scale, since a sheared or mirrored
placement is legal here. A reflection is deliberately left with the scale, so a
mirrored instance still makes the frame's determinant negative and the bake
still flips the normals it derives.

The preview shaders rebuild that frame themselves from a world position, so
they take the same rotation through a uniform; without it the fast preview and
the bake would disagree, which is the class of bug this is fixing. The gizmo's
own way back out to world carries the rotation too, which it did not need to
when the frame was world-aligned.

Tiling in the fast preview:

- the sampler wrap mode was set on the height texture but not on the colour
  texture, which sits on its own unit and is sampled at the same uv. So with
  Tile off the relief stopped at one placement while the colour carried on
  repeating around it.
- outside a single placement the colour then came out black, because a zero
  texture border cannot say "no colour here", only "the colour is black here".
  The shader now tests the uv the way sample_layer_color() does and leaves the
  surface its own colour.

The placement gizmo is a tile of a planar projection, so it is offered only for
the mappings that have one. An unwrap cuts the surface into islands and packs
them, and a model's own UV channel is whatever its author drew: one tile lands
there as an arbitrary curved patch with no rectangle to take hold of. Those two
say so and point at the UV editor instead.
2026-10-15 15:45:47 +02:00
ExPikaPaka 597b2abac4 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-15 13:13:26 +02:00
ExPikaPaka ba4de8c1fc Measure the placement gizmo in the frame the texture is projected in
The gizmo worked in mesh-local millimetres while the texture is projected in
the bake frame - world orientation and scale, anchored at the volume's origin.
On an unscaled object the two agree, which is why this went unnoticed; on a
scaled one the drawn tile differed from the rendered one by exactly the
object's scale, and a rotated one would have skewed its axes as well.

The anchor is carried into that frame at the point of use rather than stored
converted, since the object can be moved, turned or scaled without leaving the
mode. Getting from the frame to the screen is then a translation and nothing
more, which is the point: rotation and scale are what a tile's millimetres must
not be measured through.

Also in this commit: a pale casing outside the dark halo, because a dark halo
alone disappears against a dark backdrop and a photographic texture offers both
within one tile; one rotate ring instead of four, moved to whichever corner the
cursor is nearest and held there by a margin so it does not flit between two;
one constant for the casing's width, after the move grip's dots turned out to
carry nearly twice the casing of everything around them; a request for one more
frame when a panel edit changes a preview parameter, since the panel is drawn
after the 3D view and the edit was otherwise a frame late; and an animated
illustration for the square-tile lock.
2026-10-15 13:13:26 +02:00
ExPikaPaka 7bce53a773 Merge branch 'main' into texture-placement-gizmo
Three files conflicted, all in the texture displacement subsystem that main
has been working on in parallel.

apply_uv_transform and UVTransform gained main's normalized_uv parameter for
TextureProjectionMethod::ModelUV. A normalized uv has no millimetre tile to
divide by, so the extents are skipped for it, but the mirroring still applies:
a mirror is a placement choice and means the same thing on a model's own UV
layout as on a projected one. The aspect correction stays skipped, as main has
it.

adjust_tangent_basis now calls main's box_projection_axes instead of restating
the dominant-axis choice, which also picks up main's fix to the texture's
orientation. The gizmo derives its whole frame from that basis, so it follows
the new conventions without needing to know about them.

The basic settings rows took main's float_row, which carries the tooltip and
its media as arguments, and its disabling of tile size under ModelUV. The
square lock and the second extent join that disabled block, being the same
millimetres from another angle.

Layers now persist to the .3mf as JSON, which they did not when this branch
started, so tiling_scale_v, mirror_u and mirror_v are written and read there
too. The reader keeps the default for a missing key, so a project written
before the tile could be rectangular or mirrored still loads as a square,
unmirrored one.
2026-10-15 09:54:52 +02:00
ExPikaPaka e65113af6d Add edge and corner resize, mirroring and a square lock to the gizmo
Follows the first pass on the placement gizmo.

Handles and hit radii are twice the size, and the minimum frame with them,
so the corner zones cannot bury the move area on a small tile.

Each edge carries a bar that sets one tile extent, so width and height can
be set on the model rather than only in the panel. The four axis arrows
stay, further out on the same rays, kept apart from the bars by distance.

Dragging a width or height handle back through the tile's middle mirrors
the texture on that axis instead of sticking at the minimum extent. The
new mirror_u and mirror_v ride as a sign on the same division as the tile
extents, in apply_uv_transform and in all four shaders, and the shaded
shaders' normal gradient carries the sign too.

The frame was centred on uv 0, which is a corner of the image, so it
straddled the four tiles meeting there and rotation pivoted on that
corner. It is now centred on uv (0.5, 0.5): the frame bounds one whole
tile and rotating or scaling turns and resizes the image about its middle.

A scale drag read the extents straight off the cursor, so grabbing a
handle anywhere inside its hit radius jumped the tile by that much. The
offset of the grab from the handle is now subtracted.

The square-tile lock is an icon on the Tile size row instead of a text
link, reusing lock_closed.svg and lock_open.svg.
2026-10-12 09:31:32 +02:00
ExPikaPaka 91ab04e823 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.
2026-10-12 08:04:35 +02:00
11 changed files with 1513 additions and 353 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

@@ -36,6 +36,11 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -45,6 +50,14 @@ 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;
// 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;
@@ -54,6 +67,12 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv;
// 2x3 affine (lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the dragged island's uv; see the
// 140 variant. Identity when nothing is dragged.
@@ -162,13 +181,29 @@ 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);
}
// +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);
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. 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;
@@ -237,9 +272,12 @@ void main()
// World millimetres throughout, like the bake - see the 140 variant.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -270,14 +308,14 @@ void main()
} else if (weight > 0.0) {
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -289,18 +327,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// 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) {
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
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.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -322,18 +360,23 @@ 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.
// 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;
vec3 gradient = slope.x * t + slope.y * b;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -353,7 +396,9 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
albedo = palette_rgb[nearest_palette_entry(texture2D(color_tex, color_uv).rgb)];
gl_FragColor = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -16,7 +16,16 @@ 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;
// 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
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -79,13 +88,30 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// 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);
}
// +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);
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. 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;
}
@@ -105,7 +131,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor;
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -113,7 +139,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
base = (check < 0.5) ? vec3(0.22, 0.23, 0.26) : vec3(0.82, 0.83, 0.86);
@@ -95,6 +95,11 @@ uniform int palette_b[64];
uniform float prefer_pure_de; // PREFER_PURE_DE: how much better than a single filament a mix must be
uniform sampler2D color_tex; // the layer's colour image, sampled at the same uv as the height
uniform bool has_color_tex;
// False means the texture is a single placement, not a repeat. The sampler's wrap mode can express
// that for the *height* - a zero border reads as no displacement - but not for colour, because a
// zero border is indistinguishable from the image genuinely being black there. So the uv is tested
// instead, which is what sample_layer_color() does on the CPU side.
uniform bool tile_enabled;
uniform bool volume_mirrored;
uniform mat4 view_model_matrix;
@@ -104,6 +109,14 @@ 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;
// 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;
@@ -113,6 +126,12 @@ uniform bool invert;
uniform float midlevel; // the height that means "don't move"; needed by the parallax step
uniform vec3 eye_model_pos; // camera position in the texture frame (world minus tex_anchor)
uniform vec3 tex_anchor; // the volume's origin in world space: the texture frame's origin
// The placement's rotation and its inverse, so this shader turns a world position into the texture
// frame exactly as texture_displacement_bake_frame() does. Both are passed rather than one
// transposed here, because GLSL 110 has no transpose(). Identity leaves the old world-aligned
// behaviour, which is what an unset uniform gives.
uniform mat3 tex_rot; // world -> texture frame
uniform mat3 tex_rot_inv; // texture frame -> world
uniform bool use_vertex_uv; // true: sample at vertex_uv with a derived tangent frame (LSCM)
// A 2x3 affine (columns packed as lin = (m00, m01, m10, m11), tr = (m02, m12)) applied to the uv of
// the island currently being dragged in the UV editor (island_active > 0.5). Identity when nothing is
@@ -228,13 +247,29 @@ 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);
}
// +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);
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. 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;
@@ -305,9 +340,12 @@ void main()
// printed part whatever the instance's scale or rotation, so the preview has to project from the
// world position and perturb the world normal.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor; // the frame the texture is projected in, as the bake does
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor); // the frame the texture is projected in, as the bake does
if (volume_mirrored)
triangle_normal = -triangle_normal;
// The same normal in that frame. triangle_normal itself stays in world, because the lighting at
// the end of this shader needs it there.
vec3 tex_normal = tex_rot * triangle_normal;
// Where the colour is read from. Both branches below already compute the uv this fragment's
// *height* came from - including the parallax-marched one on the triplanar path - and the colour
@@ -351,14 +389,14 @@ void main()
// closed-form uv here, unlike the LSCM case.
vec3 t, b;
vec2 units_per_mm;
projection_axes(tex_pos, triangle_normal, t, b, units_per_mm);
projection_axes(tex_pos, tex_normal, t, b, units_per_mm);
// Parallax occlusion mapping: march the view ray through the height shell and shade at the
// first point where it drops below the displaced surface (see header).
float amp = (invert ? -1.0 : 1.0) * depth_mm * clamp(weight, 0.0, 1.0);
vec3 view_dir = normalize(eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, triangle_normal);
vec2 uv = project_uv(tex_pos, triangle_normal);
vec3 view_dir = normalize(tex_rot * eye_model_pos - tex_pos);
float v_dot_n = dot(view_dir, tex_normal);
vec2 uv = project_uv(tex_pos, tex_normal);
// The shell the displaced surface lives inside, as signed heights along the normal. Taken from
// both ends of h in [0, 1] so it stays correct for an inverted layer or a raised midlevel,
@@ -370,18 +408,18 @@ void main()
// How far, in mm, sweeping the ray across the shell slides the sample point sideways. Below half
// 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) {
float sweep = length(view_dir - tex_normal * v_dot_n) * (h_hi - h_lo) / max(v_dot_n, 1e-4);
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.
float s = h_hi / v_dot_n;
float ds = (h_hi - h_lo) / (v_dot_n * float(PARALLAX_STEPS));
vec2 prev_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 prev_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float prev_gap = h_hi - amp * (H_AT(prev_uv) - midlevel); // >= 0 by construction
for (int i = 0; i < PARALLAX_STEPS; ++i) {
s -= ds;
vec2 cur_uv = project_uv(tex_pos + view_dir * s, triangle_normal);
vec2 cur_uv = project_uv(tex_pos + view_dir * s, tex_normal);
float gap = s * v_dot_n - amp * (H_AT(cur_uv) - midlevel);
if (gap <= 0.0) {
// Crossed between the last two samples - interpolating the hit is what stops it
@@ -405,23 +443,28 @@ 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.
// 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.
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs) * units_per_mm;
vec3 gradient = slope.x * t + slope.y * b;
// t and b came out of the texture frame, so the gradient is in it too and has to come back
// to world before it perturbs the normal the lighting uses.
vec3 gradient = tex_rot_inv * (slope.x * t + slope.y * b);
gradient -= triangle_normal * dot(triangle_normal, gradient);
triangle_normal = normalize(triangle_normal - gradient);
}
@@ -441,7 +484,9 @@ void main()
// Only the albedo - the specular term (intensity.y) stays white - so a coloured fragment reads as
// the same material under the same light, and the relief this preview exists to show is unaffected.
vec3 albedo = uniform_color.rgb;
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0)
bool uv_covered = tile_enabled ||
(all(greaterThanEqual(color_uv, vec2(0.0))) && all(lessThan(color_uv, vec2(1.0))));
if (palette_count > 0 && has_color_tex && have_uv && weight > 0.0 && uv_covered)
albedo = palette_rgb[nearest_palette_entry(texture(color_tex, color_uv).rgb)];
out_color = vec4(vec3(intensity.y) + albedo * intensity.x, uniform_color.a);
}
@@ -25,7 +25,16 @@ 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;
// 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
// The placement's rotation, so this overlay projects the same frame the bake does. Identity, which
// is what an unset uniform gives, leaves the old world-aligned behaviour.
uniform mat3 tex_rot; // world -> texture frame
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool use_vertex_uv;
@@ -90,13 +99,30 @@ vec2 projection_raw(vec3 p, vec3 n)
return vec2(box_face_sign(n.z) * p.x, -p.y);
}
// 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);
}
// +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);
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. 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;
}
@@ -117,7 +143,7 @@ void main()
// World space anchored at the volume's origin, like the bake and the shaded preview.
vec3 triangle_normal = normalize(cross(dFdx(world_pos.xyz), dFdy(world_pos.xyz)));
vec3 tex_pos = world_pos.xyz - tex_anchor;
vec3 tex_pos = tex_rot * (world_pos.xyz - tex_anchor);
if (volume_mirrored)
triangle_normal = -triangle_normal;
@@ -125,7 +151,7 @@ void main()
if (mode == 1) {
base = heatmap(distortion);
} else {
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, triangle_normal);
vec2 uv = use_vertex_uv ? vertex_uv : project_uv(tex_pos, tex_rot * triangle_normal);
vec2 c = floor(uv * checker_freq);
float check = mod(c.x + c.y, 2.0);
// Two distinct greys, plus a faint tint on one set so orientation is readable at a glance.
+95 -26
View File
@@ -1,6 +1,7 @@
#include "TextureDisplacement.hpp"
#include <Eigen/Core>
#include <Eigen/SVD>
#include <Eigen/Geometry>
#include <algorithm>
#include <array>
@@ -722,7 +723,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;
@@ -2164,7 +2168,7 @@ 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;
// `normalized_uv` drops the two steps that only mean something for a coordinate measured on the
@@ -2172,7 +2176,18 @@ struct UVTransform
// rotation and offset. See apply_uv_transform(); TextureProjectionMethod::ModelUV is what needs it.
UVTransform(const TextureDisplacementLayer &layer, float aspect_, bool normalized_uv = false)
{
scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
// 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. A normalized uv has no millimetre
// tiling to divide by, but a mirror is still a mirror, so the sign applies either way.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
inv_u = sign.x();
inv_v = sign.y();
} else {
const Vec2f tile = layer_tile_mm(layer);
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);
@@ -2181,9 +2196,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;
@@ -2203,26 +2220,43 @@ inline Vec3f triplanar_weights(const Vec3f &normal)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect, bool normalized_uv)
{
// A uv that is already normalized has no millimetre scale to divide by and no aspect to correct -
// see the header. UVTransform does the same, and the two must stay in step.
const float scale = (normalized_uv || layer.tiling_scale <= 1e-6f) ? 1.f : (1.f / layer.tiling_scale);
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.
//
// 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.
// A uv that is already normalized has no millimetre scale to divide by - see the header - but it
// can still be mirrored, so the sign is applied in both cases and only the extents are skipped.
// UVTransform does the same, and the two must stay in step.
const Vec2f sign = layer_mirror_sign(layer);
if (normalized_uv) {
rotated.x() *= sign.x();
rotated.y() *= sign.y();
} else {
const Vec2f tile = layer_tile_mm(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 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.
// 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.
//
// 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.
// Applied after the rotation, like the extents, and for the same reason: scaling one axis of an
// already-rotated coordinate before turning it would be a shear.
if (!normalized_uv && aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
@@ -3566,13 +3600,36 @@ void texture_displacement_patch_frame(const indexed_triangle_set &patch, const s
(an.y() <= an.x() && an.y() <= an.z()) ? Vec3f::UnitY() : Vec3f::UnitZ();
}
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world)
{
// Separated out by polar decomposition rather than by assuming the linear part is a clean
// rotation times a scale: a sheared or mirrored placement is legal here. A reflection is left
// behind with the scale on purpose, so a mirrored instance still makes the bake frame's
// determinant negative and the bake still knows to flip the normals it derives.
const Matrix3d linear = volume_to_world.linear();
Eigen::JacobiSVD<Matrix3d> svd(linear, Eigen::ComputeFullU | Eigen::ComputeFullV);
Matrix3d rotation = svd.matrixU() * svd.matrixV().transpose();
if (rotation.determinant() < 0.0) {
Matrix3d u = svd.matrixU();
u.col(2) *= -1.0;
rotation = u * svd.matrixV().transpose();
}
return rotation.transpose(); // world -> texture frame
}
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world)
{
// World orientation and scale, but the origin moved to where the volume's own origin sits: the
// texture then rides with the model when it is moved about the plate (and a single, untiled stamp
// starts on the model rather than at the plate's corner), while a tile is still `tiling_scale`
// printed millimetres whatever the instance's scale.
return Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
// The model's own orientation, world scale, and the origin moved to where the volume's origin
// sits. So the texture rides with the model however it is moved *and* however it is turned,
// while a tile is still `tiling_scale` printed millimetres whatever the instance's scale, and a
// single untiled stamp starts on the model rather than at the plate's corner.
//
// Orientation used to come from the world too, which meant turning a part on the plate slid the
// decoration across its surface. Turning a part is a print-time decision - clearance, supports,
// warping - and it should not be a design one.
Transform3d unrotate = Transform3d::Identity();
unrotate.linear() = texture_displacement_frame_rotation(volume_to_world);
return unrotate * Eigen::Translation3d(-volume_to_world.translation()) * volume_to_world;
}
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume)
@@ -5783,7 +5840,13 @@ std::string texture_displacement_layers_to_json(const std::vector<TextureDisplac
j["depth_mm"] = l.depth_mm;
j["tiling_scale"] = l.tiling_scale;
j["rotation_deg"] = l.rotation_deg;
// The v extent and the mirroring. Written unconditionally, including the <= 0 "same as u"
// sentinel, so a project says plainly that the tile was square rather than leaving a reader
// to infer it from a missing key.
j["tiling_scale_v"] = l.tiling_scale_v;
j["mirror_u"] = l.mirror_u;
j["mirror_v"] = l.mirror_v;
j["rotation_deg"] = l.rotation_deg;
j["offset"] = vec2_to_json(l.offset);
j["invert"] = l.invert;
j["midlevel"] = l.midlevel;
@@ -5891,6 +5954,12 @@ bool texture_displacement_layers_from_json(const std::string
read(j, "depth_mm", l.depth_mm);
read(j, "tiling_scale", l.tiling_scale);
// Absent in a project written before the tile could be rectangular or mirrored, and
// then each keeps its default: -1 for the v extent, which layer_tile_mm() reads as
// "square", and false for the mirrors. So an older project loads as what it was.
read(j, "tiling_scale_v", l.tiling_scale_v);
read(j, "mirror_u", l.mirror_u);
read(j, "mirror_v", l.mirror_v);
read(j, "rotation_deg", l.rotation_deg);
if (const auto o = j.find("offset"); o != j.end())
l.offset = vec2_from_json(*o, l.offset);
+47 -8
View File
@@ -225,7 +225,17 @@ 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;
// 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;
@@ -362,7 +372,8 @@ 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,
mirror_u, mirror_v);
}
template<class Archive> void load(Archive &ar)
{
@@ -374,7 +385,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, mirror_u, mirror_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);
@@ -383,6 +394,25 @@ 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);
}
// +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
@@ -668,14 +698,19 @@ 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. The layer's mirroring is a sign on
// that same division.
//
// `normalized_uv` says the coordinate is already a uv rather than a millimetre measurement on the
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tiling scale (mm
// model - which is what TextureProjectionMethod::ModelUV hands in. Then neither the tile extents (mm
// per tile is meaningless for it) nor the aspect correction (the artist's layout already accounts for
// the image's proportions) applies, and only the rotation and offset are left.
// the image's proportions) applies, and only the rotation, the mirroring and the offset are left.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f,
bool normalized_uv = false);
@@ -1047,6 +1082,10 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
Transform3d texture_displacement_volume_to_world(const ModelVolume &volume);
// The frame the bake and the previews project the texture in: `volume_to_world` with its translation
// removed, i.e. world orientation and scale about the volume's own origin. See build_texture_displacement().
// The rotation that carries a world direction into the texture's frame, i.e. the inverse of the
// placement's own rotation. Exposed because the preview shaders rebuild the frame themselves from a
// world position and have to turn it the same way the bake does, or the two disagree.
Matrix3d texture_displacement_frame_rotation(const Transform3d &volume_to_world);
Transform3d texture_displacement_bake_frame(const Transform3d &volume_to_world);
// Area-weighted vertex normals of `its` - the directions the bake both projects and displaces along.
File diff suppressed because it is too large Load Diff
@@ -508,25 +508,120 @@ private:
// is where the live adjustment is applied. Called whenever the panel moves or reverts it.
void invalidate_adjust_textures();
// "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.
// 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
// 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: 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<Vec2d, 4> edge_mid{};
std::array<Vec2d, 4> bar_a{};
std::array<Vec2d, 4> 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<Vec2d, 4> arm_base{};
std::array<Vec2d, 4> arm_tip{};
std::array<Vec2d, 4> arm_dir{}; // unit, pointing outward from the centre
// A ring is placed off every corner, but only one is drawn and only that one can be picked:
// four marks for a single operation is clutter, and whichever corner the cursor is near is
// the one worth offering. `rotate_shown` says which, and see update_rotate_corner() for why
// it does not simply track the nearest.
std::array<Vec2d, 4> rotate_at{};
int rotate_shown = 0;
// 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
// The selected instance's volume-to-world transform, which the bake frame is derived from.
bool adjust_volume_to_world(Transform3d &out) const;
// The anchor, carried from mesh-local into the bake frame. Converted on every use rather than
// stored converted: the object can be moved, rotated or scaled while this mode is open, and a
// stored copy would quietly describe the shape it used to be. The expensive part - finding the
// paint patch's centroid - stays cached in the local members.
bool adjust_anchor_in_frame(Vec3f &pos, Vec3f &normal) const;
// 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;
// Chooses which corner carries the rotate ring. The nearest to the cursor, but it only moves once
// the newcomer is clearly nearer, because without that margin the ring flits between two corners
// every time the cursor wanders along the line equidistant from both. Never moves during a drag.
void update_rotate_corner(const AdjustFrame &f, const Vec2d &mouse);
// 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,
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
// 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.
void render_paint_cursor_hint();
// Mesh-local tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// Bake-frame tangent-plane basis at m_adjust_anchor_normal: box_projection_axes(), so dragging
// on-canvas maps consistently onto offset whichever face the anchor is on.
void adjust_tangent_basis(Vec3f &u_axis, Vec3f &v_axis) const;
@@ -1118,23 +1213,47 @@ private:
// See the "Adjust Texture" block of private methods above.
bool m_adjust_texture_mode = false;
bool m_adjust_anchor_valid = false;
Vec3f m_adjust_anchor_pos = Vec3f::Zero(); // mesh-local
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ(); // mesh-local
// Mesh-local, as compute_layer_paint_anchor() produces them. Everything else about the gizmo
// works in the bake frame - world orientation and scale, origin at the volume's own, which is
// where the texture is projected (texture_displacement_bake_frame()) - so these go through
// adjust_anchor_in_frame() at the point of use. A tile is so many *printed* millimetres whatever
// the instance's scale, which is why measuring the gizmo in mesh-local millimetres made its tile
// differ from the rendered one by exactly that scale.
Vec3f m_adjust_anchor_pos = Vec3f::Zero();
Vec3f m_adjust_anchor_normal = Vec3f::UnitZ();
// 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.
// Which corner currently carries the rotate ring. Persists between frames, which is what gives
// update_rotate_corner() something to apply its margin against.
int m_adjust_rotate_corner = 0;
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
// 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.
std::string m_adjust_readout;
std::map<std::string, wxString> m_desc;
+18 -5
View File
@@ -297,9 +297,12 @@ 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, 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;
@@ -661,11 +664,18 @@ 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;
// 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);
}
Vec2f UVEditorCanvas::snap_correction(int island) const
@@ -2180,7 +2190,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);
+10 -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, 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
@@ -129,7 +132,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;
@@ -292,8 +296,10 @@ 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()
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;
bool m_snap_enabled = false;