From 013ac898ba6634c913ba0f191013235589f839ff Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 9 Sep 2026 15:25:00 +0200 Subject: [PATCH] Improve texture displacement smoothing and UV editor framing --- src/libslic3r/TextureDisplacement.cpp | 157 +++++++++++++++--- src/libslic3r/TextureDisplacement.hpp | 18 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 89 +++++++++- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 11 ++ .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 3 +- .../GUI/Jobs/TextureDisplacementBakeJob.hpp | 3 + .../Jobs/TextureDisplacementPreviewJob.cpp | 2 +- .../Jobs/TextureDisplacementPreviewJob.hpp | 3 + src/slic3r/GUI/UVEditorCanvas.cpp | 85 +++++++--- src/slic3r/GUI/UVEditorCanvas.hpp | 6 +- tests/libslic3r/test_texture_displacement.cpp | 120 ++++++++++++- 11 files changed, 445 insertions(+), 52 deletions(-) diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 4fcf00b462..df87969581 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -123,10 +123,10 @@ DecodedTextureCache g_decoded_texture_cache; } // namespace namespace { -// A few passes of a separable box blur approximate a Gaussian, cheaply. `radius` is in pixels; 0 is -// a no-op. Wraps at the edges so a tiling height map stays seamless after smoothing. Operates on the -// grayscale byte buffer in place. -void smooth_height_pixels(std::vector &pixels, int width, int height, int radius) +// A few passes of a separable box blur approximate a Gaussian, cheaply. `radius` is in whole texels; +// 0 is a no-op. Wraps at the edges so a tiling height map stays seamless after smoothing. Operates on +// the grayscale byte buffer in place. +void smooth_height_pixels_box(std::vector &pixels, int width, int height, int radius) { if (radius <= 0 || width <= 0 || height <= 0 || pixels.size() != size_t(width) * size_t(height)) return; @@ -162,6 +162,28 @@ void smooth_height_pixels(std::vector &pixels, int width, int height, i } } } + +// The same blur with a *continuous* radius, which is what the Smoothing slider drives. +// +// A box blur can only work in whole texels, so mapping the slider straight onto a rounded radius +// made it move in visible jumps - and its very first step off zero was a full one-texel blur rather +// than a hint of one, which is what made the control feel like it switched on rather than ramped up. +// Blur at the next whole texel up and cross-fade the raw image back in by the fraction left over: +// below one texel that fade *is* the sub-texel kernel, and above it it turns each integer step into +// a continuous ramp. +void smooth_height_pixels(std::vector &pixels, int width, int height, float radius) +{ + if (radius <= 0.f || width <= 0 || height <= 0 || pixels.size() != size_t(width) * size_t(height)) + return; + + const int whole = std::max(1, int(std::ceil(radius))); + const float mix = std::clamp(radius / float(whole), 0.f, 1.f); + const std::vector raw = (mix < 0.999f) ? pixels : std::vector{}; + smooth_height_pixels_box(pixels, width, height, whole); + if (!raw.empty()) + for (size_t i = 0; i < pixels.size(); ++i) + pixels[i] = uint8_t(std::lround(float(raw[i]) + (float(pixels[i]) - float(raw[i])) * mix)); +} } // namespace DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer) @@ -231,11 +253,14 @@ DecodedHeightTexture decode_height_texture(const TextureDisplacementLayer &layer entries[key] = { std::weak_ptr>(layer.image_data), result }; } - // Smoothing radius scales with the texture so the effect is resolution-independent; capped so the - // blur stays affordable on large maps. + // Smoothing radius scales with the texture so the same slider value blurs the same *fraction* of + // the image whatever resolution it came in at, and stays continuous in the slider - see + // smooth_height_pixels(). The cap is a cost limit, not part of the mapping: the blur is + // O(width * height * radius) per pass, so a large map with the slider at the top would otherwise + // stall every preview rebuild. if (layer.smoothing > 0.f) { - const int max_radius = std::clamp(int(std::lround(0.02f * std::min(result.width, result.height))), 1, 32); - const int radius = std::max(1, int(std::lround(layer.smoothing * float(max_radius)))); + const float span = 0.05f * float(std::min(result.width, result.height)); + const float radius = std::clamp(layer.smoothing, 0.f, 1.f) * std::min(span, 48.f); smooth_height_pixels(result.pixels, result.width, result.height, radius); // Colour gets the same blur, per channel. It is the same knob for the same reason: detail in // the image finer than the mesh can carry is noise either way, and low-passing it here is the @@ -1372,7 +1397,8 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set const std::vector &layers, const TextureDisplacementFacetsData &facets_data, const TextureDisplacementOptions &options, - const DisplacementProgressFn &progress) + const DisplacementProgressFn &progress, + bool flip_normals) { HeightFieldSampler combined = make_combined_displacement_sampler(mesh, layers, facets_data); if (!combined) @@ -1424,11 +1450,21 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set return combined(pos, smooth_normal); }; + // The pipeline takes its displacement direction from the soup's winding, so a mirrored placement + // would drive the whole relief inwards. The paint masks were read off `mesh` above, against its + // own vertex order, so the winding can only be turned round after that - here, on the copy that + // becomes the soup - and has to be turned back on the way out, since the caller undoes the same + // mirror when it maps the result back into the volume's coordinates. + indexed_triangle_set oriented = mesh; + if (flip_normals) + for (stl_triangle_vertex_indices &t : oriented.indices) + std::swap(t[1], t[2]); + // 0 means no simplification, i.e. Bake mode. const TextureBake::PipelineMode mode = settings.max_triangles > 0 ? TextureBake::PipelineMode::Export : TextureBake::PipelineMode::Bake; TextureBake::PipelineResult result = TextureBake::run_pipeline( - TextureBake::to_soup(mesh, excluded), sample, settings, bounds, mode, excluded, + TextureBake::to_soup(oriented, excluded), sample, settings, bounds, mode, excluded, [&progress](const char *, double f) { return !progress || progress(std::clamp(int(f * 100.0), 0, 99)); }); @@ -1436,17 +1472,29 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set return {}; indexed_triangle_set out = TextureBake::to_indexed_triangle_set(result.geometry); - return out.indices.empty() ? mesh : out; + if (out.indices.empty()) + return mesh; + if (flip_normals) + for (stl_triangle_vertex_indices &t : out.indices) + std::swap(t[1], t[2]); + return out; } } // namespace -indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, - const std::vector &layers, - const TextureDisplacementFacetsData &facets_data, - const TextureDisplacementOptions &options, - const DisplacementProgressFn &progress, - const TextureColorRequest *color) +// The bake proper. Runs entirely in whatever space `base_mesh` is given in; the public entry point +// below is what puts it in world space and brings the result back. +// `flip_normals` says the mesh is wound the opposite way round from its outward direction, which is +// what a mirroring world transform leaves behind: the positions are right, but every normal derived +// from the winding points into the model. See build_texture_displacement(). +static indexed_triangle_set build_texture_displacement_in_place( + const indexed_triangle_set &base_mesh, + const std::vector &layers, + const TextureDisplacementFacetsData &facets_data, + const TextureDisplacementOptions &options, + const DisplacementProgressFn &progress, + const TextureColorRequest *color, + bool flip_normals) { // Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the // one result no caller can mistake for a finished bake and commit onto the volume. @@ -1465,7 +1513,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set return mesh; if (options.pipeline_v2) - return build_texture_displacement_v2(mesh, layers, facets_data, options, progress); + return build_texture_displacement_v2(mesh, layers, facets_data, options, progress, flip_normals); // Layers are combined in slot order, like stacked layers in an image editor: each one folds its // own displacement into the running total via its blend mode (see TextureBlendMode). @@ -1518,6 +1566,14 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set } } + // Both normal passes above read their direction out of the triangle winding, so a mirrored + // placement leaves every one of them pointing into the model - the relief would be carved rather + // than raised. Correct them once, here, where every later stage (displacement direction, the + // planar/triplanar projection axes, the cylinder axis) picks them up already right. + if (flip_normals) + for (Vec3f &n : vertex_normals) + n = -n; + if (!report(5)) return {}; @@ -1782,6 +1838,11 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set if (!any_displacement) return mesh; + // The model's own resting plane, taken before anything moves - see the clamp after smoothing. + float resting_z = std::numeric_limits::max(); + for (const Vec3f &v : mesh.vertices) + resting_z = std::min(resting_z, v.z()); + for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) if (displaced[vi]) mesh.vertices[vi] += vertex_normals[vi] * displacement[vi]; @@ -1807,6 +1868,18 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set }) : DisplacementProgressFn{}); } + // Nothing driven below the model's own resting plane can be printed: it is either through the + // build plate or, once the slicer drops the part back down onto it, holding the whole model up in + // the air. Push it back up to the plane. Only vertices the displacement actually moved are + // eligible - untouched geometry is already exactly where it started - and only those that ended + // up below it, so downward relief that stays clear of the plate is left alone. Runs in world + // space (see build_texture_displacement()), so this really is the plate and not some scaled + // stand-in for it. + if (resting_z < std::numeric_limits::max()) + for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) + if (displaced[vi] && mesh.vertices[vi].z() < resting_z) + mesh.vertices[vi].z() = resting_z; + if (!report(99)) return {}; if (want_color) { @@ -1835,6 +1908,51 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set return mesh; } +indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, + const std::vector &layers, + const TextureDisplacementFacetsData &facets_data, + const TextureDisplacementOptions &options, + const DisplacementProgressFn &progress, + const TextureColorRequest *color, + const Transform3d &volume_to_world) +{ + // An untransformed volume on an untransformed instance is by far the common case, and the round + // trip costs two matrix multiplies per vertex on a mesh that can carry millions of them - so take + // the identity out of the way rather than paying for it. + if (volume_to_world.matrix().isApprox(Transform3d::Identity().matrix())) + return build_texture_displacement_in_place(base_mesh, layers, facets_data, options, progress, color, false); + + const Transform3d to_local = volume_to_world.inverse(); + // A mirroring placement leaves the positions correct but every winding-derived normal pointing + // the wrong way. The winding itself is deliberately *not* touched here: the paint masks encode + // each split triangle against its own vertex order, so reordering a triangle's vertices would + // mirror the paint inside it. The bake is told instead, and negates the normals it computes. + const bool mirrored = volume_to_world.linear().determinant() < 0.0; + + indexed_triangle_set world = base_mesh; + for (Vec3f &v : world.vertices) + v = (volume_to_world * v.cast()).cast(); + + indexed_triangle_set out = + build_texture_displacement_in_place(world, layers, facets_data, options, progress, color, mirrored); + // A cancelled run returns {} and must stay {} - an empty mesh is the signal the caller checks + // before committing anything onto the volume. + if (out.vertices.empty()) + return out; + + for (Vec3f &v : out.vertices) + v = (to_local * v.cast()).cast(); + return out; +} + +Transform3d texture_displacement_volume_to_world(const ModelVolume &volume) +{ + const ModelObject *object = volume.get_object(); + if (object == nullptr || object->instances.empty() || object->instances.front() == nullptr) + return volume.get_matrix(); + return object->instances.front()->get_matrix() * volume.get_matrix(); +} + indexed_triangle_set build_texture_displacement(const ModelVolume &volume) { TextureDisplacementFacetsData facets_data; @@ -1842,7 +1960,8 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume) facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data, - volume.texture_displacement_options); + volume.texture_displacement_options, {}, nullptr, + texture_displacement_volume_to_world(volume)); } void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index c187c3be48..f9eabb4d82 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -709,12 +709,28 @@ struct TextureColorRequest // straight to a TriangleSelector without a second mapping table. std::vector *out_triangle = nullptr; }; +// Where the volume sits on the plate: its instance transform times its own volume transform, i.e. +// mesh coordinates -> world millimetres. +// +// Every number the user sets is in real millimetres on the printed part - "Depth (mm)", "Tile size +// (mm)" - and the build plate is a world plane, so the bake runs in world space and transforms the +// result back. Doing it in the volume's own coordinates instead made a scaled instance stretch both +// the relief depth and the tiling by the scale factor, and under a non-uniform scale it also +// displaced along the wrong direction: a mesh normal maps to the world normal through the inverse +// transpose, not through the transform itself, so the relief leaned. Identity - the default - is +// exactly the old behaviour and is what an untransformed volume gives. indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, const TextureDisplacementOptions &options = {}, const DisplacementProgressFn &progress = {}, - const TextureColorRequest *color = nullptr); + const TextureColorRequest *color = nullptr, + const Transform3d &volume_to_world = Transform3d::Identity()); + +// `volume`'s mesh coordinates -> world millimetres: its first instance's transform times its own. +// The mesh is shared by every instance, so a multi-instance object can only be baked for one of +// them; the first is what the gizmo edits against. Identity when the volume has no object yet. +Transform3d texture_displacement_volume_to_world(const ModelVolume &volume); // Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from // `volume` and forwards to the overload above. diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 8de71180f0..d31e9a8225 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -959,10 +959,30 @@ float GLGizmoTextureDisplacement::layer_texture_aspect(const TextureDisplacement return (tex.width > 0 && tex.height > 0) ? float(tex.width) / float(tex.height) : 1.f; } -std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &patch, +indexed_triangle_set GLGizmoTextureDisplacement::patch_in_world(const indexed_triangle_set &patch) const +{ + const ModelVolume *mv = texture_volume(); + if (mv == nullptr) + return patch; + const Transform3d to_world = texture_displacement_volume_to_world(*mv); + if (to_world.matrix().isApprox(Transform3d::Identity().matrix())) + return patch; + + indexed_triangle_set world = patch; + for (Vec3f &v : world.vertices) + v = (to_world * v.cast()).cast(); + return world; +} + +std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &local_patch, const TextureDisplacementLayer &layer) const { - const float aspect = layer_texture_aspect(layer); + // The bake maps the texture in world millimetres, so "Tile size (mm)" means the same thing on a + // scaled instance as it does on an untouched one (see build_texture_displacement()). Everything + // that has to agree with the bake - the fast preview's uvs, the checker/distortion overlays - + // therefore has to project from the same world positions, not from the volume's own. + const indexed_triangle_set patch = patch_in_world(local_patch); + const float aspect = layer_texture_aspect(layer); if (layer.projection_method == TextureProjectionMethod::LSCM) { // compute_lscm_uvs() returns the unwrap's own (raw, mm) coordinates with the island placement // folded in - it does *not* apply the layer's tiling/rotation/offset. The bake applies those @@ -1654,6 +1674,7 @@ void GLGizmoTextureDisplacement::queue_preview_job() input.base_mesh = mv->mesh().its; input.layers = mv->texture_displacement_layers; input.options = mv->texture_displacement_options; + input.volume_to_world = texture_displacement_volume_to_world(*mv); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); // Captured here rather than read in the handler: get_extruders_colors() is main-thread state and @@ -1774,7 +1795,9 @@ void GLGizmoTextureDisplacement::update_uv_editor() bool unwrap_changed = false; if (m_uv_unwrap_pending) { m_uv_unwrap_pending = false; - const indexed_triangle_set patch = extract_painted_patch(mv->mesh().its, state.facets); + // World millimetres, the space the bake unwraps in - otherwise the pane would lay the islands + // out at the volume's own scale and show the texture at a different size than it bakes at. + const indexed_triangle_set patch = patch_in_world(extract_painted_patch(mv->mesh().its, state.facets)); if (patch.indices.empty()) { m_uv_editor_state = UVEditorState{}; m_uv_editor_unwrap = PatchUnwrap{}; @@ -4418,8 +4441,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float if (icon_toggle(704, "texture_displacement_distortion.svg", cur_mode == 3, _L("Distortion"), _L("Distortion - blue-to-red stretch heatmap over the unwrap (needs the Unwrap/LSCM projection)"))) new_mode = 3; ImGui::SameLine(); - ImGui::Dummy(ImVec2(m_imgui->scaled(0.6f), 0.f)); - ImGui::SameLine(); if (icon_toggle(705, "texture_displacement_wireframe.svg", m_wireframe_overlay, _L("Wireframe"), _L("Wireframe - overlay the mesh edges; independent of the view above"))) wf_toggle = true; @@ -4497,8 +4518,61 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float // worth of controls the panel otherwise runs off the bottom of the screen, and there was // nothing to tell "settings that belong to this layer" apart from "settings that belong to // the tool". + // ImGui's stock scrollbar is a wide, square-cornered slab in a tinted track - against this + // flat panel it reads as a raw widget bolted onto the edge. Slim it to a rounded thumb over + // an invisible track. The narrower bar also gives back content width: a scrollbar eats it + // from the child's content region, which is what was clipping "Tile size (mm)" mid-word. + const float scrollbar_w = m_imgui->scaled(0.5f); + const ImVec4 grab = wxGetApp().dark_mode() ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_w); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * scrollbar_w); + // The panel-wide 20 px window padding is meant for the panel; inside a region that is itself + // already inset and tinted it is just a second margin, and it was spending on empty gutters + // the width the layer rows needed. Vertical padding is left alone - it separates the first + // layer's header from the region's top edge. + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(m_imgui->scaled(0.5f), ImGui::GetStyle().WindowPadding.y)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f)); ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(1.f, 1.f, 1.f, 0.04f)); - ImGui::BeginChild("##texture_layers", ImVec2(0.f, m_imgui->scaled(20.f)), true); + // NoScrollWithMouse: the wheel is handled below so the scroll can be eased instead of + // teleporting five text lines per notch, which on blocks this tall lost the reader's place. + ImGui::BeginChild("##texture_layers", ImVec2(0.f, m_imgui->scaled(20.f)), true, + ImGuiWindowFlags_NoScrollWithMouse); + { + ImGuiIO &io = ImGui::GetIO(); + const float scroll_now = ImGui::GetScrollY(); + const float scroll_max = ImGui::GetScrollMaxY(); + + // Anything that moved the scroll without us - dragging the grab, a keyboard/gamepad nav + // step, the content shrinking under a clamped offset - has to re-seed the target, or the + // easing below would immediately drag the view back to where it last animated to. + if (m_layer_scroll_applied < 0.f || std::abs(scroll_now - m_layer_scroll_applied) > 0.5f) + m_layer_scroll_target = scroll_now; + + if (io.MouseWheel != 0.f && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) + m_layer_scroll_target -= io.MouseWheel * ImGui::GetFontSize() * 4.f; + // Whole pixels: ImGui floors whatever SetScrollY() is given, so a fractional target could + // never be reached and the "still gliding" test below would stay true forever, repainting + // the canvas for good. + m_layer_scroll_target = std::floor(std::clamp(m_layer_scroll_target, 0.f, scroll_max)); + + const float delta = m_layer_scroll_target - scroll_now; + if (std::abs(delta) >= 1.f) { + // Exponential ease, formulated against the frame time so the glide takes the same wall + // time whether the canvas is running at 30 or 144 fps. The last sub-pixel step would be + // floored away, so land on the target outright once the remainder is that small. + const float t = 1.f - std::exp(-20.f * std::clamp(io.DeltaTime, 1.f / 240.f, 1.f / 15.f)); + const float step = (std::abs(delta * t) < 1.f) ? delta : delta * t; + const float next = std::floor(scroll_now + step); + ImGui::SetScrollY(next); + m_layer_scroll_applied = next; + m_parent.set_as_dirty(); // nothing else would redraw mid-glide once the mouse stops + } else { + m_layer_scroll_applied = scroll_now; + } + } for (size_t li = 0; li < ordered.size(); ++li) { TextureDisplacementLayer *layer = ordered[li]; @@ -5017,7 +5091,8 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } ImGui::EndChild(); - ImGui::PopStyleColor(); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(3); if (slot_to_remove >= 0) remove_texture_layer(slot_to_remove); // deferred: see slot_to_remove's declaration diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 05d2fa20eb..1b9d70c4f5 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -621,6 +621,10 @@ private: // the shader projects on its own. Shared by the bump preview and the UV-check overlay. std::vector compute_layer_vertex_uvs(const indexed_triangle_set &patch, const TextureDisplacementLayer &layer) const; + // `patch` with its vertices moved into world millimetres - the space the bake maps the texture in + // (see build_texture_displacement()). Returned by value because the caller usually still needs the + // original: the patch doubles as render geometry, which is drawn through the volume's own matrix. + indexed_triangle_set patch_in_world(const indexed_triangle_set &patch) const; // UV-check overlay drawn over the painted patch to sanity-check the unwrap (#13/#14). Built by // rebuild_uvcheck_mesh(), drawn by render_uvcheck_mesh() with the "texture_displacement_uvcheck" @@ -751,6 +755,13 @@ private: // open/close within a session, so the choice sticks while working. bool m_undocked = false; + // Smooth scrolling for the layer-stack child region. ImGui jumps a fixed number of lines per + // wheel notch, which on a list of tall per-layer blocks reads as a hard jolt rather than a + // scroll. The wheel is intercepted (ImGuiWindowFlags_NoScrollWithMouse) and moves a *target* + // offset instead; the real scroll is eased toward it over the following frames. + float m_layer_scroll_target = 0.f; + float m_layer_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one + // See the "Adjust Texture" block of private methods above. bool m_adjust_texture_mode = false; bool m_adjust_anchor_valid = false; diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index eac60eb392..05df414ca5 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -61,7 +61,7 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) } return true; }, - color)); + color, m_input.volume_to_world)); // Always finish at 100: this is what closes the notification. Reported even on cancel, where // build_texture_displacement() returns an empty mesh and finalize() commits nothing. @@ -152,6 +152,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, const TextureCol input.base_mesh = volume.mesh().its; input.layers = volume.texture_displacement_layers; input.options = volume.texture_displacement_options; + input.volume_to_world = texture_displacement_volume_to_world(volume); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp index cd3772ae47..753dd20742 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp @@ -23,6 +23,9 @@ struct TextureDisplacementBakeInput std::vector layers; TextureDisplacementFacetsData facets_data; TextureDisplacementOptions options; + // Mesh coordinates -> world millimetres. Captured here with everything else so the worker never + // reaches back into the live Model for it. See build_texture_displacement(). + Transform3d volume_to_world = Transform3d::Identity(); // Captured on the main thread. Empty unless some layer is colouring, in which case the bake also // writes the volume's mmu_segmentation_facets - the same per-triangle filament assignment the MMU // paint gizmo writes - alongside the displaced geometry. diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index adde26f884..013ea79750 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -43,7 +43,7 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) (!m_current_generation || m_current_generation->load() == m_generation); }, - color); + color, m_input.volume_to_world); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index 2a67b8c7cc..312cf6593e 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -23,6 +23,9 @@ struct TextureDisplacementPreviewInput std::vector layers; TextureDisplacementFacetsData facets_data; TextureDisplacementOptions options; + // Mesh coordinates -> world millimetres, so the preview is displaced in the same space the bake + // is and the two cannot disagree. See build_texture_displacement(). + Transform3d volume_to_world = Transform3d::Identity(); // Empty unless a layer is colouring, in which case the preview reports the filament per triangle // alongside the mesh, so the Normal view shows what the bake will produce - interleaving included. TextureColorSettings color; diff --git a/src/slic3r/GUI/UVEditorCanvas.cpp b/src/slic3r/GUI/UVEditorCanvas.cpp index 25d737f2bb..7ab847023d 100644 --- a/src/slic3r/GUI/UVEditorCanvas.cpp +++ b/src/slic3r/GUI/UVEditorCanvas.cpp @@ -39,6 +39,25 @@ const ColorRGBA UV_COLOR_DIAL = { 1.f, 0.85f, 0.2f, 0.9f }; // rota constexpr float SNAP_PIXELS = 28.f; // how close a boundary vertex has to come before it sticks (#2) +// wxWidgets reports this canvas' size in *logical* points, while the GL drawable behind it is sized +// in device pixels. On the backends where those differ under HiDPI (the same pair GLCanvas3D guards +// its RetinaHelper with) a viewport built straight from GetSize() covers only the bottom-left +// 1/scale of the drawable, which is exactly where the whole editor ended up drawn, shrunken. +// Mouse coordinates arrive in logical points, so only the viewport needs converting - every other +// GetSize() use here is compared against event coordinates and must stay logical. +wxSize gl_drawable_size(const wxWindow *win, const wxSize &logical_size) +{ +#if defined(__APPLE__) || defined(__WXGTK3__) + const double scale = (win != nullptr) ? win->GetContentScaleFactor() : 1.0; + if (scale > 0.0) + return wxSize(std::max(1, int(std::lround(logical_size.GetWidth() * scale))), + std::max(1, int(std::lround(logical_size.GetHeight() * scale)))); +#else + (void) win; +#endif + return wxSize(std::max(1, logical_size.GetWidth()), std::max(1, logical_size.GetHeight())); +} + // The pixel format this canvas is created with has to match the one the app's single shared // wxGLContext was created against (that of View3D's canvas, from OpenGLManager::create_wxglcanvas()), // so this mirrors that attribute list *including its multisampling*: WGL requires the HDC passed to @@ -333,13 +352,37 @@ void UVEditorCanvas::content_bounds(Vec2f &min_uv, Vec2f &max_uv) const } } +void UVEditorCanvas::framed_bounds(Vec2f &min_uv, Vec2f &max_uv) const +{ + content_bounds(min_uv, max_uv); + // With tiling on, the backdrop is snapped out to whole tiles, so it is bigger than the raw + // bounds - and by a different amount on each side. Framing the raw bounds therefore left that + // backdrop visibly off-centre: hanging past one edge of the pane with dead space against the + // other. Frame what is drawn instead. (Tiling off draws only the first tile, which the bounds + // already contain, so there is nothing to snap.) + if (m_tile_enabled) { + min_uv = Vec2f(std::floor(min_uv.x()), std::floor(min_uv.y())); + max_uv = Vec2f(std::ceil(max_uv.x()), std::ceil(max_uv.y())); + } +} + void UVEditorCanvas::fit_view_to_content() { Vec2f min_uv, max_uv; - content_bounds(min_uv, max_uv); + framed_bounds(min_uv, max_uv); - m_pan = 0.5f * (min_uv + max_uv); - m_zoom = std::max(0.5f * (max_uv - min_uv).maxCoeff() * 1.1f, 0.05f); + const wxSize size = GetSize(); + const float aspect = float(std::max(1, size.GetWidth())) / float(std::max(1, size.GetHeight())); + const Vec2f half = 0.5f * (max_uv - min_uv); + + m_pan = 0.5f * (min_uv + max_uv); + // m_zoom is the half-extent shown across the *shorter* pane edge (see view_half_extents()), so + // each axis' required half-extent has to be converted back into that unit before the larger of + // the two is taken. Sizing off the bigger axis alone, as this did, ignores the pane's shape and + // zooms out further than either axis needs on anything but a square pane. The 1.1 leaves a + // margin so the outermost island edge is not flush against the frame. + m_zoom = std::max(1.1f * std::max(half.x() / std::max(aspect, 1.f), half.y() * std::min(aspect, 1.f)), + 0.05f); m_needs_fit = false; } @@ -1073,12 +1116,11 @@ void UVEditorCanvas::rebuild_background_quad() return; Vec2f lo, hi; - content_bounds(lo, hi); if (m_tile_enabled) { - // Snap out to whole tiles. Only cosmetic, but it keeps the backdrop's edge on a tile - // boundary instead of slicing a brick in half at an arbitrary place. - lo = Vec2f(std::floor(lo.x()), std::floor(lo.y())); - hi = Vec2f(std::ceil(hi.x()), std::ceil(hi.y())); + // Whole tiles, so the backdrop's edge lands on a tile boundary instead of slicing a brick in + // half. framed_bounds() applies exactly this, and the view is framed on its result - the two + // must not drift apart or the backdrop stops being centred in the pane. + framed_bounds(lo, hi); } else { // Tiling off: the sampler reads 0 outside the first tile and nothing else exists, so the // backdrop is exactly that one tile (GL_CLAMP_TO_BORDER in render() gives it the same @@ -1165,8 +1207,13 @@ void UVEditorCanvas::render() rebuild_grid(); rebuild_rotation_dial(); - const wxSize size = GetSize(); - glsafe(::glViewport(0, 0, size.GetWidth(), size.GetHeight())); + const wxSize size = GetSize(); // logical points; the on-screen handle sizes below use it + const wxSize viewport = gl_drawable_size(this, size); + glsafe(::glViewport(0, 0, viewport.GetWidth(), viewport.GetHeight())); + // Line widths below are authored in logical points (they are chosen against the same scale the + // hit-test thresholds use), so they take the same logical -> device conversion as the viewport. + const float px_scale = float(viewport.GetWidth()) / float(std::max(1, size.GetWidth())); + const auto line_width = [px_scale](float w) { set_line_width(w * px_scale); }; glsafe(::glClearColor(UV_COLOR_BG.r(), UV_COLOR_BG.g(), UV_COLOR_BG.b(), 1.f)); glsafe(::glClear(GL_COLOR_BUFFER_BIT)); glsafe(::glDisable(GL_DEPTH_TEST)); @@ -1224,7 +1271,7 @@ void UVEditorCanvas::render() } } - set_line_width(1.f); + line_width(1.f); draw(m_grid_glmodel, UV_COLOR_GRID, identity); // The texture's first tile. Always drawn, even with nothing painted, so the pane always has a @@ -1244,7 +1291,7 @@ void UVEditorCanvas::render() tile.add_line(i, (i + 1) % 4); m_tile_outline_glmodel.init_from(std::move(tile)); } - set_line_width(2.f); + line_width(2.f); draw(m_tile_outline_glmodel, UV_COLOR_TILE_OUTLINE, identity); const auto island_matrix = [this, &identity](int c) { @@ -1262,7 +1309,7 @@ void UVEditorCanvas::render() draw(m_island_fill[size_t(c)], is_selected(c) ? UV_COLOR_SEL_FILL : fill, island_matrix(c)); } - set_line_width(1.f); + line_width(1.f); for (int c = 0; c < int(m_island_wireframe.size()); ++c) draw(m_island_wireframe[size_t(c)], is_selected(c) ? UV_COLOR_SEL_WIRE : UV_COLOR_WIRE, island_matrix(c)); @@ -1271,16 +1318,16 @@ void UVEditorCanvas::render() // pane exists to answer. The selected one gets a bold edge (#7). for (int c = 0; c < int(m_island_boundary.size()); ++c) { const bool selected = is_selected(c); - set_line_width(selected ? 3.f : 1.5f); + line_width(selected ? 3.f : 1.5f); draw(m_island_boundary[size_t(c)], selected ? UV_COLOR_SEL_BOUNDARY : UV_COLOR_BOUNDARY, island_matrix(c)); } - set_line_width(1.f); + line_width(1.f); // The rotation protractor, on top of everything while a rotation gesture is live (#11). if (m_dial_glmodel.is_initialized()) { - set_line_width(2.f); + line_width(2.f); draw(m_dial_glmodel, UV_COLOR_DIAL, identity); - set_line_width(1.f); + line_width(1.f); } // Vertex/Edge mode handles: a small square drawn over the picked vertex (or each endpoint of the @@ -1357,10 +1404,10 @@ void UVEditorCanvas::render() m_cursor_sign_glmodel.reset(); if (!sign.is_empty()) m_cursor_sign_glmodel.init_from(std::move(sign)); - set_line_width(3.f); + line_width(3.f); draw(m_cursor_sign_glmodel, removing ? ColorRGBA(1.f, 0.30f, 0.25f, 0.95f) : ColorRGBA(0.35f, 0.90f, 0.45f, 0.95f), identity); - set_line_width(1.f); + line_width(1.f); } // The GL context is shared with the 3D view; leave the bits we touched as we found them. diff --git a/src/slic3r/GUI/UVEditorCanvas.hpp b/src/slic3r/GUI/UVEditorCanvas.hpp index 40397470fd..1fbed837d5 100644 --- a/src/slic3r/GUI/UVEditorCanvas.hpp +++ b/src/slic3r/GUI/UVEditorCanvas.hpp @@ -155,7 +155,11 @@ private: // The UV region worth looking at: every island, plus always at least the texture's first tile, so // there is something sensibly framed even before anything is painted. void content_bounds(Vec2f &min_uv, Vec2f &max_uv) const; - // Frames content_bounds(). Bound to Home, and run once each time an unwrap first appears. + // What is actually *drawn*, which is content_bounds() snapped out to whole tiles whenever the + // backdrop tiles (see rebuild_background_quad()). Both the framing and the backdrop go through + // this so they cannot disagree. + void framed_bounds(Vec2f &min_uv, Vec2f &max_uv) const; + // Frames framed_bounds(). Bound to Home, and run once each time an unwrap first appears. void fit_view_to_content(); // Half-extents of the visible UV region. Split out because both rendering and every mouse diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 6d6fda7ca1..edaa5f9d9f 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -61,6 +61,19 @@ static std::shared_ptr> make_checkerboard_png(size_t return std::make_shared>(std::move(bytes)); } +// The bake never drives relief below the model's own resting plane (see build_texture_displacement()), +// so on a fully painted closed solid the vertices already sitting on that plane - a cube's four bottom +// corners, whose normals point downwards - are clamped in Z and do not move by the full depth. The +// tests below are about the displacement maths, so they check the vertices the clamp cannot touch; +// the clamp itself has its own test. +static bool above_resting_plane(const indexed_triangle_set &mesh, size_t vi) +{ + float bottom = std::numeric_limits::max(); + for (const Vec3f &v : mesh.vertices) + bottom = std::min(bottom, v.z()); + return mesh.vertices[vi].z() > bottom + 1e-4f; +} + TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]") { TextureDisplacementLayer layer; @@ -110,6 +123,8 @@ TEST_CASE("TextureDisplacement: fully painting a mesh displaces every vertex alo REQUIRE(result.vertices.size() == cube.vertices.size()); for (size_t i = 0; i < cube.vertices.size(); ++i) { + if (!above_resting_plane(cube, i)) + continue; const float moved = (result.vertices[i] - cube.vertices[i]).norm(); CHECK_THAT(moved, WithinAbs(layer.depth_mm, 1e-3f)); } @@ -154,7 +169,8 @@ TEST_CASE("TextureDisplacement: a second layer over the same area is applied too REQUIRE(result.vertices.size() == cube.vertices.size()); REQUIRE(result.indices.size() == cube.indices.size()); for (size_t i = 0; i < cube.vertices.size(); ++i) - CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(1.5f, 1e-3f)); // 1.0 + 0.5, not just 1.0 + if (above_resting_plane(cube, i)) + CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(1.5f, 1e-3f)); // 1.0 + 0.5, not just 1.0 } TEST_CASE("TextureDisplacement: blend modes combine a layer with the ones below it", "[TextureDisplacement]") @@ -189,7 +205,8 @@ TEST_CASE("TextureDisplacement: blend modes combine a layer with the ones below REQUIRE(result.vertices.size() == cube.vertices.size()); for (size_t i = 0; i < cube.vertices.size(); ++i) - CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(std::get<1>(expected), 1e-3f)); + if (above_resting_plane(cube, i)) + CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(std::get<1>(expected), 1e-3f)); } TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[TextureDisplacement]") @@ -211,7 +228,104 @@ TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[Text const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets); for (size_t i = 0; i < cube.vertices.size(); ++i) - CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.0f, 1e-3f)); + if (above_resting_plane(cube, i)) + CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.0f, 1e-3f)); +} + +TEST_CASE("TextureDisplacement: relief is never driven below the model's resting plane", "[TextureDisplacement]") +{ + // A fully painted cube displaces outward everywhere, which on the bottom face means straight + // down - through the build plate. That geometry cannot be printed, so it is clamped back up. + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(cube); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 2.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); // full depth everywhere + + float bottom = std::numeric_limits::max(); + for (const Vec3f &v : cube.vertices) + bottom = std::min(bottom, v.z()); + + const indexed_triangle_set result = build_texture_displacement(cube, {layer}, facets); + + REQUIRE(result.vertices.size() == cube.vertices.size()); + for (const Vec3f &v : result.vertices) + CHECK(v.z() >= bottom - 1e-4f); + + // ...and the clamp is confined to Z: a bottom corner still moves outwards in X and Y by the same + // amount it would have, rather than being pinned wholesale. + bool any_bottom_moved_sideways = false; + for (size_t i = 0; i < cube.vertices.size(); ++i) + if (!above_resting_plane(cube, i) && + (result.vertices[i].head<2>() - cube.vertices[i].head<2>()).norm() > 1e-3f) + any_bottom_moved_sideways = true; + CHECK(any_bottom_moved_sideways); +} + +TEST_CASE("TextureDisplacement: depth is measured in world millimetres, not the volume's own", "[TextureDisplacement]") +{ + // The same painted patch, baked once untransformed and once through a 3x scale. "Depth (mm)" is + // a millimetre on the printed part, so the *world* relief must come out the same height either + // way - which means the vertices of the scaled volume move by a third as much in its own + // coordinates. Baking both in volume space instead gave a 3x deeper relief on the scaled one. + indexed_triangle_set fan; + fan.vertices = { {0.f, 0.f, 1.f}, {1.f, 0.f, 1.f}, {0.f, 1.f, 1.f}, {-1.f, 0.f, 1.f}, {0.f, -1.f, 1.f} }; + fan.indices = { {0, 1, 2}, {0, 2, 3}, {0, 3, 4}, {0, 4, 1} }; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(fan); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); + + const indexed_triangle_set plain = build_texture_displacement(fan, {layer}, facets); + Transform3d scale3 = Transform3d::Identity(); + scale3.scale(Vec3d(3.0, 3.0, 3.0)); + const indexed_triangle_set scaled = build_texture_displacement(fan, {layer}, facets, {}, {}, nullptr, scale3); + + REQUIRE(plain.vertices.size() == fan.vertices.size()); + REQUIRE(scaled.vertices.size() == fan.vertices.size()); + for (size_t i = 0; i < fan.vertices.size(); ++i) { + CHECK_THAT(plain.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f, 1e-3f)); + // A third of the movement locally is the same movement once the 3x scale is applied. + CHECK_THAT(scaled.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f / 3.0f, 1e-3f)); + } +} + +TEST_CASE("TextureDisplacement: a mirrored placement still raises the relief outwards", "[TextureDisplacement]") +{ + // Mirroring reverses the winding, and every normal in the bake is derived from the winding - so + // without correcting for it the whole relief is carved into the surface instead of raised off it. + indexed_triangle_set fan; + fan.vertices = { {0.f, 0.f, 1.f}, {1.f, 0.f, 1.f}, {0.f, 1.f, 1.f}, {-1.f, 0.f, 1.f}, {0.f, -1.f, 1.f} }; + fan.indices = { {0, 1, 2}, {0, 2, 3}, {0, 3, 4}, {0, 4, 1} }; + + TextureDisplacementFacetsData facets{}; + facets[0] = paint_whole_mesh(fan); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 1.0f; + layer.tiling_scale = 5.0f; + layer.image_data = make_flat_gray_png(255); + + // Mirrored in X: the patch's outward direction in world space is still +Z, so in the volume's own + // coordinates the vertices must still move +Z. + Transform3d mirror_x = Transform3d::Identity(); + mirror_x.scale(Vec3d(-1.0, 1.0, 1.0)); + const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, {}, {}, nullptr, mirror_x); + + REQUIRE(result.vertices.size() == fan.vertices.size()); + for (size_t i = 0; i < fan.vertices.size(); ++i) + CHECK_THAT(result.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f, 1e-3f)); } TEST_CASE("TextureDisplacement: the patch border is displaced by default and pinned on request", "[TextureDisplacement]")