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
+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__":