Texture displacement: illustrate the settings that are a shape

Ten more tooltips get a picture: Depth, Tile size, Repeat/Mirror, Invert,
Smoothing, Edge fade and Blend as stills, Depth, Rotation and Midlevel as
animations. All drawn by scripts/gen_texture_displacement_tips.py from the
same arithmetic the feature uses, one motif throughout, so that whatever
differs between two of them is the setting and nothing else.

Midlevel is drawn in section instead of as a relief. Shading reads slopes,
so a height field moved up or down as a whole shades exactly as it did
before - and where the relief sits relative to the surface is the entire
meaning of that setting. From the side, against the bare surface, it is the
one thing visible.

A single picture is now capped at 10 em tall and centred in its column,
rather than drawn at the tooltip's full width, where a square one came out
as tall as the text beside it was long.
This commit is contained in:
ExPikaPaka
2026-10-13 13:33:05 +02:00
parent 64ddc1fbba
commit fc5ac6d2a5
18 changed files with 238 additions and 49 deletions
+19 -7
View File
@@ -61,16 +61,28 @@ for the next through `GLCanvas3D::schedule_extra_frame()`, timed to when that fr
## Making one
Captures of the real tool are the first choice for anything involving the model, the mesh or colour.
Settings whose meaning is a shape can be drawn from the same arithmetic instead:
`scripts/gen_texture_displacement_tips.py` shades a height field as a relief catching the light, and
produces `tile_off.png`, `tile_on.png` and `rotation.gif` that way.
Settings whose meaning is a shape are drawn from the same arithmetic instead, by
`scripts/gen_texture_displacement_tips.py`: it shades a height field as a relief catching the light,
and covers Depth, Tile, Tile size, Rotation, Repeat/Mirror, Midlevel, Smoothing, Edge fade, Invert and
Blend that way.
One motif - a ring inside a square frame - runs through nearly all of them, because using one shape
everywhere is what lets two pictures be compared: whatever differs between them is the setting, since
nothing else is.
Midlevel is drawn in section rather than as a relief, and the reason generalises. Shading reads
slopes, so a height field moved up or down as a whole shades identically to where it started; any
setting whose meaning is *where the relief sits relative to the surface* is invisible in a top view
and has to be drawn from the side, against the bare surface as a reference.
Either way:
- Square, around 256 px. The tooltip is about 20 em wide, so that is roughly native size on a 100%
display and downscales cleanly above it.
- Square, around 256 px (192 for an animation, where every frame ships). The tooltip is about 20 em
wide, so that is roughly native size on a 100% display and downscales cleanly above it. A single
picture is capped at 10 em tall and centred, two side by side take half the width each.
- Neutral grey. These say what a shape does; colour in one reads as a third thing the setting does,
and has to work against both themes.
- Animations loop seamlessly and stay short - a dozen frames at 100 ms, under about 250 KB. They sit
in `resources/`, which ships with every copy of the application.
- Animations loop seamlessly and stay short - 16 frames at 100 ms, under about 150 KB. They sit in
`resources/`, which ships with every copy of the application. A sweep driven by a cosine over the
frame count loops with no step, since it starts and ends at the same value and the same rate.
- No text in the picture. Captions are translated; pixels are not.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+186 -29
View File
@@ -2,11 +2,14 @@
"""Draws the illustrations the texture-displacement tooltips show.
The panel's tooltips can carry a picture under their text (see GLGizmoTextureDisplacement's
panel_tooltip()). A few of those settings - the ones that are a shape rather than a photograph -
panel_tooltip()). A good many of those settings - the ones that are a shape rather than a photograph -
are better drawn from the same arithmetic the feature uses than captured from the screen, which is
what this script does: it shades a height field exactly as a relief would catch the light, so
"what Rotation turns" and "what Tile repeats" are visible rather than described.
Settings that involve the model, the mesh or colour are not here: those want a capture of the real
tool. See docs/HLSD/tooltip-illustrations.md.
Output goes to resources/images/texture_displacement/. Run it after changing one of the patterns
below; nothing in the build depends on it.
@@ -21,6 +24,7 @@ import numpy as np
from PIL import Image
SIZE = 256 # px, square - the panel draws these at whatever width the tooltip has
GIF_SIZE = 192 # animations are the same picture, smaller: every frame of one ships in resources/
RELIEF = 30.0 # how hard the shading reacts to a slope; purely cosmetic
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "resources", "images", "texture_displacement")
@@ -41,8 +45,10 @@ def grid(size=SIZE):
def shade(height):
"""A height field in 0..1, lit as a relief and tinted: the picture the tooltip shows."""
gy, gx = np.gradient(height * RELIEF)
"""A height field in 0..1, lit as a relief: the picture the tooltip shows."""
# The slope has to mean the same thing at either size, so the gradient is taken per unit of the
# picture rather than per pixel - otherwise the smaller animations would come out twice as flat.
gy, gx = np.gradient(height * RELIEF * (height.shape[0] / float(SIZE)))
normal = np.dstack([-gx, -gy, np.ones_like(height)])
normal /= np.linalg.norm(normal, axis=2, keepdims=True)
lam = np.clip(normal @ LIGHT, 0.0, 1.0)
@@ -72,50 +78,151 @@ def vignette(rgb):
def save_png(name, rgb):
path = os.path.join(OUT_DIR, name)
Image.fromarray(vignette(rgb)).save(path, optimize=True)
print("wrote", os.path.normpath(path))
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def save_gif(name, frames, delay_ms):
path = os.path.join(OUT_DIR, name)
images = [Image.fromarray(vignette(f)).convert("P", palette=Image.ADAPTIVE, colors=32) for f in frames]
images[0].save(path, save_all=True, append_images=images[1:], duration=delay_ms, loop=0, optimize=True)
print("wrote", os.path.normpath(path))
print("wrote", os.path.normpath(path), os.path.getsize(path) // 1024, "KB")
def smoothstep(edge0, edge1, x):
t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
return t * t * (3.0 - 2.0 * t)
def blur(a, radius):
"""Separable box blur, run twice - near enough a gaussian for a picture of a blur."""
for _ in range(2):
for axis in (0, 1):
padded = np.pad(a, [(radius, radius) if i == axis else (0, 0) for i in range(2)], mode="edge")
cumulative = np.cumsum(padded, axis=axis)
cumulative = np.concatenate([np.zeros_like(np.take(cumulative, [0], axis=axis)), cumulative], axis=axis)
n = a.shape[axis]
hi = np.take(cumulative, np.arange(n) + 2 * radius + 1, axis=axis)
lo = np.take(cumulative, np.arange(n), axis=axis)
a = (hi - lo) / (2.0 * radius + 1.0)
return a
# --- the patterns ------------------------------------------------------------------------------
#
# One motif runs through nearly all of them - a ring inside a square frame. Using the same shape for
# every setting is what lets the pictures be compared: whatever changes between two of them is the
# setting, because nothing else is different.
def ridges(angle_deg, period=0.16):
"""Parallel ridges at an angle: the simplest pattern whose direction is unmistakable."""
x, y = grid()
a = np.radians(angle_deg)
t = (x * np.cos(a) + y * np.sin(a)) / period
return 0.2 + 0.6 * (0.5 + 0.5 * np.sin(2.0 * np.pi * t))
def motif(repeats):
"""A ring inside a square frame, laid out `repeats` times across the picture."""
x, y = grid()
u = (x * repeats) % 1.0 - 0.5
v = (y * repeats) % 1.0 - 0.5
def _motif_cell(u, v):
"""The motif within one cell, u and v in -0.5 .. 0.5."""
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0)
def motif(repeats, size=SIZE):
"""The motif laid out `repeats` times across the picture."""
x, y = grid(size)
return _motif_cell((x * repeats) % 1.0 - 0.5, (y * repeats) % 1.0 - 0.5)
def decal(motif_scale=0.46):
"""One copy of the motif in the middle of a flat field - what Tile off leaves."""
x, y = grid()
u, v = x / motif_scale, y / motif_scale
r = np.hypot(u, v)
ring = np.clip(1.0 - np.abs(r - 0.26) / 0.085, 0.0, 1.0)
frame_d = np.maximum(np.abs(u), np.abs(v))
frame = np.clip(1.0 - np.abs(frame_d - 0.42) / 0.055, 0.0, 1.0)
inside = (np.maximum(np.abs(u), np.abs(v)) <= 0.5).astype(float)
return np.clip(np.maximum(ring, frame) ** 0.7, 0.0, 1.0) * inside
return _motif_cell(u, v) * inside
def ridges(angle_deg, period=0.16, size=SIZE):
"""Parallel ridges at an angle: the simplest pattern whose direction is unmistakable."""
x, y = grid(size)
a = np.radians(angle_deg)
t = (x * np.cos(a) + y * np.sin(a)) / period
return 0.2 + 0.6 * (0.5 + 0.5 * np.sin(2.0 * np.pi * t))
def bosses(repeats, size=SIZE):
"""Rounded domes standing on a flat field - a shape with an unmistakable right way up."""
x, y = grid(size)
u = (x * repeats) % 1.0 - 0.5
v = (y * repeats) % 1.0 - 0.5
return smoothstep(0.36, 0.08, np.hypot(u, v))
def chevron_tiles(mirrored, repeats=3):
"""Cells of diagonal ridges, laid out repeated or mirrored.
Repeated, every cell starts the pattern over and the seam between two of them is a step; mirrored,
each cell is the reflection of its neighbour and the ridges meet across the seam as chevrons. That
seam is the whole of what the Repeat/Mirror pair does, and a motif symmetric in itself would hide
it entirely.
"""
def axis(t):
if not mirrored:
return (t * repeats) % 1.0
s = (t * repeats) % 2.0
return np.where(s < 1.0, s, 2.0 - s)
x, y = grid()
t = (axis(x + 0.5) + axis(y + 0.5)) * 1.5
return 0.22 + 0.56 * (0.5 + 0.5 * np.cos(2.0 * np.pi * t))
def painted_area(size=SIZE, radius=0.44):
"""A round painted area in the middle of bare surface, with a soft edge so it does not alias."""
x, y = grid(size)
return smoothstep(radius, radius - 1.5 / size, np.hypot(x, y))
def painted_patch(fade, size=SIZE):
"""The motif inside a painted area, either stopping at its edge or fading into it."""
x, y = grid(size)
r = np.hypot(x, y) / 0.44
amount = smoothstep(1.0, 0.55, r) if fade else 1.0
return motif(3, size) * painted_area(size) * amount
def profile_wave(size=SIZE, periods=2.5):
"""A one-dimensional height profile with clear highs, lows and a flat stretch between them."""
t = (np.arange(size) + 0.5) / size * periods * 2.0 * np.pi
h = 0.5 + 0.34 * np.cos(t) + 0.16 * np.cos(2.0 * t + 0.9)
return np.clip((h - h.min()) / (h.max() - h.min()), 0.0, 1.0)
def profile_view(height_1d, midlevel, amplitude=0.26, size=SIZE):
"""A side view of the surface: material below, air above, and the bare surface as a dashed line.
Shading cannot say how high something is - it reads slopes, and a height field moved up or down as
a whole has exactly the slopes it had before. Midlevel is entirely about where the texture sits
relative to the surface it is on, so it is drawn in section, where that is the one thing visible.
"""
base = 0.52 * size
surface = base - (height_1d - midlevel) * amplitude * size
rows = np.arange(size)[:, None]
covered = np.clip(rows - surface[None, :] + 0.5, 0.0, 1.0) # 1 where there is material
air = np.array([0.11, 0.12, 0.13])
material = np.array([0.44, 0.46, 0.47])
rgb = air * (1.0 - covered)[..., None] + material * covered[..., None]
# The lit top face, and the bare surface the texture is measured against.
edge = np.clip(1.0 - np.abs(rows - surface[None, :]) / 1.6, 0.0, 1.0)
rgb = rgb * (1.0 - edge[..., None]) + np.array([0.80, 0.86, 0.85]) * edge[..., None]
dash = ((np.arange(size) // 7) % 2 == 0)[None, :]
line = (np.abs(rows - base) < 0.9) & dash
rgb[line] = np.array([0.58, 0.62, 0.62])
return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
def dome(size=SIZE):
"""One broad swell filling the picture - the layer underneath, for the blend pictures."""
x, y = grid(size)
return smoothstep(0.52, 0.02, np.hypot(x, y))
def main():
@@ -123,13 +230,63 @@ def main():
# Tile: one copy against the pattern filling the painted area.
save_png("tile_off.png", shade(decal()))
save_png("tile_on.png", shade(motif(repeats=3)))
save_png("tile_on.png", shade(motif(3)))
# Rotation: the same ridges turning a quarter of a turn and back, so the loop has no jump.
# A half turn brings parallel ridges back onto themselves, so a single sweep loops with no jump
# and the file stays a fraction of the size a there-and-back one would be.
# Tile size: how wide one copy is.
save_png("tile_size_large.png", shade(motif(2)))
save_png("tile_size_small.png", shade(motif(5)))
# Repeat against Mirror, where the seam between two copies is the whole point.
save_png("tile_repeat.png", shade(chevron_tiles(mirrored=False)))
save_png("tile_mirror.png", shade(chevron_tiles(mirrored=True)))
# Invert: the negative of the image. Domes rather than the ring motif, which is close enough to
# its own negative that inverting it would look like nothing had happened.
save_png("invert_off.png", shade(bosses(3)))
save_png("invert_on.png", shade(1.0 - bosses(3)))
# Smoothing: a stepped image against the same one blurred before it is used. Domes again, where
# the steps show up as contour rings - what a photograph with too few grey levels does.
stepped = np.floor(bosses(3) * 5.0) / 4.0
save_png("smoothing_off.png", shade(np.clip(stepped, 0.0, 1.0)))
save_png("smoothing_on.png", shade(np.clip(blur(stepped, max(2, SIZE // 40)), 0.0, 1.0)))
# Edge fade: relief that stops at the edge of the painted area against relief that sinks into it.
save_png("edge_fade_off.png", shade(painted_patch(fade=False)))
save_png("edge_fade_on.png", shade(painted_patch(fade=True)))
# Blend: piling relief on top of the layer below against using it as a mask over it. Add keeps
# both shapes, Multiply keeps the motif only where the layer under it is high.
base = dome()
save_png("blend_add.png", shade(np.clip(0.55 * base + 0.42 * motif(3), 0.0, 1.0)))
save_png("blend_multiply.png", shade(base * motif(3)))
# Rotation: a half turn brings parallel ridges back onto themselves, so a single sweep loops with
# no jump and the file stays a fraction of the size a there-and-back one would be.
steps = 16
save_gif("rotation.gif", [shade(ridges(180.0 * i / steps)) for i in range(steps)], delay_ms=100)
save_gif("rotation.gif", [shade(ridges(180.0 * i / steps, size=GIF_SIZE)) for i in range(steps)], delay_ms=100)
# Depth and Midlevel are both about where the relief sits relative to the surface it is on, so
# both are drawn as a painted area in the middle of bare surface, which is that reference. Without
# it neither would show: shading reads slopes, and a height field that moves up or down as a whole
# has exactly the same slopes it had before.
#
# A cosine over the frame count starts and ends at the same value with no step in between, so a
# single pass through it loops.
area = painted_area(GIF_SIZE)
pattern = motif(3, GIF_SIZE)
sweep = [0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps) for i in range(steps)]
# Depth: the relief growing out of the surface and settling back flush with it.
frames = [shade(np.clip(0.5 + 0.45 * pattern * (0.08 + 0.92 * t) * area, 0.0, 1.0)) for t in sweep]
save_gif("depth.gif", frames, delay_ms=100)
# Midlevel: the grey that stays put, sweeping from "the whole texture pushes out" through
# "mid-grey stays where the surface is" to "the whole of it cuts in". Drawn in section - see
# profile_view() for why this one is not a relief like the rest.
wave = profile_wave(GIF_SIZE)
frames = [profile_view(wave, t, size=GIF_SIZE) for t in sweep]
save_gif("midlevel.gif", frames, delay_ms=100)
if __name__ == "__main__":
@@ -3768,16 +3768,28 @@ void GLGizmoTextureDisplacement::render_tip_media(const std::vector<TipMedia> &m
const float cell_w = (width - spacing * float(shown.size() - 1)) / float(shown.size());
const ImU32 frame = ImGui::GetColorU32(ImGuiCol_Text, 0.25f);
// A single illustration would otherwise be drawn at the tooltip's full width, and a square one at
// full width is a picture as tall as the text beside it is long. Capped and centred in its column.
const float max_h = m_imgui->scaled(10.f);
ImGui::Spacing();
for (size_t i = 0; i < shown.size(); ++i) {
const AnimatedTexture *texture = shown[i].first;
const TipMedia &entry = *shown[i].second;
const float cell_x = ImGui::GetCursorPosX();
float w = cell_w;
float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
if (h > max_h) {
w = std::round(max_h * float(texture->width()) / float(texture->height()));
h = max_h;
}
ImGui::BeginGroup();
double next = 0.;
const unsigned int id = texture->texture_id(time, &next);
const float h = std::round(cell_w * float(texture->height()) / float(texture->width()));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(cell_w, h));
ImGui::SetCursorPosX(cell_x + std::round(0.5f * (cell_w - w)));
ImGui::Image((ImTextureID) (intptr_t) id, ImVec2(w, h));
ImGui::GetWindowDrawList()->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), frame, style.FrameRounding);
// The canvas only redraws on demand, so an animation has to ask - and asks for exactly when its
// next frame is due, rather than for a redraw as fast as the machine will go.
@@ -3785,10 +3797,9 @@ void GLGizmoTextureDisplacement::render_tip_media(const std::vector<TipMedia> &m
m_parent.schedule_extra_frame(int(next * 1000.0));
if (!entry.caption.empty()) {
// Centred under its own illustration, and never wider than it.
// Centred under its own illustration, and never wider than its column.
const std::string caption = into_u8(entry.caption);
const float text_w = ImGui::CalcTextSize(caption.c_str()).x;
const float cell_x = ImGui::GetCursorPosX();
ImGui::SetCursorPosX(cell_x + std::max(0.f, (cell_w - text_w) * 0.5f));
ImGui::PushTextWrapPos(cell_x + cell_w);
ImGui::TextDisabled("%s", caption.c_str());
@@ -6565,11 +6576,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Depth and tile size are logarithmic, so both ends of their wide ranges stay usable.
m_preview_params_dirty |= float_row("##depth", _L("Depth"), &layer.depth_mm, 0.01f, 10.f, "%.3f mm", true, card_pad);
hover_tip(_u8L("Height of the relief: how far white in the texture lifts the surface, in "
"millimetres. Black does not move it at all, unless Midlevel says otherwise."));
"millimetres. Black does not move it at all, unless Midlevel says otherwise."),
{ { "depth.gif" } });
m_preview_params_dirty |= float_row("##tile_size", _L("Tile size"), &layer.tiling_scale, 0.2f, 200.f, "%.2f mm", true, card_pad);
hover_tip(_u8L("How wide one copy of the texture is on the model. Smaller repeats the pattern "
"more often and makes its detail finer; with Tile off, this is the size of the "
"single copy."));
"single copy."),
{ { "tile_size_large.png", _L("Larger") }, { "tile_size_small.png", _L("Smaller") } });
m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad);
hover_tip(_u8L("Turns the texture on the surface, in degrees - for lining a pattern up with an "
"edge of the model."),
@@ -6593,7 +6606,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"outwards; at 0.5 mid-gray stays put, so darker grays cut in and lighter ones "
"still push out - one image both embosses and engraves.\n\n"
"What cuts in has to fit: inside a sharp corner or through a thin wall, a deep "
"cut can pass through the other side."));
"cut can pass through the other side."),
{ { "midlevel.gif" } });
if (layer.midlevel > 0.f && layer.depth_mm > 1.f) {
ImGui::PushTextWrapPos(content_rx - card_pad);
m_imgui->warning_text(_L("Deep inward displacement may self-intersect."));
@@ -6603,12 +6617,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_preview_params_dirty |= float_row("##smoothing", _L("Smoothing"), &layer.smoothing, 0.f, 1.f, "%.2f", false, card_pad);
hover_tip(_u8L("Blurs the image before it is used, which rounds off hard steps and removes "
"speckle from a noisy photo. Raise it if the relief looks harsh or grainy; it "
"costs fine detail."));
"costs fine detail."),
{ { "smoothing_off.png", _L("Off") }, { "smoothing_on.png", _L("On") } });
// Edge fade: the relief flattens toward the boundary of the painted area.
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Edge fade") + "##edge_smoothing", layer.edge_smoothing);
hover_tip(_u8L("Flattens the relief as it approaches the edge of the painted area, so it "
"blends into the bare surface instead of stopping at a step."));
"blends into the bare surface instead of stopping at a step."),
{ { "edge_fade_off.png", _L("Off") }, { "edge_fade_on.png", _L("On") } });
ImGui::SameLine();
m_imgui->disabled_begin(!layer.edge_smoothing);
ImGui::SetNextItemWidth(-card_pad);
@@ -6623,7 +6639,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
const float x0 = ImGui::GetCursorPosX();
m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Invert"), layer.invert);
hover_tip(_u8L("Turns the relief inside out: what stood out is cut in, and the other way "
"round. The same as using a negative of the image."));
"round. The same as using a negative of the image."),
{ { "invert_off.png", _L("Off") }, { "invert_on.png", _L("On") } });
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + 0.5f * (ImGui::GetWindowContentRegionMax().x - card_pad - x0)));
@@ -6820,14 +6837,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + label_w));
const wxString tile_na = layer.tile_enabled ? wxString() : _L("Turn Tile on to choose how the texture repeats.");
if (icon_toggle(840, "texture_displacement_tile_repeat.svg", layer.tile_method == static_cast<TextureTileMethod>(0),
icon_sm, _L("Repeat"), _L("Repeat"), tile_na)) {
icon_sm, _L("Repeat"), _L("Repeat"), tile_na, { { "tile_repeat.png" } })) {
layer.tile_method = static_cast<TextureTileMethod>(0);
m_preview_params_dirty = true;
}
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(841, "menu_mirror_x.svg", layer.tile_method == static_cast<TextureTileMethod>(1), icon_sm,
_L("Mirrored repeat"), _L("Mirrored repeat - every other tile is flipped, so edges meet seamlessly"),
tile_na)) {
tile_na, { { "tile_mirror.png" } })) {
layer.tile_method = static_cast<TextureTileMethod>(1);
m_preview_params_dirty = true;
}
@@ -6863,7 +6880,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"Subtract pile relief on or carve it away. Multiply and Divide scale "
"what is underneath, which turns this layer into a mask over it - "
"there, Depth acts as a strength, and 1 mm leaves white areas "
"untouched."));
"untouched."),
base_layer ? std::vector<TipMedia>{} :
std::vector<TipMedia>{ { "blend_add.png", from_u8(blend_add) },
{ "blend_multiply.png", from_u8(blend_multiply) } });
ImGui::SameLine(0.f, gap_s);
if (icon_toggle(842, "texture_displacement_adjust.svg", m_adjust_texture_mode, icon_sm, _L("Adjust placement"),