diff --git a/docs/HLSD/tooltip-illustrations.md b/docs/HLSD/tooltip-illustrations.md new file mode 100644 index 0000000000..dc52e9ac93 --- /dev/null +++ b/docs/HLSD/tooltip-illustrations.md @@ -0,0 +1,131 @@ +# Tooltip illustrations + +The texture-displacement panel's tooltips can carry a picture under their text: a still image, or a +short animation that loops while the tooltip is open. Several settings in that panel describe a shape +rather than a number - what Rotation turns, what Tile repeats, what Invert turns inside out - and a +sentence about a shape is read twice and understood once. + +## Where a tooltip is drawn + +`GLGizmoTextureDisplacement::panel_tooltip()` replaces `ImGuiWrapper::tooltip()` throughout the panel. +It differs in two ways. + +It places the tooltip beside the panel instead of under the mouse. The panel is pinned to the right +edge of the canvas, so a tooltip following the mouse covers the control it is explaining, and one +carrying a picture covers most of the panel with it. The tooltip goes to the left of +`m_tooltip_anchor_min`/`_max` - the panel's own screen rectangle, or the texture picker's while that +popup is up and the tooltip comes from inside it - and to the right only when there is no room on the +left. This is the placement the picker popup itself already uses. + +ImGui normally keeps a tooltip glued to the mouse and ignores `SetNextWindowPos()`. Orca's imgui +branch honours a position set for a tooltip (see the `ImGuiWindowFlags_Tooltip` branch of `Begin()` in +`deps_src/imgui/imgui.cpp`) and still clamps the result inside the canvas, which is what keeps a tall +tooltip near the bottom edge on screen. + +It also gives the tooltip a fixed width rather than one derived from its text, because a picture has +to be laid out against something, and a column of tooltips that are all one width is steadier to read. + +## Where the pictures live + +`resources/images/texture_displacement/`, as PNG, JPG, BMP or GIF. A tooltip names them by file: + +```cpp +hover_tip(_u8L("Repeats the texture across the painted area. ..."), + { { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } }); +``` + +Several in one tooltip are laid out side by side at equal widths, which is how a setting is shown as a +comparison - the same model at two values of it. The caption under each is translated; the file name +is not. + +A file that is missing or will not decode is skipped, and a tooltip whose pictures are all missing is +exactly the text tooltip it was before. So the art for a tooltip can land after the tooltip does, and +a half-finished set never breaks the panel. + +## When a tooltip appears + +A tooltip waits for the pointer to rest on its control for 0.45 s. The panel is dense and the tooltip +lands on the model beside it, so with no wait a sweep from the top of the panel down to Bake flashes a +dozen large panels over the scene. Once one has just been up, the wait drops to 0.1 s for 0.7 s +afterwards, so reading several settings in a row stays quick - but not to zero, or that same sweep +would flash again the moment one tooltip had been seen. + +No tooltip appears at all while a control is being used - a slider dragged, a value typed into one, a +combo open, a layer card being dragged. The pointer sits on the very thing being edited, so a tooltip +there covers the model exactly while the user is watching it change. The full wait comes back +afterwards rather than the short one: having just let go of a slider is not a reason to be shown a +picture of it. + +A pointer resting on a control produces no events, and the canvas only renders when something asks it +to, so the wait has to ask for its own frames, and so does an animation once one is on screen. So does +a tooltip that has just appeared, animated or not: ImGui hides a window on the first frame it is +submitted while it measures it, and does so every time a tooltip reappears because those windows are +recycled, so the frame a tooltip is decided on is never the frame it is seen on. Both use +`GLCanvas3D::request_extra_frame()`, which ends `on_idle()` with `evt.RequestMore()` and so obliges wx +to deliver the next idle. `schedule_extra_frame()` is not enough on its own: it only arranges a timer +whose event an idle event is assumed to follow, and on_idle renders nothing unless the canvas was +already dirty when it ran. Asking for frames as fast as they come is safe here because which frame is +drawn comes from the clock, not from the frame count, so a redraw that arrives early simply draws the +same frame again. + +## Decoding and playback + +`slic3r/GUI/AnimatedTexture.hpp` decodes a file once and uploads one GL texture per frame. Stills go +through `wxImage`; GIFs go through `wxGIFDecoder`, whose raw palette indices are composited here frame +by frame, replaying the disposal method each frame asks for. That replay is what makes an animation +look continuous: a GIF frame is a sub-rectangle drawn over what came before it, so an animation that +only re-sends the pixels that changed draws as a flicker of disconnected patches without it. + +A frame delay of 0 or 1 hundredths of a second is read as 100 ms, the convention browsers settle on, +so an animation authored against that plays at the speed it was authored at. + +The animation restarts when its tooltip opens, rather than running off a free-running clock: these are +short loops of one setting going from one value to another, and joining one halfway through shows the +end of a change the user never saw the start of. + +Which frame is drawn is measured on a real clock, not `ImGui::GetTime()`. Nothing in OrcaSlicer ever sets +`io.DeltaTime`, so it keeps imgui's default of 1/60 per frame and imgui's clock counts frames rather +than seconds. On a canvas that renders only when asked, that is not a clock at all: an animation driven +by it crawls while the canvas is idle and races while a moving mouse is rendering it flat out. + +## Making one + +`scripts/gen_texture_displacement_tips.py` draws the lot, in four languages, picked by what the +setting actually is: + +- **A shaded relief**, for the settings that are about a pattern: Depth, Tile, Tile size, Rotation, + Repeat/Mirror, Smoothing, Edge fade, Invert, Blend and the Normal, Fast, Checker and Distortion + views. One motif - a ring inside a square frame - runs through 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. +- **A flat mesh of triangles**, for the settings that are about the mesh or about a tool: the paint + tools, brush size, the connected-area fill, subdivision, feature-adaptive refinement, remeshing, + hard edges, slivers and the wireframe view. These carry the one accent colour in the set, meaning + "painted", because that is the only thing in such a picture that is not geometry. +- **A section**, for Midlevel and for smoothing the geometry. The reason generalises: shading reads + slopes, so a height field moved up or down as a whole shades identically to where it started, and + any setting whose meaning is *where the surface sits relative to where it was* is invisible from + above and has to be drawn from the side. +- **A flat ramp of greys**, for Mix filaments, Mixed colors and Denoise. Those are about which + filament a spot ends up as; lighting one would only add a second thing changing between two + pictures meant to differ in one. + +The five Mapping pictures are one ball with a checker laid on it five ways, which is the whole of what +choosing a projection does - except Unwrap, which is not a projection onto the model at all and so is +drawn as the flat layout it produces. + +Captures of the real tool are still the first choice for anything involving a real model or real +filament colour: Bake, the mixed-colour count on a photograph, Adjust placement, the frame window and +the UV editor's seam tool have no picture yet for that reason. + +Either way: + +- 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 - 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. diff --git a/resources/images/texture_displacement/adaptive_off.png b/resources/images/texture_displacement/adaptive_off.png new file mode 100644 index 0000000000..c509ee5871 Binary files /dev/null and b/resources/images/texture_displacement/adaptive_off.png differ diff --git a/resources/images/texture_displacement/adaptive_on.png b/resources/images/texture_displacement/adaptive_on.png new file mode 100644 index 0000000000..43f2667457 Binary files /dev/null and b/resources/images/texture_displacement/adaptive_on.png differ diff --git a/resources/images/texture_displacement/blend_add.png b/resources/images/texture_displacement/blend_add.png new file mode 100644 index 0000000000..e1d836167e Binary files /dev/null and b/resources/images/texture_displacement/blend_add.png differ diff --git a/resources/images/texture_displacement/blend_multiply.png b/resources/images/texture_displacement/blend_multiply.png new file mode 100644 index 0000000000..eea49dd12b Binary files /dev/null and b/resources/images/texture_displacement/blend_multiply.png differ diff --git a/resources/images/texture_displacement/collapse_slivers_off.png b/resources/images/texture_displacement/collapse_slivers_off.png new file mode 100644 index 0000000000..f76898339a Binary files /dev/null and b/resources/images/texture_displacement/collapse_slivers_off.png differ diff --git a/resources/images/texture_displacement/collapse_slivers_on.png b/resources/images/texture_displacement/collapse_slivers_on.png new file mode 100644 index 0000000000..3b1823c59c Binary files /dev/null and b/resources/images/texture_displacement/collapse_slivers_on.png differ diff --git a/resources/images/texture_displacement/cursor_size.gif b/resources/images/texture_displacement/cursor_size.gif new file mode 100644 index 0000000000..3c6e5a7d92 Binary files /dev/null and b/resources/images/texture_displacement/cursor_size.gif differ diff --git a/resources/images/texture_displacement/denoise_off.png b/resources/images/texture_displacement/denoise_off.png new file mode 100644 index 0000000000..4450932491 Binary files /dev/null and b/resources/images/texture_displacement/denoise_off.png differ diff --git a/resources/images/texture_displacement/denoise_on.png b/resources/images/texture_displacement/denoise_on.png new file mode 100644 index 0000000000..ff679e058a Binary files /dev/null and b/resources/images/texture_displacement/denoise_on.png differ diff --git a/resources/images/texture_displacement/depth.gif b/resources/images/texture_displacement/depth.gif new file mode 100644 index 0000000000..d7e9dd615e Binary files /dev/null and b/resources/images/texture_displacement/depth.gif differ diff --git a/resources/images/texture_displacement/edge_fade_off.png b/resources/images/texture_displacement/edge_fade_off.png new file mode 100644 index 0000000000..4652e38ba7 Binary files /dev/null and b/resources/images/texture_displacement/edge_fade_off.png differ diff --git a/resources/images/texture_displacement/edge_fade_on.png b/resources/images/texture_displacement/edge_fade_on.png new file mode 100644 index 0000000000..2abb2029aa Binary files /dev/null and b/resources/images/texture_displacement/edge_fade_on.png differ diff --git a/resources/images/texture_displacement/erase.gif b/resources/images/texture_displacement/erase.gif new file mode 100644 index 0000000000..ef4a82e7b1 Binary files /dev/null and b/resources/images/texture_displacement/erase.gif differ diff --git a/resources/images/texture_displacement/erase_all.png b/resources/images/texture_displacement/erase_all.png new file mode 100644 index 0000000000..5633bdfa0a Binary files /dev/null and b/resources/images/texture_displacement/erase_all.png differ diff --git a/resources/images/texture_displacement/geometry_smoothing.gif b/resources/images/texture_displacement/geometry_smoothing.gif new file mode 100644 index 0000000000..ad55fbc09b Binary files /dev/null and b/resources/images/texture_displacement/geometry_smoothing.gif differ diff --git a/resources/images/texture_displacement/invert_off.png b/resources/images/texture_displacement/invert_off.png new file mode 100644 index 0000000000..65248727de Binary files /dev/null and b/resources/images/texture_displacement/invert_off.png differ diff --git a/resources/images/texture_displacement/invert_on.png b/resources/images/texture_displacement/invert_on.png new file mode 100644 index 0000000000..0944c0e120 Binary files /dev/null and b/resources/images/texture_displacement/invert_on.png differ diff --git a/resources/images/texture_displacement/map_cube.png b/resources/images/texture_displacement/map_cube.png new file mode 100644 index 0000000000..cf50adfd7e Binary files /dev/null and b/resources/images/texture_displacement/map_cube.png differ diff --git a/resources/images/texture_displacement/map_cylinder.png b/resources/images/texture_displacement/map_cylinder.png new file mode 100644 index 0000000000..f9c2986fb2 Binary files /dev/null and b/resources/images/texture_displacement/map_cylinder.png differ diff --git a/resources/images/texture_displacement/map_sphere.png b/resources/images/texture_displacement/map_sphere.png new file mode 100644 index 0000000000..0a8dcbbc05 Binary files /dev/null and b/resources/images/texture_displacement/map_sphere.png differ diff --git a/resources/images/texture_displacement/map_unwrap.png b/resources/images/texture_displacement/map_unwrap.png new file mode 100644 index 0000000000..4caaf6bb83 Binary files /dev/null and b/resources/images/texture_displacement/map_unwrap.png differ diff --git a/resources/images/texture_displacement/map_view.png b/resources/images/texture_displacement/map_view.png new file mode 100644 index 0000000000..1f4714689d Binary files /dev/null and b/resources/images/texture_displacement/map_view.png differ diff --git a/resources/images/texture_displacement/midlevel.gif b/resources/images/texture_displacement/midlevel.gif new file mode 100644 index 0000000000..32914d7c9d Binary files /dev/null and b/resources/images/texture_displacement/midlevel.gif differ diff --git a/resources/images/texture_displacement/mix_off.png b/resources/images/texture_displacement/mix_off.png new file mode 100644 index 0000000000..d6445d6314 Binary files /dev/null and b/resources/images/texture_displacement/mix_off.png differ diff --git a/resources/images/texture_displacement/mix_on.png b/resources/images/texture_displacement/mix_on.png new file mode 100644 index 0000000000..716f63e0b9 Binary files /dev/null and b/resources/images/texture_displacement/mix_on.png differ diff --git a/resources/images/texture_displacement/mixed_colors_few.png b/resources/images/texture_displacement/mixed_colors_few.png new file mode 100644 index 0000000000..389e22f429 Binary files /dev/null and b/resources/images/texture_displacement/mixed_colors_few.png differ diff --git a/resources/images/texture_displacement/mixed_colors_many.png b/resources/images/texture_displacement/mixed_colors_many.png new file mode 100644 index 0000000000..90754062ce Binary files /dev/null and b/resources/images/texture_displacement/mixed_colors_many.png differ diff --git a/resources/images/texture_displacement/paint.gif b/resources/images/texture_displacement/paint.gif new file mode 100644 index 0000000000..c7fedc1a3b Binary files /dev/null and b/resources/images/texture_displacement/paint.gif differ diff --git a/resources/images/texture_displacement/painted_only_off.png b/resources/images/texture_displacement/painted_only_off.png new file mode 100644 index 0000000000..ac30fea631 Binary files /dev/null and b/resources/images/texture_displacement/painted_only_off.png differ diff --git a/resources/images/texture_displacement/painted_only_on.png b/resources/images/texture_displacement/painted_only_on.png new file mode 100644 index 0000000000..b8c01e1b13 Binary files /dev/null and b/resources/images/texture_displacement/painted_only_on.png differ diff --git a/resources/images/texture_displacement/preserve_edges_off.png b/resources/images/texture_displacement/preserve_edges_off.png new file mode 100644 index 0000000000..8eb41e7d8c Binary files /dev/null and b/resources/images/texture_displacement/preserve_edges_off.png differ diff --git a/resources/images/texture_displacement/preserve_edges_on.png b/resources/images/texture_displacement/preserve_edges_on.png new file mode 100644 index 0000000000..a8a8f5b7d6 Binary files /dev/null and b/resources/images/texture_displacement/preserve_edges_on.png differ diff --git a/resources/images/texture_displacement/remesh_after.png b/resources/images/texture_displacement/remesh_after.png new file mode 100644 index 0000000000..7b46db61ed Binary files /dev/null and b/resources/images/texture_displacement/remesh_after.png differ diff --git a/resources/images/texture_displacement/remesh_before.png b/resources/images/texture_displacement/remesh_before.png new file mode 100644 index 0000000000..8c6f56c3d9 Binary files /dev/null and b/resources/images/texture_displacement/remesh_before.png differ diff --git a/resources/images/texture_displacement/rotation.gif b/resources/images/texture_displacement/rotation.gif new file mode 100644 index 0000000000..c255e5c167 Binary files /dev/null and b/resources/images/texture_displacement/rotation.gif differ diff --git a/resources/images/texture_displacement/select_all.png b/resources/images/texture_displacement/select_all.png new file mode 100644 index 0000000000..c727daf7fa Binary files /dev/null and b/resources/images/texture_displacement/select_all.png differ diff --git a/resources/images/texture_displacement/smart_fill_angle_high.png b/resources/images/texture_displacement/smart_fill_angle_high.png new file mode 100644 index 0000000000..584a8468ec Binary files /dev/null and b/resources/images/texture_displacement/smart_fill_angle_high.png differ diff --git a/resources/images/texture_displacement/smart_fill_angle_low.png b/resources/images/texture_displacement/smart_fill_angle_low.png new file mode 100644 index 0000000000..88a6c9612f Binary files /dev/null and b/resources/images/texture_displacement/smart_fill_angle_low.png differ diff --git a/resources/images/texture_displacement/smoothing_off.png b/resources/images/texture_displacement/smoothing_off.png new file mode 100644 index 0000000000..b7f66fd330 Binary files /dev/null and b/resources/images/texture_displacement/smoothing_off.png differ diff --git a/resources/images/texture_displacement/smoothing_on.png b/resources/images/texture_displacement/smoothing_on.png new file mode 100644 index 0000000000..18ed9e00fd Binary files /dev/null and b/resources/images/texture_displacement/smoothing_on.png differ diff --git a/resources/images/texture_displacement/subdivide.gif b/resources/images/texture_displacement/subdivide.gif new file mode 100644 index 0000000000..70d8abf47a Binary files /dev/null and b/resources/images/texture_displacement/subdivide.gif differ diff --git a/resources/images/texture_displacement/tile_mirror.png b/resources/images/texture_displacement/tile_mirror.png new file mode 100644 index 0000000000..0da432ff9d Binary files /dev/null and b/resources/images/texture_displacement/tile_mirror.png differ diff --git a/resources/images/texture_displacement/tile_off.png b/resources/images/texture_displacement/tile_off.png new file mode 100644 index 0000000000..399e56dca2 Binary files /dev/null and b/resources/images/texture_displacement/tile_off.png differ diff --git a/resources/images/texture_displacement/tile_on.png b/resources/images/texture_displacement/tile_on.png new file mode 100644 index 0000000000..910fa5aa8f Binary files /dev/null and b/resources/images/texture_displacement/tile_on.png differ diff --git a/resources/images/texture_displacement/tile_repeat.png b/resources/images/texture_displacement/tile_repeat.png new file mode 100644 index 0000000000..ca9793897b Binary files /dev/null and b/resources/images/texture_displacement/tile_repeat.png differ diff --git a/resources/images/texture_displacement/tile_size_large.png b/resources/images/texture_displacement/tile_size_large.png new file mode 100644 index 0000000000..4bc2659244 Binary files /dev/null and b/resources/images/texture_displacement/tile_size_large.png differ diff --git a/resources/images/texture_displacement/tile_size_small.png b/resources/images/texture_displacement/tile_size_small.png new file mode 100644 index 0000000000..11b5bc84e7 Binary files /dev/null and b/resources/images/texture_displacement/tile_size_small.png differ diff --git a/resources/images/texture_displacement/tool_face.gif b/resources/images/texture_displacement/tool_face.gif new file mode 100644 index 0000000000..be616fdd52 Binary files /dev/null and b/resources/images/texture_displacement/tool_face.gif differ diff --git a/resources/images/texture_displacement/tool_smart_fill.gif b/resources/images/texture_displacement/tool_smart_fill.gif new file mode 100644 index 0000000000..1dc23ef8c6 Binary files /dev/null and b/resources/images/texture_displacement/tool_smart_fill.gif differ diff --git a/resources/images/texture_displacement/view_checker.png b/resources/images/texture_displacement/view_checker.png new file mode 100644 index 0000000000..0198b8581c Binary files /dev/null and b/resources/images/texture_displacement/view_checker.png differ diff --git a/resources/images/texture_displacement/view_distortion.png b/resources/images/texture_displacement/view_distortion.png new file mode 100644 index 0000000000..d8be4b187c Binary files /dev/null and b/resources/images/texture_displacement/view_distortion.png differ diff --git a/resources/images/texture_displacement/view_fast.png b/resources/images/texture_displacement/view_fast.png new file mode 100644 index 0000000000..71ed3d1953 Binary files /dev/null and b/resources/images/texture_displacement/view_fast.png differ diff --git a/resources/images/texture_displacement/view_wireframe.png b/resources/images/texture_displacement/view_wireframe.png new file mode 100644 index 0000000000..6585274de9 Binary files /dev/null and b/resources/images/texture_displacement/view_wireframe.png differ diff --git a/scripts/gen_texture_displacement_tips.py b/scripts/gen_texture_displacement_tips.py new file mode 100644 index 0000000000..6bf7e3bdf6 --- /dev/null +++ b/scripts/gen_texture_displacement_tips.py @@ -0,0 +1,793 @@ +#!/usr/bin/env python3 +"""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 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. + + python3 scripts/gen_texture_displacement_tips.py + +Needs numpy and pillow. +""" + +import os + +import numpy as np +from PIL import Image, ImageDraw + +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") + +# Neutral enough to sit in a tooltip in either theme: a dark ground, a mid grey for the flat surface +# and a cool near-white for the lit slopes. +SHADOW = np.array([0.13, 0.14, 0.15]) +MIDTONE = np.array([0.40, 0.42, 0.43]) +HIGHLIGHT = np.array([0.78, 0.84, 0.83]) +LIGHT = np.array([-0.45, -0.62, 0.65]) +LIGHT /= np.linalg.norm(LIGHT) + + +def grid(size=SIZE): + """Unit coordinates, -0.5 .. 0.5, y down as in an image.""" + a = (np.arange(size) + 0.5) / size - 0.5 + return np.meshgrid(a, a) + + +def shade(height): + """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) + + # Two ramps rather than one: the dark half separates the slopes, the lit half picks up the + # accent, and the flat ground lands in the middle of both. + low = SHADOW + (MIDTONE - SHADOW) * (lam / 0.5)[..., None] + high = MIDTONE + (HIGHLIGHT - MIDTONE) * ((lam - 0.5) / 0.5)[..., None] + rgb = np.where((lam < 0.5)[..., None], low, high) + + # A touch of the height itself, so a tall feature still reads as tall where it is flat on top. + # + # Deliberately almost grey: these say what a shape does, and a relief catching the light is grey. + # A coloured one would read as a third thing the setting does, and the amount of colour would move + # with the slope - a pattern at 45 degrees would flash where the same pattern upright did not. + rgb = np.clip(rgb * (0.88 + 0.18 * height[..., None]), 0.0, 1.0) + return (rgb * 255.0 + 0.5).astype(np.uint8) + + +def vignette(rgb): + """Rounds the picture off at its border, so it does not end in a hard rectangle.""" + x, y = grid(rgb.shape[0]) + edge = np.clip((0.5 - np.maximum(np.abs(x), np.abs(y))) / 0.045, 0.0, 1.0) + return (rgb * edge[..., None]).astype(np.uint8) + + +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), 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=24) 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), 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 _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 + inside = (np.maximum(np.abs(u), np.abs(v)) <= 0.5).astype(float) + 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)) + + +# --- meshes, cursors and sections --------------------------------------------------------------- +# +# The second family of pictures. The settings above are about a pattern, and a shaded relief is what a +# pattern looks like; these are about a tool or about the mesh, where what matters is which triangles +# exist and which of them are painted. So they are drawn flat, the way the panel's own wireframe and +# paint overlays draw them, with one accent colour meaning "painted" and nothing else coloured. + +SS = 3 # supersampling: PIL fills polygons without antialiasing, so draw big and shrink + +INK = (0.62, 0.67, 0.67) # mesh edges +BARE = (0.28, 0.30, 0.31) # unpainted surface +PAINT = (0.20, 0.47, 0.45) # painted with the active layer, in the panel's own accent +CURSOR = (0.92, 0.95, 0.95) +MARGIN = 0.05 # keeps the mesh off the picture's border, where the vignette darkens it + + +def _px(p, size): + """Unit coordinates to pixels, inside the margin.""" + return ((MARGIN + p[0] * (1.0 - 2.0 * MARGIN)) * size, (MARGIN + p[1] * (1.0 - 2.0 * MARGIN)) * size) + + +def _rgb255(c): + return tuple(int(round(v * 255.0)) for v in c) + + +def raster(draw_fn, size=SIZE, background=BARE): + """Runs draw_fn(draw, size_in_pixels) on a supersampled canvas and hands back the shrunk result. + + `background` is a colour, or a picture already drawn by the relief half of this script - which is + how a mesh is shown sitting on the surface it refines. + """ + if isinstance(background, np.ndarray): + image = Image.fromarray(background).resize((size * SS, size * SS), Image.BILINEAR) + else: + image = Image.new("RGB", (size * SS, size * SS), _rgb255(background)) + draw_fn(ImageDraw.Draw(image), size * SS) + return np.asarray(image.resize((size, size), Image.LANCZOS)).astype(np.uint8) + + +def tri_mesh(n, jitter=0.0, seed=1): + """A square patch of triangles, optionally with its inner vertices nudged off the grid. + + Jitter is not decoration: a perfectly regular grid reads as graph paper, and the point of these + pictures is a mesh over a surface. + """ + rng = np.random.default_rng(seed) + v = np.stack(np.meshgrid(np.linspace(0.0, 1.0, n + 1), np.linspace(0.0, 1.0, n + 1)), axis=-1) + if jitter: + v[1:n, 1:n] += (rng.random((n - 1, n - 1, 2)) - 0.5) * (jitter / n) + tris = [] + for i in range(n): + for j in range(n): + tris.append((v[i, j], v[i, j + 1], v[i + 1, j + 1])) + tris.append((v[i, j], v[i + 1, j + 1], v[i + 1, j])) + return tris + + +def centroids(tris): + return np.array([np.mean(t, axis=0) for t in tris]) + + +def neighbours(tris): + """Which triangles share an edge with which, keyed on the rounded vertex positions.""" + edges = {} + for i, t in enumerate(tris): + for a, b in ((0, 1), (1, 2), (2, 0)): + key = tuple(sorted((tuple(np.round(t[a], 6)), tuple(np.round(t[b], 6))))) + edges.setdefault(key, []).append(i) + out = [[] for _ in tris] + for shared in edges.values(): + if len(shared) == 2: + out[shared[0]].append(shared[1]) + out[shared[1]].append(shared[0]) + return out + + +def draw_mesh(draw, size, tris, painted=None, edge=INK, edge_width=0.7): + fill_bare, fill_paint = _rgb255(BARE), _rgb255(PAINT) + for i, t in enumerate(tris): + draw.polygon([_px(p, size) for p in t], + fill=fill_paint if painted is not None and painted[i] else fill_bare, + outline=_rgb255(edge), width=max(1, int(edge_width * SS))) + + +def draw_brush(draw, size, centre, radius, filled=False): + """The brush cursor: a ring, which is how the gizmo draws it on the model.""" + c = _px(centre, size) + r = radius * (1.0 - 2.0 * MARGIN) * size + if filled: + draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], fill=(255, 255, 255, 40)) + draw.ellipse([c[0] - r, c[1] - r, c[0] + r, c[1] + r], outline=_rgb255(CURSOR), width=max(1, int(1.6 * SS))) + + +def draw_pointer(draw, size, at): + """A small arrow, for the tools that are clicked rather than dragged.""" + x, y = _px(at, size) + k = 0.055 * size + draw.polygon([(x, y), (x, y + 1.45 * k), (x + 0.38 * k, y + 1.05 * k), + (x + 0.62 * k, y + 1.55 * k), (x + 0.90 * k, y + 1.40 * k), + (x + 0.66 * k, y + 0.92 * k), (x + 1.05 * k, y + 0.84 * k)], + fill=_rgb255(CURSOR), outline=(30, 32, 34), width=max(1, int(0.9 * SS))) + + +def stroke_path(t): + """The path a demonstrated brush stroke follows, t in 0..1.""" + return np.array([0.14 + 0.72 * t, 0.46 + 0.22 * np.sin(2.0 * np.pi * t * 1.1)]) + + +def brush_frames(size, erase, steps=14, radius=0.115, hold=3): + tris = tri_mesh(9, jitter=0.55) + cents = centroids(tris) + painted = np.ones(len(tris), bool) if erase else np.zeros(len(tris), bool) + frames = [] + for k in range(steps): + centre = stroke_path(k / (steps - 1.0)) + hit = np.linalg.norm(cents - centre, axis=1) < radius + painted = painted & ~hit if erase else painted | hit + state = painted.copy() + frames.append(raster(lambda d, s, st=state, c=centre: (draw_mesh(d, s, tris, st), draw_brush(d, s, c, radius)), size)) + frames += [frames[-1]] * hold + return frames + + +def face_frames(size, steps=9, hold=3): + """The Face tool: one triangle per click, under a pointer.""" + tris = tri_mesh(7, jitter=0.5, seed=4) + cents = centroids(tris) + order = np.argsort(np.linalg.norm(cents - np.array([0.5, 0.5]), axis=1))[:steps] + painted = np.zeros(len(tris), bool) + frames = [] + for i in order: + painted[i] = True + state = painted.copy() + at = cents[i] + frames.append(raster(lambda d, s, st=state, a=at: (draw_mesh(d, s, tris, st), draw_pointer(d, s, a)), size)) + frames += [frames[-1]] * hold + return frames + + +def flood_frames(size, creases, steps=12, hold=3, seed=7): + """The Connected area tool: a flood from one click, stopped by the creases it may not cross. + + `creases` is a list of x positions; a triangle pair straddling one cannot pass the fill between + them, which is what an edge sharper than the angle threshold does on the model. + """ + tris = tri_mesh(9, jitter=0.5, seed=seed) + cents = centroids(tris) + adj = neighbours(tris) + blocked = lambda i, j: any((cents[i][0] - x) * (cents[j][0] - x) < 0.0 for x in creases) + + seed_tri = int(np.argmin(np.linalg.norm(cents - np.array([0.22, 0.5]), axis=1))) + painted = np.zeros(len(tris), bool) + painted[seed_tri] = True + frontier = [seed_tri] + frames = [] + for _ in range(steps): + state = painted.copy() + frames.append(raster(lambda d, s, st=state: (draw_mesh(d, s, tris, st), + draw_creases(d, s, creases), + draw_pointer(d, s, cents[seed_tri])), size)) + nxt = [] + for i in frontier: + for j in adj[i]: + if not painted[j] and not blocked(i, j): + painted[j] = True + nxt.append(j) + frontier = nxt + frames += [frames[-1]] * hold + return frames + + +def draw_creases(draw, size, creases): + """A fold too sharp for the fill to cross: drawn dark with a bright crest, like a real one.""" + for x in creases: + a, b = _px((x, -0.02), size), _px((x, 1.02), size) + draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS))) + draw.line([a, b], fill=_rgb255((0.97, 0.99, 0.99)), width=max(1, int(1.4 * SS))) + + + +def split4(tri): + a, b, c = tri + ab, bc, ca = (a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5 + return [(a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)] + + +def refine(tris, density, max_depth, threshold=0.45): + """Splits a triangle into four wherever `density` at its middle asks for it, up to max_depth.""" + + def go(tri, depth): + if depth >= max_depth: + return [tri] + centre = np.mean(tri, axis=0) + if density(centre[0], centre[1]) < threshold: + return [tri] + return [out for part in split4(tri) for out in go(part, depth + 1)] + + return [out for tri in tris for out in go(tri, 0)] + + +def sampler(field): + """Looks a numpy field up at unit coordinates, for use as a density.""" + n = field.shape[0] + return lambda x, y: float(field[int(np.clip(y * n, 0, n - 1)), int(np.clip(x * n, 0, n - 1))]) + + +def feature_density(size=192): + """High where the pattern bends, which is where feature-adaptive refinement spends triangles. + + One large copy of the motif rather than a field of them: the point is that the triangles gather + somewhere and not elsewhere, and a pattern that bends everywhere has no elsewhere to show. + """ + m = motif(1, size) + gy, gx = np.gradient(m) + g = blur(np.hypot(gx, gy), 2) + return g / max(g.max(), 1e-6) + + +def irregular_mesh(seed=3): + """The uneven mesh a model arrives with: coarse here, fine there, nothing of one size.""" + rng = np.random.default_rng(seed) + tris = tri_mesh(4, jitter=0.9, seed=seed) + out = [] + for t in tris: + out.extend(split4(t) if rng.random() < 0.45 else [t]) + final = [] + for t in out: + final.extend(split4(t) if rng.random() < 0.3 else [t]) + return final + + +def snap_to_crease(tris, crease, distance=0.07): + """Pulls every vertex near the crease onto it, so mesh edges run along it instead of across.""" + (x0, y0), (x1, y1) = crease + d = np.array([x1 - x0, y1 - y0], float) + d /= np.linalg.norm(d) + + def snap(p): + t = np.dot(p - np.array([x0, y0]), d) + foot = np.array([x0, y0]) + t * d + return foot if np.linalg.norm(p - foot) < distance else p + + return [tuple(snap(np.asarray(v, float)) for v in t) for t in tris] + + +def sliver_mesh(seed=5): + """A mesh carrying the thin triangles refinement inherits from a model's own triangulation.""" + rng = np.random.default_rng(seed) + tris = tri_mesh(7, jitter=0.4, seed=seed) + out = [] + for t in tris: + if rng.random() < 0.45: + a, b, c = (np.asarray(v, float) for v in t) + mid = (b + c) * 0.5 + a = mid + (a - mid) * 0.08 # all but flattened onto the opposite edge + out.append((a, b, c)) + else: + out.append(t) + return out + + +def draw_profile(draw, size, height_1d, baseline=0.62, amplitude=0.30): + """A side view of a surface, as a filled region under its profile.""" + n = len(height_1d) + top = [(i / (n - 1.0) * size, (baseline - (height_1d[i] - 0.5) * amplitude) * size) for i in range(n)] + draw.polygon(top + [(size, size * 1.1), (0, size * 1.1)], fill=_rgb255((0.44, 0.46, 0.47))) + draw.line(top, fill=_rgb255((0.82, 0.88, 0.87)), width=max(1, int(1.8 * SS))) + + +def bayer(size, n=4): + """An ordered dither matrix tiled over the picture, for the mixing illustrations.""" + m = np.array([[0]]) + while m.shape[0] < n: + m = np.block([[4 * m, 4 * m + 2], [4 * m + 3, 4 * m + 1]]) + m = (m + 0.5) / m.size + return np.tile(m, (size // m.shape[0] + 1, size // m.shape[1] + 1))[:size, :size] + + +def levels_image(levels, dither=False, speckle=0.0, size=SIZE, seed=11): + """A smooth ramp reduced to a handful of greys - what a bake does to a photograph's colours. + + Flat rather than lit: this family of settings is about which filament a pixel ends up as, and a + relief would only add a second thing changing between two pictures that are meant to differ in + one. + """ + x, y = grid(size) + ramp = np.clip(0.5 + 0.5 * np.cos(2.0 * np.pi * (x + 0.25 * y)), 0.0, 1.0) + noise = bayer(size) - 0.5 if dither else 0.0 + q = np.round(np.clip(ramp + noise / max(levels - 1, 1), 0.0, 1.0) * (levels - 1)) / (levels - 1) + if speckle > 0.0: + rng = np.random.default_rng(seed) + hit = rng.random((size, size)) < speckle + q = np.where(hit, rng.integers(0, levels, (size, size)) / (levels - 1), q) + grey = 0.16 + 0.62 * q + return (np.dstack([grey, grey * 1.03, grey * 1.02]).clip(0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) + + +def draw_mesh_lines(draw, size, tris, colour=(0.90, 0.94, 0.94), width=0.8): + """Only the edges, for drawing a mesh over a picture of the surface it sits on.""" + for t in tris: + pts = [_px(p, size) for p in t] + draw.line(pts + [pts[0]], fill=_rgb255(colour), width=max(1, int(width * SS))) + + +def draw_line(draw, size, segment, colour=(0.97, 0.99, 0.99)): + a, b = _px(segment[0], size), _px(segment[1], size) + draw.line([a, b], fill=_rgb255((0.08, 0.09, 0.10)), width=max(1, int(4.0 * SS))) + draw.line([a, b], fill=_rgb255(colour), width=max(1, int(1.4 * SS))) + + +def posterize(rgb, levels): + """The shaded picture reduced to flat bands - what the fast preview's per-fragment quantizing + does to a surface that the real preview shades smoothly.""" + v = rgb.astype(float) / 255.0 + return (np.round(v * (levels - 1)) / (levels - 1) * 255.0 + 0.5).astype(np.uint8) + + +def with_checker(rgb, squares=10, strength=0.30, warp=False): + """A checker laid over the surface, evenly or stretched. + + Even squares are what the Checker view is for - a texture laid out without stretching. The warped + version is the same squares through a mapping that does stretch, which is what the Distortion view + is there to find: cells crowded in one place and pulled apart in another. + """ + size = rgb.shape[0] + x, y = grid(size) + u, v = (np.sign(x) * np.abs(2.0 * x) ** 2.4 * 0.5, y * (1.0 + 0.9 * np.abs(x))) if warp else (x, y) + cells = (np.floor((u + 0.5) * squares) + np.floor((v + 0.5) * squares)) % 2.0 + return (rgb * (1.0 - strength * cells)[..., None]).astype(np.uint8) + + +def projected_sphere(kind, size=SIZE, squares=9): + """A ball with a checker laid on it by one of the panel's projections. + + One shape, five mappings: what differs between the pictures is only where the squares go, which is + the whole of what choosing a projection does. A sphere is the shape that shows it - every one of + these mappings is well behaved on a flat face and tells itself apart on a curved one. + """ + a = (np.arange(size) + 0.5) / size * 2.24 - 1.12 + x, y = np.meshgrid(a, a) + inside = x * x + y * y <= 1.0 + z = np.sqrt(np.clip(1.0 - x * x - y * y, 0.0, 1.0)) + + lam = np.clip(x * LIGHT[0] + y * LIGHT[1] + z * LIGHT[2], 0.0, 1.0) + rgb = SHADOW + (HIGHLIGHT - SHADOW) * (lam ** 0.85)[..., None] + + around = np.arctan2(x, z) / (2.0 * np.pi) + 0.5 + if kind == "sphere": + u, v = around, np.arccos(np.clip(-y, -1.0, 1.0)) / np.pi + elif kind == "cylinder": + u, v = around, (y + 1.0) * 0.5 + elif kind == "view": + u, v = (x + 1.0) * 0.5, (y + 1.0) * 0.5 + elif kind == "cube": + axis = np.argmax(np.abs(np.dstack([x, y, z])), axis=2) + u = np.where(axis == 0, z, x) + v = np.where(axis == 1, z, y) + u, v = (u + 1.0) * 0.5, (v + 1.0) * 0.5 + else: + raise ValueError(kind) + + cells = (np.floor(u * squares * 2.0) + np.floor(v * squares)) % 2.0 + rgb = rgb * (1.0 - 0.34 * cells)[..., None] + rgb = np.where(inside[..., None], rgb, np.array([0.13, 0.14, 0.15])) + return (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) + + +def uv_islands(size=SIZE, squares=9, seed=12): + """The flat layout an unwrap produces: the surface cut into islands and laid out side by side. + + Not a projection onto the model like the other four, so not drawn on one - an unwrap is the model + carrying its own coordinates, and this is what those look like. + """ + rng = np.random.default_rng(seed) + x, y = grid(size) + cells = (np.floor((x + 0.5) * squares * 2.0) + np.floor((y + 0.5) * squares)) % 2.0 + checker = (np.array([0.40, 0.43, 0.43]) + np.array([0.22, 0.24, 0.24]) * (1.0 - cells)[..., None]) + + def blob(centre, radius, lobes): + angles = np.linspace(0.0, 2.0 * np.pi, 26, endpoint=False) + wobble = 1.0 + 0.22 * np.sin(lobes * angles + rng.random() * 6.0) + return [(centre[0] + np.cos(t) * radius * w, centre[1] + np.sin(t) * radius * w) + for t, w in zip(angles, wobble)] + + islands = [blob((0.31, 0.32), 0.21, 3), blob((0.72, 0.30), 0.17, 4), + blob((0.34, 0.74), 0.17, 4), blob((0.72, 0.71), 0.20, 3)] + + mask = np.asarray(raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], fill=(255, 255, 255)) + for island in islands], + size, background=(0.0, 0.0, 0.0))).astype(float)[..., 0] / 255.0 + rgb = checker * mask[..., None] + np.array([0.13, 0.14, 0.15]) * (1.0 - mask)[..., None] + out = (np.clip(rgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) + return raster(lambda d, px: [d.polygon([(p[0] * px, p[1] * px) for p in island], outline=_rgb255(INK), + width=max(1, int(1.1 * SS))) + for island in islands], + size, background=out) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + + # Tile: one copy against the pattern filling the painted area. + save_png("tile_off.png", shade(decal())) + save_png("tile_on.png", shade(motif(3))) + + # 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, 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) + + # The paint tools, each shown doing the one thing that tells it apart from the others. + save_gif("paint.gif", brush_frames(GIF_SIZE, erase=False), delay_ms=110) + save_gif("erase.gif", brush_frames(GIF_SIZE, erase=True), delay_ms=110) + save_gif("tool_face.gif", face_frames(GIF_SIZE), delay_ms=180) + save_gif("tool_smart_fill.gif", flood_frames(GIF_SIZE, creases=[0.63]), delay_ms=110) + + # Brush size: the ring, and what it covers, over one size and back. + brush_tris = tri_mesh(9, jitter=0.55, seed=2) + brush_cents = centroids(brush_tris) + frames = [] + for i in range(steps): + r = 0.07 + 0.16 * (0.5 - 0.5 * np.cos(2.0 * np.pi * i / steps)) + under = np.linalg.norm(brush_cents - np.array([0.5, 0.5]), axis=1) < r + frames.append(raster(lambda d, sz, u=under, rr=r: (draw_mesh(d, sz, brush_tris, u), + draw_brush(d, sz, (0.5, 0.5), rr)), GIF_SIZE)) + save_gif("cursor_size.gif", frames, delay_ms=90) + + # Angle threshold: the same fill, let past one fold and then past both. + for name, creases in (("smart_fill_angle_low.png", [0.40, 0.70]), ("smart_fill_angle_high.png", [0.70])): + done = flood_frames(SIZE, creases=creases, steps=22, hold=0)[-1] + save_png(name, done) + + # Whole model: every face painted, and every face cleared. + whole = tri_mesh(9, jitter=0.55, seed=2) + save_png("select_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.ones(len(whole), bool)), SIZE)) + save_png("erase_all.png", raster(lambda d, sz: draw_mesh(d, sz, whole, np.zeros(len(whole), bool)), SIZE)) + + # The views the panel offers over the painted area. + relief = shade(motif(3)) + save_png("view_fast.png", posterize(relief, 3)) + # Checker and Distortion are about how the texture is laid out, not about the texture, so they go + # on a plain swell where the checker itself is what there is to look at. + plain = shade(0.25 + 0.5 * dome()) + save_png("view_checker.png", with_checker(plain, squares=7, strength=0.40)) + save_png("view_distortion.png", with_checker(plain, squares=7, strength=0.40, warp=True)) + save_png("view_wireframe.png", raster(lambda d, sz: draw_mesh_lines(d, sz, tri_mesh(9, jitter=0.55, seed=2)), + SIZE, background=relief)) + + # The projections: one ball, the checker put on it five different ways. + for kind in ("cube", "cylinder", "sphere", "view"): + save_png("map_%s.png" % kind, projected_sphere(kind)) + save_png("map_unwrap.png", uv_islands()) + + # Refinement: where the triangles go, and how many. + density_feature = sampler(feature_density()) + coarse = tri_mesh(3) + save_png("adaptive_off.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, lambda x, y: 1.0, 2)), SIZE)) + save_png("adaptive_on.png", raster(lambda d, sz: draw_mesh(d, sz, refine(coarse, density_feature, 3)), SIZE)) + + disc = sampler(painted_area(192, radius=0.34)) + painted_mesh = refine(coarse, disc, 3) + save_png("painted_only_on.png", + raster(lambda d, sz: draw_mesh(d, sz, painted_mesh, + np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in painted_mesh])), SIZE)) + everywhere = refine(coarse, lambda x, y: 1.0, 3) + save_png("painted_only_off.png", + raster(lambda d, sz: draw_mesh(d, sz, everywhere, + np.array([disc(*np.mean(t, axis=0)) > 0.5 for t in everywhere])), SIZE)) + + # Subdivide: every triangle into four, a step at a time. + level = tri_mesh(2) + frames = [] + for _ in range(4): + frames.append(raster(lambda d, sz, m=level: draw_mesh(d, sz, m), GIF_SIZE)) + level = [out for t in level for out in split4(t)] + save_gif("subdivide.gif", frames, delay_ms=650) + + # Remesh: the model's own uneven triangulation, rebuilt at one size. + save_png("remesh_before.png", raster(lambda d, sz: draw_mesh(d, sz, irregular_mesh()), SIZE)) + save_png("remesh_after.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(8, jitter=0.35, seed=9)), SIZE)) + + # Hard edges: a fold the remesh runs across, against one it lays its edges along. + crease = ((0.06, 0.84), (0.94, 0.20)) + across = tri_mesh(5, jitter=0.45, seed=6) + save_png("preserve_edges_off.png", + raster(lambda d, sz: (draw_mesh(d, sz, across), draw_line(d, sz, crease)), SIZE)) + save_png("preserve_edges_on.png", + raster(lambda d, sz: (draw_mesh(d, sz, snap_to_crease(across, crease, 0.13)), + draw_line(d, sz, crease)), SIZE)) + + # Thin triangles, and the same mesh without them. + save_png("collapse_slivers_off.png", raster(lambda d, sz: draw_mesh(d, sz, sliver_mesh()), SIZE)) + save_png("collapse_slivers_on.png", raster(lambda d, sz: draw_mesh(d, sz, tri_mesh(7, jitter=0.4, seed=5)), SIZE)) + + # Smoothing the geometry after the texture is in it: the steps relax out of the surface. + stepped_profile = np.floor(profile_wave(GIF_SIZE, periods=2.0) * 5.0) / 4.0 + frames = [] + for t in sweep: + h = stepped_profile.copy() + for _ in range(int(round(t * 6))): + h = blur(h[None, :], 2)[0] + frames.append(raster(lambda d, sz, hh=h: draw_profile(d, sz, hh), GIF_SIZE, background=(0.11, 0.12, 0.13))) + save_gif("geometry_smoothing.gif", frames, delay_ms=100) + + # Mixing, how many mixes, and denoise - all about which filament a spot ends up as, so all flat. + save_png("mix_off.png", levels_image(4)) + save_png("mix_on.png", levels_image(4, dither=True)) + save_png("mixed_colors_few.png", levels_image(3, dither=True)) + save_png("mixed_colors_many.png", levels_image(8, dither=True)) + save_png("denoise_off.png", levels_image(5, dither=True, speckle=0.05)) + save_png("denoise_on.png", levels_image(5, dither=True)) + + +if __name__ == "__main__": + main() diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 0af1f2f2e1..75e5897f35 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -20,6 +20,8 @@ set(SLIC3R_GUI_SOURCES GUI/Widgets/FilamentLoad.hpp GUI/AboutDialog.cpp GUI/AboutDialog.hpp + GUI/AnimatedTexture.cpp + GUI/AnimatedTexture.hpp GUI/AmsMappingPopup.cpp GUI/AmsMappingPopupUpdate.cpp GUI/AmsMappingPopup.hpp diff --git a/src/slic3r/GUI/AnimatedTexture.cpp b/src/slic3r/GUI/AnimatedTexture.cpp new file mode 100644 index 0000000000..fee8688ba5 --- /dev/null +++ b/src/slic3r/GUI/AnimatedTexture.cpp @@ -0,0 +1,240 @@ +#include "AnimatedTexture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "slic3r/GUI/GLTexture.hpp" + +namespace Slic3r::GUI { + +namespace { + +std::vector read_file_bytes(const std::string &path) +{ + std::ifstream ifs(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); +} + +bool has_gif_signature(const std::vector &bytes) +{ + return bytes.size() > 6 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F'; +} + +// wxImage keeps colour and alpha in two separate buffers, and a file with no alpha at all has none; +// OpenGL wants the four channels interleaved. +std::vector image_to_rgba(const wxImage &image) +{ + const size_t n = size_t(image.GetWidth()) * size_t(image.GetHeight()); + const unsigned char *rgb = image.GetData(); + const unsigned char *a = image.HasAlpha() ? image.GetAlpha() : nullptr; + + std::vector out(n * 4); + for (size_t i = 0; i < n; ++i) { + out[i * 4 + 0] = rgb[i * 3 + 0]; + out[i * 4 + 1] = rgb[i * 3 + 1]; + out[i * 4 + 2] = rgb[i * 3 + 2]; + out[i * 4 + 3] = a != nullptr ? a[i] : 255; + } + return out; +} + +// One decoded frame on its way to the GPU: the whole animation's area, already composited. +struct DecodedFrame +{ + std::vector rgba; + double delay; // seconds +}; + +// Every frame of a GIF, composited onto the animation's own canvas. +// +// A GIF frame is a sub-rectangle that is drawn over what came before it, and the one before says how +// to clean up after itself (leave it, erase it, put back what it covered). Without replaying that, an +// animation that only re-sends the pixels that changed - which is most of them - draws as a flicker of +// disconnected patches. wxGIFDecoder hands over the raw palette indices, which is what makes replaying +// it here straightforward: a transparent index means "keep what is underneath", nothing else does. +bool decode_gif(const std::vector &bytes, int &width, int &height, std::vector &out) +{ + wxMemoryInputStream stream(bytes.data(), bytes.size()); + wxGIFDecoder decoder; + + // A truncated file still decodes everything up to the cut, which is worth showing. + const wxGIFErrorCode err = decoder.LoadGIF(stream); + if ((err != wxGIF_OK && err != wxGIF_TRUNCATED) || decoder.GetFrameCount() == 0) + return false; + + const wxSize size = decoder.GetAnimationSize(); + width = size.GetWidth(); + height = size.GetHeight(); + if (width <= 0 || height <= 0) + return false; + + std::vector canvas(size_t(width) * size_t(height) * 4, 0); + std::vector saved; + + for (unsigned int i = 0; i < decoder.GetFrameCount(); ++i) { + const wxSize frame_size = decoder.GetFrameSize(i); + const wxPoint frame_pos = decoder.GetFramePosition(i); + const wxAnimationDisposal disposal = decoder.GetDisposalMethod(i); + const unsigned char *indices = decoder.GetData(i); + const unsigned char *palette = decoder.GetPalette(i); + const int transparent = decoder.GetTransparentColourIndex(i); + + if (disposal == wxANIM_TOPREVIOUS) + saved = canvas; + + if (indices != nullptr && palette != nullptr) + for (int y = 0; y < frame_size.GetHeight(); ++y) { + const int cy = frame_pos.y + y; + if (cy < 0 || cy >= height) + continue; + for (int x = 0; x < frame_size.GetWidth(); ++x) { + const int cx = frame_pos.x + x; + if (cx < 0 || cx >= width) + continue; + const unsigned char index = indices[size_t(y) * size_t(frame_size.GetWidth()) + size_t(x)]; + if (int(index) == transparent) + continue; + unsigned char *dst = &canvas[(size_t(cy) * size_t(width) + size_t(cx)) * 4]; + dst[0] = palette[3 * size_t(index) + 0]; + dst[1] = palette[3 * size_t(index) + 1]; + dst[2] = palette[3 * size_t(index) + 2]; + dst[3] = 255; + } + } + + // Browsers treat 0 and 1 hundredths of a second as "as fast as the file can mean", and settle + // on 100 ms; an animation authored against that convention plays at the intended speed only if + // this does the same. + const long delay_ms = decoder.GetDelay(i); + out.push_back({ canvas, (delay_ms <= 10 ? 100.0 : double(delay_ms)) / 1000.0 }); + + if (disposal == wxANIM_TOBACKGROUND) { + for (int y = 0; y < frame_size.GetHeight(); ++y) { + const int cy = frame_pos.y + y; + if (cy < 0 || cy >= height) + continue; + const int x0 = std::max(0, frame_pos.x); + const int x1 = std::min(width, frame_pos.x + frame_size.GetWidth()); + if (x1 > x0) + std::fill_n(&canvas[(size_t(cy) * size_t(width) + size_t(x0)) * 4], size_t(x1 - x0) * 4, 0); + } + } else if (disposal == wxANIM_TOPREVIOUS) { + canvas = saved; + } + } + return !out.empty(); +} + +} // namespace + +bool AnimatedTexture::load_from_file(const std::string &path) +{ + reset(); + + boost::system::error_code ec; + if (!boost::filesystem::is_regular_file(path, ec)) + return false; + + const std::vector bytes = read_file_bytes(path); + if (bytes.empty()) + return false; + + std::vector frames; + int width = 0, height = 0; + + if (has_gif_signature(bytes)) { + if (!decode_gif(bytes, width, height, frames)) { + BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode GIF " << path; + return false; + } + } else { + wxLogNull no_log; // a format wxWidgets cannot read is handled here, not in a dialog + wxImage image; + wxMemoryInputStream stream(bytes.data(), bytes.size()); + if (!image.LoadFile(stream, wxBITMAP_TYPE_ANY) || !image.IsOk()) { + BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot decode image " << path; + return false; + } + width = image.GetWidth(); + height = image.GetHeight(); + frames.push_back({ image_to_rgba(image), 0.0 }); + } + + m_width = width; + m_height = height; + m_frames.reserve(frames.size()); + for (DecodedFrame &frame : frames) { + auto texture = std::make_unique(); + // Mipmapped: a hint is drawn at whatever width the panel has room for, which is usually + // smaller than the source, and an unfiltered downscale of a diagram is all aliasing. + if (!texture->load_from_raw_data(std::move(frame.rgba), (unsigned int) width, (unsigned int) height, false, + /* use_mipmaps */ true)) { + BOOST_LOG_TRIVIAL(error) << "AnimatedTexture: cannot upload " << path; + reset(); + return false; + } + m_frames.push_back({ std::move(texture), m_duration, frame.delay }); + m_duration += frame.delay; + } + return true; +} + +void AnimatedTexture::reset() +{ + m_frames.clear(); + m_width = 0; + m_height = 0; + m_duration = 0.; +} + +unsigned int AnimatedTexture::texture_id(double time) const +{ + if (m_frames.empty()) + return 0; + if (m_frames.size() == 1 || m_duration <= 0.) + return m_frames.front().texture->get_id(); + + const double t = time - m_duration * std::floor(time / m_duration); + // Frames are few; a linear walk beats keeping an index around and getting it stale. + size_t i = 0; + while (i + 1 < m_frames.size() && t >= m_frames[i].start + m_frames[i].delay) + ++i; + return m_frames[i].texture->get_id(); +} + +const AnimatedTexture *AnimatedTextureCache::get(const std::string &path) +{ + const auto it = m_items.find(path); + if (it != m_items.end()) + return it->second.get(); + + auto texture = std::make_unique(); + if (!texture->load_from_file(path)) + texture.reset(); // remembered as a failure, so the file is not re-read every frame + + const AnimatedTexture *result = texture.get(); + m_items.emplace(path, std::move(texture)); + return result; +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/AnimatedTexture.hpp b/src/slic3r/GUI/AnimatedTexture.hpp new file mode 100644 index 0000000000..be6a0c8b7f --- /dev/null +++ b/src/slic3r/GUI/AnimatedTexture.hpp @@ -0,0 +1,75 @@ +#ifndef slic3r_AnimatedTexture_hpp_ +#define slic3r_AnimatedTexture_hpp_ + +#include +#include +#include +#include + +#include "slic3r/GUI/GLTexture.hpp" + +namespace Slic3r::GUI { + +// An illustration for the UI to draw: one still image, or the frames of an animated GIF together with +// the delays the file asks for. Decoded on the CPU once and uploaded as one GL texture per frame. +// +// A texture per frame rather than an atlas: these are small hints a few hundred pixels across and a +// handful of frames long, so the extra textures cost little, while drawing a frame stays a plain +// ImGui::Image with no UV arithmetic and no dependence on the maximum texture size. +// +// load_from_file() uploads to the GPU, so it needs a current GL context - call it from rendering code. +class AnimatedTexture +{ +public: + AnimatedTexture() = default; + AnimatedTexture(AnimatedTexture &&) = default; + AnimatedTexture &operator=(AnimatedTexture &&) = default; + AnimatedTexture(const AnimatedTexture &) = delete; + AnimatedTexture &operator=(const AnimatedTexture &) = delete; + + // PNG, JPG and BMP through wxImage; GIF, animated or not, through wxGIFDecoder. False, and nothing + // loaded, if the file is missing or cannot be decoded. + bool load_from_file(const std::string &path); + void reset(); + + bool is_valid() const { return !m_frames.empty(); } + bool is_animated() const { return m_frames.size() > 1; } + int width() const { return m_width; } + int height() const { return m_height; } + // Length of one loop, in seconds; 0 for a still image. + double duration() const { return m_duration; } + + // The texture to draw `time` seconds into the loop, which wraps. 0 when nothing is loaded. + unsigned int texture_id(double time) const; + +private: + struct Frame + { + std::unique_ptr texture; + double start{0.}; // seconds into the loop this frame appears at + double delay{0.}; // seconds it stays up + }; + + std::vector m_frames; + int m_width{0}; + int m_height{0}; + double m_duration{0.}; +}; + +// The illustrations a panel has shown at least once, keyed by absolute path, decoded and uploaded on +// first use and kept until clear() - which has to run while the GL context is still current. +class AnimatedTextureCache +{ +public: + // Null when the file is missing or cannot be decoded. The failure is remembered too, so a tooltip + // whose illustration has not been drawn yet costs one lookup, not one decode attempt per frame. + const AnimatedTexture *get(const std::string &path); + void clear() { m_items.clear(); } + +private: + std::map> m_items; // null value: tried once, failed +}; + +} // namespace Slic3r::GUI + +#endif // slic3r_AnimatedTexture_hpp_ diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 16d70a7340..8253563e80 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -3830,6 +3830,189 @@ void GLGizmoTextureDisplacement::ensure_panel_icons() m_panel_icon_map[names[i]] = icons[i]; } +// Seconds on a real clock, for the tooltips' own timing. +// +// Not ImGui::GetTime(): nothing in OrcaSlicer ever sets io.DeltaTime, so it keeps imgui's default of +// 1/60 per frame and its clock counts frames rather than seconds. On a canvas that renders only when +// something asks it to, that is not a clock at all - an animation driven by it crawled while the canvas +// was idle and raced while a moving mouse was rendering it flat out. +static double tip_now() +{ + using clock = std::chrono::steady_clock; + static const clock::time_point base = clock::now(); + return std::chrono::duration(clock::now() - base).count(); +} + +// How long the pointer has to rest on a control before its tooltip appears. The panel is dense and the +// tooltip lands on the model beside it, so with no wait at all a sweep from the top of the panel down to +// Bake flashes a dozen large panels over the scene. Once one has just been up the wait drops to almost +// nothing, so reading several settings in a row stays quick - but not to nothing, or that same sweep +// would flash again the moment one tooltip had been seen. +static constexpr double TIP_DELAY = 0.45; +static constexpr double TIP_DELAY_AGAIN = 0.10; +static constexpr double TIP_STICKY = 0.70; + +void GLGizmoTextureDisplacement::panel_tooltip(const wxString &text, const std::vector &media) +{ + panel_tooltip(into_u8(text), media); +} + +void GLGizmoTextureDisplacement::panel_tooltip(const std::string &text, const std::vector &media) +{ + // Nothing explains a control while the control is being used: dragging a slider, typing a value + // into one, picking from a combo, dragging a layer card. The pointer sits on the very thing being + // edited, so without this the tooltip covers the model exactly while the user is watching it change. + // The full wait comes back afterwards rather than the short one - having just let go of a slider is + // not a reason to be shown a picture of it. + if (ImGui::IsAnyItemActive() || ImGui::IsAnyMouseDown()) { + m_tip_item = ImVec2(-1.f, -1.f); + m_tip_last_shown = -1e9; + return; + } + + // The item's position stands in for its identity: moving to another control restarts the wait, and + // staying on this one carries it on. + const double now = tip_now(); + const ImVec2 item = ImGui::GetItemRectMin(); + if (item.x != m_tip_item.x || item.y != m_tip_item.y) { + m_tip_item = item; + m_tip_hover_since = now; + } + if (now - m_tip_hover_since < (now - m_tip_last_shown < TIP_STICKY ? TIP_DELAY_AGAIN : TIP_DELAY)) { + // A pointer resting on a control produces no events at all, so unless the wait asks for frames + // itself nothing redraws the canvas and the wait never finishes - the tooltip then appears only + // on the next pixel of mouse movement. + // + // request_extra_frame() and not schedule_extra_frame(): the latter only arranges a timer, whose + // event an idle event is assumed to follow, and GLCanvas3D::on_idle() renders nothing unless the + // canvas was already dirty when it ran. request_extra_frame() ends on_idle with + // evt.RequestMore(), which obliges wx to deliver the next one. It is what 3DBed's fade uses. + // Costing a few hundred milliseconds of frames is the cheaper half of that trade. + m_parent.request_extra_frame(); + return; + } + m_tip_last_shown = now; + + // ImGui hides a window on the first frame it is submitted, to measure it before showing it - and + // for a tooltip it does so every time one reappears, because the windows are recycled (see the + // Popup|Tooltip branch of Begin()). So the frame on which a tooltip is decided is never the frame + // on which it is seen, and one that then asks for nothing more stays invisible until the next pixel + // of mouse movement happens to produce another frame. Keep frames coming until it has settled. + const int shown_frame = ImGui::GetFrameCount(); + m_tip_settled = (shown_frame == m_tip_shown_frame + 1) ? m_tip_settled + 1 : 0; + m_tip_shown_frame = shown_frame; + if (m_tip_settled < 2) + m_parent.request_extra_frame(); + + const ImGuiStyle &style = ImGui::GetStyle(); + const float gap = m_imgui->scaled(0.5f); + const float wrap = m_imgui->scaled(20.f); + // The width has to be known here, not left to ImGui, because the tooltip is placed by its left edge + // (below) and an illustration has to be laid out against something. One carrying illustrations takes + // the full wrap width; a plain one takes exactly what its text needs, as it did when ImGui sized it, + // so a two-word tooltip is not a wide box with two words in it. + const float text_w = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap).x; + const float width = (media.empty() ? std::min(wrap, std::ceil(text_w)) : wrap) + 2.f * style.WindowPadding.x; + + // Beside the anchor (the panel, or the texture picker when the tooltip comes from inside it), never + // over it - the same reasoning, and the same placement, as the picker popup itself. ImGui normally + // keeps a tooltip glued to the mouse; it honours a position set here instead (see the Orca branch in + // imgui's Begin()), and still clamps the result inside the canvas. + const bool left = m_tooltip_anchor_min.x - gap - width >= 0.f; + const float x = left ? m_tooltip_anchor_min.x - gap - width : m_tooltip_anchor_max.x + gap; + ImGui::SetNextWindowPos(ImVec2(x, ImGui::GetItemRectMin().y)); // level with the control it explains + ImGui::SetNextWindowSize(ImVec2(width, 0.f)); // height still follows the content + + ImGui::BeginTooltip(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); + ImGui::PushTextWrapPos(0.f); // the window's own edge, which is what the fixed width above sets + ImGui::TextUnformatted(text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + render_tip_media(media, width - 2.f * style.WindowPadding.x); + ImGui::EndTooltip(); +} + +void GLGizmoTextureDisplacement::render_tip_media(const std::vector &media, float width) +{ + // Collect what is actually there first: the layout divides the width between the illustrations, so + // one that is missing has to drop out of the count rather than leave a gap. + std::vector> shown; + std::string key; + for (const TipMedia &m : media) { + const AnimatedTexture *texture = + m_tip_media.get((boost::filesystem::path(resources_dir()) / "images" / "texture_displacement" / m.file).string()); + if (texture != nullptr && texture->is_valid() && texture->width() > 0 && texture->height() > 0) { + shown.emplace_back(texture, &m); + key += m.file + "|"; + } + } + if (shown.empty()) + return; + + // Each animation plays from its first frame when its tooltip opens: these are short loops showing + // one setting going from one value to another, and joining one halfway through shows the user the + // end of a change they never saw the start of. + // Restart on a different tooltip, and on the same one opening again - which is what a gap in the + // frames it was drawn on means, since it is drawn on every frame for as long as it is on screen. + const int this_frame = ImGui::GetFrameCount(); + if (key != m_tip_media_key || this_frame != m_tip_media_frame + 1) { + m_tip_media_key = key; + m_tip_media_start = tip_now(); + } + m_tip_media_frame = this_frame; + const double time = tip_now() - m_tip_media_start; + + const ImGuiStyle &style = ImGui::GetStyle(); + const float spacing = style.ItemSpacing.x; + 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(); + const unsigned int id = texture->texture_id(time); + 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 for its own frames. It asks for + // them as fast as they come rather than pacing itself with a timer: which frame is drawn is + // decided by the clock above, so a redraw that arrives early simply draws the same frame again, + // and a guaranteed frame is worth more here than a frugal one that may never arrive. + if (texture->is_animated()) + m_parent.request_extra_frame(); + + if (!entry.caption.empty()) { + // 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; + 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()); + ImGui::PopTextWrapPos(); + } + ImGui::EndGroup(); + + if (i + 1 < shown.size()) + ImGui::SameLine(0.f, spacing); + } +} + void GLGizmoTextureDisplacement::add_texture_layer() { ModelVolume *mv = texture_volume(); @@ -3979,6 +4162,14 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane return; } + // While this popup is up its own tooltips belong beside it, not beside the panel it is already + // covering the left of. Restored before every exit below. + const ImVec2 prev_anchor_min = m_tooltip_anchor_min; + const ImVec2 prev_anchor_max = m_tooltip_anchor_max; + m_tooltip_anchor_min = ImGui::GetWindowPos(); + m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x, + m_tooltip_anchor_min.y + ImGui::GetWindowSize().y); + m_imgui->push_bold_font(); m_imgui->text(_L("Choose a texture")); m_imgui->pop_bold_font(); @@ -4037,7 +4228,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane if (entry.path == layer->path) dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f); if (ImGui::IsItemHovered()) - m_imgui->tooltip(entry.name, m_imgui->scaled(18.f)); + panel_tooltip(entry.name); if (user && ImGui::IsMouseHoveringRect(a, b)) { const float r = std::round(tile * 0.17f); @@ -4050,7 +4241,7 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane dl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), IM_COL32_WHITE, 1.5f); dl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), IM_COL32_WHITE, 1.5f); if (hot) - m_imgui->tooltip(_u8L("Remove from My textures"), m_imgui->scaled(18.f)); + panel_tooltip(_u8L("Remove from My textures")); if (remove_clicked) to_remove = entry; } @@ -4077,10 +4268,9 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane if (ImGui::Button("+##import_texture", ImVec2(tile, tile))) import_clicked = true; if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer " - "bakes from and saved to your personal texture folder, kept separate from the " - "textures shipped with OrcaSlicer."), - m_imgui->scaled(20.f)); + panel_tooltip(_u8L("Import your own image as a height map. It is converted to the format the slicer " + "bakes from and saved to your personal texture folder, kept separate from the " + "textures shipped with OrcaSlicer.")); end_grid(origin, count + 1); } @@ -4090,6 +4280,8 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane if (chosen || import_clicked) ImGui::CloseCurrentPopup(); + m_tooltip_anchor_min = prev_anchor_min; + m_tooltip_anchor_max = prev_anchor_max; ImGui::EndPopup(); // Only once the loop over the library is done: importing, picking (which reorders the recently used) @@ -6066,18 +6258,16 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv) run_stage_debug(); m_imgui->disabled_end(); if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every " - "stage, and step through them here. Nothing is committed to the model - " - "this is for finding which stage a bad result came from."), - m_imgui->scaled(20.f)); + panel_tooltip(_u8L("Run the same bake the Bake button runs, keeping the mesh after every " + "stage, and step through them here. Nothing is committed to the model - " + "this is for finding which stage a bad result came from.")); ImGui::SameLine(); m_imgui->bbl_checkbox(_L("Check topology"), m_debug_check_topology); if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage " - "that tore the mesh is spotted. It scans every edge, so it adds noticeably " - "to the capture on a fine bake."), - m_imgui->scaled(20.f)); + panel_tooltip(_u8L("Count open and non-manifold edges after each stage, which is how a stage " + "that tore the mesh is spotted. It scans every edge, so it adds noticeably " + "to the capture on a fine bake.")); if (m_debug_stages.empty()) return; @@ -6145,9 +6335,8 @@ void GLGizmoTextureDisplacement::render_debug_stage_panel(ModelVolume *mv) show_error(nullptr, _u8L("Could not write the stage meshes.")); } if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened " - "side by side in a mesh viewer."), - m_imgui->scaled(20.f)); + panel_tooltip(_u8L("Write every captured stage out as an OBJ file, so they can be opened " + "side by side in a mesh viewer.")); } void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams ¶ms, @@ -6274,6 +6463,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGuiWrapper::push_toolbar_style(m_parent.get_scale()); GizmoImguiBegin(get_name(), flags); + // Where this frame's tooltips place themselves (see panel_tooltip()). The size is last frame's - + // this window auto-resizes, so its height is only known once its content has been laid out - which + // is close enough for a tooltip that needs a frame of hovering to appear in the first place. + m_tooltip_anchor_min = ImGui::GetWindowPos(); + m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x, + m_tooltip_anchor_min.y + ImGui::GetWindowSize().y); ensure_panel_icons(); process_uv_commands(); // clicks from the UV editor pane, run here where the GL context is current // After the pane's commands, which can close it or change the view, and before the View row, so the row @@ -6300,7 +6495,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const float gap_s = std::round(m_imgui->scaled(0.4f)); const float label_w = m_imgui->scaled(4.8f); // label column of a label + slider row const float card_pad = std::round(m_imgui->scaled(0.55f)); - const float wrap_w = m_imgui->scaled(20.f); const ImVec4 orca = ImGuiWrapper::COL_ORCA; const ImVec4 col_link = ImGuiWrapper::COL_ORCA; const ImVec4 col_frame = ImGui::GetStyleColorVec4(ImGuiCol_Separator); @@ -6314,9 +6508,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGuiWrapper::pop_combo_style(); return changed; }; - const auto hover_tip = [&](const auto &text) { + const auto hover_tip = [&](const auto &text, const std::vector &media = {}) { if (ImGui::IsItemHovered()) - m_imgui->tooltip(text, wrap_w); + panel_tooltip(text, media); }; // Framed icon toggle: a 1 px frame, and when on a teal frame over a teal tint with the icon in its @@ -6324,7 +6518,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // `unavailable`, when not empty, draws it faded but still hoverable, so the tooltip can say why it // cannot be used - a disabled ImGui item would swallow the hover and leave the user guessing. const auto icon_toggle = [&](int uid, const std::string &iconfile, bool active, float sz, const wxString &label, - const wxString &tip_text, const wxString &unavailable = wxString()) -> bool { + const wxString &tip_text, const std::vector &media = {}, + const wxString &unavailable = wxString()) -> bool { const bool na = !unavailable.empty(); const auto it = m_panel_icon_map.find(iconfile); const float pad = std::max(1.f, std::round(sz * 0.14f)); @@ -6351,12 +6546,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::PopStyleVar(2); ImGui::PopID(); if (ImGui::IsItemHovered()) - m_imgui->tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, wrap_w); + panel_tooltip(na ? tip_text + "\n\n" + unavailable : tip_text, media); return clicked && !na; }; // Borderless icon button (non-toggle): always the grey monochrome variant. const auto icon_button = [&](int uid, const std::string &iconfile, float sz, const wxString &label, - const wxString &tip_text) -> bool { + const wxString &tip_text, const std::vector &media = {}) -> bool { const auto it = m_panel_icon_map.find(iconfile); const float pad = std::max(1.f, std::round(sz * 0.14f)); bool clicked = false; @@ -6374,7 +6569,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::PopStyleVar(); ImGui::PopID(); if (!tip_text.empty() && ImGui::IsItemHovered()) - m_imgui->tooltip(tip_text, wrap_w); + panel_tooltip(tip_text, media); return clicked; }; // A short vertical rule between groups of buttons on one row; call it right after an item. @@ -6453,10 +6648,10 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float }; // Draws `slider` at `item_w` (SetNextItemWidth's convention: negative measures back from the right - // edge, and the caller has already left edit_icon_room in it), then the edit icon. `tip` and the - // range go on both. Returns whatever the slider returned. - const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::string &range, - const std::function &slider) -> bool { + // edge, and the caller has already left edit_icon_room in it), then the edit icon. The range goes on + // both tooltips; `tip` and its illustrations `media` on the slider's. Returns whatever the slider returned. + const auto typed_row = [&](const char *id, float item_w, const wxString &tip, const std::vector &media, + const std::string &range, const std::function &slider) -> bool { // Deferred from the click a frame ago: SetKeyboardFocusHere() is a request for the *next* // frame, so it has to be issued before the slider it names, not from the icon beside it. if (m_slider_type_request == id) { @@ -6472,7 +6667,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float wxString full = tip; if (!full.IsEmpty()) full += "\n\n"; - m_imgui->tooltip(full + from_u8(range), wrap_w); + panel_tooltip(full + from_u8(range), media); } // SameLine() puts the cursor back at the slider's own top (ItemSize records the Y the item was // drawn at), so an icon of the same height lands on the track wherever the row placed it. @@ -6489,15 +6684,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // already given, so no row states its bounds or its format twice and the tooltip cannot drift from // the track. `item_w` is as typed_row() takes it. const auto typed_slider = [&](const char *id, float item_w, float *v, float v_min, float v_max, const char *format, - bool log, const wxString &tip) -> bool { - return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + bool log, const wxString &tip, const std::vector &media = {}) -> bool { + return typed_row(id, item_w, tip, media, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { return ImGui::SliderFloat(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); }); }; const auto typed_slider_int = [&](const char *id, float item_w, int *v, int v_min, int v_max, const char *format, - bool log, const wxString &tip) -> bool { - return typed_row(id, item_w, tip, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { + bool log, const wxString &tip, const std::vector &media = {}) -> bool { + return typed_row(id, item_w, tip, media, range_text(fmt_bound(format, v_min), fmt_bound(format, v_max)), [&] { return ImGui::SliderInt(id, v, v_min, v_max, format, ImGuiSliderFlags_AlwaysClamp | (log ? ImGuiSliderFlags_Logarithmic : 0)); }); @@ -6505,14 +6700,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Label column + the slider filling the rest of the row. const auto float_row = [&](const char *id, const wxString &label, float *v, float v_min, float v_max, const char *format, - bool log, float right_inset, const wxString &tip) -> bool { + bool log, float right_inset, const wxString &tip, const std::vector &media = {}) -> bool { slider_label(label); - return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip); + return typed_slider(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, log, tip, media); }; const auto int_row = [&](const char *id, const wxString &label, int *v, int v_min, int v_max, const char *format, - float right_inset, const wxString &tip) -> bool { + float right_inset, const wxString &tip, const std::vector &media = {}) -> bool { slider_label(label); - return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip); + return typed_slider_int(id, -(std::max(right_inset, 1.f) + edit_icon_room), v, v_min, v_max, format, false, tip, media); }; const auto layer_name = [](const TextureDisplacementLayer &l) { return l.name.empty() ? Slic3r::format(_u8L("Layer %1%"), l.slot + 1) : l.name; @@ -6668,11 +6863,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SetCursorPosX(toggles_x); if (icon_toggle(820, "texture_displacement_add.svg", !m_erase_mode, icon_sm, _L("Paint"), _L("Paint - add the active layer where you click or drag. The right button erases, and so does " - "holding Shift."))) + "holding Shift."), + { { "paint.gif" } })) m_erase_mode = false; ImGui::SameLine(0.f, gap_s); if (icon_toggle(821, "texture_displacement_add_negative.svg", m_erase_mode, icon_sm, _L("Erase"), - _L("Erase - remove the active layer where you click or drag. The right button paints."))) + _L("Erase - remove the active layer where you click or drag. The right button paints."), + { { "erase.gif" } })) m_erase_mode = true; } @@ -6686,20 +6883,22 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const bool is_area_mode = m_tool_type == ToolType::SMART_FILL; const float row_y = ImGui::GetCursorPosY(); if (icon_toggle(801, "texture_displacement_brush.svg", is_brush_mode, icon_md, _L("Brush"), - _L("Brush - paint over the surface by dragging"))) { + _L("Brush - paint over the surface by dragging"), { { "paint.gif" } })) { m_tool_type = ToolType::BRUSH; if (m_cursor_type == TriangleSelector::CursorType::POINTER) m_cursor_type = TriangleSelector::CursorType::CIRCLE; } ImGui::SameLine(0.f, gap_s); - if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"))) { + if (icon_toggle(802, "texture_displacement_face.svg", is_face_mode, icon_md, _L("Face"), _L("Face - click individual triangles"), + { { "tool_face.gif" } })) { m_tool_type = ToolType::BRUSH; m_cursor_type = TriangleSelector::CursorType::POINTER; } ImGui::SameLine(0.f, gap_s); if (icon_toggle(803, "texture_displacement_connected_area.svg", is_area_mode, icon_md, _L("Connected area"), _L("Connected area - flood-fill the region reachable without crossing an edge sharper than the " - "angle threshold"))) { + "angle threshold"), + { { "tool_smart_fill.gif" } })) { m_tool_type = ToolType::SMART_FILL; m_cursor_type = TriangleSelector::CursorType::POINTER; } @@ -6713,11 +6912,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float wxString(); ImGui::SameLine(0.f, gap_s); if (icon_toggle(806, "texture_displacement_select_all.svg", false, icon_md, _L("Select whole model"), - _L("Select whole model - paint every face of the model with the active layer"), whole_na)) + _L("Select whole model - paint every face of the model with the active layer"), + { { "select_all.png" } }, whole_na)) select_whole_model(); ImGui::SameLine(0.f, gap_s); if (icon_toggle(807, "texture_displacement_erase_all.svg", false, icon_md, _L("Erase whole model"), - _L("Erase whole model - clear the active layer's paint from every face"), erase_na)) { + _L("Erase whole model - clear the active layer's paint from every face"), + { { "erase_all.png" } }, erase_na)) { Plater::TakeSnapshot snapshot(wxGetApp().plater(), _u8L("Reset texture displacement selection"), UndoRedo::SnapshotType::GizmoAction); int idx = -1; @@ -6739,7 +6940,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float centre_on_row(frame_h); typed_slider("##cursor_radius", std::max(1.f, row_end - ImGui::GetCursorPosX() - (3.f * gap_s + 1.f + 2.f * icon_sm) - edit_icon_room), - &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size")); + &m_cursor_radius, CursorRadiusMin, CursorRadiusMax, "%.2f mm", false, m_desc.at("cursor_size"), + { { "cursor_size.gif" } }); vsep(icon_sm); const bool is_circle = m_cursor_type == TriangleSelector::CursorType::CIRCLE; centre_on_row(icon_sm); @@ -6755,7 +6957,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float centre_on_row(frame_h); typed_slider("##smart_fill_angle", std::max(1.f, row_end - ImGui::GetCursorPosX() - edit_icon_room), &m_smart_fill_angle, SmartFillAngleMin, SmartFillAngleMax, "%.0f°", false, - _L("Angle threshold - the fill stops at edges sharper than this")); + _L("Angle threshold - the fill stops at edges sharper than this"), + { { "smart_fill_angle_low.png", _L("Low") }, { "smart_fill_angle_high.png", _L("High") } }); } else { centre_on_row(frame_h); ImGui::AlignTextToFramePadding(); @@ -6791,19 +6994,21 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(); ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), x0 + m_imgui->scaled(2.6f))); if (icon_toggle(701, "texture_displacement_real_preview.svg", cur_mode == 0, icon_md, _L("Normal"), - _L("Normal - the real geometry, exactly what Bake will produce. Slower to update"))) + _L("Normal - the real geometry, exactly what Bake will produce. Slower to update"), + { { "tile_on.png" } })) new_mode = picked = 0; ImGui::SameLine(0.f, gap_s); if (icon_toggle(702, "texture_displacement_fast_preview.svg", cur_mode == 1, icon_md, _L("Fast"), _L("Fast - the relief is only shaded on, not built, and only for the layer you are " - "editing. Updates instantly while you paint"))) + "editing. Updates instantly while you paint"), + { { "view_fast.png" } })) new_mode = picked = 1; ImGui::SameLine(0.f, gap_s); if (icon_toggle(703, "texture_displacement_checker.svg", cur_mode == 2, icon_md, _L("Checker"), _L("Checker - a test grid instead of the texture. Where the squares stay square the " "texture is undistorted; where they stretch, it will too. Opens the UV editor if " "it is closed"), - uv_view_na)) { + { { "view_checker.png" } }, uv_view_na)) { new_mode = picked = 2; open_uv_editor = true; } @@ -6811,13 +7016,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, icon_md, _L("Distortion"), _L("Distortion - blue-to-red stretch heatmap over the unwrap. Opens the UV editor if " "it is closed"), - uv_view_na)) { + { { "view_distortion.png" } }, uv_view_na)) { new_mode = picked = 3; open_uv_editor = true; } vsep(icon_md); if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, icon_md, _L("Wireframe"), - _L("Wireframe - overlay the mesh edges; independent of the view above"))) + _L("Wireframe - overlay the mesh edges; independent of the view above"), + { { "view_wireframe.png" } })) wf_toggle = true; const std::string auto_label = _u8L("Auto"); @@ -7038,7 +7244,8 @@ 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, _L("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" } }); // Mapped by the model's own UV layout there is no millimetre scale to set: the layout is the // placement (see TextureProjectionMethod::ModelUV), so the slider would move nothing. The row // states why rather than hiding: float_row()'s tooltip is shown for a disabled slider too. @@ -7050,11 +7257,15 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "copy of the texture covers it exactly, so there is no tile size to set.") : _L("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_na ? std::vector{} : + std::vector{ { "tile_size_large.png", _L("Larger") }, + { "tile_size_small.png", _L("Smaller") } }); m_imgui->disabled_end(); m_preview_params_dirty |= float_row("##rotation", _L("Rotation"), &layer.rotation_deg, 0.f, 360.f, "%.0f°", false, card_pad, _L("Turns the texture on the surface, in degrees - for lining a pattern up with an " - "edge of the model.")); + "edge of the model."), + { { "rotation.gif" } }); if (m_layer_expanded[size_t(layer.slot)]) { { @@ -7074,7 +7285,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.")); @@ -7084,12 +7296,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, _L("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); m_preview_params_dirty |= typed_slider("##edge_amount", -(card_pad + edit_icon_room), @@ -7103,7 +7317,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))); @@ -7119,13 +7334,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_imgui->disabled_end(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - m_imgui->tooltip(has_color ? - _u8L("Prints the painted area in the texture's colors as well as its " - "relief. Each color is matched to the nearest of your loaded " - "filaments; anything you did not paint keeps the object's own.") : - _u8L("This texture is a grayscale height map, so it has no colors to " - "apply. Import a color image to use this."), - wrap_w); + panel_tooltip(has_color ? + _u8L("Prints the painted area in the texture's colors as well as its " + "relief. Each color is matched to the nearest of your loaded " + "filaments; anything you did not paint keeps the object's own.") : + _u8L("This texture is a grayscale height map, so it has no colors to " + "apply. Import a color image to use this.")); // The rest of colour belongs to the whole stack, not to this layer, so it only appears once - // under whichever layer turned colour on. @@ -7135,7 +7349,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty = true; hover_tip(_u8L("Interleaves two filaments to fake the colors in between, so a handful " "of filaments can cover a photo or a gradient. An image of flat colors " - "prints the same either way. Off uses one filament per area.")); + "prints the same either way. Off uses one filament per area."), + { { "mix_off.png", _L("Off") }, { "mix_on.png", _L("On") } }); if (opts.color_mix_enabled) { cached_palette(); // brings m_palette_filaments up to date // Every mix can become a filament slot, and there are only so many beside the @@ -7143,7 +7358,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const int max_mixes = std::max(1, int(EnforcerBlockerType::ExtruderMax) - int(m_palette_filaments.size())); if (int_row("##color_mix_count", _L("Mixed colors"), &opts.color_mix_count, 1, max_mixes, "%d", card_pad, _L("The most mixed filaments a bake may add. They are picked from the " - "texture's colors, and only the ones the bake actually uses are created."))) + "texture's colors, and only the ones the bake actually uses are created."), + { { "mixed_colors_few.png", _L("Few") }, { "mixed_colors_many.png", _L("Many") } })) m_preview_params_dirty = true; // After the slider, so a change shows in the same frame. const int n_colors = int(cached_palette().size()); @@ -7157,7 +7373,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (int_row("##color_despeckle", _L("Denoise"), &opts.color_despeckle, 0, 6, "%d", card_pad, _L("Cleans up single stray triangles of the wrong color, which detail finer " "than the mesh leaves behind. Raise it if the result looks speckled, " - "lower it if small features are being swallowed."))) + "lower it if small features are being swallowed."), + { { "denoise_off.png", _L("Off") }, { "denoise_on.png", _L("On") } })) m_preview_params_dirty = true; } } @@ -7174,26 +7391,33 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float const char *file; wxString label; wxString tip; + const char *picture; // the same ball with the checker laid on it this way, or null for none }; const MappingIcon mappings[] = { { "menu_obj_cube.svg", _L("Triplanar (blended)"), _L("Triplanar - projects the texture from all three axes at once and blends between them, so a " - "patch wrapping around a sharp edge has no seam.") }, + "patch wrapping around a sharp edge has no seam."), + "map_cube.png" }, { "menu_obj_cylinder.svg", _L("Cylindrical"), - _L("Cylindrical - wraps the texture around the painted area's own center, for round shapes.") }, + _L("Cylindrical - wraps the texture around the painted area's own center, for round shapes."), + "map_cylinder.png" }, { "menu_obj_sphere.svg", _L("Spherical"), - _L("Spherical - wraps the texture around the painted area's own center in both directions.") }, + _L("Spherical - wraps the texture around the painted area's own center in both directions."), + "map_sphere.png" }, { "texture_displacement_map_unwrap.svg", _L("Unwrap (LSCM)"), _L("Unwrap - flattens the painted area and maps the texture onto it with as little stretching as " "possible. The area is cut into pieces at its sharp edges first (see Seam angle), so each " - "piece can lie flat on its own.") }, + "piece can lie flat on its own."), + "map_unwrap.png" }, { "texture_displacement_map_view.svg", _L("From view"), _L("From view - projects straight onto the painted area from where you are looking, like a slide " - "projector.") }, + "projector."), + "map_view.png" }, { "texture_displacement_map_model_uv.svg", _L("Model UV"), _L("Model UV - uses the texture coordinates the model itself came with, so a texture made for " "that model lands exactly where it was painted. Nothing is unwrapped, and Tile size does " - "not apply: the model's own layout is the placement.") }, + "not apply: the model's own layout is the placement."), + nullptr }, }; // Only a volume that actually carries a UV channel can be mapped by it. Shown // greyed out rather than hidden, so the reason is visible instead of the control @@ -7208,8 +7432,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::SameLine(0.f, gap_s); const auto method = static_cast(mi); const wxString na = (method == TextureProjectionMethod::ModelUV) ? model_uv_na : wxString(); + const std::vector picture = mappings[mi].picture != nullptr ? + std::vector{ { mappings[mi].picture } } : + std::vector{}; if (icon_toggle(830 + mi, mappings[mi].file, layer.projection_method == method, icon_sm, mappings[mi].label, - mappings[mi].tip, na) && + mappings[mi].tip, picture, na) && layer.projection_method != method) { // Capture the current view the moment "From view" is chosen, so it does something // sensible immediately rather than projecting from a stale/default direction. @@ -7314,19 +7541,20 @@ 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("Tile") + "##tile_enabled", layer.tile_enabled); hover_tip(_u8L("Repeats the texture across the painted area. Off places one copy, like a decal, " - "at the size set by Tile size.")); + "at the size set by Tile size."), + { { "tile_off.png", _L("Off") }, { "tile_on.png", _L("On") } }); ImGui::SameLine(); 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(0), - icon_sm, _L("Repeat"), _L("Repeat"), tile_na)) { + icon_sm, _L("Repeat"), _L("Repeat"), { { "tile_repeat.png" } }, tile_na)) { layer.tile_method = static_cast(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(1), icon_sm, _L("Mirrored repeat"), _L("Mirrored repeat - every other tile is flipped, so edges meet seamlessly"), - tile_na)) { + { { "tile_mirror.png" } }, tile_na)) { layer.tile_method = static_cast(1); m_preview_params_dirty = true; } @@ -7371,15 +7599,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } m_imgui->disabled_end(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - m_imgui->tooltip(base_layer ? - _u8L("The base layer has nothing beneath it to combine with, so it always adds.") : - _u8L("What this layer does where it overlaps the ones below. Add and " - "Subtract pile relief on or carve it away. Override replaces them, " - "so only this layer's relief is left where it is painted. 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."), - wrap_w); + panel_tooltip(base_layer ? + _u8L("The base layer has nothing beneath it to combine with, so it always adds.") : + _u8L("What this layer does where it overlaps the ones below. Add and " + "Subtract pile relief on or carve it away. Override replaces them, " + "so only this layer's relief is left where it is painted. 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."), + base_layer ? std::vector{} : + std::vector{ { "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"), @@ -7562,7 +7792,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } hover_tip(_u8L("Adds triangles only where you painted, instead of everywhere. On a large part with a small " "decal that is the difference between thousands of triangles and millions, and your paint " - "survives the refinement.")); + "survives the refinement."), + { { "painted_only_off.png", _L("Off") }, { "painted_only_on.png", _L("On") } }); if (m_subdivide_adaptive) { if (m_subdivide_target_mm <= 0.f && mv != nullptr) { @@ -7590,7 +7821,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float preview_live(); hover_tip(_u8L("Spends the triangles where the texture actually bends - packed along ridges and edges, " "sparse over flat ground - instead of spreading them evenly. The same detail for fewer " - "triangles on most textures.")); + "triangles on most textures."), + { { "adaptive_off.png", _L("Off") }, { "adaptive_on.png", _L("On") } }); // The edge-length target is a baseline in both modes. In feature mode it is what guarantees the // curvature test can actually see the texture: left too coarse, a big triangle over a fine @@ -7643,7 +7875,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (int_row("##subdiv_steps", _L("Steps"), &m_subdivide_count, 0, 5, "%d", 0.f, _L("Splits every triangle of the model into four, this many times over. Each step " "quadruples the count - 3 turns 100 k triangles into 6.4 M - so prefer refining " - "only the painted area unless you need the whole model finer."))) { + "only the painted area unless you need the whole model finer."), + { { "subdivide.gif" } })) { if (m_subdivide_editing) rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one m_parent.set_as_dirty(); @@ -7711,12 +7944,14 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } float_row("##remesh_edge", _L("Target edge"), &m_remesh_target_edge_mm, 0.1f, 20.f, "%.2f mm", true, 0.f, _L("Triangle size the whole model is rebuilt with, in millimetres. Displacement works best on an " - "even mesh; this is what makes one out of an uneven import.")); + "even mesh; this is what makes one out of an uneven import."), + { { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } }); m_imgui->bbl_checkbox(_L("Keep sharp edges") + "##remesh_sharp", m_remesh_keep_sharp_edges); hover_tip(_u8L("Holds hard edges and open borders in place while the rest is remeshed. Without it " "the remesher slides vertices along the surface and rounds every crisp edge off - " - "a cube comes back with wobbly edges.")); + "a cube comes back with wobbly edges."), + { { "preserve_edges_off.png", _L("Off") }, { "preserve_edges_on.png", _L("On") } }); ImGui::SameLine(); m_imgui->disabled_begin(!m_remesh_keep_sharp_edges); typed_slider("##remesh_sharp_angle", -(1.f + edit_icon_room), &m_remesh_sharp_angle_deg, 5.f, 90.f, "%.0f°", false, @@ -7731,7 +7966,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float hover_tip(_u8L("Rebuilds the whole model with triangles close to this edge length - splitting the big " "ones and merging the small ones - so displacement has an even density to work with. " "Replaces the geometry; your paint is carried onto the new triangles spatially, so it " - "survives (already-baked relief is kept too).")); + "survives (already-baked relief is kept too)."), + { { "remesh_before.png", _L("Before") }, { "remesh_after.png", _L("After") } }); // Settings for the whole stack rather than one layer. Standard mode pins them instead of showing them. if (mv != nullptr) { @@ -7747,7 +7983,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_preview_params_dirty |= m_imgui->bbl_checkbox(_L("Smooth result"), opts.smooth_enabled); hover_tip(_u8L("Smooths the geometry after the texture has been applied, to take the hard steps out of a " "low-resolution image. Only what the displacement moved is touched. The Smoothing slider " - "on a layer is a different thing: it blurs the image before it is used.")); + "on a layer is a different thing: it blurs the image before it is used."), + { { "geometry_smoothing.gif" } }); if (opts.smooth_enabled) { float percent = opts.smooth_strength * 100.f; if (float_row("##dispsmooth", _L_CONTEXT("Strength", "Texture Displacement"), &percent, 1.f, 100.f, "%.0f %%", false, 0.f, @@ -7807,7 +8044,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "Relief already baked into the rest of the model is kept on top of it, so a " "second bake somewhere else gets the same budget as the first. Raise it if the " "warning below Resolution says the detail will not fit; 0 keeps every triangle " - "the refinement produced, however many that is."))) { + "the refinement produced, however many that is."), + { { "subdivide.gif" } })) { opts.v2_max_triangles_k = shown_k; m_preview_params_dirty = true; } @@ -7826,7 +8064,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float hover_tip(_u8L("Collapse the thin triangles refinement inherits from the model's own " "tessellation, before displacement samples them. A sliver's three corners " "land on three unrelated parts of the texture, which is what makes the " - "relief look jagged.")); + "relief look jagged."), + { { "collapse_slivers_off.png", _L("Off") }, { "collapse_slivers_on.png", _L("On") } }); } // Below both pipelines' own settings, because it replays whichever of them is selected. if (SHOW_PIPELINE_DEV_CONTROLS) @@ -7927,8 +8166,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // Held in a local: the format outlives the call that reads it, and the slider and the // range in its tooltip are then formatted the same way. const std::string v2edge_format = auto_res ? _u8L("%.2f mm (auto)") : std::string("%.2f mm"); + const bool v2edge_chosen = auto_res && rec.edge_mm > 0.f; const wxString v2edge_tip = - auto_res && rec.edge_mm > 0.f ? + v2edge_chosen ? from_u8(Slic3r::format(_u8L("Chosen from the size of the model: %1$.2f mm triangles, " "budget %2% k. Untick Auto to set them yourself."), rec.edge_mm, rec.budget_k)) : @@ -7939,7 +8179,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float "back down and the detail goes with it."); m_imgui->disabled_begin(busy || auto_res); if (typed_slider("##v2edge", x0 + panel_w - ImGui::GetCursorPosX() - edit_icon_room, &shown, 0.02f, 2.f, - v2edge_format.c_str(), true, v2edge_tip)) { + v2edge_format.c_str(), true, v2edge_tip, + v2edge_chosen ? std::vector{} : std::vector{ { "subdivide.gif" } })) { opts.v2_refine_mm = shown; m_preview_params_dirty = true; m_parent.set_as_dirty(); @@ -7981,7 +8222,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float &m_subdivide_budget_k, 10, 2000, "+%d k", true, _L("How many thousand triangles the refinement may add to the model. The triangle " "that fits worst is always split first, so even a run that spends the lot has " - "spent it where it shows most. Raise it if the relief still looks coarse."))) { + "spent it where it shows most. Raise it if the relief still looks coarse."), + { { "subdivide.gif" } })) { if (m_subdivide_editing) rebuild_subdivide_preview(); m_parent.set_as_dirty(); @@ -8038,18 +8280,17 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->pop_bold_font(); GLGizmoUtils::pop_orca_button_style(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - m_imgui->tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ? - (m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so " - "strokes on the model mark seams instead of painting - turn " - "it off in the pane to paint.") : - _u8L("Nothing is painted yet.")) : - pro_mode() ? - _u8L("Turn the painted height maps into real geometry, by moving the vertices that are " - "already there. Use Subdivide first if the mesh is too coarse to show the detail.") : - _u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even " - "density and refined where the texture bends first, so the detail has vertices to " - "land on - all in one step."), - wrap_w); + panel_tooltip(mv != nullptr && !mv->is_texture_displacement_painted() ? + (m_seam_edit_mode ? _u8L("Nothing is painted yet. The UV editor's seam tool is on, so " + "strokes on the model mark seams instead of painting - turn " + "it off in the pane to paint.") : + _u8L("Nothing is painted yet.")) : + pro_mode() ? + _u8L("Turn the painted height maps into real geometry, by moving the vertices that are " + "already there. Use Subdivide first if the mesh is too coarse to show the detail.") : + _u8L("Turn the painted height maps into real geometry. The mesh is remeshed to an even " + "density and refined where the texture bends first, so the detail has vertices to " + "land on - all in one step.")); ImGui::SameLine(); if (busy) { diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 232f1c1729..bd6ec6f345 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -4,6 +4,7 @@ #include "GLGizmoPainterBase.hpp" #include "libslic3r/TextureBake/TextureBakeDebug.hpp" #include "libslic3r/TextureDisplacement.hpp" +#include "slic3r/GUI/AnimatedTexture.hpp" #include "slic3r/GUI/GLModel.hpp" #include "slic3r/GUI/GLTexture.hpp" #include "slic3r/GUI/I18N.hpp" @@ -1155,6 +1156,50 @@ private: std::map m_panel_icon_map; // file name -> [normal, colour, disabled] bool m_panel_icons_tried = false; void ensure_panel_icons(); + + // One illustration under a tooltip's text: a still image or an animation (see AnimatedTexture), + // named by file within resources/images/texture_displacement, with a caption under it. Several of + // them in one tooltip are laid out side by side, which is how a setting is shown as a comparison - + // the same model at two values of it. + struct TipMedia + { + std::string file; + wxString caption; + }; + + // Every tooltip the panel shows. Placed beside the panel rather than under the mouse: the panel is + // pinned to the right edge of the canvas, so a tooltip left to follow the mouse covers the control + // it is explaining, and one carrying an illustration covers most of the panel with it. + // + // `media` is drawn under the text, at the tooltip's full width; files that are missing or will not + // decode are skipped, so a tooltip whose illustration has not been drawn yet is exactly the text + // tooltip it was before. + void panel_tooltip(const std::string &text, const std::vector &media = {}); + void panel_tooltip(const wxString &text, const std::vector &media = {}); + void render_tip_media(const std::vector &media, float width); + + // Screen rectangle a tooltip places itself beside: the panel window, or - while the texture picker + // is up and tooltips are coming from inside it - the picker popup, which already sits beside the + // panel. Both are set from the window's own rectangle as it is drawn. + ImVec2 m_tooltip_anchor_min{0.f, 0.f}; + ImVec2 m_tooltip_anchor_max{0.f, 0.f}; + // Illustrations any tooltip has shown, decoded and uploaded once (see AnimatedTexture.hpp). + AnimatedTextureCache m_tip_media; + // Which tooltip's illustrations are on screen, and when they first appeared: an animation plays + // from its first frame each time its tooltip opens, rather than from wherever a free-running clock + // happened to be. + std::string m_tip_media_key; + double m_tip_media_start = 0.; + int m_tip_media_frame = -2; // the frame it was last drawn on, to spot the tooltip reopening + // How long the pointer has rested on the control a tooltip belongs to, and when one was last on + // screen, both on the real clock tip_now() keeps (see the .cpp for why ImGui's own will not do). + ImVec2 m_tip_item{-1.f, -1.f}; + double m_tip_hover_since = 0.; + double m_tip_last_shown = -1e9; + // The frame a tooltip was last drawn on, and how many it has been drawn on in a row: ImGui hides + // one on its first frame while it measures it, so it has to be kept on screen to be seen at all. + int m_tip_shown_frame = -2; + int m_tip_settled = 0; }; } // namespace Slic3r::GUI