From 2db770b672ca6fccfb9703796473968550722aed Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Wed, 29 Jul 2026 08:44:16 +0200 Subject: [PATCH] Displace the painted patch border and add post-process smoothing --- TEXTURE_DISPLACEMENT.md | 68 ++++- src/libslic3r/Model.hpp | 10 +- src/libslic3r/TextureDisplacement.cpp | 130 +++++++++- src/libslic3r/TextureDisplacement.hpp | 74 +++++- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 142 +++++++++++ .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 7 + .../GUI/Jobs/TextureDisplacementBakeJob.cpp | 4 +- .../GUI/Jobs/TextureDisplacementBakeJob.hpp | 1 + .../Jobs/TextureDisplacementPreviewJob.cpp | 2 +- .../Jobs/TextureDisplacementPreviewJob.hpp | 1 + tests/libslic3r/test_texture_displacement.cpp | 235 ++++++++++++++++-- 11 files changed, 623 insertions(+), 51 deletions(-) diff --git a/TEXTURE_DISPLACEMENT.md b/TEXTURE_DISPLACEMENT.md index 680c984998..c30592de77 100644 --- a/TEXTURE_DISPLACEMENT.md +++ b/TEXTURE_DISPLACEMENT.md @@ -35,16 +35,21 @@ semantics). ### Bake algorithm (`libslic3r/TextureDisplacement.cpp`) -`build_texture_displacement(base_mesh, layers, facets_data)` is **accumulate-then-displace, and -topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the same -order - only the positions of displaced vertices differ. +`build_texture_displacement(base_mesh, layers, facets_data, options)` is **accumulate-then-displace, +and topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the +same order - only the positions of displaced vertices differ. 1. `its_compactify_vertices()` on a copy of the input. In practice a no-op (it only drops *unreferenced* vertices, and preserves the order and indices of the rest). It is there to guarantee the index alignment step 3 depends on. 2. Area-weighted vertex normals of the **undisplaced** mesh, computed once. Every layer both projects and displaces along these, so a vertex covered by several layers moves along one single - well-defined direction. + well-defined direction. Where the paint does *not* cover every triangle around a vertex, the normal + is recomputed from the painted triangles alone (the union over all layers, so it stays one direction + per vertex). On the rim of a fully painted top face the whole-mesh normal is the 45 degrees bisector + it shares with the side wall, and displacing along that flares the rim outwards instead of raising + it. Interior vertices are unaffected - all their triangles are painted, so the two coincide. Paint + coverage per original triangle comes straight off `TriangleSplittingData::triangles_to_split`. 3. For each layer in slot order: deserialize its stored paint mask into a `TriangleSelector` against the **base mesh** (never against a previous layer's output), then `selector.get_facets_strict(ENFORCER)` → the painted patch. Two facts are exploited: @@ -57,16 +62,55 @@ order - only the positions of displaced vertices differ. referenced ones in order. Combined with step 1, **selector vertex index `i` is our vertex `i`**. Split vertices live past the end of our array and are simply skipped - they sit on the paint boundary anyway (splitting only happens at partial coverage), so they would be pinned regardless. -4. A vertex used by at least one **unpainted** triangle is a boundary vertex - pinned, never - displaced (its final position is ambiguous, it belongs to both regions). Only vertices used - exclusively by painted triangles get displaced. This is what keeps bakes seamless with zero - remeshing/hole-filling at the seam. +4. A vertex used by at least one **unpainted** triangle is a border vertex. Whether it moves is + `TextureDisplacementOptions::displace_border`, and it **does by default**. The original design + pinned it, justified as stopping the patch tearing away from the surrounding surface - which stopped + being true the moment the bake became topology-preserving. There is no seam to tear: a border vertex + is *one* vertex shared by both regions, and moving it simply tilts the unpainted triangles that use + it. What pinning actually does is clamp the outermost ring of relief to zero, so on a fully painted + face the pattern collapses into a ring of steep ramps right at the edge - the reported "it doesn't + extrude at the border" artifact. Pinning is kept as an option for the case where the relief must not + spill past the paint at all. Either way the border still drives the `edge_smoothing` falloff. 5. Per interior vertex: sample the height texture (`sample_layer_height()`, see Projection methods) and fold `height * depth_mm * (invert ? -1 : 1)` into that vertex's running total via the layer's `TextureBlendMode` (see Blend modes). A `visited` set makes each layer fold in exactly **once** per vertex, no matter how many of the patch's triangles share it - otherwise a Multiply/Subtract layer would apply two or three times over depending on local triangle fan-out. 6. Finally, move each touched vertex along its (step 2) normal by its accumulated total. +7. Optionally (`TextureDisplacementOptions::smooth_*`) relax the result - see Post-process smoothing. + +### Post-process smoothing + +`smooth_mesh_vertices(mesh, movable, strength, iterations)` - plain Laplacian relaxation, run after all +layers have been folded in, restricted to the vertices flagged in `movable`. Each pass moves a movable +vertex a `strength` fraction of the way to the average of its one-ring, read from a **snapshot** of the +previous pass so the result does not depend on vertex order (a Gauss-Seidel sweep would smooth several +times as hard at the end of the array as at the start). Neighbours come from a CSR-style adjacency built +once per call. + +Its job is to round off the hard steps a bitmap height map leaves behind, which is a different knob from +`TextureDisplacementLayer::smoothing` - that blurs the *height map* before it is ever sampled, this +relaxes the *geometry* afterwards. Topology-preserving, like the bake. + +Two ways in, sharing one set of settings on the volume: +- The **"Smooth result"** checkbox + "Smoothing (%)" / "Passes" ride along with Preview and Bake. + `movable` is exactly the set of vertices the displacement moved, so the untouched part of the model + keeps its exact geometry and the ring just outside the displaced set anchors the relaxation (the + relief cannot creep outward). +- **"Smooth baked mesh now"** (`GLGizmoTextureDisplacement::smooth_model()`) applies the same settings to + the volume's *committed* geometry, for relief that is already baked in. `movable` there is the painted + triangles' vertices. Because smoothing never touches the triangle list, this is the one geometry + operation in the gizmo that keeps **every** paint channel verbatim - it saves and restores the eight + texture-displacement masks around `set_mesh()` rather than remapping or dropping them. + +**"Ignore outer ring"** (`smooth_skip_border`, **on by default**) drops the patch's own outermost ring of +vertices from `movable`. That ring's neighbours *outside* the paint never move, so relaxing it drags the +rim of the relief back down toward the flat surface and the pattern comes out half-melted exactly where +it meets the edge - crisp everywhere else, which is what makes it look like a bug rather than a setting. +Held out, the border keeps the full depth the texture asked for and only the interior relaxes. Turning it +off softens the outer edge deliberately (a blunter version of the per-layer edge-smoothing falloff). +Note this is the *smoothing* rim, a separate question from whether that rim is displaced at all +(`displace_border`, step 4 above) - the two are independent and both default to "keep the border sharp". ### Blend modes @@ -451,8 +495,9 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward **libslic3r (core, no GUI dependency):** - `src/libslic3r/TextureDisplacement.hpp/.cpp` - data model, bake algorithm, projection methods, - tiling, subdivision (uniform + adaptive longest-edge bisection). See doc comments throughout, - they're kept accurate and up to date. + tiling, subdivision (uniform + adaptive longest-edge bisection), post-process smoothing + (`smooth_mesh_vertices()`), and `TextureDisplacementOptions` (the whole-stack settings). See doc + comments throughout, they're kept accurate and up to date. - `src/libslic3r/MeshBoolean.hpp/.cpp` - added `parameterize_lscm()` and `remesh_isotropic()` in the `cgal` sub-namespace, reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already there for @@ -461,7 +506,8 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward LSCM_parameterizer_3, parameterize}.h`. No new dependency - CGAL 5.6.3 is already vendored and the `Surface_mesh_parameterization` package headers were already present, just unused before now. - `src/libslic3r/Model.hpp/.cpp` - the 8 named `FacetsAnnotation` fields + accessor, - `texture_displacement_layers`, and all the mirrored touch points (see Data model above). + `texture_displacement_layers`, `texture_displacement_options`, and all the mirrored touch points + (see Data model above). **GUI:** - `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo. Panel controls: dock/ diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index a8c6ac4d02..f29cf0d8a7 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -938,6 +938,11 @@ public: // see build_texture_displacement()); it only reflects UI insertion order. std::vector texture_displacement_layers; + // Whole-stack displacement settings (border handling, post-process smoothing) - see + // TextureDisplacementOptions. They live beside the layers rather than on one of them because + // they are not a property of any single layer. + TextureDisplacementOptions texture_displacement_options; + // Save painting data before reset_extra_facets() discards it. // Used for replacing mesh without losing painting data. // Only for model parts (not modifiers/connectors). @@ -1190,6 +1195,7 @@ private: texture_displacement_facets_4(other.texture_displacement_facets_4), texture_displacement_facets_5(other.texture_displacement_facets_5), texture_displacement_facets_6(other.texture_displacement_facets_6), texture_displacement_facets_7(other.texture_displacement_facets_7), texture_displacement_layers(other.texture_displacement_layers), + texture_displacement_options(other.texture_displacement_options), cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape) { assert(this->id().valid()); @@ -1288,7 +1294,7 @@ private: cereal::load_by_value(ar, f); mesh_changed |= tf != f.timestamp(); } - ar(texture_displacement_layers); + ar(texture_displacement_layers, texture_displacement_options); cereal::load_by_value(ar, config); cereal::load(ar, text_configuration); cereal::load(ar, emboss_shape); @@ -1312,7 +1318,7 @@ private: cereal::save_by_value(ar, fuzzy_skin_facets); for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) cereal::save_by_value(ar, texture_displacement_facet(i)); - ar(texture_displacement_layers); + ar(texture_displacement_layers, texture_displacement_options); cereal::save_by_value(ar, config); cereal::save(ar, text_configuration); cereal::save(ar, emboss_shape); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 1d8df784e7..71a8b8001f 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1117,7 +1117,8 @@ 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 TextureDisplacementFacetsData &facets_data, + const TextureDisplacementOptions &options) { indexed_triangle_set mesh = base_mesh; // TriangleSelector's vertex array starts with the mesh's own vertices (any extra ones, created @@ -1144,10 +1145,51 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // rather than against whatever the previous layer left behind. That is what lets all the layers // be evaluated independently and merged per vertex, instead of having to re-mesh and remap the // paint masks between them (see the header for why that earlier design was dropped). - const std::vector vertex_normals = texture_displacement_vertex_normals(mesh); + std::vector vertex_normals = texture_displacement_vertex_normals(mesh); + + // ... with one correction, applied where the painted area does not cover every triangle around a + // vertex: there the direction to move in is the normal of the *painted* surface, not of the whole + // mesh. On the rim of a fully painted top face the whole-mesh normal is the 45 degrees bisector + // between the face and the side wall it meets, so displacing along it flares the rim outwards + // instead of raising it. Taken over the union of every layer's paint (triangles_to_split is + // exactly the set of original triangles a layer's brush touched - serialize() records an entry for + // each one that is split or carries a non-default state), so a vertex still has one single + // direction however many layers cover it. Interior vertices are unaffected: all their triangles + // are painted, so the two normals coincide. + { + std::vector painted_face(mesh.indices.size(), 0); + bool any_paint = false; + for (const TextureDisplacementLayer *layer : ordered_layers) + for (const TriangleSelector::TriangleBitStreamMapping &m : facets_data[size_t(layer->slot)].triangles_to_split) + if (size_t(m.triangle_idx) < mesh.indices.size()) { + painted_face[size_t(m.triangle_idx)] = 1; + any_paint = true; + } + if (any_paint) { + std::vector painted_normals(mesh.vertices.size(), Vec3f::Zero()); + for (size_t i = 0; i < mesh.indices.size(); ++i) { + if (!painted_face[i]) + continue; + const stl_triangle_vertex_indices &t = mesh.indices[i]; + const Vec3f fn = (mesh.vertices[t[1]] - mesh.vertices[t[0]]).cross(mesh.vertices[t[2]] - mesh.vertices[t[0]]); + for (int k = 0; k < 3; ++k) + painted_normals[size_t(t[k])] += fn; // area-weighted, same convention as the full normals + } + for (size_t v = 0; v < vertex_normals.size(); ++v) + if (const float l = painted_normals[v].norm(); l > 1e-8f) + vertex_normals[v] = painted_normals[v] / l; + // else: no painted triangle touches this vertex, so it will not be displaced anyway - + // leave the whole-mesh normal in place rather than zeroing it. + } + } std::vector displacement(mesh.vertices.size(), 0.f); std::vector displaced(mesh.vertices.size(), false); + // 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 + // rim another layer put there. + std::vector on_patch_border(mesh.vertices.size(), false); bool any_displacement = false; const TriangleMesh selector_mesh(mesh); @@ -1171,13 +1213,19 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // compactify above, it is our own. const indexed_triangle_set rest = selector.get_facets_strict(EnforcerBlockerType::NONE); - // A vertex used by even one *unpainted* triangle sits on this layer's boundary: it belongs - // to both the painted patch and the untouched surface, so displacing it would tear the two - // apart. Pinning it is what keeps the result seamless with no remeshing at the seam. + // A vertex used by even one *unpainted* triangle sits on this layer's boundary. It is still + // needed either way - the edge-smoothing falloff measures distance from it - but whether it is + // held flat is now the user's call (TextureDisplacementOptions::displace_border), because + // nothing can tear: the bake is topology-preserving, so a border vertex is one vertex shared + // by both regions and moving it just tilts the unpainted triangles that use it. std::vector is_boundary(patch.vertices.size(), false); for (const stl_triangle_vertex_indices &tri : rest.indices) - for (int i = 0; i < 3; ++i) + for (int i = 0; i < 3; ++i) { is_boundary[tri[i]] = true; + if (tri[i] < int(mesh.vertices.size())) + on_patch_border[size_t(tri[i])] = true; + } + const bool pin_boundary = !options.displace_border; // Only the Cylindrical/Spherical methods need these; Triplanar blends each vertex's own // normal and LSCM solves the patch globally. @@ -1246,7 +1294,7 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set const int vi = tri[i]; // Split vertices the brush introduced live past the end of our own vertex array; // they carry no displacement of their own and are not part of the output mesh. - if (vi >= int(mesh.vertices.size()) || is_boundary[vi] || visited[vi]) + if (vi >= int(mesh.vertices.size()) || (pin_boundary && is_boundary[vi]) || visited[vi]) continue; visited[vi] = true; @@ -1277,6 +1325,19 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set if (displaced[vi]) mesh.vertices[vi] += vertex_normals[vi] * displacement[vi]; + // Post-process relaxation of what the height maps left behind, restricted to the vertices that + // actually moved - the untouched part of the model keeps its exact geometry, and the ring of + // vertices just outside the displaced set stays put and anchors the smoothing so the relief does + // not creep outward. `smooth_skip_border` additionally holds the patch's own outermost ring, whose + // 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) { + 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); + } + return mesh; } @@ -1286,7 +1347,60 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); - return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data); + return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data, + volume.texture_displacement_options); +} + +void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, + int iterations) +{ + if (iterations <= 0 || mesh.vertices.empty() || movable.size() != mesh.vertices.size()) + return; + strength = std::clamp(strength, 0.f, 1.f); + if (strength <= 0.f) + return; + if (std::none_of(movable.begin(), movable.end(), [](uint8_t m) { return m != 0; })) + return; + + // One-ring neighbours as a CSR-style pair of arrays: counted, prefix-summed, then filled. A + // triangle contributes each of its edges to both endpoints, so a shared edge is listed once per + // incident triangle - the duplicates are harmless here, they just weight an interior edge the same + // way from both sides, and dropping them would cost a sort per vertex for no visible difference. + const size_t nv = mesh.vertices.size(); + std::vector start(nv + 1, 0); + for (const stl_triangle_vertex_indices &t : mesh.indices) + for (int e = 0; e < 3; ++e) { + ++start[size_t(t[e]) + 1]; + ++start[size_t(t[(e + 1) % 3]) + 1]; + } + for (size_t v = 0; v < nv; ++v) + start[v + 1] += start[v]; + const size_t total_refs = size_t(start[nv]); + std::vector nbr(total_refs, 0); + std::vector fill(start.begin(), start.begin() + nv); + for (const stl_triangle_vertex_indices &t : mesh.indices) + for (int e = 0; e < 3; ++e) { + const int a = t[e], b = t[(e + 1) % 3]; + nbr[size_t(fill[size_t(a)]++)] = b; + nbr[size_t(fill[size_t(b)]++)] = a; + } + + // Read every pass from a snapshot of the previous one, so the result does not depend on the order + // vertices happen to be visited in (a Gauss-Seidel sweep would smooth several times as hard at the + // end of the array as at the start). + 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; + } + } } HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 39e3ce634f..6a824b2054 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -295,6 +295,47 @@ struct TextureDisplacementLayer } }; +// Settings that apply to the whole layer stack rather than to one layer, held per ModelVolume next +// to texture_displacement_layers and consumed by build_texture_displacement(). +struct TextureDisplacementOptions +{ + // Whether the painted patch's *border* vertices - the ones also used by unpainted triangles - + // are displaced along with the rest, or pinned flat. + // + // Pinning them was originally justified as keeping the patch from tearing away from the + // surrounding surface. That reasoning no longer applies: since the bake became + // topology-preserving it only ever *moves* the input's own vertices, so a border vertex is one + // vertex shared by both regions and moving it simply tilts the unpainted triangles that use it - + // nothing can come apart. What pinning actually does is clamp the outermost ring of the relief to + // zero, which on a fully painted face collapses the pattern into a ring of steep ramps right at + // the edge (the "it doesn't extrude at the border" artifact). Displacing it is the default; + // pinning is kept for the case where the relief must not spill past the paint at all. + bool displace_border = true; + + // Optional Laplacian relaxation of the displaced surface, run after all layers have been folded + // in - a post-process, not a texture filter (TextureDisplacementLayer::smoothing blurs the height + // map instead, before it is ever sampled). Rounds off the hard steps a bitmap height map leaves + // behind. Restricted to vertices the displacement actually moved, so the rest of the model keeps + // its exact geometry. `smooth_strength` in [0, 1] is how far each pass moves a vertex toward the + // average of its neighbours. + bool smooth_enabled = false; + float smooth_strength = 0.3f; + int smooth_iterations = 2; + + // Hold the painted patch's outermost ring of vertices out of the smoothing. Those vertices sit + // next to unpainted ones that are pinned by definition, so relaxing them drags the rim of the + // relief back down toward the undisplaced surface - the pattern looks half-melted exactly where it + // meets the edge, however crisp the rest of it is. Excluding them keeps the border extruded at + // full depth and smooths only the interior. On by default; turn it off to soften the outer edge + // deliberately (which is a blunter version of the per-layer edge-smoothing falloff). + bool smooth_skip_border = true; + + template void serialize(Archive &ar) + { + ar(displace_border, smooth_enabled, smooth_strength, smooth_iterations, smooth_skip_border); + } +}; + // Decoded 8-bit grayscale height sample, independent of any GUI/OpenGL texture object so it can // be evaluated from a background bake Job as well as from GUI-side preview code. struct DecodedHeightTexture @@ -473,9 +514,14 @@ using TextureDisplacementFacetsData = std::array &layers, - const TextureDisplacementFacetsData &facets_data); + const TextureDisplacementFacetsData &facets_data, + const TextureDisplacementOptions &options = {}); -// Convenience overload for main-thread callers: extracts the mesh/layers/paint data from `volume` -// and forwards to the overload above. +// Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from +// `volume` and forwards to the overload above. indexed_triangle_set build_texture_displacement(const ModelVolume &volume); +// Laplacian relaxation of `mesh` in place, restricted to the vertices flagged in `movable` (sized to +// the mesh's vertex count; anything else is held exactly where it is and still acts as an anchor for +// its neighbours). Each of `iterations` passes moves a movable vertex a `strength` fraction of the +// way to the average of the vertices it shares an edge with, computed from the positions at the +// start of that pass so the result does not depend on vertex order. +// +// Topology-preserving like the bake itself, so it composes with it: this is what "smooth the relief +// 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. +void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector &movable, float strength, + int iterations); + // 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 // where the displaced surface has *curvature* worth spending triangles on. Called serially from the diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index ea489364aa..78886f6be8 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1192,6 +1192,7 @@ void GLGizmoTextureDisplacement::rebuild_preview() TextureDisplacementPreviewInput input; input.base_mesh = mv->mesh().its; input.layers = mv->texture_displacement_layers; + input.options = mv->texture_displacement_options; for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); @@ -2836,6 +2837,90 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive() m_parent.set_as_dirty(); } +void GLGizmoTextureDisplacement::smooth_model() +{ + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr) + return; + const TextureDisplacementOptions &opts = mv->texture_displacement_options; + if (opts.smooth_strength <= 0.f || opts.smooth_iterations <= 0) + return; + + update_model_object(); // flush any in-progress stroke, so the painted region below is current + + // Movable = the vertices of the painted triangles, so the first ring of purely unpainted vertices + // outside them stays put and anchors the result. Restricted rather than whole-model on purpose: + // this runs on geometry that has already been baked, and relaxing the whole thing would quietly + // round off every unrelated feature on the part. With "Ignore outer ring" the patch's own rim is + // held as well - see TextureDisplacementOptions::smooth_skip_border. + const indexed_triangle_set &its = mv->mesh().its; + std::vector painted_face(its.indices.size(), 0); + bool any = false; + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) + for (const TriangleSelector::TriangleBitStreamMapping &m : mv->texture_displacement_facet(slot).get_data().triangles_to_split) + if (size_t(m.triangle_idx) < its.indices.size()) { + painted_face[size_t(m.triangle_idx)] = 1; + any = true; + } + if (!any) { + show_error(nullptr, _u8L("Paint the area you want to smooth first - smoothing only touches the " + "painted part of the model.")); + return; + } + std::vector movable(its.vertices.size(), 0); + for (size_t i = 0; i < its.indices.size(); ++i) + if (painted_face[i]) + for (int k = 0; k < 3; ++k) + movable[size_t(its.indices[i][k])] = 1; + if (opts.smooth_skip_border) + for (size_t i = 0; i < its.indices.size(); ++i) + if (!painted_face[i]) + for (int k = 0; k < 3; ++k) + movable[size_t(its.indices[i][k])] = 0; // also used by unpainted geometry -> the rim + if (std::none_of(movable.begin(), movable.end(), [](uint8_t m) { return m != 0; })) { + // Every painted vertex is on the patch's rim, so "Ignore outer ring" leaves nothing to move. + show_error(nullptr, _u8L("Nothing to smooth - the painted area is only one triangle deep, so with " + "\"Ignore outer ring\" on there are no interior vertices to relax.")); + return; + } + + indexed_triangle_set smoothed = its; + { + wxBusyCursor wait; + smooth_mesh_vertices(smoothed, movable, opts.smooth_strength, opts.smooth_iterations); + } + + Plater *plater = wxGetApp().plater(); + Plater::TakeSnapshot snapshot(plater, _u8L("Smooth texture displacement"), UndoRedo::SnapshotType::GizmoAction); + + // No save/restore-painting dance here, unlike subdivide and remesh: smoothing only moves vertices, + // it does not touch the triangle list, so every paint channel still refers to exactly the triangles + // it did before. set_mesh() clears the extra facets, so this saves and puts back the + // texture-displacement masks verbatim - no remap needed, and none of them is lost. + std::optional saved_painting = mv->save_painting(); + std::array saved_texture; + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + saved_texture[size_t(i)] = mv->texture_displacement_facet(i).get_data(); + + mv->set_mesh(TriangleMesh(std::move(smoothed))); + mv->set_new_unique_id(); + mv->calculate_convex_hull(); + mv->restore_painting(saved_painting); + for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) + mv->texture_displacement_facet(i).set_data(std::move(saved_texture[size_t(i)])); + + if (ObjectList *obj_list = wxGetApp().obj_list()) { + const ModelObjectPtrs &objs = plater->model().objects; + auto it = std::find(objs.begin(), objs.end(), mo); + if (it != objs.end()) + obj_list->update_info_items(size_t(it - objs.begin())); + } + plater->changed_object(*mo); + update_from_model_object(false); + m_parent.set_as_dirty(); +} + void GLGizmoTextureDisplacement::remesh_model() { ModelVolume *mv = texture_volume(); @@ -3769,6 +3854,63 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float } // (The "Add layer" button now lives next to the "Texture layers" heading, as an icon.) + // Settings for the whole stack rather than one layer, so they sit outside the layer list. + if (mv != nullptr) { + TextureDisplacementOptions &opts = mv->texture_displacement_options; + ImGui::Separator(); + + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Displace up to the border").c_str(), &opts.displace_border); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Move the outermost ring of painted vertices as well, so the relief runs all the " + "way to the edge of the painted area. Turn this off to hold that ring flat, which " + "keeps the displacement strictly inside the paint but flattens the pattern right " + "at the border."), + m_imgui->scaled(20.f)); + + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Smooth result").c_str(), &opts.smooth_enabled); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Relax the displaced surface after the textures have been applied, to round off " + "the hard steps a bitmap height map leaves behind. Only the vertices the " + "displacement moved are touched. This is geometry smoothing - the per-layer " + "\"Blur\" slider instead softens the height map before it is sampled."), + m_imgui->scaled(20.f)); + if (opts.smooth_enabled) { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + float percent = opts.smooth_strength * 100.f; + if (m_imgui->slider_float(std::string(_u8L("Smoothing (%)")) + "##dispsmooth", &percent, 1.f, 100.f, "%.0f", 1.f)) { + opts.smooth_strength = std::clamp(percent / 100.f, 0.01f, 1.f); + m_preview_params_dirty = true; + } + if (ImGui::SliderInt((_u8L("Passes") + "##dispsmoothit").c_str(), &opts.smooth_iterations, 1, 10)) { + opts.smooth_iterations = std::clamp(opts.smooth_iterations, 1, 10); + m_preview_params_dirty = true; + } + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("How many relaxation passes to run. More passes reach further across the " + "surface; strength controls how much each one moves a vertex."), + m_imgui->scaled(20.f)); + ImGui::PopItemWidth(); + + m_preview_params_dirty |= ImGui::Checkbox(_u8L("Ignore outer ring").c_str(), &opts.smooth_skip_border); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Leave the outermost ring of painted vertices out of the smoothing. Their " + "neighbours outside the paint never move, so relaxing them drags the relief " + "back down and the pattern comes out half-melted right at the edge. Turn this " + "off only if you want that edge softened on purpose."), + m_imgui->scaled(20.f)); + + // The settings above ride along with Preview/Bake. This button is for geometry that has + // *already* been baked, where there is no displacement left to fold the smoothing into. + if (m_imgui->button(_u8L("Smooth baked mesh now"))) + smooth_model(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Apply the smoothing above to the model's real geometry right now, using the " + "painted area to decide what to touch. For relief that is already baked in - " + "unbaked displacement is smoothed by Bake itself."), + m_imgui->scaled(20.f)); + } + } + ImGui::Separator(); m_imgui->text(_u8L("Not enough vertices for fine detail?")); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index a4807eb61d..1f3c6bcc73 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -335,6 +335,13 @@ private: bool collect_paint_region(std::vector ®ion, std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) 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; + // this is the escape hatch for relief that has already been baked in, where there is no + // displacement pass left to attach them to. Topology-preserving, so unlike subdivide and remesh it + // keeps every paint channel - including texture displacement - exactly as it was. + void smooth_model(); + // Isotropic remeshing (CGAL) to even out wildly varying triangle sizes so displacement has a // consistent density to work with. Target edge length in mm; 0 means "not yet initialised", filled // with the mesh's mean edge length the first time the control is shown. Like subdivide, it replaces diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp index 155760709d..ae7e4dc3dd 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementBakeJob.cpp @@ -24,7 +24,8 @@ void TextureDisplacementBakeJob::process(Ctl &ctl) // 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_result = TriangleMesh(build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, + m_input.options)); } void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &eptr) @@ -78,6 +79,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, std::function layers; TextureDisplacementFacetsData facets_data; + TextureDisplacementOptions options; }; // Bakes a volume's painted texture-displacement layers into real mesh geometry in the background, diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp index 48b031dcc3..555ed570b1 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.cpp @@ -16,7 +16,7 @@ void TextureDisplacementPreviewJob::process(Ctl &ctl) // 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_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options); } void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr) diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp index 93d66bcfb2..ed59c1f302 100644 --- a/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp @@ -19,6 +19,7 @@ struct TextureDisplacementPreviewInput indexed_triangle_set base_mesh; std::vector layers; TextureDisplacementFacetsData facets_data; + TextureDisplacementOptions options; }; // Computes the true (unbaked) displaced-mesh preview in the background. With several painted diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 6b3420845e..6f41c76bce 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1,8 +1,10 @@ #define NOMINMAX #include +#include #include #include +#include #include #include "libslic3r/TextureDisplacement.hpp" @@ -35,6 +37,30 @@ static std::shared_ptr> make_flat_gray_png(uint8_t va return std::make_shared>(std::move(bytes)); } +// A hard-edged black/white checkerboard, the worst case for a height map: every texel boundary is a +// step, which is precisely the relief the post-process smoothing exists to round off. +static std::shared_ptr> make_checkerboard_png(size_t w = 16, size_t h = 16) +{ + std::vector pixels(w * h); + for (size_t y = 0; y < h; ++y) + for (size_t x = 0; x < w; ++x) + pixels[y * w + x] = ((x / 2 + y / 2) % 2) ? 255 : 0; + const boost::filesystem::path tmp_path = boost::filesystem::temp_directory_path() + / boost::filesystem::unique_path("texdisp_test_%%%%%%%%.png"); + REQUIRE(Slic3r::png::write_gray_to_file(tmp_path.string(), w, h, pixels)); + + std::vector bytes; + { + std::ifstream ifs(tmp_path.string(), std::ios::binary); + bytes.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + boost::system::error_code ec; + boost::filesystem::remove(tmp_path, ec); + + REQUIRE_FALSE(bytes.empty()); + return std::make_shared>(std::move(bytes)); +} + TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]") { TextureDisplacementLayer layer; @@ -188,15 +214,15 @@ TEST_CASE("TextureDisplacement: the lowest layer ignores its blend mode", "[Text CHECK_THAT((result.vertices[i] - cube.vertices[i]).norm(), WithinAbs(2.0f, 1e-3f)); } -TEST_CASE("TextureDisplacement: boundary vertices shared with unpainted triangles are pinned", "[TextureDisplacement]") +TEST_CASE("TextureDisplacement: the patch border is displaced by default and pinned on request", "[TextureDisplacement]") { // A small triangle fan around a central vertex O, with 4 outer points A/B/C/D forming 4 - // triangles T0..T3 in the XY plane. Only T0, T1, T2 are painted, T3 is left unpainted: - // O: touches all 4 triangles (incl. unpainted T3) -> boundary, must NOT move - // A: touches T0 (painted) and T3 (unpainted) -> boundary, must NOT move - // D: touches T2 (painted) and T3 (unpainted) -> boundary, must NOT move - // B: touches only T0 and T1 (both painted) -> interior, SHOULD move - // C: touches only T1 and T2 (both painted) -> interior, SHOULD move + // triangles T0..T3 in the XY plane. Only T0, T1, T2 are painted, T3 is left unpainted, so: + // O: touches all 4 triangles (incl. unpainted T3) -> patch border + // A: touches T0 (painted) and T3 (unpainted) -> patch border + // D: touches T2 (painted) and T3 (unpainted) -> patch border + // B: touches only T0 and T1 (both painted) -> interior + // C: touches only T1 and T2 (both painted) -> interior indexed_triangle_set fan; fan.vertices = { {0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {-1.f, 0.f, 0.f}, {0.f, -1.f, 0.f} }; fan.indices = { {0, 1, 2}, {0, 2, 3}, {0, 3, 4}, {0, 4, 1} }; @@ -217,22 +243,189 @@ TEST_CASE("TextureDisplacement: boundary vertices shared with unpainted triangle layer.tiling_scale = 5.0f; layer.image_data = make_flat_gray_png(255); - const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets); - - // Find each named vertex's post-bake position by matching the original (pinned vertices keep - // their exact original position; moved ones won't match any original position anymore). - auto still_at_original_position = [&](const Vec3f &original) { - for (const Vec3f &v : result.vertices) - if ((v - original).norm() < 1e-6f) - return true; - return false; + // The bake is topology-preserving, so it only ever moves fan's own vertices, in order. + auto moved = [&](const indexed_triangle_set &result, size_t i) { + return (result.vertices[i] - fan.vertices[i]).norm() > 1e-6f; }; - CHECK(still_at_original_position(fan.vertices[0])); // O: boundary - CHECK(still_at_original_position(fan.vertices[1])); // A: boundary - CHECK(still_at_original_position(fan.vertices[4])); // D: boundary - CHECK_FALSE(still_at_original_position(fan.vertices[2])); // B: interior, must have moved - CHECK_FALSE(still_at_original_position(fan.vertices[3])); // C: interior, must have moved + SECTION("by default the whole painted patch moves, border included") + { + const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets); + REQUIRE(result.vertices.size() == fan.vertices.size()); + for (size_t i = 0; i < fan.vertices.size(); ++i) + CHECK(moved(result, i)); + // ... straight along the painted surface's own normal (+Z here), by the full depth. Nothing + // has torn: the unpainted triangle T3 simply shares the moved vertices. + for (size_t i = 0; i < fan.vertices.size(); ++i) + CHECK_THAT(result.vertices[i].z() - fan.vertices[i].z(), WithinAbs(1.0f, 1e-3f)); + CHECK(result.indices == fan.indices); + } + + SECTION("pinning the border holds exactly the vertices an unpainted triangle also uses") + { + TextureDisplacementOptions options; + options.displace_border = false; + const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets, options); + CHECK_FALSE(moved(result, 0)); // O: border + CHECK_FALSE(moved(result, 1)); // A: border + CHECK_FALSE(moved(result, 4)); // D: border + CHECK(moved(result, 2)); // B: interior + CHECK(moved(result, 3)); // C: interior + } + +} + +TEST_CASE("TextureDisplacement: post-process smoothing relaxes only what moved", "[TextureDisplacement]") +{ + // A checkerboard height map on a fine grid gives a relief full of hard steps - exactly what the + // smoothing pass is for. Measure it as the spread of the displaced heights: relaxing the surface + // must pull the extremes in without touching the mesh's topology. + indexed_triangle_set plane; + plane.vertices = { { 0.f, 0.f, 0.f }, { 20.f, 0.f, 0.f }, { 20.f, 20.f, 0.f }, { 0.f, 20.f, 0.f } }; + plane.indices = { { 0, 1, 2 }, { 0, 2, 3 } }; + const indexed_triangle_set grid = subdivide_mesh_uniform(plane, 1.f, 6); + REQUIRE(grid.indices.size() > 256); + + // Paint only the central square, so the patch has a real border to test against. + std::vector painted(grid.indices.size(), 0); + const TriangleMesh grid_mesh(grid); + TriangleSelector selector(grid_mesh); + for (int i = 0; i < int(grid.indices.size()); ++i) { + const auto &t = grid.indices[i]; + float cx = 0.f, cy = 0.f; + for (int k = 0; k < 3; ++k) { + cx += grid.vertices[t[k]].x() / 3.f; + cy += grid.vertices[t[k]].y() / 3.f; + } + if (cx > 5.f && cx < 15.f && cy > 5.f && cy < 15.f) { + selector.set_facet(i, EnforcerBlockerType::ENFORCER); + painted[size_t(i)] = 1; + } + } + REQUIRE(std::count(painted.begin(), painted.end(), uint8_t(1)) > 32); + TextureDisplacementFacetsData facets{}; + facets[0] = selector.serialize(); + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.depth_mm = 2.0f; + layer.tiling_scale = 6.0f; + layer.image_data = make_checkerboard_png(); + + auto z_spread = [](const indexed_triangle_set &its) { + float lo = its.vertices.front().z(), hi = lo; + for (const Vec3f &v : its.vertices) { + lo = std::min(lo, v.z()); + hi = std::max(hi, v.z()); + } + return hi - lo; + }; + + const indexed_triangle_set raw = build_texture_displacement(grid, { layer }, facets); + REQUIRE(z_spread(raw) > 1.f); // the checkerboard really did produce relief to smooth + + TextureDisplacementOptions options; + options.smooth_enabled = true; + options.smooth_strength = 0.5f; + options.smooth_iterations = 4; + + SECTION("it rounds off the steps without touching the topology") + { + const indexed_triangle_set smoothed = build_texture_displacement(grid, { layer }, facets, options); + CHECK(z_spread(smoothed) < z_spread(raw)); + CHECK(smoothed.indices == raw.indices); + CHECK(smoothed.vertices.size() == raw.vertices.size()); + } + + SECTION("\"ignore outer ring\" leaves the patch rim at the depth the texture asked for") + { + // The rim: vertices shared by a painted and an unpainted triangle. Their neighbours outside + // the paint never move, so relaxing them drags the relief back down toward the flat surface - + // which is what makes the pattern look half-melted right at the edge. + std::vector rim(grid.vertices.size(), 0), inside(grid.vertices.size(), 0); + for (size_t i = 0; i < grid.indices.size(); ++i) + for (int k = 0; k < 3; ++k) + (painted[i] ? inside : rim)[size_t(grid.indices[i][k])] = 1; + size_t rim_count = 0; + for (size_t v = 0; v < rim.size(); ++v) { + rim[v] = (rim[v] && inside[v]) ? 1 : 0; + rim_count += rim[v]; + } + REQUIRE(rim_count > 8); + + // The tallest point of the rim. Laplacian relaxation is a convex average, so no vertex can ever + // exceed the mesh's current maximum - and a rim vertex sitting at that maximum has at least one + // neighbour outside the paint at zero, so it must come down. That makes this a strict, not a + // statistical, comparison. + auto rim_peak = [&](const indexed_triangle_set &its) { + float peak = 0.f; + for (size_t v = 0; v < rim.size(); ++v) + if (rim[v]) + peak = std::max(peak, its.vertices[v].z()); + return peak; + }; + + options.smooth_skip_border = true; + const indexed_triangle_set kept = build_texture_displacement(grid, { layer }, facets, options); + options.smooth_skip_border = false; + const indexed_triangle_set relaxed = build_texture_displacement(grid, { layer }, facets, options); + + // Held out of the smoothing, the rim is bit-for-bit the un-smoothed displacement. + for (size_t v = 0; v < rim.size(); ++v) + if (rim[v]) + CHECK_THAT(kept.vertices[v].z(), WithinAbs(raw.vertices[v].z(), 1e-6f)); + CHECK(rim_peak(relaxed) < rim_peak(kept)); // ... and melted back down without it + // ... while the interior really was smoothed - this is not just "smoothing turned off". + CHECK_FALSE(kept.vertices == raw.vertices); + } +} + +TEST_CASE("TextureDisplacement: smooth_mesh_vertices holds everything outside its mask", "[TextureDisplacement]") +{ + // A single spike on a flat sheet: relaxing it must pull the spike down and leave every vertex + // that is not flagged movable at exactly the coordinates it started at. + indexed_triangle_set plane; + plane.vertices = { { 0.f, 0.f, 0.f }, { 8.f, 0.f, 0.f }, { 8.f, 8.f, 0.f }, { 0.f, 8.f, 0.f } }; + plane.indices = { { 0, 1, 2 }, { 0, 2, 3 } }; + indexed_triangle_set grid = subdivide_mesh_uniform(plane, 1.f, 4); + + // Raise one interior vertex, and let only it and its immediate neighbours move. + size_t spike = 0; + float best = std::numeric_limits::max(); + for (size_t i = 0; i < grid.vertices.size(); ++i) + if (const float d = (grid.vertices[i] - Vec3f(4.f, 4.f, 0.f)).norm(); d < best) { + best = d; + spike = i; + } + grid.vertices[spike].z() = 5.f; + + std::vector movable(grid.vertices.size(), 0); + movable[spike] = 1; + for (const auto &t : grid.indices) + for (int e = 0; e < 3; ++e) + if (size_t(t[e]) == spike) + for (int k = 0; k < 3; ++k) + movable[size_t(t[k])] = 1; + + const indexed_triangle_set before = grid; + smooth_mesh_vertices(grid, movable, 0.5f, 3); + + CHECK(grid.vertices[spike].z() < before.vertices[spike].z()); // the spike came down + CHECK(grid.vertices[spike].z() > 0.f); // but was not flattened outright + CHECK(grid.indices == before.indices); // topology untouched + for (size_t i = 0; i < grid.vertices.size(); ++i) + if (!movable[i]) + CHECK_THAT((grid.vertices[i] - before.vertices[i]).norm(), WithinAbs(0.f, 1e-9f)); + + // Guard rails: each of these must leave the mesh byte-identical. + for (const auto &noop : { std::make_pair(0.f, 3), std::make_pair(0.5f, 0) }) { + indexed_triangle_set copy = before; + smooth_mesh_vertices(copy, movable, noop.first, noop.second); + CHECK(copy.vertices == before.vertices); + } + indexed_triangle_set copy = before; + smooth_mesh_vertices(copy, std::vector(3, 1), 0.5f, 3); // mis-sized mask + CHECK(copy.vertices == before.vertices); } // Every undirected edge of a closed manifold mesh is shared by exactly two triangles. A T-junction