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
+8 -2
View File
@@ -938,6 +938,11 @@ public:
// see build_texture_displacement()); it only reflects UI insertion order.
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.
// 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);
+122 -8
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,
const std::vector<TextureDisplacementLayer> &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<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<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;
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<bool> 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<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;
}
@@ -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<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,
+67 -7
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
// 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<TriangleSelector::TriangleSplit
// 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.
//
// A vertex used by even one *unpainted* triangle of a layer's mask is that layer's boundary: its
// displacement is pinned to zero, so the patch never tears away from the surrounding surface. Only
// vertices used exclusively by painted triangles move.
// A vertex used by even one *unpainted* triangle of a layer's mask sits on that layer's boundary.
// Whether it moves is TextureDisplacementOptions::displace_border; see that field for why displacing
// 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
// 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.
// 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.
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 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<uint8_t> &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