From 6166bac17a6b7d8040895360ca8ec1a08e4798c2 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 26 Aug 2026 09:07:26 +0200 Subject: [PATCH] Improve adaptive subdivision at border & fix some visual bugs --- .../shaders/110/texture_displacement_bump.fs | 13 +- .../shaders/140/texture_displacement_bump.fs | 13 +- src/libslic3r/MeshBoolean.cpp | 19 + src/libslic3r/TextureDisplacement.cpp | 185 ++++-- src/libslic3r/TextureDisplacement.hpp | 48 +- src/libslic3r/TriangleSelector.cpp | 11 +- src/libslic3r/TriangleSelector.hpp | 9 +- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 590 +++++++++++++++--- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 102 ++- src/slic3r/GUI/Gizmos/GLGizmosManager.cpp | 6 +- .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 28 +- .../Jobs/TextureDisplacementPreviewJob.cpp | 29 +- .../Jobs/TextureDisplacementPreviewJob.hpp | 14 + 13 files changed, 914 insertions(+), 153 deletions(-) diff --git a/resources/shaders/110/texture_displacement_bump.fs b/resources/shaders/110/texture_displacement_bump.fs index d569b42d0c..fe0e32ef96 100644 --- a/resources/shaders/110/texture_displacement_bump.fs +++ b/resources/shaders/110/texture_displacement_bump.fs @@ -30,6 +30,9 @@ uniform sampler2D height_tex; uniform vec2 height_tex_texel; uniform float depth_mm; uniform float tiling_scale; +// Height map width / height. Scales the v axis so a non-square image keeps its proportions +// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform(). +uniform float tex_aspect; uniform float rotation_rad; uniform vec2 uv_offset; uniform bool invert; @@ -70,7 +73,10 @@ vec2 project_uv(vec3 p, vec3 n) planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); - return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset; + vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // After the rotation, so the rotation stays a rotation rather than becoming a shear. + r.y *= tex_aspect; + return r + uv_offset; } void main() @@ -156,7 +162,10 @@ void main() float cs = cos(rotation_rad); float sn = sin(rotation_rad); - vec2 slope = amplitude * vec2(dh_duv.x * cs + dh_duv.y * sn, -dh_duv.x * sn + dh_duv.y * cs); + // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v + // component of the gradient carries the extra factor before being rotated back into t/b. + vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); + vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs); vec3 gradient = slope.x * t + slope.y * b; gradient -= triangle_normal * dot(triangle_normal, gradient); diff --git a/resources/shaders/140/texture_displacement_bump.fs b/resources/shaders/140/texture_displacement_bump.fs index 17b550d5e6..351452f0e9 100644 --- a/resources/shaders/140/texture_displacement_bump.fs +++ b/resources/shaders/140/texture_displacement_bump.fs @@ -89,6 +89,9 @@ uniform sampler2D height_tex; uniform vec2 height_tex_texel; // (1/width, 1/height) of height_tex uniform float depth_mm; uniform float tiling_scale; +// Height map width / height. Scales the v axis so a non-square image keeps its proportions +// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform(). +uniform float tex_aspect; uniform float rotation_rad; uniform vec2 uv_offset; uniform bool invert; @@ -136,7 +139,10 @@ vec2 project_uv(vec3 p, vec3 n) planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0; float cs = cos(rotation_rad); float sn = sin(rotation_rad); - return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset; + vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs); + // After the rotation, so the rotation stays a rotation rather than becoming a shear. + r.y *= tex_aspect; + return r + uv_offset; } void main() @@ -240,7 +246,10 @@ void main() // gradient back into the axes' frame. float cs = cos(rotation_rad); float sn = sin(rotation_rad); - vec2 slope = amplitude * vec2(dh_duv.x * cs + dh_duv.y * sn, -dh_duv.x * sn + dh_duv.y * cs); + // One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v + // component of the gradient carries the extra factor before being rotated back into t/b. + vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect); + vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs); vec3 gradient = slope.x * t + slope.y * b; gradient -= triangle_normal * dot(triangle_normal, gradient); diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 6a143b0a37..bf248e101e 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -356,6 +356,25 @@ std::optional> parameterize_lscm(const indexed_triangle_set & if (border == halfedge_descriptor()) return std::nullopt; // no boundary at all -- a closed patch, which isn't a disk either + // ...and exactly one boundary loop. One connected component is not enough on its own: a patch with + // a hole in it (paint a ring, or erase the middle of a stroke) is a single component with two + // loops, and LSCM will happily "parameterize" it into an overlapping, folded-over chart rather + // than fail. Walk the border halfedges and check every one of them belongs to the longest loop. + { + std::size_t border_halfedges = 0; + for (halfedge_descriptor h : halfedges(cgal_mesh)) + if (is_border(h, cgal_mesh)) + ++border_halfedges; + std::size_t loop_length = 0; + halfedge_descriptor h = border; + do { + ++loop_length; + h = next(h, cgal_mesh); + } while (h != border && loop_length <= border_halfedges); + if (loop_length != border_halfedges) + return std::nullopt; // more than one boundary loop -- not a topological disk + } + using Point_2 = EpicKernel::Point_2; using UV_pmap = _EpicMesh::Property_map; UV_pmap uv_map = cgal_mesh.add_property_map("h:uv", Point_2(0, 0)).first; diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 71a8b8001f..1a044ae956 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -13,6 +13,9 @@ #include #include +#include +#include + #include "MeshBoolean.hpp" #include "Model.hpp" #include "PNGReadWrite.hpp" @@ -897,7 +900,7 @@ std::vector compute_lscm_uvs(const indexed_triangle_set &patch, const Tex return per_vertex; } -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer) +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect) { const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f; const Vec2f scaled = planar * scale; @@ -905,7 +908,20 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la const float rad = layer.rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad); const float sn = std::sin(rad); - const Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); + Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs); + + // Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its + // proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out + // half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is + // `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so + // dividing v by that extent is the same as multiplying it by width / height. A square texture has + // aspect 1 and is untouched, which is why this changes nothing for the shipped library. + // + // Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a + // shear, and doing it the other way round would make "Rotation" skew the pattern instead of + // turning it. + if (aspect > 0.f && aspect != 1.f) + rotated.y() *= aspect; return rotated + layer.offset; } @@ -954,8 +970,12 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp if (texture.empty()) return 0.f; + // width / height of the height map, so a non-square image keeps its proportions (see + // apply_uv_transform()). Every projection except the projective "from view" one funnels through + // here, so this one line is what makes them all aspect-correct. + const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f; auto sample_at = [&](const Vec2f &planar) { - return texture.sample(apply_uv_transform(planar, layer), layer.tile_enabled, layer.tile_method); + return texture.sample(apply_uv_transform(planar, layer, aspect), layer.tile_enabled, layer.tile_method); }; // Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the @@ -1118,8 +1138,13 @@ std::vector patch_boundary_distance(const indexed_triangle_set &patch, co indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, const std::vector &layers, const TextureDisplacementFacetsData &facets_data, - const TextureDisplacementOptions &options) + const TextureDisplacementOptions &options, + const DisplacementProgressFn &progress) { + // 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. + const auto report = [&progress](int percent) { return !progress || progress(percent); }; + indexed_triangle_set mesh = base_mesh; // TriangleSelector's vertex array starts with the mesh's own vertices (any extra ones, created // where a brush stroke split a triangle, are appended after them), and get_facets_strict() @@ -1183,8 +1208,14 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set } } + if (!report(5)) + return {}; + std::vector displacement(mesh.vertices.size(), 0.f); - std::vector displaced(mesh.vertices.size(), false); + // uint8_t rather than std::vector: the sampling loop below writes these from several + // threads at once, and vector's bit packing makes writes to *distinct* elements a data + // race on the shared word. + std::vector displaced(mesh.vertices.size(), 0); // Union, over every layer, of that layer's patch border - the vertices the post-process smoothing // holds when TextureDisplacementOptions::smooth_skip_border is set. A vertex on any patch's edge // counts, which is the conservative choice: hold it rather than let one layer's smoothing melt the @@ -1193,7 +1224,21 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set bool any_displacement = false; const TriangleMesh selector_mesh(mesh); + // One selector for the whole stack, re-deserialized per layer. Its constructor computes + // its_face_neighbors() and its_face_normals() over the *entire* mesh, which on a subdivided model + // is by far the most expensive thing here - building a fresh one per layer paid that cost up to + // eight times over. reset() (what deserialize(..., true) calls) only rebuilds the vertex/triangle + // arrays; the neighbour and face-normal tables are immutable members and survive it. + TriangleSelector selector(selector_mesh); + bool selector_dirty = false; + + const int layer_count = std::max(int(ordered_layers.size()), 1); + int layer_index = 0; for (const TextureDisplacementLayer *layer : ordered_layers) { + // Progress spans 5..65% across the layers; the apply and smoothing passes take it from there. + if (!report(5 + (60 * layer_index++) / layer_count)) + return {}; + const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)]; if (data.triangles_to_split.empty()) continue; @@ -1202,8 +1247,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set if (height.empty()) continue; - TriangleSelector selector(selector_mesh); - selector.deserialize(data, false); + // needs_reset only from the second layer on: the selector is already pristine on the first. + selector.deserialize(data, selector_dirty); + selector_dirty = true; const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER); if (patch.indices.empty()) @@ -1287,8 +1333,13 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const float sign = layer->invert ? -1.f : 1.f; // A vertex may be reached by several of the patch's triangles; each must fold into the // running total exactly once, or a Multiply/Subtract layer would apply two or three times - // over depending on how many painted triangles happen to share the vertex. - std::vector visited(patch.vertices.size(), false); + // over depending on how many painted triangles happen to share the vertex. Collecting the + // unique list up front (cheap, one pass) is also what lets the expensive part - the texture + // sampling, which is three bilinear fetches plus three pow()s per vertex for triplanar - run + // in parallel below, instead of serially inside the triangle walk. + std::vector layer_vertices; + std::vector visited(patch.vertices.size(), 0); + layer_vertices.reserve(patch.vertices.size()); for (const stl_triangle_vertex_indices &tri : patch.indices) for (int i = 0; i < 3; ++i) { const int vi = tri[i]; @@ -1296,28 +1347,53 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // they carry no displacement of their own and are not part of the output mesh. if (vi >= int(mesh.vertices.size()) || (pin_boundary && is_boundary[vi]) || visited[vi]) continue; - visited[vi] = true; + visited[vi] = 1; + layer_vertices.push_back(vi); + } + if (layer_vertices.empty()) + continue; - const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[size_t(vi)]; + std::vector sampled(layer_vertices.size(), 0.f); + tbb::parallel_for(tbb::blocked_range(0, layer_vertices.size()), + [&](const tbb::blocked_range &range) { + for (size_t k = range.begin(); k < range.end(); ++k) { + const size_t vi = size_t(layer_vertices[k]); + const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi]; const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi], patch_centroid, patch_axis, lscm_uv); - // midlevel is the height that means "stay put", so anything below it displaces // *inwards* - see TextureDisplacementLayer::midlevel. At the default of 0 this is // exactly the old outward-only behaviour. - const float edge_w = edge_weight.empty() ? 1.f : edge_weight[size_t(vi)]; - const float signed_height = (h - layer->midlevel) * layer->depth_mm * sign * edge_w; - displacement[size_t(vi)] = blend_displacement(displacement[size_t(vi)], signed_height, - displaced[size_t(vi)] ? layer->blend_mode : TextureBlendMode::Add); - // The first layer to reach a vertex has nothing underneath it to blend with, so it - // always starts the total off additively - a Multiply/Divide against an implicit - // zero base would otherwise annihilate (or blow up) it, which is never what the - // user means by putting a mask on the bottom of the stack. - displaced[size_t(vi)] = true; - any_displacement = true; + sampled[k] = (h - layer->midlevel) * layer->depth_mm * sign; } + }); + + for (size_t k = 0; k < layer_vertices.size(); ++k) { + const size_t vi = size_t(layer_vertices[k]); + // The first layer to reach a vertex has nothing underneath it to blend with, so it + // always starts the total off additively - a Multiply/Divide against an implicit + // zero base would otherwise annihilate (or blow up) it, which is never what the + // user means by putting a mask on the bottom of the stack. + const float accumulated = displacement[vi]; + const float blended = blend_displacement(accumulated, sampled[k], + displaced[vi] ? layer->blend_mode : TextureBlendMode::Add); + // Edge smoothing fades this layer's *effect*, not its input. Scaling the input instead is + // only correct for Add/Subtract, whose neutral value is 0: on a Multiply layer a faded + // input approaches 0, which annihilates everything beneath it at the rim rather than + // leaving it alone, and on a Divide layer it approaches the 0.05 divisor floor, which + // amplifies the relief underneath by up to 20x exactly where it was meant to fade out. + // Interpolating the blended result back toward the accumulated total is the neutral + // element for every mode at once, and reduces to the old formula exactly for Add. + const float edge_w = edge_weight.empty() ? 1.f : edge_weight[vi]; + displacement[vi] = accumulated + (blended - accumulated) * edge_w; + displaced[vi] = 1; + } + any_displacement = true; } + if (!report(65)) + return {}; + if (!any_displacement) return mesh; @@ -1332,12 +1408,22 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // neighbours are those pinned outsiders: relaxing it would drag the rim of the relief back down and // leave the pattern looking half-melted right where it meets the edge. if (options.smooth_enabled && options.smooth_strength > 0.f && options.smooth_iterations > 0) { + if (!report(70)) + return {}; std::vector movable(mesh.vertices.size(), 0); for (size_t vi = 0; vi < mesh.vertices.size(); ++vi) movable[vi] = (displaced[vi] && !(options.smooth_skip_border && on_patch_border[vi])) ? 1 : 0; - smooth_mesh_vertices(mesh, movable, options.smooth_strength, options.smooth_iterations); + // The pass hook only *stops* the relaxation early; the report(99) below is what turns a + // cancellation into an empty (uncommittable) result, since a cancelled run keeps reporting + // cancelled. + smooth_mesh_vertices(mesh, movable, options.smooth_strength, options.smooth_iterations, + progress ? DisplacementProgressFn([&report, it = options.smooth_iterations](int pass) { + return report(70 + (29 * (pass + 1)) / std::max(it, 1)); + }) : DisplacementProgressFn{}); } + if (!report(99)) + return {}; return mesh; } @@ -1352,7 +1438,7 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume) } void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, - int iterations) + int iterations, const DisplacementProgressFn &on_pass) { if (iterations <= 0 || mesh.vertices.empty() || movable.size() != mesh.vertices.size()) return; @@ -1391,15 +1477,21 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector std::vector prev; for (int it = 0; it < iterations; ++it) { prev = mesh.vertices; - for (size_t v = 0; v < nv; ++v) { - if (!movable[v] || start[v] == start[v + 1]) - continue; - Vec3f sum = Vec3f::Zero(); - for (int k = start[v]; k < start[v + 1]; ++k) - sum += prev[size_t(nbr[size_t(k)])]; - const Vec3f avg = sum / float(start[v + 1] - start[v]); - mesh.vertices[v] = prev[v] + (avg - prev[v]) * strength; - } + // Each vertex reads only from `prev` and writes only its own slot, so the sweep parallelises + // with no synchronisation at all. + tbb::parallel_for(tbb::blocked_range(0, nv), [&](const tbb::blocked_range &range) { + for (size_t v = range.begin(); v < range.end(); ++v) { + if (!movable[v] || start[v] == start[v + 1]) + continue; + Vec3f sum = Vec3f::Zero(); + for (int k = start[v]; k < start[v + 1]; ++k) + sum += prev[size_t(nbr[size_t(k)])]; + const Vec3f avg = sum / float(start[v + 1] - start[v]); + mesh.vertices[v] = prev[v] + (avg - prev[v]) * strength; + } + }); + if (on_pass && !on_pass(it)) + return; // cancelled: leave the passes done so far in place, the caller decides what to do } } @@ -1552,7 +1644,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, const std::vector &refine_region, float target_edge_length_mm, int max_triangles, std::vector *out_source, const HeightFieldSampler &sampler, - float chord_tolerance_mm, float min_edge_length_mm) + float chord_tolerance_mm, float min_edge_length_mm, + float border_edge_length_mm) { // Neighbour slots that are not a triangle index. constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone @@ -1590,12 +1683,13 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f; const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f; const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f; + const float border_sq = border_edge_length_mm > 0.f ? border_edge_length_mm * border_edge_length_mm : 0.f; // refine_region is indexed by input-triangle index, and every triangle's src stays in that range // (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it. if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles) return emit(); - if (!feature_mode && target_sq <= 0.f) + if (!feature_mode && target_sq <= 0.f && border_sq <= 0.f) return emit(); // no criterion at all if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; })) return emit(); // nothing flagged: no-op @@ -1720,16 +1814,27 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, // reach. Triangles outside the region always score 0 - they are only ever touched by the conformal // closure below, never refined on their own account. auto priority = [&](int ti) -> float { - const Tri &t = tris[ti]; - if (refine_region[t.src] == 0) + const Tri &t = tris[ti]; + const uint8_t flags = refine_region[t.src]; + if (flags == 0) return 0.f; const int le = longest_local(ti); const float ll = elen_sq(t.v[le], t.v[(le + 1) % 3]); if (ll <= min_floor_sq) return 0.f; // at the resolution floor - also what stops a sharp texture step going forever - float p = (target_sq > 0.f) ? ll / target_sq : 0.f; - if (feature_mode) - p = std::max(p, detail_error(ti) / chord_tolerance_mm); + float p = 0.f; + if (flags & REFINE_PAINTED) { + p = (target_sq > 0.f) ? ll / target_sq : 0.f; + if (feature_mode) + p = std::max(p, detail_error(ti) / chord_tolerance_mm); + } + // The band straddling the paint's edge, refined by plain edge length. Deliberately *not* run + // through detail_error(): outside the paint the sampler still reports full relief (it has no + // per-point paint test), so the chord test there would chase texture detail on a surface the + // bake is going to leave flat. Length alone is what this band needs - the error it is fixing + // is the size of the triangles spanning the displacement step, not the curvature of anything. + if ((flags & REFINE_BORDER) && border_sq > 0.f) + p = std::max(p, ll / border_sq); return p; }; diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 6a824b2054..74c590bbdb 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -22,6 +22,14 @@ namespace Slic3r { class ModelVolume; +// Bits of subdivide_mesh_adaptive()'s per-triangle `refine_region` mask. See that function. +static constexpr uint8_t REFINE_PAINTED = 1; +static constexpr uint8_t REFINE_BORDER = 2; + +// Progress/cancellation hook for the (potentially multi-second) bake. Called with a 0..100 +// percentage; return false to abort. See build_texture_displacement(). +using DisplacementProgressFn = std::function; + // Maximum number of simultaneous texture-displacement layers a single ModelVolume can hold. // Each layer owns its own paint mask (ModelVolume::texture_displacement_facet(slot)), so this // is also the number of independent EnforcerBlockerType selectors kept per volume. @@ -370,7 +378,11 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal); // LSCM's per-patch UV solve through the same scale/rotate/offset controls as every other // projection method, without going through project_texture_displacement_uv()'s own dispatch // (which only knows how to compute the *analytic* methods from a single vertex + normal). -Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer); +// `aspect` is the height map's width / height. It scales the v axis so a non-square image is not +// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is +// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the +// square case and leaves the coordinate exactly as it always was. +Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f); // Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a // local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched - @@ -532,10 +544,18 @@ using TextureDisplacementFacetsData = std::array &layers, const TextureDisplacementFacetsData &facets_data, - const TextureDisplacementOptions &options = {}); + const TextureDisplacementOptions &options = {}, + const DisplacementProgressFn &progress = {}); // Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from // `volume` and forwards to the overload above. @@ -551,8 +571,10 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume); // after displacing it" runs, and it is also safe to run standalone on an already baked mesh. // `strength` is clamped to [0, 1]; 0 iterations, an empty/mis-sized `movable`, or an all-false one // leave the mesh untouched. +// `on_pass`, when set, is called with the 0-based index of each completed pass; returning false stops +// the relaxation there, leaving the passes already done in place. void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, - int iterations); + int iterations, const DisplacementProgressFn &on_pass = {}); // Returns a scalar height (in mm - a displacement magnitude) at a surface point, given that point's // position and interpolated normal. This is what feature-adaptive subdivision samples to decide @@ -638,12 +660,30 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl // the input triangle that output triangle i descends from (children inherit their parent's index), so // a caller can carry per-triangle data - e.g. a paint mask - across the topology change without a // geometric remap. +// +// `refine_region` is a **bitmask** per input triangle, not a plain flag: +// bit 0 (REFINE_PAINTED) - inside the painted area: refine by the length baseline and, in feature +// mode, by the chord-error test. +// bit 1 (REFINE_BORDER) - inside the band straddling the paint's edge: refine by +// `border_edge_length_mm` alone. +// A value of 1 therefore means exactly what a plain 1 always meant, and 0 still means "never touch +// this triangle except through the conformal closure". +// +// The border band exists because the chord-error test is blind to the one discontinuity the bake +// actually creates. `make_combined_displacement_sampler()` evaluates the height field everywhere, +// with no per-point paint test, so where the paint *stops* it keeps reporting full relief - smooth +// and low-curvature - while the baked surface steps from full displacement to zero. The test sees no +// error there and leaves the transition at whatever density the input had, which is what turns the +// rim of an unpainted island into a ring of large, steeply tilted triangles. Refining that band by +// plain edge length is bounded (it is a thin ring, and a length target always terminates) and needs +// no paint-aware sampler. indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, const std::vector &refine_region, float target_edge_length_mm, int max_triangles = 1000000, std::vector *out_source = nullptr, const HeightFieldSampler &sampler = nullptr, - float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f); + float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f, + float border_edge_length_mm = 0.f); } // namespace Slic3r diff --git a/src/libslic3r/TriangleSelector.cpp b/src/libslic3r/TriangleSelector.cpp index 3b032cc57e..64a4c6d724 100644 --- a/src/libslic3r/TriangleSelector.cpp +++ b/src/libslic3r/TriangleSelector.cpp @@ -1519,9 +1519,11 @@ void TriangleSelector::get_facets(std::vector& facets_per_ } } -indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType state) const +indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType state, std::vector *out_source) const { indexed_triangle_set out; + if (out_source) + out_source->clear(); size_t num_vertices = 0; for (const Vertex &v : m_vertices) @@ -1535,8 +1537,13 @@ indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType sta out.vertices.emplace_back(v.v); } - for (int itriangle = 0; itriangle < m_orig_size_indices; ++ itriangle) + for (int itriangle = 0; itriangle < m_orig_size_indices; ++ itriangle) { this->get_facets_strict_recursive(m_triangles[itriangle], m_neighbors[itriangle], state, out.indices); + // Everything the recursion just appended came from this original triangle, whatever depth it + // was split to. Recording it here keeps the recursive helpers untouched. + if (out_source) + out_source->resize(out.indices.size(), itriangle); + } for (auto &triangle : out.indices) for (int i = 0; i < 3; ++ i) diff --git a/src/libslic3r/TriangleSelector.hpp b/src/libslic3r/TriangleSelector.hpp index 11517f5c6c..e11618095a 100644 --- a/src/libslic3r/TriangleSelector.hpp +++ b/src/libslic3r/TriangleSelector.hpp @@ -332,7 +332,14 @@ public: // Get facets at a given state. Don't triangulate T-joints. indexed_triangle_set get_facets(EnforcerBlockerType state) const; // Get facets at a given state. Triangulate T-joints. - indexed_triangle_set get_facets_strict(EnforcerBlockerType state) const; + // Sub-triangles in `state`, with the *whole* mesh's referenced vertex array (only .indices is + // filtered by state, so two calls with different states share one indexing). + // + // `out_source`, when given, is filled parallel to the returned .indices with the index of the + // original mesh triangle each sub-triangle came from. That is what lets a caller carry partial + // paint - the pieces of a triangle a brush stroke only partly covered - across a refinement of + // the same surface, instead of having to round each source triangle to wholly painted or not. + indexed_triangle_set get_facets_strict(EnforcerBlockerType state, std::vector *out_source = nullptr) const; // Get edges around the selected area by seed fill. std::vector get_seed_fill_contour() const; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 579d9a5792..2fbaf7bf53 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -68,13 +68,43 @@ constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic); // frequencies that would alias, and it cuts the VRAM these hold by ~16x as a bonus. constexpr int THUMBNAIL_MAX_PX = 128; -std::unique_ptr upload_height_thumbnail(const DecodedHeightTexture &decoded) +// Everything above is about drawing a ~48 px panel row, and none of it applies to the height texture +// the fast-preview *shader* samples: that one is magnified across the model, not minified into a +// row, and every texel it loses is relief the preview cannot show. It gets its own upload at (up to) +// this size, so the bump preview reads the same height field the bake does instead of a 128 px box +// blur of it - which is what made Fast look flatter and softer than the result it was previewing. +constexpr int HEIGHT_TEX_MAX_PX = 2048; + +// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine +// band (see collect_paint_region()). Two is enough to grade the size change without the band's own +// cost growing to matter: it is a ring around a perimeter, not an area. +constexpr int BORDER_BAND_RINGS = 2; + +// Is `p` inside triangle `t`, given that it already lies in the triangle's plane? Barycentric via the +// three sub-triangle cross products, compared against the whole triangle's normal. Used to carry a +// partly painted source triangle's coverage onto the children of a subdivision, which are coplanar +// with it by construction (subdivision only adds edge midpoints). +bool point_in_triangle_coplanar(const Vec3f &p, const std::array &t) +{ + const Vec3f n = (t[1] - t[0]).cross(t[2] - t[0]); + const float n2 = n.squaredNorm(); + if (n2 < 1e-20f) + return false; // degenerate: it covers no area, so nothing is inside it + // A small negative tolerance, scaled by the triangle, keeps a centroid sitting exactly on a shared + // edge from falling through the gap between two neighbouring pieces. + const float eps = -1e-4f * n2; + return (t[1] - t[0]).cross(p - t[0]).dot(n) >= eps && + (t[2] - t[1]).cross(p - t[1]).dot(n) >= eps && + (t[0] - t[2]).cross(p - t[2]).dot(n) >= eps; +} + +std::unique_ptr upload_height_thumbnail(const DecodedHeightTexture &decoded, int max_px = THUMBNAIL_MAX_PX) { if (decoded.empty()) return nullptr; // Preserve aspect; never upscale a texture that is already small. - const int scale = std::max(1, (std::max(decoded.width, decoded.height) + THUMBNAIL_MAX_PX - 1) / THUMBNAIL_MAX_PX); + const int scale = std::max(1, (std::max(decoded.width, decoded.height) + max_px - 1) / max_px); const int w = std::max(1, decoded.width / scale); const int h = std::max(1, decoded.height / scale); @@ -155,6 +185,12 @@ void GLGizmoTextureDisplacement::on_shutdown() m_parent.toggle_model_objects_visibility(true); m_preview_glmodel.reset(); m_bump_preview_glmodel.reset(); + m_paint_overlay_glmodel.reset(); + m_paint_overlay_dirty = false; + // Any preview still in flight is superseded: bumping the shared counter makes it abort at its next + // progress poll, and its completion handler then finds nothing to do. + m_preview_generation->fetch_add(1); + m_preview_job_pending = false; m_uvcheck_glmodel.reset(); m_wireframe_overlay_glmodel.reset(); m_wireframe_overlay_vcount = 0; @@ -221,32 +257,50 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() // // The bump preview is different: it never actually moves geometry (it's a shading trick), so // its depth is identical to the overlay's *everywhere*, not just in the unpainted area - the - // depth-biased overlay would win the depth test across the whole surface and hide the bump - // shading entirely. So the overlay is skipped for it; the bump shading itself is the only - // feedback in that mode (still fine for painting, since render_cursor() below shows the brush). + // depth-biased opaque overlay would win the depth test across the whole surface and hide the bump + // shading entirely. So render_triangles() is skipped for it. What is *not* skipped is + // render_paint_overlay(): leaving the bump shading as the only paint feedback meant a stroke that + // erased paint, or added it with no texture picked, changed nothing on screen until the whole + // preview rebuilt at stroke end - and in the true-displacement view the opaque overlay is hidden + // by the raised surface for the same reason. The translucent tint covers both cases. // Coalesced bump rebuild from an in-progress UV island drag (see on_island_edited): done here, at // most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler. if (m_use_bump_preview && m_bump_preview_dirty) { rebuild_bump_preview_mesh(); m_bump_preview_dirty = false; } - const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized(); + // Same coalescing for the paint tint, but on its own flag: a stroke marks this every mouse move + // (see on_mouse()) and it only costs the painted patch, whereas the bump mesh also carries every + // unpainted triangle of the volume and stays on the stroke-end cadence. + if (m_paint_overlay_dirty) { + rebuild_paint_overlay(); + m_paint_overlay_dirty = false; + } + // is_initialized() alone is not enough: render_bump_preview_mesh() also needs an active layer + // with a decoded texture and a compiled shader, and bails silently without them. Hiding the real + // volume for a bump pass that then draws nothing is what made the model vanish - most obviously + // with zero layers, but equally with a layer that has no texture picked yet. + const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized() && bump_preview_ready(); + const bool use_true_preview = !use_bump && m_preview_glmodel.is_initialized(); // In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is // looking at, so the opaque paint-selection highlight must not be drawn on top of it - same // reasoning as skipping it for the bump preview (see bug #12). Without this the painted area // covers the checker/heatmap and it can't be seen. const bool show_paint_overlay = m_uv_check_mode == UVCheckMode::None; + + // Hide the real volume only when something is actually going to be drawn in its place; otherwise + // put it back. Getting this wrong leaves an invisible model, so it is decided once, here, rather + // than per branch below. + m_parent.toggle_model_objects_visibility(true); + if (use_bump || use_true_preview) { + if (ModelVolume *mv = texture_volume()) + m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), + m_c->selection_info()->get_active_instance(), mv); + } + if (use_bump) { - m_parent.toggle_model_objects_visibility(true); - if (ModelVolume *mv = texture_volume()) - m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), - m_c->selection_info()->get_active_instance(), mv); render_bump_preview_mesh(); - } else if (m_preview_glmodel.is_initialized()) { - m_parent.toggle_model_objects_visibility(true); - if (ModelVolume *mv = texture_volume()) - m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(), - m_c->selection_info()->get_active_instance(), mv); + } else if (use_true_preview) { render_preview_mesh(); if (show_paint_overlay) { @@ -259,6 +313,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() render_triangles(selection); } + // The translucent paint tint. Needed in the bump view because the opaque highlight above is + // skipped there, and in the true-displacement view because the displaced surface rises *above* + // the undisplaced overlay geometry and hides it exactly where the relief is strongest - in both + // cases leaving an erase stroke with no visible effect until the next full preview rebuild. + if (show_paint_overlay && (use_bump || use_true_preview)) + render_paint_overlay(); + // Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera // with a polygon offset so they win the depth test against the coincident surface). if (m_uv_check_mode != UVCheckMode::None) @@ -290,7 +351,16 @@ bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event) return on_mouse_seam(mouse_event); if (m_adjust_texture_mode) return on_mouse_adjust_texture(mouse_event); - return GLGizmoPainterBase::on_mouse(mouse_event); + + const bool handled = GLGizmoPainterBase::on_mouse(mouse_event); + // A consumed drag/click is a paint (or erase) event: the base class has already updated the live + // TriangleSelector, but nothing is flushed to the model - and so nothing rebuilds - until the + // stroke ends. Mark the tint stale so it follows the brush from the first frame instead. Only the + // flag is set here; the rebuild is coalesced to once per drawn frame in render_painter_gizmo(). + if (handled && (mouse_event.Dragging() || mouse_event.LeftDown() || mouse_event.RightDown() || + mouse_event.LeftUp() || mouse_event.RightUp())) + m_paint_overlay_dirty = true; + return handled; } bool GLGizmoTextureDisplacement::on_mouse_seam(const wxMouseEvent &mouse_event) @@ -707,11 +777,29 @@ void GLGizmoTextureDisplacement::render_preview_mesh() shader->stop_using(); } +float GLGizmoTextureDisplacement::layer_texture_aspect(const TextureDisplacementLayer &layer) +{ + // decode_height_texture() is cached on the image_data allocation, so this is a hash lookup rather + // than a PNG decode - cheap enough to call per rebuild. + const DecodedHeightTexture tex = decode_height_texture(layer); + 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, const TextureDisplacementLayer &layer) const { - if (layer.projection_method == TextureProjectionMethod::LSCM) - return compute_lscm_uvs(patch, layer); // one final uv per patch vertex (0 where unassigned) + 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 + // on top (sample_layer_height()'s lscm branch runs the result through sample_at()), so the + // shader's precomputed-uv path has to as well, or the fast preview samples millimetre-valued + // coordinates as if they were uv and shows the texture at a wildly wrong scale. + std::vector uv = compute_lscm_uvs(patch, layer); + for (Vec2f &p : uv) + p = apply_uv_transform(p, layer, aspect); + return uv; + } if (layer.projection_method == TextureProjectionMethod::ViewProjected) { std::vector uv(patch.vertices.size()); for (size_t vi = 0; vi < patch.vertices.size(); ++vi) { @@ -725,7 +813,7 @@ std::vector GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in } const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right), patch.vertices[vi].dot(layer.view_project_up)); - uv[vi] = apply_uv_transform(planar, layer); + uv[vi] = apply_uv_transform(planar, layer, aspect); } return uv; } @@ -913,11 +1001,13 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh() if (layer == nullptr || layer->empty()) return; - // Reuses the layer-list panel's already-decoded, already-uploaded GPU thumbnail (smoothing-aware), - // whose grayscale value lives in the R channel exactly as the shader samples it. Its width/height - // are read straight off the texture - decoding the PNG here every frame would re-run the smoothing - // blur on every camera move, which is what tanked the frame rate at high smoothing. - GLTexture *tex = get_layer_thumbnail(*layer); + // Full-resolution height upload (smoothing-aware), whose grayscale value lives in the R channel + // exactly as the shader samples it. Deliberately *not* the layer-list panel's thumbnail: that one + // is box-filtered down to 128 px for a ~48 px row, and feeding it to the shader cost the preview + // three quarters of the height map's detail - and, since height_tex_texel is derived from it, also + // flattened the shading gradient and made the parallax march skip itself at angles where it should + // run. Cached on the image_data pointer + smoothing, so no PNG is decoded per frame. + GLTexture *tex = get_layer_height_texture(*layer); if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0) return; @@ -942,10 +1032,36 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh() shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0); glsafe(::glActiveTexture(GL_TEXTURE0)); glsafe(::glBindTexture(GL_TEXTURE_2D, tex->get_id())); + // Match DecodedHeightTexture::sample()'s tiling. The sampler's wrap mode is the only place the + // GPU path can express this, and nothing ever set it - so it sat at GL_REPEAT no matter what the + // layer said: a MirroredRepeat layer previewed as a plain repeat, and a layer with tiling *off* + // previewed as an endless tiling where the bake produces one placement and nothing around it. + // CLAMP_TO_BORDER with a zero border is the exact analogue of sample()'s "outside [0,1) is 0". + // GL_CLAMP_TO_BORDER is desktop-GL only; on ES the nearest thing is CLAMP_TO_EDGE, which smears + // the border row instead of vanishing - still much closer to the bake than an endless repeat. +#if SLIC3R_OPENGL_ES + const GLint no_tile_wrap = GL_CLAMP_TO_EDGE; +#else + const GLint no_tile_wrap = GL_CLAMP_TO_BORDER; +#endif + const GLint wrap = !layer->tile_enabled ? no_tile_wrap : + (layer->tile_method == TextureTileMethod::MirroredRepeat) ? GL_MIRRORED_REPEAT : + GL_REPEAT; + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap)); + glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap)); +#if !SLIC3R_OPENGL_ES + if (wrap == GL_CLAMP_TO_BORDER) { + static const GLfloat border[4] = { 0.f, 0.f, 0.f, 0.f }; + glsafe(::glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border)); + } +#endif // !SLIC3R_OPENGL_ES shader->set_uniform("height_tex", 0); shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height()))); shader->set_uniform("depth_mm", layer->depth_mm); shader->set_uniform("tiling_scale", layer->tiling_scale); + // Read off the uploaded texture rather than the decoded one: they are the same image, and this is + // the aspect the sampler will actually see. + shader->set_uniform("tex_aspect", float(tex->get_width()) / float(tex->get_height())); shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f); shader->set_uniform("uv_offset", layer->offset); shader->set_uniform("invert", layer->invert); @@ -969,6 +1085,100 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh() shader->stop_using(); } +bool GLGizmoTextureDisplacement::bump_preview_ready() const +{ + // Mirrors render_bump_preview_mesh()'s own preconditions. Kept as a separate query because the + // caller has to know whether the bump pass will draw *before* it hides the real volume for it. + if (m_c->selection_info() == nullptr || m_c->selection_info()->model_object() == nullptr) + return false; + if (texture_volume() == nullptr) + return false; + const TextureDisplacementLayer *layer = active_layer(); + if (layer == nullptr || layer->empty()) + return false; + // The same texture render_bump_preview_mesh() will bind, not the panel thumbnail - the two are + // separate caches and either can fail on its own. + const GLTexture *tex = const_cast(this)->get_layer_height_texture(*layer); + if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0) + return false; + return wxGetApp().get_shader("texture_displacement_bump") != nullptr; +} + +void GLGizmoTextureDisplacement::rebuild_paint_overlay() +{ + m_paint_overlay_glmodel.reset(); + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || m_triangle_selectors.empty()) + return; + + // The *live* selector, so an in-progress stroke shows immediately - which is the whole point: + // this is the only feedback that a brush actually added or erased anything until the (much more + // expensive) preview catches up at stroke end. + const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER); + if (patch.indices.empty()) + return; + + // In the true-displacement view the surface on screen is the *raised* one, and a tint built on + // the flat base mesh would sink underneath it wherever the relief is deepest - which is precisely + // where the user is looking. The bake is topology-preserving (patch vertex i is mesh vertex i, see + // build_texture_displacement()), so the displaced positions can be read straight across. Vertices + // the brush split live past the end of that array and keep their flat position; they sit on the + // patch boundary, where the displacement is smallest anyway. + const std::vector *displaced = nullptr; + if (!m_use_bump_preview && m_preview_its.vertices.size() == mv->mesh().its.vertices.size() && + !m_preview_its.vertices.empty()) + displaced = &m_preview_its.vertices; + + GLModel::Geometry init_data; + init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 }; + init_data.reserve_vertices(patch.indices.size() * 3); + init_data.reserve_indices(patch.indices.size() * 3); + unsigned n = 0; + for (const stl_triangle_vertex_indices &tri : patch.indices) { + for (int i = 0; i < 3; ++i) { + const size_t idx = size_t(tri[i]); + init_data.add_vertex((displaced != nullptr && idx < displaced->size()) ? (*displaced)[idx] + : patch.vertices[idx]); + } + init_data.add_triangle(n, n + 1, n + 2); + n += 3; + } + m_paint_overlay_glmodel.init_from(std::move(init_data)); + // GLModel::render() re-sets "uniform_color" from this field just before drawing, so the colour + // has to be set here rather than as a uniform at draw time. + m_paint_overlay_glmodel.set_color(ColorRGBA(0.16f, 0.79f, 0.35f, 0.38f)); +} + +void GLGizmoTextureDisplacement::render_paint_overlay() +{ + const ModelObject *mo = m_c->selection_info()->model_object(); + const ModelVolume *mv = texture_volume(); + if (mo == nullptr || mv == nullptr || !m_paint_overlay_glmodel.is_initialized()) + return; + GLShaderProgram *shader = wxGetApp().get_shader("flat"); + if (shader == nullptr) + return; + + const Selection &selection = m_parent.get_selection(); + const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix(); + const Camera &camera = wxGetApp().plater()->get_camera(); + + shader->start_using(); + shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix); + shader->set_uniform("projection_matrix", camera.get_projection_matrix()); + // Translucent, and pulled toward the camera so it wins the depth test against the coincident + // bump surface. Depth writes are off: this is a tint, and letting it own the depth buffer would + // make the wireframe and seam overlays drawn after it fight with geometry that is not really + // there. Blending is already enabled by render_painter_gizmo(). + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-1.5f, -1.5f)); + glsafe(::glDepthMask(GL_FALSE)); + m_paint_overlay_glmodel.render(); + glsafe(::glDepthMask(GL_TRUE)); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + shader->stop_using(); +} + void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh() { m_uvcheck_glmodel.reset(); @@ -1178,10 +1388,12 @@ void GLGizmoTextureDisplacement::rebuild_preview() { // Bumped first: any in-flight job's result (captured generation from before this call) will // now compare unequal to m_preview_generation and be discarded when it completes, even if it - // finishes after the job queued below. - const uint64_t generation = ++m_preview_generation; + // finishes after the job queued below - and, since the counter is shared with the worker, that + // job also notices mid-run and aborts rather than computing a result nobody will use. + m_preview_generation->fetch_add(1); update_uv_editor(); rebuild_bump_preview_mesh(); + rebuild_paint_overlay(); rebuild_uvcheck_mesh(); rebuild_seam_overlay(); // The adaptive subdivision preview is driven by the painted area, so it has to follow the paint @@ -1194,13 +1406,43 @@ void GLGizmoTextureDisplacement::rebuild_preview() if (mv == nullptr || !mv->is_texture_displacement_painted()) { m_preview_glmodel.reset(); m_preview_its = indexed_triangle_set{}; // no displaced mesh; wireframe falls back to the base + m_preview_job_pending = false; refresh_wireframe(); return; } // In Fast/paint modes the wireframe follows the base mesh and can be built now; the true-displacement // view's wireframe needs the displaced mesh, which only exists once the job below completes. - if (m_use_bump_preview) + if (m_use_bump_preview) { refresh_wireframe(); + // Fast view: the shader *is* the preview, and m_preview_glmodel is never drawn. Running the + // full CPU displacement anyway - which is what happened on every stroke and slider release - + // was the single largest cost in the gizmo, and it bought nothing. The switch back to the + // true-displacement view queues it (see the View row in on_render_input_window()). + m_preview_job_pending = false; + return; + } + + queue_preview_job(); +} + +void GLGizmoTextureDisplacement::queue_preview_job() +{ + // A job in flight when the gizmo closes still runs its completion handler, which would otherwise + // happily queue the follow-up run it was holding - against a gizmo nobody is looking at any more. + if (m_state != On) + return; + const ModelVolume *mv = texture_volume(); + if (mv == nullptr || !mv->is_texture_displacement_painted()) + return; + + // One in flight at a time; everything requested meanwhile collapses into a single follow-up run + // issued from the completion handler. See m_preview_job_running. + if (m_preview_job_running) { + m_preview_job_pending = true; + return; + } + + const uint64_t generation = m_preview_generation->load(); TextureDisplacementPreviewInput input; input.base_mesh = mv->mesh().its; @@ -1209,20 +1451,35 @@ void GLGizmoTextureDisplacement::rebuild_preview() for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); + m_preview_job_running = true; auto &worker = wxGetApp().plater()->get_ui_job_worker(); - queue_job(worker, std::make_unique(std::move(input), generation, + queue_job(worker, std::make_unique(std::move(input), generation, m_preview_generation, [this](indexed_triangle_set its, uint64_t result_generation) { - if (result_generation != m_preview_generation) - return; // superseded by a newer edit while this was computing - m_preview_glmodel.reset(); - if (!its.indices.empty()) { + m_preview_job_running = false; + if (result_generation != m_preview_generation->load()) { + // Superseded while this was computing (it will have aborted early and come back + // empty). Whatever the newest state is, it still needs a run. + m_preview_job_pending = true; + } else if (its.indices.empty()) { + // Aborted or cancelled rather than finished - the handler runs on every outcome so + // the in-flight latch above always clears. Keep whatever preview is already on screen + // rather than blanking it; there is no new result to show, not a new empty one. + } else { + m_preview_glmodel.reset(); m_preview_glmodel.init_from(its); m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR); + // Keep the displaced mesh so the wireframe overlay can be drawn on it (the + // true-displacement view), then refresh the wireframe from it. + m_preview_its = std::move(its); + refresh_wireframe(); + // The paint tint rides the displaced surface in this view, so it follows the new mesh. + m_paint_overlay_dirty = true; + } + if (m_preview_job_pending) { + m_preview_job_pending = false; + if (!m_use_bump_preview) + queue_preview_job(); // no-ops if the gizmo has closed in the meantime } - // Keep the displaced mesh so the wireframe overlay can be drawn on it (the true-displacement - // view), then refresh the wireframe from it. - m_preview_its = std::move(its); - refresh_wireframe(); m_parent.set_as_dirty(); })); } @@ -1935,7 +2192,11 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement // follows the cursor precisely, and is back on the anchor exactly when offset is zero. const float rad = layer.rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad), sn = std::sin(rad); - const Vec2f unrotated(layer.offset.x() * cs + layer.offset.y() * sn, -layer.offset.x() * sn + layer.offset.y() * cs); + // Undo the non-square v scaling first - it is the last thing apply_uv_transform() does, so it is + // the first thing to come off on the way back. + const float aspect = layer_texture_aspect(layer); + const Vec2f o(layer.offset.x(), (aspect > 0.f) ? layer.offset.y() / aspect : layer.offset.y()); + const Vec2f unrotated(o.x() * cs + o.y() * sn, -o.x() * sn + o.y() * cs); const Vec2f planar = -unrotated * layer.tiling_scale; Vec3f u_axis, v_axis; @@ -2168,7 +2429,10 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou const Vec2f delta_scaled = delta_planar * scale; const float rad = layer->rotation_deg * float(M_PI) / 180.f; const float cs = std::cos(rad), sn = std::sin(rad); - const Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs); + Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs); + // ...and the same v scaling apply_uv_transform() applies for a non-square texture, so the + // handle keeps tracking the cursor exactly instead of drifting on the v axis. + delta_rotated.y() *= layer_texture_aspect(*layer); // Increasing `offset` shifts which texel is sampled at a fixed world position, which // visually slides the pattern the *opposite* way - subtracting is this session's // best-effort reasoning about the direction that feels like "dragging the texture", @@ -2701,7 +2965,7 @@ void GLGizmoTextureDisplacement::subdivide_model() bool GLGizmoTextureDisplacement::collect_paint_region( std::vector ®ion, - std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const + std::array *paint) const { const ModelVolume *mv = texture_volume(); if (mv == nullptr) @@ -2711,21 +2975,24 @@ bool GLGizmoTextureDisplacement::collect_paint_region( const size_t nvert = its.vertices.size(); region.assign(ntri, 0); - if (painted_tri) - for (auto &pt : *painted_tri) - pt.clear(); + if (paint) + for (LayerPaintMap &pm : *paint) + pm = LayerPaintMap{}; - // Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes back - // with the original mesh's own three vertex indices) can be mapped to its source triangle. Only - // the paint carry-forward needs it, and the live subdivide preview calls this on every slider - // frame, so it is not built for the region-only path. - std::map, int> tri_by_verts; - if (painted_tri) - for (size_t i = 0; i < ntri; ++i) { - std::array k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] }; - std::sort(k.begin(), k.end()); - tri_by_verts.emplace(k, int(i)); - } + // Twice the area of each source triangle, for the "is this one covered edge to edge" test below. + // Only the paint carry-forward needs it, and the live subdivide preview calls this on every + // slider frame, so it is not built for the region-only path. + const auto tri_area2 = [](const Vec3f &a, const Vec3f &b, const Vec3f &c) { + return (b - a).cross(c - a).norm(); + }; + std::vector source_area2; + if (paint) { + source_area2.resize(ntri); + for (size_t i = 0; i < ntri; ++i) + source_area2[i] = tri_area2(its.vertices[size_t(its.indices[i][0])], + its.vertices[size_t(its.indices[i][1])], + its.vertices[size_t(its.indices[i][2])]); + } bool any_paint = false; for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { @@ -2743,27 +3010,114 @@ bool GLGizmoTextureDisplacement::collect_paint_region( // subdivide_mesh_adaptive() already grades the size change outward on its own. for (const TriangleSelector::TriangleBitStreamMapping &m : data.triangles_to_split) if (size_t(m.triangle_idx) < ntri) - region[m.triangle_idx] = 1; + region[m.triangle_idx] |= REFINE_PAINTED; - if (painted_tri) { + if (paint) { TriangleSelector sel(mv->mesh()); sel.deserialize(data, false); - (*painted_tri)[slot].assign(ntri, 0); - for (const stl_triangle_vertex_indices &t : sel.get_facets_strict(EnforcerBlockerType::ENFORCER).indices) { - // A sub-triangle produced by a *partial* stroke always carries at least one appended - // (split) vertex, so "all three indices are original" is exactly the test for a whole, - // fully-painted triangle - the only kind whose paint can be inherited wholesale. - if (size_t(t[0]) >= nvert || size_t(t[1]) >= nvert || size_t(t[2]) >= nvert) + // get_facets_strict() now reports which source triangle each sub-triangle came from, which + // is what lets partial coverage be carried forward geometrically instead of being rounded + // away. Note a *fully* painted source can still come back as several sub-triangles (T-joint + // splits forced by a refined neighbour), so "wholly painted" is an area test, not a + // one-piece test. + std::vector src; + const indexed_triangle_set patch = sel.get_facets_strict(EnforcerBlockerType::ENFORCER, &src); + + LayerPaintMap &pm = (*paint)[slot]; + pm.full.assign(ntri, 0); + pm.part_start.assign(ntri + 1, 0); + + std::vector covered2(ntri, 0.f); + for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j) { + if (size_t(src[j]) >= ntri) continue; - std::array k{ t[0], t[1], t[2] }; - std::sort(k.begin(), k.end()); - if (auto it = tri_by_verts.find(k); it != tri_by_verts.end()) - (*painted_tri)[slot][it->second] = 1; + const stl_triangle_vertex_indices &t = patch.indices[j]; + covered2[size_t(src[j])] += tri_area2(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], + patch.vertices[size_t(t[2])]); + } + for (size_t i = 0; i < ntri; ++i) + pm.full[i] = (source_area2[i] > 0.f && covered2[i] >= 0.999f * source_area2[i]) ? 1 : 0; + + // Only partly covered sources need their pieces kept - a full one answers every query with + // "painted", and an untouched one with "not painted". + for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j) + if (size_t(src[j]) < ntri && !pm.full[size_t(src[j])]) + ++pm.part_start[size_t(src[j]) + 1]; + for (size_t i = 0; i < ntri; ++i) + pm.part_start[i + 1] += pm.part_start[i]; + pm.part.resize(size_t(pm.part_start[ntri])); + { + std::vector fill(pm.part_start.begin(), pm.part_start.begin() + ntri); + for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j) { + const size_t S = size_t(src[j]); + if (S >= ntri || pm.full[S]) + continue; + const stl_triangle_vertex_indices &t = patch.indices[j]; + pm.part[size_t(fill[S]++)] = { patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])], + patch.vertices[size_t(t[2])] }; + } } } any_paint = true; } - return any_paint; + if (!any_paint) + return false; + + // The band straddling the paint's edge. The bake steps the surface from full displacement to zero + // across it, and nothing else in the refinement criteria can see that step: the chord-error + // sampler has no per-point paint test, so just outside the paint it keeps reporting the same + // smooth height field and reports no error at all. Left alone, the transition therefore stays at + // whatever density the input had - which is what makes the rim of an unpainted island a ring of + // big, steeply tilted triangles. + // + // Seeded from the vertices shared by a painted and an unpainted triangle (the actual edge of the + // paint) and grown outward over vertex adjacency, so the band covers both sides of the step. + if (BORDER_BAND_RINGS > 0) { + // Vertex -> incident triangles, CSR-style (counted, prefix-summed, filled). This runs on every + // frame of the subdivide preview's sliders, so it must not allocate a small vector per vertex. + std::vector vstart(nvert + 1, 0); + for (size_t i = 0; i < ntri; ++i) + for (int k = 0; k < 3; ++k) + if (size_t(its.indices[i][k]) < nvert) + ++vstart[size_t(its.indices[i][k]) + 1]; + for (size_t v = 0; v < nvert; ++v) + vstart[v + 1] += vstart[v]; + std::vector vtri(size_t(vstart[nvert]), 0); + { + std::vector fill(vstart.begin(), vstart.begin() + nvert); + for (size_t i = 0; i < ntri; ++i) + for (int k = 0; k < 3; ++k) + if (size_t(its.indices[i][k]) < nvert) + vtri[size_t(fill[size_t(its.indices[i][k])]++)] = int(i); + } + + // A vertex used by both a painted and an unpainted triangle sits exactly on the paint's edge. + std::vector ring_vertex(nvert, 0); + for (size_t v = 0; v < nvert; ++v) { + bool painted = false, unpainted = false; + for (int k = vstart[v]; k < vstart[v + 1]; ++k) + ((region[size_t(vtri[size_t(k)])] & REFINE_PAINTED) ? painted : unpainted) = true; + ring_vertex[v] = (painted && unpainted) ? 1 : 0; + } + + for (int ring = 0; ring < BORDER_BAND_RINGS; ++ring) { + std::vector next = ring_vertex; + for (size_t v = 0; v < nvert; ++v) { + if (!ring_vertex[v]) + continue; + for (int k = vstart[v]; k < vstart[v + 1]; ++k) { + const size_t t = size_t(vtri[size_t(k)]); + region[t] |= REFINE_BORDER; + // Grow through this triangle's other corners, for the following ring. + for (int c = 0; c < 3; ++c) + if (size_t(its.indices[t][c]) < nvert) + next[size_t(its.indices[t][c])] = 1; + } + } + ring_vertex.swap(next); + } + } + return true; } bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const @@ -2772,7 +3126,7 @@ bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv return false; std::vector region; - if (!collect_paint_region(region, &out.painted_tri)) + if (!collect_paint_region(region, &out.paint)) return false; // Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A @@ -2796,7 +3150,7 @@ bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv // baseline - otherwise the control would be meaningless (or a dead end) on a dense model. out.refined = subdivide_mesh_adaptive(mv.mesh().its, region, m_subdivide_target_mm, int(mv.mesh().its.indices.size()) + m_subdivide_budget_k * 1000, - &out.source, sampler, tol, floor); + &out.source, sampler, tol, floor, m_subdivide_border_mm); } return out.refined.indices.size() != mv.mesh().its.indices.size(); } @@ -2811,15 +3165,33 @@ void GLGizmoTextureDisplacement::apply_adaptive_subdivision(ModelVolume &mv, Sub mv.calculate_convex_hull(); mv.restore_painting(saved_painting); // resets extra facets (incl. texture-displacement) + remaps the rest - // Carry each layer's paint onto the new mesh: a new triangle is painted iff its source triangle - // was fully painted in that layer. Children inherit their parent's source, so this is exact. + // Carry each layer's paint onto the new mesh. Children inherit their parent's source triangle, and + // subdivision only ever adds edge midpoints, so every new triangle lies inside its source and on + // the same surface - which means the source's painted *pieces* can be queried directly by point + // containment. That is what keeps a brush outline smooth: rounding each source to wholly painted + // or not instead leaves a ragged fringe of isolated triangles along any curved boundary, and the + // refined mesh then reproduces that fringe exactly rather than hiding it. + const indexed_triangle_set &new_its = mv.mesh().its; for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { - if (plan.painted_tri[size_t(slot)].empty()) + const LayerPaintMap &pm = plan.paint[size_t(slot)]; + if (pm.empty()) continue; TriangleSelector sel(mv.mesh()); - for (size_t i = 0; i < plan.source.size(); ++i) - if (plan.painted_tri[size_t(slot)][size_t(plan.source[i])]) + for (size_t i = 0; i < plan.source.size() && i < new_its.indices.size(); ++i) { + const size_t S = size_t(plan.source[i]); + if (S >= pm.full.size()) + continue; + bool painted = pm.full[S] != 0; + if (!painted && pm.part_start[S] != pm.part_start[S + 1]) { + const stl_triangle_vertex_indices &t = new_its.indices[i]; + const Vec3f centroid = (new_its.vertices[size_t(t[0])] + new_its.vertices[size_t(t[1])] + + new_its.vertices[size_t(t[2])]) / 3.f; + for (int k = pm.part_start[S]; k < pm.part_start[S + 1] && !painted; ++k) + painted = point_in_triangle_coplanar(centroid, pm.part[size_t(k)]); + } + if (painted) sel.set_facet(int(i), EnforcerBlockerType::ENFORCER); + } mv.texture_displacement_facet(slot).set(sel); } } @@ -3063,7 +3435,7 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f; its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000, - nullptr, sampler, tol, floor); + nullptr, sampler, tol, floor, m_subdivide_border_mm); } else { if (m_subdivide_count < 1) return; @@ -3135,6 +3507,26 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace return m_thumbnails[slot].get(); } +GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDisplacementLayer &layer) +{ + if (layer.empty()) + return nullptr; + + // One slot, not one per layer: the bump shader only ever shades the *active* layer, so a single + // full-resolution upload is enough and the VRAM cost stays at one texture rather than eight. + if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing) + return m_height_tex.get(); + + std::unique_ptr texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX); + if (!texture) + return nullptr; + + m_height_tex = std::move(texture); + m_height_tex_source = layer.image_data.get(); + m_height_tex_smoothing = layer.smoothing; + return m_height_tex.get(); +} + void GLGizmoTextureDisplacement::bake(bool own_snapshot) { ModelVolume *mv = texture_volume(); @@ -3177,6 +3569,10 @@ static constexpr float STD_REMESH_SHARP_DEG = 40.f; static constexpr float STD_SUBDIV_MAX_EDGE_MM = 20.f; static constexpr float STD_SUBDIV_DETAIL_MM = 0.02f; static constexpr float STD_SUBDIV_MIN_EDGE_MM = 0.02f; +// Edge length the band straddling the paint's edge is refined to. This is the one number that decides +// how clean the rim of an unpainted island looks: the bake steps the surface from full displacement to +// zero across that band, and nothing else in the criteria can see the step (see collect_paint_region()). +static constexpr float STD_SUBDIV_BORDER_MM = 0.4f; bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv) { @@ -3197,6 +3593,7 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv) pin(m_subdivide_target_mm, STD_SUBDIV_MAX_EDGE_MM); pin(m_subdivide_detail_mm, STD_SUBDIV_DETAIL_MM); pin(m_subdivide_min_edge_mm, STD_SUBDIV_MIN_EDGE_MM); + pin(m_subdivide_border_mm, STD_SUBDIV_BORDER_MM); // Deliberately *not* pinned: the triangle budget stays visible and editable in Standard mode, so // pinning it would fight the user's own slider every frame. pin(m_remesh_target_edge_mm, STD_REMESH_EDGE_MM); @@ -3226,6 +3623,7 @@ void GLGizmoTextureDisplacement::bake_standard() const bool do_remesh = plan_remesh(*mv, STD_REMESH_EDGE_MM, STD_REMESH_SHARP_DEG, remeshed); Plater *plater = wxGetApp().plater(); + bool paint_transfer_failed = false; { // ONE undo step for the whole pipeline. take_snapshot() records the state *before* the change, // so a single Undo goes all the way back to the untouched mesh - which is the only thing "undo @@ -3241,17 +3639,19 @@ void GLGizmoTextureDisplacement::bake_standard() // The remesh carries the paint across spatially, but if that remap came back empty the rest of // the pipeline has nothing to work from - stop here rather than silently baking a flat mesh. - if (!mv->is_texture_displacement_painted()) { - show_error(nullptr, _u8L("The painted area could not be transferred onto the remeshed model. Undo, " - "then switch to Pro mode to prepare the mesh before painting.")); - return; - } + // Note this cannot just `return`: the remesh above has already replaced the volume's mesh, so + // the scene and the gizmo's own TriangleSelectors still have to be brought back into step with + // it below. Returning from here left the gizmo painting and raycasting against a mesh that no + // longer existed. + paint_transfer_failed = !mv->is_texture_displacement_painted(); // Planning the subdivision has to happen inside the snapshot because it reads the mesh the // remesh just produced. It is the expensive step, but by here we are committed anyway. - SubdivisionPlan plan; - if (plan_adaptive_subdivision(*mv, plan)) - apply_adaptive_subdivision(*mv, std::move(plan)); + if (!paint_transfer_failed) { + SubdivisionPlan plan; + if (plan_adaptive_subdivision(*mv, plan)) + apply_adaptive_subdivision(*mv, std::move(plan)); + } } if (ObjectList *obj_list = wxGetApp().obj_list()) { @@ -3264,6 +3664,12 @@ void GLGizmoTextureDisplacement::bake_standard() update_from_model_object(false); // reload selectors against the prepared mesh + carried paint m_parent.set_as_dirty(); + if (paint_transfer_failed) { + show_error(nullptr, _u8L("The painted area could not be transferred onto the remeshed model. Undo, " + "then switch to Pro mode to prepare the mesh before painting.")); + return; + } + // ... and finally the displacement itself, in the background exactly as the Pro-mode button does - // except that it commits into the snapshot taken above instead of pushing another one. bake(/* own_snapshot */ false); @@ -3526,6 +3932,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float rebuild_uvcheck_mesh(); if (m_use_bump_preview) rebuild_bump_preview_mesh(); + else + // The Fast view skips the CPU displacement entirely (see rebuild_preview()), so + // leaving it means m_preview_glmodel may be stale or absent - ask for it now. + queue_preview_job(); + // ...and the paint tint between the base and the displaced surface, for the same reason. + m_paint_overlay_dirty = true; refresh_wireframe(); // Normal<->Fast swaps the wireframe between displaced and base mesh update_uv_editor(); // mirror the checker / distortion heatmap into the UV pane too (#7) m_parent.set_as_dirty(); @@ -4225,6 +4637,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float m_imgui->scaled(20.f)); } + // Applies in both adaptive sub-modes: it is not a texture-detail criterion, it is about the + // step the bake puts at the paint's edge, which exists whether or not "Follow texture detail" + // is on. 0 turns the band off and restores the old behaviour. + if (m_imgui->slider_float(std::string(_u8L("Edge detail (mm)")) + "##subdivborder", &m_subdivide_border_mm, + 0.f, 5.f, "%.3f")) + preview_live(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Triangle size along the boundary of the painted area. The relief drops back to the " + "flat surface across that boundary, and the triangles spanning the drop are what you " + "see as a jagged rim around an unpainted region - smaller values make the outline " + "cleaner. Costs triangles along the outline only, not over the whole area. " + "0 turns it off."), + m_imgui->scaled(20.f)); + ImGui::PopItemWidth(); budget_slider(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 29fc28a114..7874f2656f 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -10,6 +10,7 @@ #include "slic3r/GUI/TextureLibrary.hpp" #include +#include #include #include #include @@ -101,11 +102,26 @@ private: // so a run that turns out to be a no-op does not leave an empty undo step behind - and it keeps // snapshot ownership with the caller, which matters because the buttons want one snapshot per click // while the pipeline wants a single one around remesh + subdivide together. + // How one layer's paint sits on the pre-subdivision mesh, precise enough to carry across the + // refinement without rounding each source triangle to wholly painted or not. + // + // Rounding is what made the outline of a painted region come out ragged: a source triangle near a + // smooth brush boundary is wholly painted essentially at random, so "painted iff the source was + // full" turns a clean curve into a noisy fringe of isolated painted and unpainted triangles - and + // once the border band refines the mesh there, that fringe is reproduced faithfully instead of + // being blurred away by coarse geometry. + struct LayerPaintMap + { + std::vector full; // per source triangle: covered edge to edge + std::vector part_start; // CSR offsets into `part`, size (source tris + 1) + std::vector> part; // painted pieces of partly covered source triangles + bool empty() const { return full.empty(); } + }; struct SubdivisionPlan { - indexed_triangle_set refined; - std::vector source; // new tri -> input tri - std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri; // per layer, per input tri + indexed_triangle_set refined; + std::vector source; // new tri -> input tri + std::array paint; // per layer }; // False means "nothing to refine" and `out` must not be used. bool plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const; @@ -162,10 +178,20 @@ private: // remapped (texture-displacement paint has no remap-across-topology-change support yet). void subdivide_model(); + // The layer height map's width / height, for apply_uv_transform()'s non-square handling. 1 when + // there is no usable texture. + static float layer_texture_aspect(const TextureDisplacementLayer &layer); + // Returns a cached GPU thumbnail of layer's texture (decoding + uploading it the first time it // is requested, or whenever its image_data changes), or nullptr if it has no usable texture. + // Panel-sized: box-filtered down to THUMBNAIL_MAX_PX, which is right for a list row and wrong for + // anything the shader samples - see get_layer_height_texture(). GLTexture *get_layer_thumbnail(const TextureDisplacementLayer &layer); + // The same texture at full resolution, for the fast-preview shader. One slot, shared by whichever + // layer is active, because that is the only one the bump shader ever shades. + GLTexture *get_layer_height_texture(const TextureDisplacementLayer &layer); + // A texture from the picker's library (see slic3r/GUI/TextureLibrary.hpp), read and uploaded // once and then kept for the gizmo's lifetime. The decoded bytes are held alongside the GPU // thumbnail so that picking the texture can hand the layer this very same image_data buffer - @@ -366,18 +392,33 @@ private: bool m_subdivide_feature = false; float m_subdivide_detail_mm = 0.05f; float m_subdivide_min_edge_mm = 0.1f; + // Edge length the band straddling the paint's boundary is refined to (0 = leave it alone). Applies + // in both adaptive sub-modes, because it is not a texture-detail criterion: the bake steps the + // surface from full displacement to zero across that boundary whatever the texture is doing, and + // the chord-error test cannot see that step at all - its sampler has no per-point paint test, so + // just outside the paint it goes on reporting the same smooth height field. Without this the + // transition keeps the input's density and the rim of an unpainted island comes out as a ring of + // large, steeply tilted triangles. See collect_paint_region() and subdivide_mesh_adaptive(). + float m_subdivide_border_mm = 0.4f; // How many thousand triangles refinement may *add* (the mesh's own count is added on before it is // passed as subdivide_mesh_adaptive()'s absolute cap, so the control still means something on a // dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh // that many triangles can buy - and it is what keeps a fine "Detail" over a noisy texture from // turning into an out-of-memory, or an unrenderable preview wireframe. - int m_subdivide_budget_k = 1500; + // + // The default used to be 1500 (i.e. +1.5 M triangles), which is what made Standard mode's Bake + // take minutes: every stage after the subdivision - the displacement itself, the convex hull, the + // GLModel upload, and the re-slice changed_object() triggers - then runs on a mesh two orders of + // magnitude denser than the input. 300k is still far finer than any FDM nozzle resolves at the + // 0.02 mm detail tolerance Standard uses, and the slider goes to 2000 for anyone who wants more. + int m_subdivide_budget_k = 300; void subdivide_model_adaptive(); - // Fills `region` (per current-mesh triangle, 1 = refine) from the union of every layer's painted - // area. If `painted_tri` is non-null, also fills, per layer, the fully-painted triangles to carry - // forward. Returns false when nothing is painted at all. Shared by the preview and the commit. + // Fills `region` (per current-mesh triangle, a REFINE_* bitmask) from the union of every layer's + // painted area plus the band straddling its edge. If `paint` is non-null, also fills the per-layer + // coverage map the subdivision carries forward - the expensive half, skipped by the live preview, + // which only needs the region. Returns false when nothing is painted at all. bool collect_paint_region(std::vector ®ion, - std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const; + std::array *paint) const; // Runs the volume's TextureDisplacementOptions smoothing over the *already committed* geometry, // restricted to the painted area. The same settings are folded into Preview/Bake automatically; @@ -417,6 +458,25 @@ private: // and the 3D view. The rebuild is instead coalesced to once per 3D frame (render_painter_gizmo). bool m_bump_preview_dirty = false; GLModel m_bump_preview_glmodel; + + // Translucent tint over the active layer's painted triangles, drawn on top of whichever preview + // is showing. The base painter's own opaque paint highlight (render_triangles()) cannot be used + // in either preview mode - it is coincident with the surface and simply covers it - so the only + // paint feedback the gizmo had was the relief itself, which meant erasing showed nothing at all + // until the stroke ended and the whole preview rebuilt. This is that feedback: cheap (the painted + // patch only), translucent (the preview stays visible through it) and rebuilt live during a + // stroke. + GLModel m_paint_overlay_glmodel; + // Set on every paint event, cleared when the overlay is rebuilt in render_painter_gizmo(). Kept + // separate from m_bump_preview_dirty so a stroke refreshes only the small painted patch per frame, + // not the bump mesh (which also carries every *unpainted* triangle of the volume). + bool m_paint_overlay_dirty = false; + void rebuild_paint_overlay(); + void render_paint_overlay(); + // Whether render_bump_preview_mesh() would actually draw something. Checked before the real volume + // is hidden: with no layer, no texture or no shader the bump path draws nothing, and hiding the + // volume for it left the model invisible. + bool bump_preview_ready() const; // Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader // should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh(). bool m_bump_preview_uses_vertex_uv = false; @@ -507,16 +567,36 @@ private: // Bumped on every rebuild_preview() call; a background TextureDisplacementPreviewJob's result // is only applied if this hasn't moved on since the job was queued (see rebuild_preview()), // so a burst of edits can't have an earlier, now-stale job clobber a later one's result. - uint64_t m_preview_generation = 0; + // + // Shared with the worker thread (hence the atomic) so a running job can notice mid-computation + // that it has been superseded and abort, instead of running to completion for a result that will + // only be discarded on arrival. + std::shared_ptr> m_preview_generation = std::make_shared>(0); + // At most one preview job is ever queued. The UI job worker is a single FIFO queue shared with + // Bake (and with arrange/orient/send), and rebuild_preview() is called on every stroke end, every + // slider release and - with "Auto update" on - every frame of a slider drag. Queuing one full + // displacement per call built a backlog that took minutes to drain: the preview appeared frozen, + // and a Bake pressed afterwards sat behind the whole queue. So a request made while a job is in + // flight is recorded here and issued once that job settles, collapsing any number of edits into a + // single follow-up run. + bool m_preview_job_running = false; + bool m_preview_job_pending = false; + void queue_preview_job(); // Per-slot GPU thumbnail cache for the layer list panel, keyed by the image_data pointer that // was current the last time each thumbnail was built (see get_layer_thumbnail()). std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnails; std::array m_thumbnail_source{}; - // The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it (and the - // fast/bump preview, which samples this texture, actually shows the blur). + // The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it. std::array m_thumbnail_smoothing{}; + // Full-resolution height texture for the bump shader, keyed the same way (see + // get_layer_height_texture()). A smoothing change re-uploads it, so the fast preview shows the + // blur the bake will apply. + std::unique_ptr m_height_tex; + const void *m_height_tex_source = nullptr; + float m_height_tex_smoothing = -1.f; + // Library textures the picker has shown at least once, keyed by file path (see LibraryTexture). std::map m_library_textures; diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 0977e388b5..28846ca822 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -166,7 +166,11 @@ void GLGizmosManager::switch_gizmos_icon_filename() gizmo->set_icon_filename(m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg"); break; case(EType::TextureDisplacement): - gizmo->set_icon_filename(m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg"); + // One shared icon in both themes (no dedicated dark variant yet) - but it must still be + // *this* gizmo's icon. Handing it the fuzzy-skin one here quietly replaced the icon set at + // construction, so the toolbar ended up showing two identical fuzzy-skin buttons after any + // light/dark switch. + gizmo->set_icon_filename("toolbar_texture_displacement.svg"); break; case(EType::MeshBoolean): gizmo->set_icon_filename(m_is_dark ? "toolbar_meshboolean_dark.svg" : "toolbar_meshboolean.svg"); diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 7d6119ce30..91493a9173 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -20,12 +20,34 @@ TextureDisplacementBakeJob::TextureDisplacementBakeJob(TextureDisplacementBakeIn void TextureDisplacementBakeJob::process(Ctl &ctl) { - ctl.update_status(0, _u8L("Baking texture displacement")); + const std::string status = _u8L("Baking texture displacement"); + ctl.update_status(1, status); // Only ever touches m_input (captured by value before this job was queued) and local state - // never the live Model - so this is safe to run concurrently with the UI thread. - m_result = TriangleMesh(build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, - m_input.options)); + // + // The progress hook matters for more than cosmetics: the framework's progress notification only + // grows a close button once it reaches 100%, so a job that reports 0 and nothing else leaves an + // uncloseable notification pinned on screen. It also carries the Cancel button's effect into the + // bake, which on a subdivided mesh can run for several seconds. + int last_reported = 1; + m_result = TriangleMesh(build_texture_displacement( + m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options, + [&ctl, &status, &last_reported](int percent) { + if (ctl.was_canceled()) + return false; + // The notification repaints (and wakes the idle loop) on every call, so only push a + // message when the displayed integer percentage actually moves. + if (percent > last_reported) { + last_reported = percent; + ctl.update_status(percent, status); + } + return true; + })); + + // 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. + ctl.update_status(100, status); } void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 555ed570b1..44bcf37725 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -5,25 +5,44 @@ namespace Slic3r::GUI { TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, + std::shared_ptr> current_generation, std::function on_finished) - : m_input(std::move(input)), m_generation(generation), m_on_finished(std::move(on_finished)) + : m_input(std::move(input)), m_generation(generation), m_current_generation(std::move(current_generation)), + m_on_finished(std::move(on_finished)) { } void TextureDisplacementPreviewJob::process(Ctl &ctl) { - ctl.update_status(0, _u8L("Computing texture displacement preview")); + // No ctl.update_status() anywhere in here on purpose - see the class comment. A preview is + // invisible bookkeeping; the only thing on screen should be the preview itself. // Only ever touches m_input (captured by value before this job was queued) and local state - // never the live Model - so this is safe to run concurrently with the UI thread. - m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options); + m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options, + [this, &ctl](int) { + // Bail the moment this preview stops being the current + // one; build_texture_displacement() then returns an + // empty mesh and finalize() drops it. + return !ctl.was_canceled() && + (!m_current_generation || + m_current_generation->load() == m_generation); + }); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) { - if (canceled || eptr || !m_on_finished) + if (!m_on_finished) return; - m_on_finished(std::move(m_result), m_generation); + // The handler must run on *every* outcome, cancellation included, because the caller uses it to + // clear its "a job is in flight" latch. Returning early on `canceled` - which is what a cancel_all() + // from Plater (project load/close, app exit) delivers - left that latch stuck true and no preview + // was ever queued again for the rest of the session. An empty result is the caller's signal that + // nothing usable came back; it already handles that. + if (canceled || eptr) + m_on_finished(indexed_triangle_set{}, m_generation); + else + m_on_finished(std::move(m_result), m_generation); } } // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index ed59c1f302..f9e2a9f389 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -1,8 +1,10 @@ #ifndef slic3r_TextureDisplacementPreviewJob_hpp_ #define slic3r_TextureDisplacementPreviewJob_hpp_ +#include #include #include +#include #include #include "libslic3r/TextureDisplacement.hpp" @@ -27,6 +29,10 @@ struct TextureDisplacementPreviewInput // to run synchronously on every paint stroke and parameter tweak and made editing feel slow with // more than one or two layers. Unlike Bake, this never touches the live Model - a preview is // purely informational, there is nothing to commit. +// +// Deliberately reports no status: the Job framework turns the first update_status() call into an +// on-screen progress notification, and a preview firing one on every stroke and slider release +// buried the user in notifications that only closed at 100%. class TextureDisplacementPreviewJob : public Job { public: @@ -35,7 +41,14 @@ public: // current generation when the job completes, so that a burst of edits queuing several of // these jobs in a row can't have an earlier, now-stale result clobber a later one that // finishes first. + // + // `current_generation` is the caller's live counter, shared with the worker thread. The job + // polls it *while computing* and aborts as soon as it no longer matches - so a preview that has + // already been superseded stops burning CPU instead of running to completion for a result that + // will only be thrown away. That matters because the UI job worker runs one job at a time in FIFO + // order: without it, a Bake queued behind a handful of stale previews waits for every one of them. TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation, + std::shared_ptr> current_generation, std::function on_finished); void process(Ctl &ctl) override; @@ -44,6 +57,7 @@ public: private: TextureDisplacementPreviewInput m_input; uint64_t m_generation; + std::shared_ptr> m_current_generation; indexed_triangle_set m_result; std::function m_on_finished; };