Displace the painted patch border and add post-process smoothing

This commit is contained in:
ExPikaPaka
2026-07-29 08:44:16 +02:00
parent 2a46197322
commit 2db770b672
11 changed files with 623 additions and 51 deletions

View File

@@ -35,16 +35,21 @@ semantics).
### Bake algorithm (`libslic3r/TextureDisplacement.cpp`) ### Bake algorithm (`libslic3r/TextureDisplacement.cpp`)
`build_texture_displacement(base_mesh, layers, facets_data)` is **accumulate-then-displace, and `build_texture_displacement(base_mesh, layers, facets_data, options)` is **accumulate-then-displace,
topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the same and topology-preserving**: the returned mesh has exactly the input's vertices and triangles, in the
order - only the positions of displaced vertices differ. 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 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 *unreferenced* vertices, and preserves the order and indices of the rest). It is there to
guarantee the index alignment step 3 depends on. guarantee the index alignment step 3 depends on.
2. Area-weighted vertex normals of the **undisplaced** mesh, computed once. Every layer both 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 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 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 the **base mesh** (never against a previous layer's output), then
`selector.get_facets_strict(ENFORCER)` → the painted patch. Two facts are exploited: `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`**. 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 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. 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 4. A vertex used by at least one **unpainted** triangle is a border vertex. Whether it moves is
displaced (its final position is ambiguous, it belongs to both regions). Only vertices used `TextureDisplacementOptions::displace_border`, and it **does by default**. The original design
exclusively by painted triangles get displaced. This is what keeps bakes seamless with zero pinned it, justified as stopping the patch tearing away from the surrounding surface - which stopped
remeshing/hole-filling at the seam. 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) 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 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** `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 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. 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. 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 ### Blend modes
@@ -451,8 +495,9 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward
**libslic3r (core, no GUI dependency):** **libslic3r (core, no GUI dependency):**
- `src/libslic3r/TextureDisplacement.hpp/.cpp` - data model, bake algorithm, projection methods, - `src/libslic3r/TextureDisplacement.hpp/.cpp` - data model, bake algorithm, projection methods,
tiling, subdivision (uniform + adaptive longest-edge bisection). See doc comments throughout, tiling, subdivision (uniform + adaptive longest-edge bisection), post-process smoothing
they're kept accurate and up to date. (`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()` - `src/libslic3r/MeshBoolean.hpp/.cpp` - added `parameterize_lscm()` and `remesh_isotropic()`
in the `cgal` sub-namespace, in the `cgal` sub-namespace,
reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already there for 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 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. the `Surface_mesh_parameterization` package headers were already present, just unused before now.
- `src/libslic3r/Model.hpp/.cpp` - the 8 named `FacetsAnnotation` fields + accessor, - `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:** **GUI:**
- `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo. Panel controls: dock/ - `src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp/.cpp` - the gizmo. Panel controls: dock/

View File

@@ -938,6 +938,11 @@ public:
// see build_texture_displacement()); it only reflects UI insertion order. // see build_texture_displacement()); it only reflects UI insertion order.
std::vector<TextureDisplacementLayer> texture_displacement_layers; std::vector<TextureDisplacementLayer> 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. // Save painting data before reset_extra_facets() discards it.
// Used for replacing mesh without losing painting data. // Used for replacing mesh without losing painting data.
// Only for model parts (not modifiers/connectors). // 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_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_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_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) cut_info(other.cut_info), text_configuration(other.text_configuration), emboss_shape(other.emboss_shape)
{ {
assert(this->id().valid()); assert(this->id().valid());
@@ -1288,7 +1294,7 @@ private:
cereal::load_by_value(ar, f); cereal::load_by_value(ar, f);
mesh_changed |= tf != f.timestamp(); mesh_changed |= tf != f.timestamp();
} }
ar(texture_displacement_layers); ar(texture_displacement_layers, texture_displacement_options);
cereal::load_by_value(ar, config); cereal::load_by_value(ar, config);
cereal::load(ar, text_configuration); cereal::load(ar, text_configuration);
cereal::load(ar, emboss_shape); cereal::load(ar, emboss_shape);
@@ -1312,7 +1318,7 @@ private:
cereal::save_by_value(ar, fuzzy_skin_facets); cereal::save_by_value(ar, fuzzy_skin_facets);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
cereal::save_by_value(ar, texture_displacement_facet(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_by_value(ar, config);
cereal::save(ar, text_configuration); cereal::save(ar, text_configuration);
cereal::save(ar, emboss_shape); cereal::save(ar, emboss_shape);

View File

@@ -1117,7 +1117,8 @@ std::vector<float> patch_boundary_distance(const indexed_triangle_set &patch, co
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data) const TextureDisplacementFacetsData &facets_data,
const TextureDisplacementOptions &options)
{ {
indexed_triangle_set mesh = base_mesh; indexed_triangle_set mesh = base_mesh;
// TriangleSelector's vertex array starts with the mesh's own vertices (any extra ones, created // 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 // 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 // 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). // paint masks between them (see the header for why that earlier design was dropped).
const std::vector<Vec3f> vertex_normals = texture_displacement_vertex_normals(mesh); std::vector<Vec3f> 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<uint8_t> 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<Vec3f> 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<float> displacement(mesh.vertices.size(), 0.f); std::vector<float> displacement(mesh.vertices.size(), 0.f);
std::vector<bool> displaced(mesh.vertices.size(), false); std::vector<bool> 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<bool> on_patch_border(mesh.vertices.size(), false);
bool any_displacement = false; bool any_displacement = false;
const TriangleMesh selector_mesh(mesh); 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. // compactify above, it is our own.
const indexed_triangle_set rest = selector.get_facets_strict(EnforcerBlockerType::NONE); 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 // A vertex used by even one *unpainted* triangle sits on this layer's boundary. It is still
// to both the painted patch and the untouched surface, so displacing it would tear the two // needed either way - the edge-smoothing falloff measures distance from it - but whether it is
// apart. Pinning it is what keeps the result seamless with no remeshing at the seam. // 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<bool> is_boundary(patch.vertices.size(), false); std::vector<bool> is_boundary(patch.vertices.size(), false);
for (const stl_triangle_vertex_indices &tri : rest.indices) 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; 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 // Only the Cylindrical/Spherical methods need these; Triplanar blends each vertex's own
// normal and LSCM solves the patch globally. // 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]; const int vi = tri[i];
// Split vertices the brush introduced live past the end of our own vertex array; // 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. // 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; continue;
visited[vi] = true; visited[vi] = true;
@@ -1277,6 +1325,19 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
if (displaced[vi]) if (displaced[vi])
mesh.vertices[vi] += vertex_normals[vi] * displacement[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<uint8_t> 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; 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) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); 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<uint8_t> &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<int> 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<int> nbr(total_refs, 0);
std::vector<int> 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<Vec3f> 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, HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,

View File

@@ -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<class Archive> 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 // 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. // be evaluated from a background bake Job as well as from GUI-side preview code.
struct DecodedHeightTexture struct DecodedHeightTexture
@@ -473,9 +514,14 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
// welding, one pass over the mesh), and - because the output keeps the input's exact vertex // welding, one pass over the mesh), and - because the output keeps the input's exact vertex
// indexing - lets the GUI overlay a preview on the base mesh without any index translation. // indexing - lets the GUI overlay a preview on the base mesh without any index translation.
// //
// A vertex used by even one *unpainted* triangle of a layer's mask is that layer's boundary: its // A vertex used by even one *unpainted* triangle of a layer's mask sits on that layer's boundary.
// displacement is pinned to zero, so the patch never tears away from the surrounding surface. Only // Whether it moves is TextureDisplacementOptions::displace_border; see that field for why displacing
// vertices used exclusively by painted triangles move. // it is safe (and the default). Either way the *direction* every vertex moves in is the area-weighted
// normal of the triangles that are painted in at least one layer - not of the whole mesh - so a
// border vertex travels along the painted surface's own normal instead of a blend with whatever
// unpainted geometry meets it there. Without that, the rim of a fully painted face would displace
// along the 45 degrees bisector it shares with the side wall and flare outwards. Interior vertices
// have every incident triangle painted, so for them the two are the same normal.
// //
// Takes plain copied data rather than a ModelVolume reference so it is safe to call from a // Takes plain copied data rather than a ModelVolume reference so it is safe to call from a
// background thread (e.g. a bake Job's process() method) on a snapshot captured on the main // background thread (e.g. a bake Job's process() method) on a snapshot captured on the main
@@ -486,14 +532,28 @@ using TextureDisplacementFacetsData = std::array<TriangleSelector::TriangleSplit
// mesh-boolean ops) the way TriangleSelector::remap_painting() does for the other paint channels. // mesh-boolean ops) the way TriangleSelector::remap_painting() does for the other paint channels.
// Such operations will silently drop any unbaked texture-displacement paint on the affected // Such operations will silently drop any unbaked texture-displacement paint on the affected
// volume. This is an explicit extension point for a later phase, not an oversight. // volume. This is an explicit extension point for a later phase, not an oversight.
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh, indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers, const std::vector<TextureDisplacementLayer> &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` // Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from
// and forwards to the overload above. // `volume` and forwards to the overload above.
indexed_triangle_set build_texture_displacement(const ModelVolume &volume); 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<uint8_t> &movable, float strength,
int iterations);
// Returns a scalar height (in mm - a displacement magnitude) at a surface point, given that point's // 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 // 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 // where the displaced surface has *curvature* worth spending triangles on. Called serially from the

View File

@@ -1192,6 +1192,7 @@ void GLGizmoTextureDisplacement::rebuild_preview()
TextureDisplacementPreviewInput input; TextureDisplacementPreviewInput input;
input.base_mesh = mv->mesh().its; input.base_mesh = mv->mesh().its;
input.layers = mv->texture_displacement_layers; input.layers = mv->texture_displacement_layers;
input.options = mv->texture_displacement_options;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data(); 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(); 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<uint8_t> 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<uint8_t> 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<TriangleSelector::SavedPainting> saved_painting = mv->save_painting();
std::array<TriangleSelector::TriangleSplittingData, TEXTURE_DISPLACEMENT_MAX_LAYERS> 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() void GLGizmoTextureDisplacement::remesh_model()
{ {
ModelVolume *mv = texture_volume(); 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.) // (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(); ImGui::Separator();
m_imgui->text(_u8L("Not enough vertices for fine detail?")); m_imgui->text(_u8L("Not enough vertices for fine detail?"));

View File

@@ -335,6 +335,13 @@ private:
bool collect_paint_region(std::vector<uint8_t> &region, bool collect_paint_region(std::vector<uint8_t> &region,
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const; std::array<std::vector<uint8_t>, 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 // 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 // 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 // with the mesh's mean edge length the first time the control is shown. Like subdivide, it replaces

View File

@@ -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 - // 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. // 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) void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &eptr)
@@ -78,6 +79,7 @@ void queue_texture_displacement_bake(const ModelVolume &volume, std::function<vo
input.volume_id = volume.id(); input.volume_id = volume.id();
input.base_mesh = volume.mesh().its; input.base_mesh = volume.mesh().its;
input.layers = volume.texture_displacement_layers; input.layers = volume.texture_displacement_layers;
input.options = volume.texture_displacement_options;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data(); input.facets_data[size_t(i)] = volume.texture_displacement_facet(i).get_data();

View File

@@ -21,6 +21,7 @@ struct TextureDisplacementBakeInput
indexed_triangle_set base_mesh; indexed_triangle_set base_mesh;
std::vector<TextureDisplacementLayer> layers; std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data; TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
}; };
// Bakes a volume's painted texture-displacement layers into real mesh geometry in the background, // Bakes a volume's painted texture-displacement layers into real mesh geometry in the background,

View File

@@ -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 - // 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. // 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) void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)

View File

@@ -19,6 +19,7 @@ struct TextureDisplacementPreviewInput
indexed_triangle_set base_mesh; indexed_triangle_set base_mesh;
std::vector<TextureDisplacementLayer> layers; std::vector<TextureDisplacementLayer> layers;
TextureDisplacementFacetsData facets_data; TextureDisplacementFacetsData facets_data;
TextureDisplacementOptions options;
}; };
// Computes the true (unbaked) displaced-mesh preview in the background. With several painted // Computes the true (unbaked) displaced-mesh preview in the background. With several painted

View File

@@ -1,8 +1,10 @@
#define NOMINMAX #define NOMINMAX
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath> #include <cmath>
#include <fstream> #include <fstream>
#include <limits>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TextureDisplacement.hpp"
@@ -35,6 +37,30 @@ static std::shared_ptr<std::vector<unsigned char>> make_flat_gray_png(uint8_t va
return std::make_shared<std::vector<unsigned char>>(std::move(bytes)); return std::make_shared<std::vector<unsigned char>>(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<std::vector<unsigned char>> make_checkerboard_png(size_t w = 16, size_t h = 16)
{
std::vector<uint8_t> 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<unsigned char> bytes;
{
std::ifstream ifs(tmp_path.string(), std::ios::binary);
bytes.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
boost::system::error_code ec;
boost::filesystem::remove(tmp_path, ec);
REQUIRE_FALSE(bytes.empty());
return std::make_shared<std::vector<unsigned char>>(std::move(bytes));
}
TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]") TEST_CASE("TextureDisplacement: decode_height_texture round-trips an 8-bit grayscale PNG", "[TextureDisplacement]")
{ {
TextureDisplacementLayer layer; 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)); 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 // 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: // 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) -> boundary, must NOT move // O: touches all 4 triangles (incl. unpainted T3) -> patch border
// A: touches T0 (painted) and T3 (unpainted) -> boundary, must NOT move // A: touches T0 (painted) and T3 (unpainted) -> patch border
// D: touches T2 (painted) and T3 (unpainted) -> boundary, must NOT move // D: touches T2 (painted) and T3 (unpainted) -> patch border
// B: touches only T0 and T1 (both painted) -> interior, SHOULD move // B: touches only T0 and T1 (both painted) -> interior
// C: touches only T1 and T2 (both painted) -> interior, SHOULD move // C: touches only T1 and T2 (both painted) -> interior
indexed_triangle_set fan; 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.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} }; 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.tiling_scale = 5.0f;
layer.image_data = make_flat_gray_png(255); layer.image_data = make_flat_gray_png(255);
const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets); // 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) {
// Find each named vertex's post-bake position by matching the original (pinned vertices keep return (result.vertices[i] - fan.vertices[i]).norm() > 1e-6f;
// 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;
}; };
CHECK(still_at_original_position(fan.vertices[0])); // O: boundary SECTION("by default the whole painted patch moves, border included")
CHECK(still_at_original_position(fan.vertices[1])); // A: boundary {
CHECK(still_at_original_position(fan.vertices[4])); // D: boundary const indexed_triangle_set result = build_texture_displacement(fan, {layer}, facets);
CHECK_FALSE(still_at_original_position(fan.vertices[2])); // B: interior, must have moved REQUIRE(result.vertices.size() == fan.vertices.size());
CHECK_FALSE(still_at_original_position(fan.vertices[3])); // C: interior, must have moved 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<uint8_t> 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<uint8_t> 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<float>::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<uint8_t> 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<uint8_t>(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 // Every undirected edge of a closed manifold mesh is shared by exactly two triangles. A T-junction