Improve adaptive subdivision at border & fix some visual bugs

This commit is contained in:
ExPikaPaka
2026-08-26 09:07:26 +02:00
parent 165a1e9f4c
commit 6166bac17a
13 changed files with 914 additions and 153 deletions

View File

@@ -30,6 +30,9 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel;
uniform float depth_mm;
uniform float tiling_scale;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
@@ -70,7 +73,10 @@ vec2 project_uv(vec3 p, vec3 n)
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset;
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
}
void main()
@@ -156,7 +162,10 @@ void main()
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 slope = amplitude * vec2(dh_duv.x * cs + dh_duv.y * sn, -dh_duv.x * sn + dh_duv.y * cs);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs);
vec3 gradient = slope.x * t + slope.y * b;
gradient -= triangle_normal * dot(triangle_normal, gradient);

View File

@@ -89,6 +89,9 @@ uniform sampler2D height_tex;
uniform vec2 height_tex_texel; // (1/width, 1/height) of height_tex
uniform float depth_mm;
uniform float tiling_scale;
// Height map width / height. Scales the v axis so a non-square image keeps its proportions
// instead of being squeezed into a square tile - mirrors libslic3r's apply_uv_transform().
uniform float tex_aspect;
uniform float rotation_rad;
uniform vec2 uv_offset;
uniform bool invert;
@@ -136,7 +139,10 @@ vec2 project_uv(vec3 p, vec3 n)
planar *= (tiling_scale > 1e-6) ? (1.0 / tiling_scale) : 1.0;
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
return vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs) + uv_offset;
vec2 r = vec2(planar.x * cs - planar.y * sn, planar.x * sn + planar.y * cs);
// After the rotation, so the rotation stays a rotation rather than becoming a shear.
r.y *= tex_aspect;
return r + uv_offset;
}
void main()
@@ -240,7 +246,10 @@ void main()
// gradient back into the axes' frame.
float cs = cos(rotation_rad);
float sn = sin(rotation_rad);
vec2 slope = amplitude * vec2(dh_duv.x * cs + dh_duv.y * sn, -dh_duv.x * sn + dh_duv.y * cs);
// One uv unit is tiling_scale mm along u but tiling_scale / tex_aspect mm along v, so the v
// component of the gradient carries the extra factor before being rotated back into t/b.
vec2 g = vec2(dh_duv.x, dh_duv.y * tex_aspect);
vec2 slope = amplitude * vec2(g.x * cs + g.y * sn, -g.x * sn + g.y * cs);
vec3 gradient = slope.x * t + slope.y * b;
gradient -= triangle_normal * dot(triangle_normal, gradient);

View File

@@ -356,6 +356,25 @@ std::optional<std::vector<Vec2f>> parameterize_lscm(const indexed_triangle_set &
if (border == halfedge_descriptor())
return std::nullopt; // no boundary at all -- a closed patch, which isn't a disk either
// ...and exactly one boundary loop. One connected component is not enough on its own: a patch with
// a hole in it (paint a ring, or erase the middle of a stroke) is a single component with two
// loops, and LSCM will happily "parameterize" it into an overlapping, folded-over chart rather
// than fail. Walk the border halfedges and check every one of them belongs to the longest loop.
{
std::size_t border_halfedges = 0;
for (halfedge_descriptor h : halfedges(cgal_mesh))
if (is_border(h, cgal_mesh))
++border_halfedges;
std::size_t loop_length = 0;
halfedge_descriptor h = border;
do {
++loop_length;
h = next(h, cgal_mesh);
} while (h != border && loop_length <= border_halfedges);
if (loop_length != border_halfedges)
return std::nullopt; // more than one boundary loop -- not a topological disk
}
using Point_2 = EpicKernel::Point_2;
using UV_pmap = _EpicMesh::Property_map<vertex_descriptor, Point_2>;
UV_pmap uv_map = cgal_mesh.add_property_map<vertex_descriptor, Point_2>("h:uv", Point_2(0, 0)).first;

View File

@@ -13,6 +13,9 @@
#include <unordered_map>
#include <unordered_set>
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>
#include "MeshBoolean.hpp"
#include "Model.hpp"
#include "PNGReadWrite.hpp"
@@ -897,7 +900,7 @@ std::vector<Vec2f> compute_lscm_uvs(const indexed_triangle_set &patch, const Tex
return per_vertex;
}
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer)
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect)
{
const float scale = (layer.tiling_scale > 1e-6f) ? (1.f / layer.tiling_scale) : 1.f;
const Vec2f scaled = planar * scale;
@@ -905,7 +908,20 @@ Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &la
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad);
const float sn = std::sin(rad);
const Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
Vec2f rotated(scaled.x() * cs - scaled.y() * sn, scaled.x() * sn + scaled.y() * cs);
// Non-square textures. Without this the [0,1] square of uv covers the whole image whatever its
// proportions, so a 2:1 image is squeezed into a square tile and every feature in it comes out
// half as wide as it should be. `tiling_scale` is the tile's size along u; the tile is
// `tiling_scale * height / width` mm along v, which is exactly what keeps texels square - so
// dividing v by that extent is the same as multiplying it by width / height. A square texture has
// aspect 1 and is untouched, which is why this changes nothing for the shipped library.
//
// Applied after the rotation, not before: scaling one axis of an already-rotated coordinate is a
// shear, and doing it the other way round would make "Rotation" skew the pattern instead of
// turning it.
if (aspect > 0.f && aspect != 1.f)
rotated.y() *= aspect;
return rotated + layer.offset;
}
@@ -954,8 +970,12 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
if (texture.empty())
return 0.f;
// width / height of the height map, so a non-square image keeps its proportions (see
// apply_uv_transform()). Every projection except the projective "from view" one funnels through
// here, so this one line is what makes them all aspect-correct.
const float aspect = (texture.height > 0) ? float(texture.width) / float(texture.height) : 1.f;
auto sample_at = [&](const Vec2f &planar) {
return texture.sample(apply_uv_transform(planar, layer), layer.tile_enabled, layer.tile_method);
return texture.sample(apply_uv_transform(planar, layer, aspect), layer.tile_enabled, layer.tile_method);
};
// Precomputed per-patch LSCM solve wins over the layer's own method (see the header): the
@@ -1118,8 +1138,13 @@ std::vector<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 TextureDisplacementOptions &options)
const TextureDisplacementOptions &options,
const DisplacementProgressFn &progress)
{
// Returns true to keep going. An aborted run returns {} (see the header): an empty mesh is the
// one result no caller can mistake for a finished bake and commit onto the volume.
const auto report = [&progress](int percent) { return !progress || progress(percent); };
indexed_triangle_set mesh = base_mesh;
// TriangleSelector's vertex array starts with the mesh's own vertices (any extra ones, created
// where a brush stroke split a triangle, are appended after them), and get_facets_strict()
@@ -1183,8 +1208,14 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
}
}
if (!report(5))
return {};
std::vector<float> displacement(mesh.vertices.size(), 0.f);
std::vector<bool> displaced(mesh.vertices.size(), false);
// uint8_t rather than std::vector<bool>: the sampling loop below writes these from several
// threads at once, and vector<bool>'s bit packing makes writes to *distinct* elements a data
// race on the shared word.
std::vector<uint8_t> displaced(mesh.vertices.size(), 0);
// Union, over every layer, of that layer's patch border - the vertices the post-process smoothing
// holds when TextureDisplacementOptions::smooth_skip_border is set. A vertex on any patch's edge
// counts, which is the conservative choice: hold it rather than let one layer's smoothing melt the
@@ -1193,7 +1224,21 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
bool any_displacement = false;
const TriangleMesh selector_mesh(mesh);
// One selector for the whole stack, re-deserialized per layer. Its constructor computes
// its_face_neighbors() and its_face_normals() over the *entire* mesh, which on a subdivided model
// is by far the most expensive thing here - building a fresh one per layer paid that cost up to
// eight times over. reset() (what deserialize(..., true) calls) only rebuilds the vertex/triangle
// arrays; the neighbour and face-normal tables are immutable members and survive it.
TriangleSelector selector(selector_mesh);
bool selector_dirty = false;
const int layer_count = std::max(int(ordered_layers.size()), 1);
int layer_index = 0;
for (const TextureDisplacementLayer *layer : ordered_layers) {
// Progress spans 5..65% across the layers; the apply and smoothing passes take it from there.
if (!report(5 + (60 * layer_index++) / layer_count))
return {};
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
if (data.triangles_to_split.empty())
continue;
@@ -1202,8 +1247,9 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
if (height.empty())
continue;
TriangleSelector selector(selector_mesh);
selector.deserialize(data, false);
// needs_reset only from the second layer on: the selector is already pristine on the first.
selector.deserialize(data, selector_dirty);
selector_dirty = true;
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
@@ -1287,8 +1333,13 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
const float sign = layer->invert ? -1.f : 1.f;
// A vertex may be reached by several of the patch's triangles; each must fold into the
// running total exactly once, or a Multiply/Subtract layer would apply two or three times
// over depending on how many painted triangles happen to share the vertex.
std::vector<bool> visited(patch.vertices.size(), false);
// over depending on how many painted triangles happen to share the vertex. Collecting the
// unique list up front (cheap, one pass) is also what lets the expensive part - the texture
// sampling, which is three bilinear fetches plus three pow()s per vertex for triplanar - run
// in parallel below, instead of serially inside the triangle walk.
std::vector<int> layer_vertices;
std::vector<char> visited(patch.vertices.size(), 0);
layer_vertices.reserve(patch.vertices.size());
for (const stl_triangle_vertex_indices &tri : patch.indices)
for (int i = 0; i < 3; ++i) {
const int vi = tri[i];
@@ -1296,28 +1347,53 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// they carry no displacement of their own and are not part of the output mesh.
if (vi >= int(mesh.vertices.size()) || (pin_boundary && is_boundary[vi]) || visited[vi])
continue;
visited[vi] = true;
visited[vi] = 1;
layer_vertices.push_back(vi);
}
if (layer_vertices.empty())
continue;
const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[size_t(vi)];
std::vector<float> sampled(layer_vertices.size(), 0.f);
tbb::parallel_for(tbb::blocked_range<size_t>(0, layer_vertices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t k = range.begin(); k < range.end(); ++k) {
const size_t vi = size_t(layer_vertices[k]);
const Vec2f *lscm_uv = lscm_uvs.empty() ? nullptr : &lscm_uvs[vi];
const float h = sample_layer_height(height, *layer, mesh.vertices[vi], vertex_normals[vi],
patch_centroid, patch_axis, lscm_uv);
// midlevel is the height that means "stay put", so anything below it displaces
// *inwards* - see TextureDisplacementLayer::midlevel. At the default of 0 this is
// exactly the old outward-only behaviour.
const float edge_w = edge_weight.empty() ? 1.f : edge_weight[size_t(vi)];
const float signed_height = (h - layer->midlevel) * layer->depth_mm * sign * edge_w;
displacement[size_t(vi)] = blend_displacement(displacement[size_t(vi)], signed_height,
displaced[size_t(vi)] ? layer->blend_mode : TextureBlendMode::Add);
// The first layer to reach a vertex has nothing underneath it to blend with, so it
// always starts the total off additively - a Multiply/Divide against an implicit
// zero base would otherwise annihilate (or blow up) it, which is never what the
// user means by putting a mask on the bottom of the stack.
displaced[size_t(vi)] = true;
any_displacement = true;
sampled[k] = (h - layer->midlevel) * layer->depth_mm * sign;
}
});
for (size_t k = 0; k < layer_vertices.size(); ++k) {
const size_t vi = size_t(layer_vertices[k]);
// The first layer to reach a vertex has nothing underneath it to blend with, so it
// always starts the total off additively - a Multiply/Divide against an implicit
// zero base would otherwise annihilate (or blow up) it, which is never what the
// user means by putting a mask on the bottom of the stack.
const float accumulated = displacement[vi];
const float blended = blend_displacement(accumulated, sampled[k],
displaced[vi] ? layer->blend_mode : TextureBlendMode::Add);
// Edge smoothing fades this layer's *effect*, not its input. Scaling the input instead is
// only correct for Add/Subtract, whose neutral value is 0: on a Multiply layer a faded
// input approaches 0, which annihilates everything beneath it at the rim rather than
// leaving it alone, and on a Divide layer it approaches the 0.05 divisor floor, which
// amplifies the relief underneath by up to 20x exactly where it was meant to fade out.
// Interpolating the blended result back toward the accumulated total is the neutral
// element for every mode at once, and reduces to the old formula exactly for Add.
const float edge_w = edge_weight.empty() ? 1.f : edge_weight[vi];
displacement[vi] = accumulated + (blended - accumulated) * edge_w;
displaced[vi] = 1;
}
any_displacement = true;
}
if (!report(65))
return {};
if (!any_displacement)
return mesh;
@@ -1332,12 +1408,22 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// neighbours are those pinned outsiders: relaxing it would drag the rim of the relief back down and
// leave the pattern looking half-melted right where it meets the edge.
if (options.smooth_enabled && options.smooth_strength > 0.f && options.smooth_iterations > 0) {
if (!report(70))
return {};
std::vector<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);
// The pass hook only *stops* the relaxation early; the report(99) below is what turns a
// cancellation into an empty (uncommittable) result, since a cancelled run keeps reporting
// cancelled.
smooth_mesh_vertices(mesh, movable, options.smooth_strength, options.smooth_iterations,
progress ? DisplacementProgressFn([&report, it = options.smooth_iterations](int pass) {
return report(70 + (29 * (pass + 1)) / std::max(it, 1));
}) : DisplacementProgressFn{});
}
if (!report(99))
return {};
return mesh;
}
@@ -1352,7 +1438,7 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume)
}
void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t> &movable, float strength,
int iterations)
int iterations, const DisplacementProgressFn &on_pass)
{
if (iterations <= 0 || mesh.vertices.empty() || movable.size() != mesh.vertices.size())
return;
@@ -1391,15 +1477,21 @@ void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t>
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;
}
// Each vertex reads only from `prev` and writes only its own slot, so the sweep parallelises
// with no synchronisation at all.
tbb::parallel_for(tbb::blocked_range<size_t>(0, nv), [&](const tbb::blocked_range<size_t> &range) {
for (size_t v = range.begin(); v < range.end(); ++v) {
if (!movable[v] || start[v] == start[v + 1])
continue;
Vec3f sum = Vec3f::Zero();
for (int k = start[v]; k < start[v + 1]; ++k)
sum += prev[size_t(nbr[size_t(k)])];
const Vec3f avg = sum / float(start[v + 1] - start[v]);
mesh.vertices[v] = prev[v] + (avg - prev[v]) * strength;
}
});
if (on_pass && !on_pass(it))
return; // cancelled: leave the passes done so far in place, the caller decides what to do
}
}
@@ -1552,7 +1644,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const std::vector<uint8_t> &refine_region,
float target_edge_length_mm, int max_triangles,
std::vector<int> *out_source, const HeightFieldSampler &sampler,
float chord_tolerance_mm, float min_edge_length_mm)
float chord_tolerance_mm, float min_edge_length_mm,
float border_edge_length_mm)
{
// Neighbour slots that are not a triangle index.
constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone
@@ -1590,12 +1683,13 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f;
const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f;
const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f;
const float border_sq = border_edge_length_mm > 0.f ? border_edge_length_mm * border_edge_length_mm : 0.f;
// refine_region is indexed by input-triangle index, and every triangle's src stays in that range
// (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it.
if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles)
return emit();
if (!feature_mode && target_sq <= 0.f)
if (!feature_mode && target_sq <= 0.f && border_sq <= 0.f)
return emit(); // no criterion at all
if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; }))
return emit(); // nothing flagged: no-op
@@ -1720,16 +1814,27 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
// reach. Triangles outside the region always score 0 - they are only ever touched by the conformal
// closure below, never refined on their own account.
auto priority = [&](int ti) -> float {
const Tri &t = tris[ti];
if (refine_region[t.src] == 0)
const Tri &t = tris[ti];
const uint8_t flags = refine_region[t.src];
if (flags == 0)
return 0.f;
const int le = longest_local(ti);
const float ll = elen_sq(t.v[le], t.v[(le + 1) % 3]);
if (ll <= min_floor_sq)
return 0.f; // at the resolution floor - also what stops a sharp texture step going forever
float p = (target_sq > 0.f) ? ll / target_sq : 0.f;
if (feature_mode)
p = std::max(p, detail_error(ti) / chord_tolerance_mm);
float p = 0.f;
if (flags & REFINE_PAINTED) {
p = (target_sq > 0.f) ? ll / target_sq : 0.f;
if (feature_mode)
p = std::max(p, detail_error(ti) / chord_tolerance_mm);
}
// The band straddling the paint's edge, refined by plain edge length. Deliberately *not* run
// through detail_error(): outside the paint the sampler still reports full relief (it has no
// per-point paint test), so the chord test there would chase texture detail on a surface the
// bake is going to leave flat. Length alone is what this band needs - the error it is fixing
// is the size of the triangles spanning the displacement step, not the curvature of anything.
if ((flags & REFINE_BORDER) && border_sq > 0.f)
p = std::max(p, ll / border_sq);
return p;
};

View File

@@ -22,6 +22,14 @@ namespace Slic3r {
class ModelVolume;
// Bits of subdivide_mesh_adaptive()'s per-triangle `refine_region` mask. See that function.
static constexpr uint8_t REFINE_PAINTED = 1;
static constexpr uint8_t REFINE_BORDER = 2;
// Progress/cancellation hook for the (potentially multi-second) bake. Called with a 0..100
// percentage; return false to abort. See build_texture_displacement().
using DisplacementProgressFn = std::function<bool(int)>;
// Maximum number of simultaneous texture-displacement layers a single ModelVolume can hold.
// Each layer owns its own paint mask (ModelVolume::texture_displacement_facet(slot)), so this
// is also the number of independent EnforcerBlockerType selectors kept per volume.
@@ -370,7 +378,11 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// LSCM's per-patch UV solve through the same scale/rotate/offset controls as every other
// projection method, without going through project_texture_displacement_uv()'s own dispatch
// (which only knows how to compute the *analytic* methods from a single vertex + normal).
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer);
// `aspect` is the height map's width / height. It scales the v axis so a non-square image is not
// squeezed into a square tile: `tiling_scale` is the tile's size along u, and the tile is
// `tiling_scale * height / width` mm along v, which keeps texels square. 1 (the default) is the
// square case and leaves the coordinate exactly as it always was.
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer, float aspect = 1.f);
// Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a
// local-space point, writing the resulting texture uv. Returns false - and leaves `uv` untouched -
@@ -532,10 +544,18 @@ using TextureDisplacementFacetsData = std::array<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.
//
// `progress`, when set, is called from the worker thread with a 0..100 completion percentage as the
// bake proceeds. Returning false from it aborts the run, which then returns an *empty* mesh - never
// a partially displaced one, so a cancelled bake can never be mistaken for a finished result and
// committed. It exists because this is the one call in the feature that can take seconds on a
// subdivided mesh, and without it the progress notification the Job framework puts on screen sits at
// 0% for the whole run and offers no way to close it (its close button only appears at 100%).
indexed_triangle_set build_texture_displacement(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data,
const TextureDisplacementOptions &options = {});
const TextureDisplacementOptions &options = {},
const DisplacementProgressFn &progress = {});
// Convenience overload for main-thread callers: extracts the mesh/layers/paint data/options from
// `volume` and forwards to the overload above.
@@ -551,8 +571,10 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume);
// after displacing it" runs, and it is also safe to run standalone on an already baked mesh.
// `strength` is clamped to [0, 1]; 0 iterations, an empty/mis-sized `movable`, or an all-false one
// leave the mesh untouched.
// `on_pass`, when set, is called with the 0-based index of each completed pass; returning false stops
// the relaxation there, leaving the passes already done in place.
void smooth_mesh_vertices(indexed_triangle_set &mesh, const std::vector<uint8_t> &movable, float strength,
int iterations);
int iterations, const DisplacementProgressFn &on_pass = {});
// Returns a scalar height (in mm - a displacement magnitude) at a surface point, given that point's
// position and interpolated normal. This is what feature-adaptive subdivision samples to decide
@@ -638,12 +660,30 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
// the input triangle that output triangle i descends from (children inherit their parent's index), so
// a caller can carry per-triangle data - e.g. a paint mask - across the topology change without a
// geometric remap.
//
// `refine_region` is a **bitmask** per input triangle, not a plain flag:
// bit 0 (REFINE_PAINTED) - inside the painted area: refine by the length baseline and, in feature
// mode, by the chord-error test.
// bit 1 (REFINE_BORDER) - inside the band straddling the paint's edge: refine by
// `border_edge_length_mm` alone.
// A value of 1 therefore means exactly what a plain 1 always meant, and 0 still means "never touch
// this triangle except through the conformal closure".
//
// The border band exists because the chord-error test is blind to the one discontinuity the bake
// actually creates. `make_combined_displacement_sampler()` evaluates the height field everywhere,
// with no per-point paint test, so where the paint *stops* it keeps reporting full relief - smooth
// and low-curvature - while the baked surface steps from full displacement to zero. The test sees no
// error there and leaves the transition at whatever density the input had, which is what turns the
// rim of an unpainted island into a ring of large, steeply tilted triangles. Refining that band by
// plain edge length is bounded (it is a thin ring, and a length target always terminates) and needs
// no paint-aware sampler.
indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const std::vector<uint8_t> &refine_region,
float target_edge_length_mm, int max_triangles = 1000000,
std::vector<int> *out_source = nullptr,
const HeightFieldSampler &sampler = nullptr,
float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f);
float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f,
float border_edge_length_mm = 0.f);
} // namespace Slic3r

View File

@@ -1519,9 +1519,11 @@ void TriangleSelector::get_facets(std::vector<indexed_triangle_set>& facets_per_
}
}
indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType state) const
indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType state, std::vector<int> *out_source) const
{
indexed_triangle_set out;
if (out_source)
out_source->clear();
size_t num_vertices = 0;
for (const Vertex &v : m_vertices)
@@ -1535,8 +1537,13 @@ indexed_triangle_set TriangleSelector::get_facets_strict(EnforcerBlockerType sta
out.vertices.emplace_back(v.v);
}
for (int itriangle = 0; itriangle < m_orig_size_indices; ++ itriangle)
for (int itriangle = 0; itriangle < m_orig_size_indices; ++ itriangle) {
this->get_facets_strict_recursive(m_triangles[itriangle], m_neighbors[itriangle], state, out.indices);
// Everything the recursion just appended came from this original triangle, whatever depth it
// was split to. Recording it here keeps the recursive helpers untouched.
if (out_source)
out_source->resize(out.indices.size(), itriangle);
}
for (auto &triangle : out.indices)
for (int i = 0; i < 3; ++ i)

View File

@@ -332,7 +332,14 @@ public:
// Get facets at a given state. Don't triangulate T-joints.
indexed_triangle_set get_facets(EnforcerBlockerType state) const;
// Get facets at a given state. Triangulate T-joints.
indexed_triangle_set get_facets_strict(EnforcerBlockerType state) const;
// Sub-triangles in `state`, with the *whole* mesh's referenced vertex array (only .indices is
// filtered by state, so two calls with different states share one indexing).
//
// `out_source`, when given, is filled parallel to the returned .indices with the index of the
// original mesh triangle each sub-triangle came from. That is what lets a caller carry partial
// paint - the pieces of a triangle a brush stroke only partly covered - across a refinement of
// the same surface, instead of having to round each source triangle to wholly painted or not.
indexed_triangle_set get_facets_strict(EnforcerBlockerType state, std::vector<int> *out_source = nullptr) const;
// Get edges around the selected area by seed fill.
std::vector<Vec2i32> get_seed_fill_contour() const;

View File

@@ -68,13 +68,43 @@ constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic);
// frequencies that would alias, and it cuts the VRAM these hold by ~16x as a bonus.
constexpr int THUMBNAIL_MAX_PX = 128;
std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &decoded)
// Everything above is about drawing a ~48 px panel row, and none of it applies to the height texture
// the fast-preview *shader* samples: that one is magnified across the model, not minified into a
// row, and every texel it loses is relief the preview cannot show. It gets its own upload at (up to)
// this size, so the bump preview reads the same height field the bake does instead of a 128 px box
// blur of it - which is what made Fast look flatter and softer than the result it was previewing.
constexpr int HEIGHT_TEX_MAX_PX = 2048;
// How many rings of vertex-adjacent triangles either side of the paint's edge join the border refine
// band (see collect_paint_region()). Two is enough to grade the size change without the band's own
// cost growing to matter: it is a ring around a perimeter, not an area.
constexpr int BORDER_BAND_RINGS = 2;
// Is `p` inside triangle `t`, given that it already lies in the triangle's plane? Barycentric via the
// three sub-triangle cross products, compared against the whole triangle's normal. Used to carry a
// partly painted source triangle's coverage onto the children of a subdivision, which are coplanar
// with it by construction (subdivision only adds edge midpoints).
bool point_in_triangle_coplanar(const Vec3f &p, const std::array<Vec3f, 3> &t)
{
const Vec3f n = (t[1] - t[0]).cross(t[2] - t[0]);
const float n2 = n.squaredNorm();
if (n2 < 1e-20f)
return false; // degenerate: it covers no area, so nothing is inside it
// A small negative tolerance, scaled by the triangle, keeps a centroid sitting exactly on a shared
// edge from falling through the gap between two neighbouring pieces.
const float eps = -1e-4f * n2;
return (t[1] - t[0]).cross(p - t[0]).dot(n) >= eps &&
(t[2] - t[1]).cross(p - t[1]).dot(n) >= eps &&
(t[0] - t[2]).cross(p - t[2]).dot(n) >= eps;
}
std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &decoded, int max_px = THUMBNAIL_MAX_PX)
{
if (decoded.empty())
return nullptr;
// Preserve aspect; never upscale a texture that is already small.
const int scale = std::max(1, (std::max(decoded.width, decoded.height) + THUMBNAIL_MAX_PX - 1) / THUMBNAIL_MAX_PX);
const int scale = std::max(1, (std::max(decoded.width, decoded.height) + max_px - 1) / max_px);
const int w = std::max(1, decoded.width / scale);
const int h = std::max(1, decoded.height / scale);
@@ -155,6 +185,12 @@ void GLGizmoTextureDisplacement::on_shutdown()
m_parent.toggle_model_objects_visibility(true);
m_preview_glmodel.reset();
m_bump_preview_glmodel.reset();
m_paint_overlay_glmodel.reset();
m_paint_overlay_dirty = false;
// Any preview still in flight is superseded: bumping the shared counter makes it abort at its next
// progress poll, and its completion handler then finds nothing to do.
m_preview_generation->fetch_add(1);
m_preview_job_pending = false;
m_uvcheck_glmodel.reset();
m_wireframe_overlay_glmodel.reset();
m_wireframe_overlay_vcount = 0;
@@ -221,32 +257,50 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
//
// The bump preview is different: it never actually moves geometry (it's a shading trick), so
// its depth is identical to the overlay's *everywhere*, not just in the unpainted area - the
// depth-biased overlay would win the depth test across the whole surface and hide the bump
// shading entirely. So the overlay is skipped for it; the bump shading itself is the only
// feedback in that mode (still fine for painting, since render_cursor() below shows the brush).
// depth-biased opaque overlay would win the depth test across the whole surface and hide the bump
// shading entirely. So render_triangles() is skipped for it. What is *not* skipped is
// render_paint_overlay(): leaving the bump shading as the only paint feedback meant a stroke that
// erased paint, or added it with no texture picked, changed nothing on screen until the whole
// preview rebuilt at stroke end - and in the true-displacement view the opaque overlay is hidden
// by the raised surface for the same reason. The translucent tint covers both cases.
// Coalesced bump rebuild from an in-progress UV island drag (see on_island_edited): done here, at
// most once per drawn frame, rather than synchronously in the UV canvas's mouse-move handler.
if (m_use_bump_preview && m_bump_preview_dirty) {
rebuild_bump_preview_mesh();
m_bump_preview_dirty = false;
}
const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized();
// Same coalescing for the paint tint, but on its own flag: a stroke marks this every mouse move
// (see on_mouse()) and it only costs the painted patch, whereas the bump mesh also carries every
// unpainted triangle of the volume and stays on the stroke-end cadence.
if (m_paint_overlay_dirty) {
rebuild_paint_overlay();
m_paint_overlay_dirty = false;
}
// is_initialized() alone is not enough: render_bump_preview_mesh() also needs an active layer
// with a decoded texture and a compiled shader, and bails silently without them. Hiding the real
// volume for a bump pass that then draws nothing is what made the model vanish - most obviously
// with zero layers, but equally with a layer that has no texture picked yet.
const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized() && bump_preview_ready();
const bool use_true_preview = !use_bump && m_preview_glmodel.is_initialized();
// In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is
// looking at, so the opaque paint-selection highlight must not be drawn on top of it - same
// reasoning as skipping it for the bump preview (see bug #12). Without this the painted area
// covers the checker/heatmap and it can't be seen.
const bool show_paint_overlay = m_uv_check_mode == UVCheckMode::None;
// Hide the real volume only when something is actually going to be drawn in its place; otherwise
// put it back. Getting this wrong leaves an invisible model, so it is decided once, here, rather
// than per branch below.
m_parent.toggle_model_objects_visibility(true);
if (use_bump || use_true_preview) {
if (ModelVolume *mv = texture_volume())
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
m_c->selection_info()->get_active_instance(), mv);
}
if (use_bump) {
m_parent.toggle_model_objects_visibility(true);
if (ModelVolume *mv = texture_volume())
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
m_c->selection_info()->get_active_instance(), mv);
render_bump_preview_mesh();
} else if (m_preview_glmodel.is_initialized()) {
m_parent.toggle_model_objects_visibility(true);
if (ModelVolume *mv = texture_volume())
m_parent.toggle_model_objects_visibility(false, m_c->selection_info()->model_object(),
m_c->selection_info()->get_active_instance(), mv);
} else if (use_true_preview) {
render_preview_mesh();
if (show_paint_overlay) {
@@ -259,6 +313,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
render_triangles(selection);
}
// The translucent paint tint. Needed in the bump view because the opaque highlight above is
// skipped there, and in the true-displacement view because the displaced surface rises *above*
// the undisplaced overlay geometry and hides it exactly where the relief is strongest - in both
// cases leaving an erase stroke with no visible effect until the next full preview rebuild.
if (show_paint_overlay && (use_bump || use_true_preview))
render_paint_overlay();
// Diagnostic overlays, drawn on top of whatever preview is active (both pull toward the camera
// with a polygon offset so they win the depth test against the coincident surface).
if (m_uv_check_mode != UVCheckMode::None)
@@ -290,7 +351,16 @@ bool GLGizmoTextureDisplacement::on_mouse(const wxMouseEvent &mouse_event)
return on_mouse_seam(mouse_event);
if (m_adjust_texture_mode)
return on_mouse_adjust_texture(mouse_event);
return GLGizmoPainterBase::on_mouse(mouse_event);
const bool handled = GLGizmoPainterBase::on_mouse(mouse_event);
// A consumed drag/click is a paint (or erase) event: the base class has already updated the live
// TriangleSelector, but nothing is flushed to the model - and so nothing rebuilds - until the
// stroke ends. Mark the tint stale so it follows the brush from the first frame instead. Only the
// flag is set here; the rebuild is coalesced to once per drawn frame in render_painter_gizmo().
if (handled && (mouse_event.Dragging() || mouse_event.LeftDown() || mouse_event.RightDown() ||
mouse_event.LeftUp() || mouse_event.RightUp()))
m_paint_overlay_dirty = true;
return handled;
}
bool GLGizmoTextureDisplacement::on_mouse_seam(const wxMouseEvent &mouse_event)
@@ -707,11 +777,29 @@ void GLGizmoTextureDisplacement::render_preview_mesh()
shader->stop_using();
}
float GLGizmoTextureDisplacement::layer_texture_aspect(const TextureDisplacementLayer &layer)
{
// decode_height_texture() is cached on the image_data allocation, so this is a hash lookup rather
// than a PNG decode - cheap enough to call per rebuild.
const DecodedHeightTexture tex = decode_height_texture(layer);
return (tex.width > 0 && tex.height > 0) ? float(tex.width) / float(tex.height) : 1.f;
}
std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const indexed_triangle_set &patch,
const TextureDisplacementLayer &layer) const
{
if (layer.projection_method == TextureProjectionMethod::LSCM)
return compute_lscm_uvs(patch, layer); // one final uv per patch vertex (0 where unassigned)
const float aspect = layer_texture_aspect(layer);
if (layer.projection_method == TextureProjectionMethod::LSCM) {
// compute_lscm_uvs() returns the unwrap's own (raw, mm) coordinates with the island placement
// folded in - it does *not* apply the layer's tiling/rotation/offset. The bake applies those
// on top (sample_layer_height()'s lscm branch runs the result through sample_at()), so the
// shader's precomputed-uv path has to as well, or the fast preview samples millimetre-valued
// coordinates as if they were uv and shows the texture at a wildly wrong scale.
std::vector<Vec2f> uv = compute_lscm_uvs(patch, layer);
for (Vec2f &p : uv)
p = apply_uv_transform(p, layer, aspect);
return uv;
}
if (layer.projection_method == TextureProjectionMethod::ViewProjected) {
std::vector<Vec2f> uv(patch.vertices.size());
for (size_t vi = 0; vi < patch.vertices.size(); ++vi) {
@@ -725,7 +813,7 @@ std::vector<Vec2f> GLGizmoTextureDisplacement::compute_layer_vertex_uvs(const in
}
const Vec2f planar(patch.vertices[vi].dot(layer.view_project_right),
patch.vertices[vi].dot(layer.view_project_up));
uv[vi] = apply_uv_transform(planar, layer);
uv[vi] = apply_uv_transform(planar, layer, aspect);
}
return uv;
}
@@ -913,11 +1001,13 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh()
if (layer == nullptr || layer->empty())
return;
// Reuses the layer-list panel's already-decoded, already-uploaded GPU thumbnail (smoothing-aware),
// whose grayscale value lives in the R channel exactly as the shader samples it. Its width/height
// are read straight off the texture - decoding the PNG here every frame would re-run the smoothing
// blur on every camera move, which is what tanked the frame rate at high smoothing.
GLTexture *tex = get_layer_thumbnail(*layer);
// Full-resolution height upload (smoothing-aware), whose grayscale value lives in the R channel
// exactly as the shader samples it. Deliberately *not* the layer-list panel's thumbnail: that one
// is box-filtered down to 128 px for a ~48 px row, and feeding it to the shader cost the preview
// three quarters of the height map's detail - and, since height_tex_texel is derived from it, also
// flattened the shading gradient and made the parallax march skip itself at angles where it should
// run. Cached on the image_data pointer + smoothing, so no PNG is decoded per frame.
GLTexture *tex = get_layer_height_texture(*layer);
if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0)
return;
@@ -942,10 +1032,36 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh()
shader->set_uniform("volume_mirrored", trafo_matrix.matrix().determinant() < 0.0);
glsafe(::glActiveTexture(GL_TEXTURE0));
glsafe(::glBindTexture(GL_TEXTURE_2D, tex->get_id()));
// Match DecodedHeightTexture::sample()'s tiling. The sampler's wrap mode is the only place the
// GPU path can express this, and nothing ever set it - so it sat at GL_REPEAT no matter what the
// layer said: a MirroredRepeat layer previewed as a plain repeat, and a layer with tiling *off*
// previewed as an endless tiling where the bake produces one placement and nothing around it.
// CLAMP_TO_BORDER with a zero border is the exact analogue of sample()'s "outside [0,1) is 0".
// GL_CLAMP_TO_BORDER is desktop-GL only; on ES the nearest thing is CLAMP_TO_EDGE, which smears
// the border row instead of vanishing - still much closer to the bake than an endless repeat.
#if SLIC3R_OPENGL_ES
const GLint no_tile_wrap = GL_CLAMP_TO_EDGE;
#else
const GLint no_tile_wrap = GL_CLAMP_TO_BORDER;
#endif
const GLint wrap = !layer->tile_enabled ? no_tile_wrap :
(layer->tile_method == TextureTileMethod::MirroredRepeat) ? GL_MIRRORED_REPEAT :
GL_REPEAT;
glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap));
glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap));
#if !SLIC3R_OPENGL_ES
if (wrap == GL_CLAMP_TO_BORDER) {
static const GLfloat border[4] = { 0.f, 0.f, 0.f, 0.f };
glsafe(::glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border));
}
#endif // !SLIC3R_OPENGL_ES
shader->set_uniform("height_tex", 0);
shader->set_uniform("height_tex_texel", Vec2f(1.f / float(tex->get_width()), 1.f / float(tex->get_height())));
shader->set_uniform("depth_mm", layer->depth_mm);
shader->set_uniform("tiling_scale", layer->tiling_scale);
// Read off the uploaded texture rather than the decoded one: they are the same image, and this is
// the aspect the sampler will actually see.
shader->set_uniform("tex_aspect", float(tex->get_width()) / float(tex->get_height()));
shader->set_uniform("rotation_rad", layer->rotation_deg * float(M_PI) / 180.f);
shader->set_uniform("uv_offset", layer->offset);
shader->set_uniform("invert", layer->invert);
@@ -969,6 +1085,100 @@ void GLGizmoTextureDisplacement::render_bump_preview_mesh()
shader->stop_using();
}
bool GLGizmoTextureDisplacement::bump_preview_ready() const
{
// Mirrors render_bump_preview_mesh()'s own preconditions. Kept as a separate query because the
// caller has to know whether the bump pass will draw *before* it hides the real volume for it.
if (m_c->selection_info() == nullptr || m_c->selection_info()->model_object() == nullptr)
return false;
if (texture_volume() == nullptr)
return false;
const TextureDisplacementLayer *layer = active_layer();
if (layer == nullptr || layer->empty())
return false;
// The same texture render_bump_preview_mesh() will bind, not the panel thumbnail - the two are
// separate caches and either can fail on its own.
const GLTexture *tex = const_cast<GLGizmoTextureDisplacement *>(this)->get_layer_height_texture(*layer);
if (tex == nullptr || tex->get_width() <= 0 || tex->get_height() <= 0)
return false;
return wxGetApp().get_shader("texture_displacement_bump") != nullptr;
}
void GLGizmoTextureDisplacement::rebuild_paint_overlay()
{
m_paint_overlay_glmodel.reset();
const ModelVolume *mv = texture_volume();
if (mv == nullptr || m_triangle_selectors.empty())
return;
// The *live* selector, so an in-progress stroke shows immediately - which is the whole point:
// this is the only feedback that a brush actually added or erased anything until the (much more
// expensive) preview catches up at stroke end.
const indexed_triangle_set patch = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
return;
// In the true-displacement view the surface on screen is the *raised* one, and a tint built on
// the flat base mesh would sink underneath it wherever the relief is deepest - which is precisely
// where the user is looking. The bake is topology-preserving (patch vertex i is mesh vertex i, see
// build_texture_displacement()), so the displaced positions can be read straight across. Vertices
// the brush split live past the end of that array and keep their flat position; they sit on the
// patch boundary, where the displacement is smallest anyway.
const std::vector<Vec3f> *displaced = nullptr;
if (!m_use_bump_preview && m_preview_its.vertices.size() == mv->mesh().its.vertices.size() &&
!m_preview_its.vertices.empty())
displaced = &m_preview_its.vertices;
GLModel::Geometry init_data;
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3 };
init_data.reserve_vertices(patch.indices.size() * 3);
init_data.reserve_indices(patch.indices.size() * 3);
unsigned n = 0;
for (const stl_triangle_vertex_indices &tri : patch.indices) {
for (int i = 0; i < 3; ++i) {
const size_t idx = size_t(tri[i]);
init_data.add_vertex((displaced != nullptr && idx < displaced->size()) ? (*displaced)[idx]
: patch.vertices[idx]);
}
init_data.add_triangle(n, n + 1, n + 2);
n += 3;
}
m_paint_overlay_glmodel.init_from(std::move(init_data));
// GLModel::render() re-sets "uniform_color" from this field just before drawing, so the colour
// has to be set here rather than as a uniform at draw time.
m_paint_overlay_glmodel.set_color(ColorRGBA(0.16f, 0.79f, 0.35f, 0.38f));
}
void GLGizmoTextureDisplacement::render_paint_overlay()
{
const ModelObject *mo = m_c->selection_info()->model_object();
const ModelVolume *mv = texture_volume();
if (mo == nullptr || mv == nullptr || !m_paint_overlay_glmodel.is_initialized())
return;
GLShaderProgram *shader = wxGetApp().get_shader("flat");
if (shader == nullptr)
return;
const Selection &selection = m_parent.get_selection();
const Transform3d trafo_matrix = mo->instances[selection.get_instance_idx()]->get_transformation().get_matrix() * mv->get_matrix();
const Camera &camera = wxGetApp().plater()->get_camera();
shader->start_using();
shader->set_uniform("view_model_matrix", camera.get_view_matrix() * trafo_matrix);
shader->set_uniform("projection_matrix", camera.get_projection_matrix());
// Translucent, and pulled toward the camera so it wins the depth test against the coincident
// bump surface. Depth writes are off: this is a tint, and letting it own the depth buffer would
// make the wireframe and seam overlays drawn after it fight with geometry that is not really
// there. Blending is already enabled by render_painter_gizmo().
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
glsafe(::glPolygonOffset(-1.5f, -1.5f));
glsafe(::glDepthMask(GL_FALSE));
m_paint_overlay_glmodel.render();
glsafe(::glDepthMask(GL_TRUE));
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
shader->stop_using();
}
void GLGizmoTextureDisplacement::rebuild_uvcheck_mesh()
{
m_uvcheck_glmodel.reset();
@@ -1178,10 +1388,12 @@ void GLGizmoTextureDisplacement::rebuild_preview()
{
// Bumped first: any in-flight job's result (captured generation from before this call) will
// now compare unequal to m_preview_generation and be discarded when it completes, even if it
// finishes after the job queued below.
const uint64_t generation = ++m_preview_generation;
// finishes after the job queued below - and, since the counter is shared with the worker, that
// job also notices mid-run and aborts rather than computing a result nobody will use.
m_preview_generation->fetch_add(1);
update_uv_editor();
rebuild_bump_preview_mesh();
rebuild_paint_overlay();
rebuild_uvcheck_mesh();
rebuild_seam_overlay();
// The adaptive subdivision preview is driven by the painted area, so it has to follow the paint
@@ -1194,13 +1406,43 @@ void GLGizmoTextureDisplacement::rebuild_preview()
if (mv == nullptr || !mv->is_texture_displacement_painted()) {
m_preview_glmodel.reset();
m_preview_its = indexed_triangle_set{}; // no displaced mesh; wireframe falls back to the base
m_preview_job_pending = false;
refresh_wireframe();
return;
}
// In Fast/paint modes the wireframe follows the base mesh and can be built now; the true-displacement
// view's wireframe needs the displaced mesh, which only exists once the job below completes.
if (m_use_bump_preview)
if (m_use_bump_preview) {
refresh_wireframe();
// Fast view: the shader *is* the preview, and m_preview_glmodel is never drawn. Running the
// full CPU displacement anyway - which is what happened on every stroke and slider release -
// was the single largest cost in the gizmo, and it bought nothing. The switch back to the
// true-displacement view queues it (see the View row in on_render_input_window()).
m_preview_job_pending = false;
return;
}
queue_preview_job();
}
void GLGizmoTextureDisplacement::queue_preview_job()
{
// A job in flight when the gizmo closes still runs its completion handler, which would otherwise
// happily queue the follow-up run it was holding - against a gizmo nobody is looking at any more.
if (m_state != On)
return;
const ModelVolume *mv = texture_volume();
if (mv == nullptr || !mv->is_texture_displacement_painted())
return;
// One in flight at a time; everything requested meanwhile collapses into a single follow-up run
// issued from the completion handler. See m_preview_job_running.
if (m_preview_job_running) {
m_preview_job_pending = true;
return;
}
const uint64_t generation = m_preview_generation->load();
TextureDisplacementPreviewInput input;
input.base_mesh = mv->mesh().its;
@@ -1209,20 +1451,35 @@ void GLGizmoTextureDisplacement::rebuild_preview()
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
input.facets_data[size_t(i)] = mv->texture_displacement_facet(i).get_data();
m_preview_job_running = true;
auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation,
queue_job(worker, std::make_unique<TextureDisplacementPreviewJob>(std::move(input), generation, m_preview_generation,
[this](indexed_triangle_set its, uint64_t result_generation) {
if (result_generation != m_preview_generation)
return; // superseded by a newer edit while this was computing
m_preview_glmodel.reset();
if (!its.indices.empty()) {
m_preview_job_running = false;
if (result_generation != m_preview_generation->load()) {
// Superseded while this was computing (it will have aborted early and come back
// empty). Whatever the newest state is, it still needs a run.
m_preview_job_pending = true;
} else if (its.indices.empty()) {
// Aborted or cancelled rather than finished - the handler runs on every outcome so
// the in-flight latch above always clears. Keep whatever preview is already on screen
// rather than blanking it; there is no new result to show, not a new empty one.
} else {
m_preview_glmodel.reset();
m_preview_glmodel.init_from(its);
m_preview_glmodel.set_color(GLVolume::NEUTRAL_COLOR);
// Keep the displaced mesh so the wireframe overlay can be drawn on it (the
// true-displacement view), then refresh the wireframe from it.
m_preview_its = std::move(its);
refresh_wireframe();
// The paint tint rides the displaced surface in this view, so it follows the new mesh.
m_paint_overlay_dirty = true;
}
if (m_preview_job_pending) {
m_preview_job_pending = false;
if (!m_use_bump_preview)
queue_preview_job(); // no-ops if the gizmo has closed in the meantime
}
// Keep the displaced mesh so the wireframe overlay can be drawn on it (the true-displacement
// view), then refresh the wireframe from it.
m_preview_its = std::move(its);
refresh_wireframe();
m_parent.set_as_dirty();
}));
}
@@ -1935,7 +2192,11 @@ Vec3f GLGizmoTextureDisplacement::adjust_handle_center(const TextureDisplacement
// follows the cursor precisely, and is back on the anchor exactly when offset is zero.
const float rad = layer.rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad), sn = std::sin(rad);
const Vec2f unrotated(layer.offset.x() * cs + layer.offset.y() * sn, -layer.offset.x() * sn + layer.offset.y() * cs);
// Undo the non-square v scaling first - it is the last thing apply_uv_transform() does, so it is
// the first thing to come off on the way back.
const float aspect = layer_texture_aspect(layer);
const Vec2f o(layer.offset.x(), (aspect > 0.f) ? layer.offset.y() / aspect : layer.offset.y());
const Vec2f unrotated(o.x() * cs + o.y() * sn, -o.x() * sn + o.y() * cs);
const Vec2f planar = -unrotated * layer.tiling_scale;
Vec3f u_axis, v_axis;
@@ -2168,7 +2429,10 @@ bool GLGizmoTextureDisplacement::on_mouse_adjust_texture(const wxMouseEvent &mou
const Vec2f delta_scaled = delta_planar * scale;
const float rad = layer->rotation_deg * float(M_PI) / 180.f;
const float cs = std::cos(rad), sn = std::sin(rad);
const Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs);
Vec2f delta_rotated(delta_scaled.x() * cs - delta_scaled.y() * sn, delta_scaled.x() * sn + delta_scaled.y() * cs);
// ...and the same v scaling apply_uv_transform() applies for a non-square texture, so the
// handle keeps tracking the cursor exactly instead of drifting on the v axis.
delta_rotated.y() *= layer_texture_aspect(*layer);
// Increasing `offset` shifts which texel is sampled at a fixed world position, which
// visually slides the pattern the *opposite* way - subtracting is this session's
// best-effort reasoning about the direction that feels like "dragging the texture",
@@ -2701,7 +2965,7 @@ void GLGizmoTextureDisplacement::subdivide_model()
bool GLGizmoTextureDisplacement::collect_paint_region(
std::vector<uint8_t> &region,
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint) const
{
const ModelVolume *mv = texture_volume();
if (mv == nullptr)
@@ -2711,21 +2975,24 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
const size_t nvert = its.vertices.size();
region.assign(ntri, 0);
if (painted_tri)
for (auto &pt : *painted_tri)
pt.clear();
if (paint)
for (LayerPaintMap &pm : *paint)
pm = LayerPaintMap{};
// Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes back
// with the original mesh's own three vertex indices) can be mapped to its source triangle. Only
// the paint carry-forward needs it, and the live subdivide preview calls this on every slider
// frame, so it is not built for the region-only path.
std::map<std::array<int, 3>, int> tri_by_verts;
if (painted_tri)
for (size_t i = 0; i < ntri; ++i) {
std::array<int, 3> k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] };
std::sort(k.begin(), k.end());
tri_by_verts.emplace(k, int(i));
}
// Twice the area of each source triangle, for the "is this one covered edge to edge" test below.
// Only the paint carry-forward needs it, and the live subdivide preview calls this on every
// slider frame, so it is not built for the region-only path.
const auto tri_area2 = [](const Vec3f &a, const Vec3f &b, const Vec3f &c) {
return (b - a).cross(c - a).norm();
};
std::vector<float> source_area2;
if (paint) {
source_area2.resize(ntri);
for (size_t i = 0; i < ntri; ++i)
source_area2[i] = tri_area2(its.vertices[size_t(its.indices[i][0])],
its.vertices[size_t(its.indices[i][1])],
its.vertices[size_t(its.indices[i][2])]);
}
bool any_paint = false;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
@@ -2743,27 +3010,114 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
// subdivide_mesh_adaptive() already grades the size change outward on its own.
for (const TriangleSelector::TriangleBitStreamMapping &m : data.triangles_to_split)
if (size_t(m.triangle_idx) < ntri)
region[m.triangle_idx] = 1;
region[m.triangle_idx] |= REFINE_PAINTED;
if (painted_tri) {
if (paint) {
TriangleSelector sel(mv->mesh());
sel.deserialize(data, false);
(*painted_tri)[slot].assign(ntri, 0);
for (const stl_triangle_vertex_indices &t : sel.get_facets_strict(EnforcerBlockerType::ENFORCER).indices) {
// A sub-triangle produced by a *partial* stroke always carries at least one appended
// (split) vertex, so "all three indices are original" is exactly the test for a whole,
// fully-painted triangle - the only kind whose paint can be inherited wholesale.
if (size_t(t[0]) >= nvert || size_t(t[1]) >= nvert || size_t(t[2]) >= nvert)
// get_facets_strict() now reports which source triangle each sub-triangle came from, which
// is what lets partial coverage be carried forward geometrically instead of being rounded
// away. Note a *fully* painted source can still come back as several sub-triangles (T-joint
// splits forced by a refined neighbour), so "wholly painted" is an area test, not a
// one-piece test.
std::vector<int> src;
const indexed_triangle_set patch = sel.get_facets_strict(EnforcerBlockerType::ENFORCER, &src);
LayerPaintMap &pm = (*paint)[slot];
pm.full.assign(ntri, 0);
pm.part_start.assign(ntri + 1, 0);
std::vector<float> covered2(ntri, 0.f);
for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j) {
if (size_t(src[j]) >= ntri)
continue;
std::array<int, 3> k{ t[0], t[1], t[2] };
std::sort(k.begin(), k.end());
if (auto it = tri_by_verts.find(k); it != tri_by_verts.end())
(*painted_tri)[slot][it->second] = 1;
const stl_triangle_vertex_indices &t = patch.indices[j];
covered2[size_t(src[j])] += tri_area2(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])],
patch.vertices[size_t(t[2])]);
}
for (size_t i = 0; i < ntri; ++i)
pm.full[i] = (source_area2[i] > 0.f && covered2[i] >= 0.999f * source_area2[i]) ? 1 : 0;
// Only partly covered sources need their pieces kept - a full one answers every query with
// "painted", and an untouched one with "not painted".
for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j)
if (size_t(src[j]) < ntri && !pm.full[size_t(src[j])])
++pm.part_start[size_t(src[j]) + 1];
for (size_t i = 0; i < ntri; ++i)
pm.part_start[i + 1] += pm.part_start[i];
pm.part.resize(size_t(pm.part_start[ntri]));
{
std::vector<int> fill(pm.part_start.begin(), pm.part_start.begin() + ntri);
for (size_t j = 0; j < patch.indices.size() && j < src.size(); ++j) {
const size_t S = size_t(src[j]);
if (S >= ntri || pm.full[S])
continue;
const stl_triangle_vertex_indices &t = patch.indices[j];
pm.part[size_t(fill[S]++)] = { patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])],
patch.vertices[size_t(t[2])] };
}
}
}
any_paint = true;
}
return any_paint;
if (!any_paint)
return false;
// The band straddling the paint's edge. The bake steps the surface from full displacement to zero
// across it, and nothing else in the refinement criteria can see that step: the chord-error
// sampler has no per-point paint test, so just outside the paint it keeps reporting the same
// smooth height field and reports no error at all. Left alone, the transition therefore stays at
// whatever density the input had - which is what makes the rim of an unpainted island a ring of
// big, steeply tilted triangles.
//
// Seeded from the vertices shared by a painted and an unpainted triangle (the actual edge of the
// paint) and grown outward over vertex adjacency, so the band covers both sides of the step.
if (BORDER_BAND_RINGS > 0) {
// Vertex -> incident triangles, CSR-style (counted, prefix-summed, filled). This runs on every
// frame of the subdivide preview's sliders, so it must not allocate a small vector per vertex.
std::vector<int> vstart(nvert + 1, 0);
for (size_t i = 0; i < ntri; ++i)
for (int k = 0; k < 3; ++k)
if (size_t(its.indices[i][k]) < nvert)
++vstart[size_t(its.indices[i][k]) + 1];
for (size_t v = 0; v < nvert; ++v)
vstart[v + 1] += vstart[v];
std::vector<int> vtri(size_t(vstart[nvert]), 0);
{
std::vector<int> fill(vstart.begin(), vstart.begin() + nvert);
for (size_t i = 0; i < ntri; ++i)
for (int k = 0; k < 3; ++k)
if (size_t(its.indices[i][k]) < nvert)
vtri[size_t(fill[size_t(its.indices[i][k])]++)] = int(i);
}
// A vertex used by both a painted and an unpainted triangle sits exactly on the paint's edge.
std::vector<uint8_t> ring_vertex(nvert, 0);
for (size_t v = 0; v < nvert; ++v) {
bool painted = false, unpainted = false;
for (int k = vstart[v]; k < vstart[v + 1]; ++k)
((region[size_t(vtri[size_t(k)])] & REFINE_PAINTED) ? painted : unpainted) = true;
ring_vertex[v] = (painted && unpainted) ? 1 : 0;
}
for (int ring = 0; ring < BORDER_BAND_RINGS; ++ring) {
std::vector<uint8_t> next = ring_vertex;
for (size_t v = 0; v < nvert; ++v) {
if (!ring_vertex[v])
continue;
for (int k = vstart[v]; k < vstart[v + 1]; ++k) {
const size_t t = size_t(vtri[size_t(k)]);
region[t] |= REFINE_BORDER;
// Grow through this triangle's other corners, for the following ring.
for (int c = 0; c < 3; ++c)
if (size_t(its.indices[t][c]) < nvert)
next[size_t(its.indices[t][c])] = 1;
}
}
ring_vertex.swap(next);
}
}
return true;
}
bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const
@@ -2772,7 +3126,7 @@ bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv
return false;
std::vector<uint8_t> region;
if (!collect_paint_region(region, &out.painted_tri))
if (!collect_paint_region(region, &out.paint))
return false;
// Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A
@@ -2796,7 +3150,7 @@ bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv
// baseline - otherwise the control would be meaningless (or a dead end) on a dense model.
out.refined = subdivide_mesh_adaptive(mv.mesh().its, region, m_subdivide_target_mm,
int(mv.mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
&out.source, sampler, tol, floor);
&out.source, sampler, tol, floor, m_subdivide_border_mm);
}
return out.refined.indices.size() != mv.mesh().its.indices.size();
}
@@ -2811,15 +3165,33 @@ void GLGizmoTextureDisplacement::apply_adaptive_subdivision(ModelVolume &mv, Sub
mv.calculate_convex_hull();
mv.restore_painting(saved_painting); // resets extra facets (incl. texture-displacement) + remaps the rest
// Carry each layer's paint onto the new mesh: a new triangle is painted iff its source triangle
// was fully painted in that layer. Children inherit their parent's source, so this is exact.
// Carry each layer's paint onto the new mesh. Children inherit their parent's source triangle, and
// subdivision only ever adds edge midpoints, so every new triangle lies inside its source and on
// the same surface - which means the source's painted *pieces* can be queried directly by point
// containment. That is what keeps a brush outline smooth: rounding each source to wholly painted
// or not instead leaves a ragged fringe of isolated triangles along any curved boundary, and the
// refined mesh then reproduces that fringe exactly rather than hiding it.
const indexed_triangle_set &new_its = mv.mesh().its;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
if (plan.painted_tri[size_t(slot)].empty())
const LayerPaintMap &pm = plan.paint[size_t(slot)];
if (pm.empty())
continue;
TriangleSelector sel(mv.mesh());
for (size_t i = 0; i < plan.source.size(); ++i)
if (plan.painted_tri[size_t(slot)][size_t(plan.source[i])])
for (size_t i = 0; i < plan.source.size() && i < new_its.indices.size(); ++i) {
const size_t S = size_t(plan.source[i]);
if (S >= pm.full.size())
continue;
bool painted = pm.full[S] != 0;
if (!painted && pm.part_start[S] != pm.part_start[S + 1]) {
const stl_triangle_vertex_indices &t = new_its.indices[i];
const Vec3f centroid = (new_its.vertices[size_t(t[0])] + new_its.vertices[size_t(t[1])] +
new_its.vertices[size_t(t[2])]) / 3.f;
for (int k = pm.part_start[S]; k < pm.part_start[S + 1] && !painted; ++k)
painted = point_in_triangle_coplanar(centroid, pm.part[size_t(k)]);
}
if (painted)
sel.set_facet(int(i), EnforcerBlockerType::ENFORCER);
}
mv.texture_displacement_facet(slot).set(sel);
}
}
@@ -3063,7 +3435,7 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
nullptr, sampler, tol, floor);
nullptr, sampler, tol, floor, m_subdivide_border_mm);
} else {
if (m_subdivide_count < 1)
return;
@@ -3135,6 +3507,26 @@ GLTexture *GLGizmoTextureDisplacement::get_layer_thumbnail(const TextureDisplace
return m_thumbnails[slot].get();
}
GLTexture *GLGizmoTextureDisplacement::get_layer_height_texture(const TextureDisplacementLayer &layer)
{
if (layer.empty())
return nullptr;
// One slot, not one per layer: the bump shader only ever shades the *active* layer, so a single
// full-resolution upload is enough and the VRAM cost stays at one texture rather than eight.
if (m_height_tex && m_height_tex_source == layer.image_data.get() && m_height_tex_smoothing == layer.smoothing)
return m_height_tex.get();
std::unique_ptr<GLTexture> texture = upload_height_thumbnail(decode_height_texture(layer), HEIGHT_TEX_MAX_PX);
if (!texture)
return nullptr;
m_height_tex = std::move(texture);
m_height_tex_source = layer.image_data.get();
m_height_tex_smoothing = layer.smoothing;
return m_height_tex.get();
}
void GLGizmoTextureDisplacement::bake(bool own_snapshot)
{
ModelVolume *mv = texture_volume();
@@ -3177,6 +3569,10 @@ static constexpr float STD_REMESH_SHARP_DEG = 40.f;
static constexpr float STD_SUBDIV_MAX_EDGE_MM = 20.f;
static constexpr float STD_SUBDIV_DETAIL_MM = 0.02f;
static constexpr float STD_SUBDIV_MIN_EDGE_MM = 0.02f;
// Edge length the band straddling the paint's edge is refined to. This is the one number that decides
// how clean the rim of an unpainted island looks: the bake steps the surface from full displacement to
// zero across that band, and nothing else in the criteria can see the step (see collect_paint_region()).
static constexpr float STD_SUBDIV_BORDER_MM = 0.4f;
bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
{
@@ -3197,6 +3593,7 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
pin(m_subdivide_target_mm, STD_SUBDIV_MAX_EDGE_MM);
pin(m_subdivide_detail_mm, STD_SUBDIV_DETAIL_MM);
pin(m_subdivide_min_edge_mm, STD_SUBDIV_MIN_EDGE_MM);
pin(m_subdivide_border_mm, STD_SUBDIV_BORDER_MM);
// Deliberately *not* pinned: the triangle budget stays visible and editable in Standard mode, so
// pinning it would fight the user's own slider every frame.
pin(m_remesh_target_edge_mm, STD_REMESH_EDGE_MM);
@@ -3226,6 +3623,7 @@ void GLGizmoTextureDisplacement::bake_standard()
const bool do_remesh = plan_remesh(*mv, STD_REMESH_EDGE_MM, STD_REMESH_SHARP_DEG, remeshed);
Plater *plater = wxGetApp().plater();
bool paint_transfer_failed = false;
{
// ONE undo step for the whole pipeline. take_snapshot() records the state *before* the change,
// so a single Undo goes all the way back to the untouched mesh - which is the only thing "undo
@@ -3241,17 +3639,19 @@ void GLGizmoTextureDisplacement::bake_standard()
// The remesh carries the paint across spatially, but if that remap came back empty the rest of
// the pipeline has nothing to work from - stop here rather than silently baking a flat mesh.
if (!mv->is_texture_displacement_painted()) {
show_error(nullptr, _u8L("The painted area could not be transferred onto the remeshed model. Undo, "
"then switch to Pro mode to prepare the mesh before painting."));
return;
}
// Note this cannot just `return`: the remesh above has already replaced the volume's mesh, so
// the scene and the gizmo's own TriangleSelectors still have to be brought back into step with
// it below. Returning from here left the gizmo painting and raycasting against a mesh that no
// longer existed.
paint_transfer_failed = !mv->is_texture_displacement_painted();
// Planning the subdivision has to happen inside the snapshot because it reads the mesh the
// remesh just produced. It is the expensive step, but by here we are committed anyway.
SubdivisionPlan plan;
if (plan_adaptive_subdivision(*mv, plan))
apply_adaptive_subdivision(*mv, std::move(plan));
if (!paint_transfer_failed) {
SubdivisionPlan plan;
if (plan_adaptive_subdivision(*mv, plan))
apply_adaptive_subdivision(*mv, std::move(plan));
}
}
if (ObjectList *obj_list = wxGetApp().obj_list()) {
@@ -3264,6 +3664,12 @@ void GLGizmoTextureDisplacement::bake_standard()
update_from_model_object(false); // reload selectors against the prepared mesh + carried paint
m_parent.set_as_dirty();
if (paint_transfer_failed) {
show_error(nullptr, _u8L("The painted area could not be transferred onto the remeshed model. Undo, "
"then switch to Pro mode to prepare the mesh before painting."));
return;
}
// ... and finally the displacement itself, in the background exactly as the Pro-mode button does -
// except that it commits into the snapshot taken above instead of pushing another one.
bake(/* own_snapshot */ false);
@@ -3526,6 +3932,12 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
rebuild_uvcheck_mesh();
if (m_use_bump_preview)
rebuild_bump_preview_mesh();
else
// The Fast view skips the CPU displacement entirely (see rebuild_preview()), so
// leaving it means m_preview_glmodel may be stale or absent - ask for it now.
queue_preview_job();
// ...and the paint tint between the base and the displaced surface, for the same reason.
m_paint_overlay_dirty = true;
refresh_wireframe(); // Normal<->Fast swaps the wireframe between displaced and base mesh
update_uv_editor(); // mirror the checker / distortion heatmap into the UV pane too (#7)
m_parent.set_as_dirty();
@@ -4225,6 +4637,20 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->scaled(20.f));
}
// Applies in both adaptive sub-modes: it is not a texture-detail criterion, it is about the
// step the bake puts at the paint's edge, which exists whether or not "Follow texture detail"
// is on. 0 turns the band off and restores the old behaviour.
if (m_imgui->slider_float(std::string(_u8L("Edge detail (mm)")) + "##subdivborder", &m_subdivide_border_mm,
0.f, 5.f, "%.3f"))
preview_live();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Triangle size along the boundary of the painted area. The relief drops back to the "
"flat surface across that boundary, and the triangles spanning the drop are what you "
"see as a jagged rim around an unpainted region - smaller values make the outline "
"cleaner. Costs triangles along the outline only, not over the whole area. "
"0 turns it off."),
m_imgui->scaled(20.f));
ImGui::PopItemWidth();
budget_slider();

View File

@@ -10,6 +10,7 @@
#include "slic3r/GUI/TextureLibrary.hpp"
#include <array>
#include <atomic>
#include <map>
#include <memory>
#include <string>
@@ -101,11 +102,26 @@ private:
// so a run that turns out to be a no-op does not leave an empty undo step behind - and it keeps
// snapshot ownership with the caller, which matters because the buttons want one snapshot per click
// while the pipeline wants a single one around remesh + subdivide together.
// How one layer's paint sits on the pre-subdivision mesh, precise enough to carry across the
// refinement without rounding each source triangle to wholly painted or not.
//
// Rounding is what made the outline of a painted region come out ragged: a source triangle near a
// smooth brush boundary is wholly painted essentially at random, so "painted iff the source was
// full" turns a clean curve into a noisy fringe of isolated painted and unpainted triangles - and
// once the border band refines the mesh there, that fringe is reproduced faithfully instead of
// being blurred away by coarse geometry.
struct LayerPaintMap
{
std::vector<uint8_t> full; // per source triangle: covered edge to edge
std::vector<int> part_start; // CSR offsets into `part`, size (source tris + 1)
std::vector<std::array<Vec3f, 3>> part; // painted pieces of partly covered source triangles
bool empty() const { return full.empty(); }
};
struct SubdivisionPlan
{
indexed_triangle_set refined;
std::vector<int> source; // new tri -> input tri
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri; // per layer, per input tri
indexed_triangle_set refined;
std::vector<int> source; // new tri -> input tri
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> paint; // per layer
};
// False means "nothing to refine" and `out` must not be used.
bool plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const;
@@ -162,10 +178,20 @@ private:
// remapped (texture-displacement paint has no remap-across-topology-change support yet).
void subdivide_model();
// The layer height map's width / height, for apply_uv_transform()'s non-square handling. 1 when
// there is no usable texture.
static float layer_texture_aspect(const TextureDisplacementLayer &layer);
// Returns a cached GPU thumbnail of layer's texture (decoding + uploading it the first time it
// is requested, or whenever its image_data changes), or nullptr if it has no usable texture.
// Panel-sized: box-filtered down to THUMBNAIL_MAX_PX, which is right for a list row and wrong for
// anything the shader samples - see get_layer_height_texture().
GLTexture *get_layer_thumbnail(const TextureDisplacementLayer &layer);
// The same texture at full resolution, for the fast-preview shader. One slot, shared by whichever
// layer is active, because that is the only one the bump shader ever shades.
GLTexture *get_layer_height_texture(const TextureDisplacementLayer &layer);
// A texture from the picker's library (see slic3r/GUI/TextureLibrary.hpp), read and uploaded
// once and then kept for the gizmo's lifetime. The decoded bytes are held alongside the GPU
// thumbnail so that picking the texture can hand the layer this very same image_data buffer -
@@ -366,18 +392,33 @@ private:
bool m_subdivide_feature = false;
float m_subdivide_detail_mm = 0.05f;
float m_subdivide_min_edge_mm = 0.1f;
// Edge length the band straddling the paint's boundary is refined to (0 = leave it alone). Applies
// in both adaptive sub-modes, because it is not a texture-detail criterion: the bake steps the
// surface from full displacement to zero across that boundary whatever the texture is doing, and
// the chord-error test cannot see that step at all - its sampler has no per-point paint test, so
// just outside the paint it goes on reporting the same smooth height field. Without this the
// transition keeps the input's density and the rim of an unpainted island comes out as a ring of
// large, steeply tilted triangles. See collect_paint_region() and subdivide_mesh_adaptive().
float m_subdivide_border_mm = 0.4f;
// How many thousand triangles refinement may *add* (the mesh's own count is added on before it is
// passed as subdivide_mesh_adaptive()'s absolute cap, so the control still means something on a
// dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh
// that many triangles can buy - and it is what keeps a fine "Detail" over a noisy texture from
// turning into an out-of-memory, or an unrenderable preview wireframe.
int m_subdivide_budget_k = 1500;
//
// The default used to be 1500 (i.e. +1.5 M triangles), which is what made Standard mode's Bake
// take minutes: every stage after the subdivision - the displacement itself, the convex hull, the
// GLModel upload, and the re-slice changed_object() triggers - then runs on a mesh two orders of
// magnitude denser than the input. 300k is still far finer than any FDM nozzle resolves at the
// 0.02 mm detail tolerance Standard uses, and the slider goes to 2000 for anyone who wants more.
int m_subdivide_budget_k = 300;
void subdivide_model_adaptive();
// Fills `region` (per current-mesh triangle, 1 = refine) from the union of every layer's painted
// area. If `painted_tri` is non-null, also fills, per layer, the fully-painted triangles to carry
// forward. Returns false when nothing is painted at all. Shared by the preview and the commit.
// Fills `region` (per current-mesh triangle, a REFINE_* bitmask) from the union of every layer's
// painted area plus the band straddling its edge. If `paint` is non-null, also fills the per-layer
// coverage map the subdivision carries forward - the expensive half, skipped by the live preview,
// which only needs the region. Returns false when nothing is painted at all.
bool collect_paint_region(std::vector<uint8_t> &region,
std::array<std::vector<uint8_t>, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const;
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint) const;
// Runs the volume's TextureDisplacementOptions smoothing over the *already committed* geometry,
// restricted to the painted area. The same settings are folded into Preview/Bake automatically;
@@ -417,6 +458,25 @@ private:
// and the 3D view. The rebuild is instead coalesced to once per 3D frame (render_painter_gizmo).
bool m_bump_preview_dirty = false;
GLModel m_bump_preview_glmodel;
// Translucent tint over the active layer's painted triangles, drawn on top of whichever preview
// is showing. The base painter's own opaque paint highlight (render_triangles()) cannot be used
// in either preview mode - it is coincident with the surface and simply covers it - so the only
// paint feedback the gizmo had was the relief itself, which meant erasing showed nothing at all
// until the stroke ended and the whole preview rebuilt. This is that feedback: cheap (the painted
// patch only), translucent (the preview stays visible through it) and rebuilt live during a
// stroke.
GLModel m_paint_overlay_glmodel;
// Set on every paint event, cleared when the overlay is rebuilt in render_painter_gizmo(). Kept
// separate from m_bump_preview_dirty so a stroke refreshes only the small painted patch per frame,
// not the bump mesh (which also carries every *unpainted* triangle of the volume).
bool m_paint_overlay_dirty = false;
void rebuild_paint_overlay();
void render_paint_overlay();
// Whether render_bump_preview_mesh() would actually draw something. Checked before the real volume
// is hidden: with no layer, no texture or no shader the bump path draws nothing, and hiding the
// volume for it left the model invisible.
bool bump_preview_ready() const;
// Whether the current bump mesh carries a precomputed per-vertex uv (LSCM) that the shader
// should sample at directly, rather than projecting in-shader. Set by rebuild_bump_preview_mesh().
bool m_bump_preview_uses_vertex_uv = false;
@@ -507,16 +567,36 @@ private:
// Bumped on every rebuild_preview() call; a background TextureDisplacementPreviewJob's result
// is only applied if this hasn't moved on since the job was queued (see rebuild_preview()),
// so a burst of edits can't have an earlier, now-stale job clobber a later one's result.
uint64_t m_preview_generation = 0;
//
// Shared with the worker thread (hence the atomic) so a running job can notice mid-computation
// that it has been superseded and abort, instead of running to completion for a result that will
// only be discarded on arrival.
std::shared_ptr<std::atomic<uint64_t>> m_preview_generation = std::make_shared<std::atomic<uint64_t>>(0);
// At most one preview job is ever queued. The UI job worker is a single FIFO queue shared with
// Bake (and with arrange/orient/send), and rebuild_preview() is called on every stroke end, every
// slider release and - with "Auto update" on - every frame of a slider drag. Queuing one full
// displacement per call built a backlog that took minutes to drain: the preview appeared frozen,
// and a Bake pressed afterwards sat behind the whole queue. So a request made while a job is in
// flight is recorded here and issued once that job settles, collapsing any number of edits into a
// single follow-up run.
bool m_preview_job_running = false;
bool m_preview_job_pending = false;
void queue_preview_job();
// Per-slot GPU thumbnail cache for the layer list panel, keyed by the image_data pointer that
// was current the last time each thumbnail was built (see get_layer_thumbnail()).
std::array<std::unique_ptr<GLTexture>, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnails;
std::array<const void *, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_source{};
// The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it (and the
// fast/bump preview, which samples this texture, actually shows the blur).
// The smoothing each cached thumbnail was built at, so a smoothing change re-uploads it.
std::array<float, TEXTURE_DISPLACEMENT_MAX_LAYERS> m_thumbnail_smoothing{};
// Full-resolution height texture for the bump shader, keyed the same way (see
// get_layer_height_texture()). A smoothing change re-uploads it, so the fast preview shows the
// blur the bake will apply.
std::unique_ptr<GLTexture> m_height_tex;
const void *m_height_tex_source = nullptr;
float m_height_tex_smoothing = -1.f;
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
std::map<std::string, LibraryTexture> m_library_textures;

View File

@@ -166,7 +166,11 @@ void GLGizmosManager::switch_gizmos_icon_filename()
gizmo->set_icon_filename(m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg");
break;
case(EType::TextureDisplacement):
gizmo->set_icon_filename(m_is_dark ? "toolbar_fuzzy_skin_paint_dark.svg" : "toolbar_fuzzy_skin_paint.svg");
// One shared icon in both themes (no dedicated dark variant yet) - but it must still be
// *this* gizmo's icon. Handing it the fuzzy-skin one here quietly replaced the icon set at
// construction, so the toolbar ended up showing two identical fuzzy-skin buttons after any
// light/dark switch.
gizmo->set_icon_filename("toolbar_texture_displacement.svg");
break;
case(EType::MeshBoolean):
gizmo->set_icon_filename(m_is_dark ? "toolbar_meshboolean_dark.svg" : "toolbar_meshboolean.svg");

View File

@@ -20,12 +20,34 @@ TextureDisplacementBakeJob::TextureDisplacementBakeJob(TextureDisplacementBakeIn
void TextureDisplacementBakeJob::process(Ctl &ctl)
{
ctl.update_status(0, _u8L("Baking texture displacement"));
const std::string status = _u8L("Baking texture displacement");
ctl.update_status(1, status);
// Only ever touches m_input (captured by value before this job was queued) and local state -
// never the live Model - so this is safe to run concurrently with the UI thread.
m_result = TriangleMesh(build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data,
m_input.options));
//
// The progress hook matters for more than cosmetics: the framework's progress notification only
// grows a close button once it reaches 100%, so a job that reports 0 and nothing else leaves an
// uncloseable notification pinned on screen. It also carries the Cancel button's effect into the
// bake, which on a subdivided mesh can run for several seconds.
int last_reported = 1;
m_result = TriangleMesh(build_texture_displacement(
m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
// The notification repaints (and wakes the idle loop) on every call, so only push a
// message when the displayed integer percentage actually moves.
if (percent > last_reported) {
last_reported = percent;
ctl.update_status(percent, status);
}
return true;
}));
// Always finish at 100: this is what closes the notification. Reported even on cancel, where
// build_texture_displacement() returns an empty mesh and finalize() commits nothing.
ctl.update_status(100, status);
}
void TextureDisplacementBakeJob::finalize(bool canceled, std::exception_ptr &eptr)

View File

@@ -5,25 +5,44 @@
namespace Slic3r::GUI {
TextureDisplacementPreviewJob::TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
std::function<void(indexed_triangle_set, uint64_t)> on_finished)
: m_input(std::move(input)), m_generation(generation), m_on_finished(std::move(on_finished))
: m_input(std::move(input)), m_generation(generation), m_current_generation(std::move(current_generation)),
m_on_finished(std::move(on_finished))
{
}
void TextureDisplacementPreviewJob::process(Ctl &ctl)
{
ctl.update_status(0, _u8L("Computing texture displacement preview"));
// No ctl.update_status() anywhere in here on purpose - see the class comment. A preview is
// invisible bookkeeping; the only thing on screen should be the preview itself.
// Only ever touches m_input (captured by value before this job was queued) and local state -
// never the live Model - so this is safe to run concurrently with the UI thread.
m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options);
m_result = build_texture_displacement(m_input.base_mesh, m_input.layers, m_input.facets_data, m_input.options,
[this, &ctl](int) {
// Bail the moment this preview stops being the current
// one; build_texture_displacement() then returns an
// empty mesh and finalize() drops it.
return !ctl.was_canceled() &&
(!m_current_generation ||
m_current_generation->load() == m_generation);
});
}
void TextureDisplacementPreviewJob::finalize(bool canceled, std::exception_ptr &eptr)
{
if (canceled || eptr || !m_on_finished)
if (!m_on_finished)
return;
m_on_finished(std::move(m_result), m_generation);
// The handler must run on *every* outcome, cancellation included, because the caller uses it to
// clear its "a job is in flight" latch. Returning early on `canceled` - which is what a cancel_all()
// from Plater (project load/close, app exit) delivers - left that latch stuck true and no preview
// was ever queued again for the rest of the session. An empty result is the caller's signal that
// nothing usable came back; it already handles that.
if (canceled || eptr)
m_on_finished(indexed_triangle_set{}, m_generation);
else
m_on_finished(std::move(m_result), m_generation);
}
} // namespace Slic3r::GUI

View File

@@ -1,8 +1,10 @@
#ifndef slic3r_TextureDisplacementPreviewJob_hpp_
#define slic3r_TextureDisplacementPreviewJob_hpp_
#include <atomic>
#include <cstdint>
#include <functional>
#include <memory>
#include <vector>
#include "libslic3r/TextureDisplacement.hpp"
@@ -27,6 +29,10 @@ struct TextureDisplacementPreviewInput
// to run synchronously on every paint stroke and parameter tweak and made editing feel slow with
// more than one or two layers. Unlike Bake, this never touches the live Model - a preview is
// purely informational, there is nothing to commit.
//
// Deliberately reports no status: the Job framework turns the first update_status() call into an
// on-screen progress notification, and a preview firing one on every stroke and slider release
// buried the user in notifications that only closed at 100%.
class TextureDisplacementPreviewJob : public Job
{
public:
@@ -35,7 +41,14 @@ public:
// current generation when the job completes, so that a burst of edits queuing several of
// these jobs in a row can't have an earlier, now-stale result clobber a later one that
// finishes first.
//
// `current_generation` is the caller's live counter, shared with the worker thread. The job
// polls it *while computing* and aborts as soon as it no longer matches - so a preview that has
// already been superseded stops burning CPU instead of running to completion for a result that
// will only be thrown away. That matters because the UI job worker runs one job at a time in FIFO
// order: without it, a Bake queued behind a handful of stale previews waits for every one of them.
TextureDisplacementPreviewJob(TextureDisplacementPreviewInput &&input, uint64_t generation,
std::shared_ptr<const std::atomic<uint64_t>> current_generation,
std::function<void(indexed_triangle_set, uint64_t)> on_finished);
void process(Ctl &ctl) override;
@@ -44,6 +57,7 @@ public:
private:
TextureDisplacementPreviewInput m_input;
uint64_t m_generation;
std::shared_ptr<const std::atomic<uint64_t>> m_current_generation;
indexed_triangle_set m_result;
std::function<void(indexed_triangle_set, uint64_t)> m_on_finished;
};