Move computation to background thread & resolve freeze

This commit is contained in:
ExPikaPaka
2026-08-27 09:20:02 +02:00
parent 6166bac17a
commit 045504c880
7 changed files with 650 additions and 249 deletions

View File

@@ -1645,7 +1645,8 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
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 border_edge_length_mm)
float border_edge_length_mm,
const DisplacementProgressFn &progress)
{
// Neighbour slots that are not a triangle index.
constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone
@@ -1948,7 +1949,18 @@ indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
// Every iteration either drops one satisfied triangle from the queue or performs exactly one
// bisection, and bisections are capped by the triangle budget, so this always terminates.
// Progress is reported against the triangle budget, which is what the loop is bounded by. Polled
// rather than pushed on every bisection: a refinement spends its budget in hundreds of thousands
// of them, and every hook call wakes the UI's idle loop to repaint the notification.
const int start_tris = int(tris.size());
const int budget_tris = std::max(1, max_triangles - start_tris);
int next_poll = start_tris;
while (!queue.empty() && int(tris.size()) + 2 <= max_triangles) {
if (progress && int(tris.size()) >= next_poll) {
next_poll = int(tris.size()) + std::max(1024, budget_tris / 100);
if (!progress(std::clamp((int(tris.size()) - start_tris) * 100 / budget_tris, 0, 100)))
break; // still conformal - whole bisections only; the caller decides whether to keep it
}
const int ti = queue.top().second;
queue.pop();
if (priority(ti) <= 1.f)

View File

@@ -677,13 +677,51 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
// 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.
//
// `progress`, when given, is called with a 0..100 percentage of the triangle budget spent; returning
// false stops the refinement early. What it hands back then is still a complete, conformal mesh - the
// loop only ever finishes whole bisections - so a caller that wants to discard it has to do so itself.
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 border_edge_length_mm = 0.f);
float border_edge_length_mm = 0.f,
const DisplacementProgressFn &progress = nullptr);
// The recipe for getting a mesh ready to receive displacement: even out the triangle density, then
// refine it where the texture bends. Either stage is skipped when its target is <= 0. Pure data, and
// the whole of it, so the preparation can be handed to a background job instead of running on the UI
// thread - see GLGizmoTextureDisplacement::prepare_mesh().
struct TextureDisplacementPrepareParams
{
// Isotropic remesh (CGAL). The target is clamped against the part's own surface area before it is
// used, so a value that would produce millions of triangles cannot be asked for by accident.
float remesh_edge_mm = 0.f;
float remesh_sharp_deg = 0.f; // 0 = do not protect sharp edges
// Adaptive (Rivara) subdivision of the painted area. See subdivide_mesh_adaptive().
float subdiv_target_mm = 0.f; // "Max edge": the length baseline, and the only criterion when
// subdiv_feature is off
float subdiv_detail_mm = 0.f; // "Detail": chord tolerance, feature mode only
float subdiv_min_edge_mm = 0.f; // "Min edge": the floor under both, feature mode only
float subdiv_border_mm = 0.f; // "Edge detail": the band straddling the paint's edge, 0 = off
bool subdiv_feature = false; // follow texture curvature, not just edge length
int subdiv_added_triangles = 0; // budget, *added* to the mesh's own count
};
// What a preparation run produced. An empty `mesh` means there was nothing to do and the caller must
// commit nothing - which is not a failure: a mesh that is already even needs no remesh, and one that
// is already fine enough for the texture needs no subdivision.
struct TextureDisplacementPrepareResult
{
indexed_triangle_set mesh;
TextureDisplacementFacetsData masks;
// The remesh landed but no layer's paint survived being carried onto it. Nothing is committed:
// everything downstream is driven by that paint, so baking on would bake a flat mesh.
bool paint_lost = false;
};
} // namespace Slic3r

View File

@@ -327,6 +327,8 @@ set(SLIC3R_GUI_SOURCES
GUI/Jobs/SLAImportJob.hpp
GUI/Jobs/TextureDisplacementBakeJob.cpp
GUI/Jobs/TextureDisplacementBakeJob.hpp
GUI/Jobs/TextureDisplacementPrepareJob.cpp
GUI/Jobs/TextureDisplacementPrepareJob.hpp
GUI/Jobs/TextureDisplacementPreviewJob.cpp
GUI/Jobs/TextureDisplacementPreviewJob.hpp
GUI/Jobs/ThreadSafeQueue.hpp

View File

@@ -2,6 +2,7 @@
#include <boost/log/trivial.hpp>
#include "libslic3r/AABBTreeIndirect.hpp"
#include "libslic3r/MeshBoolean.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Utils.hpp"
@@ -23,11 +24,13 @@
#include "slic3r/GUI/TextureProjectorFrame.hpp"
#include "slic3r/GUI/UVEditorCanvas.hpp"
#include "slic3r/GUI/Jobs/TextureDisplacementBakeJob.hpp"
#include "slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp"
#include "slic3r/GUI/Jobs/TextureDisplacementPreviewJob.hpp"
#include "slic3r/Utils/UndoRedo.hpp"
#include "GLGizmoUtils.hpp"
#include <glad/gl.h>
#include <tbb/parallel_for.h>
#include <algorithm>
#include <array>
#include <cmath>
@@ -98,6 +101,117 @@ bool point_in_triangle_coplanar(const Vec3f &p, const std::array<Vec3f, 3> &t)
(t[0] - t[2]).cross(p - t[2]).dot(n) >= eps;
}
// Carries one texture-displacement paint mask from `src_mesh` onto `dst_mesh` - a different
// tessellation of the same surface (an isotropic remesh, in practice).
//
// TriangleSelector::remap_painting(), which ModelVolume::restore_painting() uses for the four
// standard paint channels, is not usable here. It runs one select_patch() flood fill per
// (painted sub-triangle x overlapping target facet) pair, and select_patch() splits the *target's*
// triangles down to a 0.02-0.05 mm edge limit along every cursor boundary, behind an
// O(target triangles) visited-set allocation per call. A coarse import painted with a fine brush
// carries tens of thousands of painted sub-triangles, so that is tens of millions of sub-triangles
// created on the target - which is what made Remesh, and Standard mode's Bake (which remeshes for
// you), appear to hang rather than finish.
//
// Each target triangle here asks one question instead: is the point on the source surface closest
// to my centroid inside a painted piece? One AABB-tree query per target triangle, whole facets
// only, no splitting - O(target log source). `src_tree` is built over `src_mesh.its` by the caller
// and shared across the layers; `dst_to_src` brings the target's vertices into the source's frame.
TriangleSelector::TriangleSplittingData remap_texture_paint_spatial(
const TriangleMesh &src_mesh, const TriangleSelector::TriangleSplittingData &src_data,
const AABBTreeIndirect::Tree3f &src_tree, const TriangleMesh &dst_mesh, const Vec3f &dst_to_src)
{
const indexed_triangle_set &src = src_mesh.its;
const indexed_triangle_set &dst = dst_mesh.its;
const size_t ntri = src.indices.size();
if (src_data.bitstream.empty() || ntri == 0 || dst.indices.empty() || src_tree.empty())
return {};
// The painted patch, split into per-source-triangle pieces - the same shape
// collect_paint_region() builds, and for the same reason: a source triangle the brush only
// partly covered has to answer "is this point painted" geometrically rather than be rounded to
// painted-or-not, which leaves a ragged fringe along any curved brush boundary.
TriangleSelector src_sel(src_mesh);
src_sel.deserialize(src_data, false);
std::vector<int> piece_src;
const indexed_triangle_set patch = src_sel.get_facets_strict(EnforcerBlockerType::ENFORCER, &piece_src);
if (patch.indices.empty())
return {};
const auto tri_area2 = [](const Vec3f &a, const Vec3f &b, const Vec3f &c) {
return (b - a).cross(c - a).norm();
};
std::vector<float> src_area2(ntri, 0.f), covered2(ntri, 0.f);
for (size_t i = 0; i < ntri; ++i)
src_area2[i] = tri_area2(src.vertices[size_t(src.indices[i][0])], src.vertices[size_t(src.indices[i][1])],
src.vertices[size_t(src.indices[i][2])]);
for (size_t j = 0; j < patch.indices.size() && j < piece_src.size(); ++j) {
if (size_t(piece_src[j]) >= ntri)
continue;
const stl_triangle_vertex_indices &t = patch.indices[j];
covered2[size_t(piece_src[j])] += tri_area2(patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])],
patch.vertices[size_t(t[2])]);
}
std::vector<uint8_t> full(ntri, 0);
for (size_t i = 0; i < ntri; ++i)
full[i] = (src_area2[i] > 0.f && covered2[i] >= 0.999f * src_area2[i]) ? 1 : 0;
// CSR pieces, kept for partly covered sources only - a full one answers every query "painted".
std::vector<int> part_start(ntri + 1, 0);
for (size_t j = 0; j < patch.indices.size() && j < piece_src.size(); ++j)
if (size_t(piece_src[j]) < ntri && !full[size_t(piece_src[j])])
++part_start[size_t(piece_src[j]) + 1];
for (size_t i = 0; i < ntri; ++i)
part_start[i + 1] += part_start[i];
std::vector<std::array<Vec3f, 3>> part;
part.resize(size_t(part_start[ntri])); // not a constructor call: `vector<T> p(size_t(x[n]));` parses
// as a function declaration, and every use of `part` below
// then fails with something that does not mention the cause.
{
std::vector<int> fill(part_start.begin(), part_start.begin() + ntri);
for (size_t j = 0; j < patch.indices.size() && j < piece_src.size(); ++j) {
const size_t S = size_t(piece_src[j]);
if (S >= ntri || full[S])
continue;
const stl_triangle_vertex_indices &t = patch.indices[j];
part[size_t(fill[S]++)] = { patch.vertices[size_t(t[0])], patch.vertices[size_t(t[1])],
patch.vertices[size_t(t[2])] };
}
}
// Classify in parallel, then write the mask serially - TriangleSelector is not thread safe.
std::vector<uint8_t> painted(dst.indices.size(), 0);
tbb::parallel_for(tbb::blocked_range<size_t>(0, dst.indices.size()),
[&](const tbb::blocked_range<size_t> &range) {
for (size_t i = range.begin(); i < range.end(); ++i) {
const stl_triangle_vertex_indices &t = dst.indices[i];
const Vec3f centroid = (dst.vertices[size_t(t[0])] + dst.vertices[size_t(t[1])] +
dst.vertices[size_t(t[2])]) / 3.f + dst_to_src;
size_t hit = 0;
Vec3f hit_pos = Vec3f::Zero();
if (AABBTreeIndirect::squared_distance_to_indexed_triangle_set(src.vertices, src.indices, src_tree,
centroid, hit, hit_pos) < 0.f ||
hit >= ntri)
continue;
if (full[hit]) {
painted[i] = 1;
continue;
}
for (int k = part_start[hit]; k < part_start[hit + 1]; ++k)
if (point_in_triangle_coplanar(hit_pos, part[size_t(k)])) {
painted[i] = 1;
break;
}
}
});
TriangleSelector dst_sel(dst_mesh);
for (size_t i = 0; i < painted.size(); ++i)
if (painted[i])
dst_sel.set_facet(int(i), EnforcerBlockerType::ENFORCER);
return dst_sel.serialize();
}
std::unique_ptr<GLTexture> upload_height_thumbnail(const DecodedHeightTexture &decoded, int max_px = THUMBNAIL_MAX_PX)
{
if (decoded.empty())
@@ -2964,13 +3078,11 @@ void GLGizmoTextureDisplacement::subdivide_model()
}
bool GLGizmoTextureDisplacement::collect_paint_region(
const TriangleMesh &mesh, const TextureDisplacementFacetsData &facets,
std::vector<uint8_t> &region,
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint) const
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint)
{
const ModelVolume *mv = texture_volume();
if (mv == nullptr)
return false;
const indexed_triangle_set &its = mv->mesh().its;
const indexed_triangle_set &its = mesh.its;
const size_t ntri = its.indices.size();
const size_t nvert = its.vertices.size();
@@ -2996,7 +3108,7 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
bool any_paint = false;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const TriangleSelector::TriangleSplittingData &data = mv->texture_displacement_facet(slot).get_data();
const TriangleSelector::TriangleSplittingData &data = facets[size_t(slot)];
if (!TriangleSelector::has_facets(data, EnforcerBlockerType::ENFORCER))
continue;
@@ -3013,7 +3125,7 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
region[m.triangle_idx] |= REFINE_PAINTED;
if (paint) {
TriangleSelector sel(mv->mesh());
TriangleSelector sel(mesh);
sel.deserialize(data, false);
// 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
@@ -3120,87 +3232,146 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
return true;
}
bool GLGizmoTextureDisplacement::plan_adaptive_subdivision(const ModelVolume &mv, SubdivisionPlan &out) const
TextureDisplacementFacetsData GLGizmoTextureDisplacement::masks_after_subdivision(
const TriangleMesh &new_mesh, const std::vector<int> &source,
const std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> &paint)
{
if (m_subdivide_target_mm <= 0.f)
return false;
std::vector<uint8_t> region;
if (!collect_paint_region(region, &out.paint))
return false;
// Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A
// null sampler (only LSCM layers, or nothing decodable) falls back to the length baseline alone.
HeightFieldSampler sampler;
if (m_subdivide_feature) {
TextureDisplacementFacetsData facets{};
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
facets[size_t(i)] = mv.texture_displacement_facet(i).get_data();
sampler = make_combined_displacement_sampler(mv.mesh().its, mv.texture_displacement_layers, facets);
}
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down to); in
// plain adaptive mode the target edge length is the only criterion, so the floor must not be
// allowed to silently override a target the user set below it.
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
{
wxBusyCursor wait;
// The budget slider is "triangles the refinement may *add*", so the model's own count is the
// 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, m_subdivide_border_mm);
}
return out.refined.indices.size() != mv.mesh().its.indices.size();
}
void GLGizmoTextureDisplacement::apply_adaptive_subdivision(ModelVolume &mv, SubdivisionPlan &&plan)
{
// Other paint channels ride across via the standard remap; texture-displacement paint is rebuilt
// below from the plan's source map, which is the whole point of driving this by the paint.
std::optional<TriangleSelector::SavedPainting> saved_painting = mv.save_painting();
mv.set_mesh(TriangleMesh(std::move(plan.refined)));
mv.set_new_unique_id();
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. 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;
// 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.
TextureDisplacementFacetsData out{};
const indexed_triangle_set &its = new_mesh.its;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
const LayerPaintMap &pm = plan.paint[size_t(slot)];
const LayerPaintMap &pm = paint[size_t(slot)];
if (pm.empty())
continue;
TriangleSelector sel(mv.mesh());
for (size_t i = 0; i < plan.source.size() && i < new_its.indices.size(); ++i) {
const size_t S = size_t(plan.source[i]);
TriangleSelector sel(new_mesh);
for (size_t i = 0; i < source.size() && i < its.indices.size(); ++i) {
const size_t S = size_t(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;
const stl_triangle_vertex_indices &t = its.indices[i];
const Vec3f centroid = (its.vertices[size_t(t[0])] + its.vertices[size_t(t[1])] +
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);
out[size_t(slot)] = sel.serialize();
}
return out;
}
TextureDisplacementFacetsData GLGizmoTextureDisplacement::facets_data_of(const ModelVolume &mv)
{
TextureDisplacementFacetsData out{};
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
out[size_t(i)] = mv.texture_displacement_facet(i).get_data();
return out;
}
TextureDisplacementPrepareResult GLGizmoTextureDisplacement::prepare_mesh(
const indexed_triangle_set &base, const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers, const TextureDisplacementPrepareParams &params,
const DisplacementProgressFn &progress)
{
TextureDisplacementPrepareResult out;
const auto report = [&progress](int pct) { return !progress || progress(pct); };
TriangleMesh mesh(base);
TextureDisplacementFacetsData current = masks;
bool changed = false;
bool had_paint = false;
for (const TriangleSelector::TriangleSplittingData &m : masks)
had_paint = had_paint || TriangleSelector::has_facets(m, EnforcerBlockerType::ENFORCER);
if (!report(1))
return out;
// 1. Even out the triangle density, and carry the paint onto the result. Everything downstream is
// driven by that paint, so losing it is a hard stop rather than something to bake around - and
// stopping here, before anything is committed, leaves the user's model exactly as it was.
if (params.remesh_edge_mm > 0.f) {
indexed_triangle_set remeshed;
if (plan_remesh(mesh.its, params.remesh_edge_mm, params.remesh_sharp_deg, remeshed)) {
TriangleMesh new_mesh(std::move(remeshed));
const AABBTreeIndirect::Tree3f old_tree =
AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(mesh.its.vertices, mesh.its.indices);
TextureDisplacementFacetsData carried{};
bool any = false;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) {
// Both meshes are the volume's own local frame, so there is no shift between them.
carried[size_t(i)] =
remap_texture_paint_spatial(mesh, current[size_t(i)], old_tree, new_mesh, Vec3f::Zero());
any = any || !carried[size_t(i)].bitstream.empty();
}
mesh = std::move(new_mesh);
current = std::move(carried);
changed = true;
if (had_paint && !any) {
out.paint_lost = true;
return out;
}
}
}
if (!report(50))
return {};
// 2. Refine where the texture bends - the painted area only, plus the graded band the conformal
// closure pulls in around it.
if (params.subdiv_target_mm > 0.f) {
std::vector<uint8_t> region;
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> paint;
if (collect_paint_region(mesh, current, region, &paint)) {
// Feature-adaptive: sample the combined displacement so refinement follows texture
// curvature. A null sampler (only LSCM layers, or nothing decodable) falls back to the
// length baseline alone.
HeightFieldSampler sampler;
if (params.subdiv_feature)
sampler = make_combined_displacement_sampler(mesh.its, layers, current);
// "Min edge" is a feature-mode control (it is the floor the curvature test refines down
// to); in plain adaptive mode the target edge length is the only criterion, so the floor
// must not be allowed to silently override a target the user set below it.
const float tol = params.subdiv_feature ? params.subdiv_detail_mm : 0.f;
const float floor = params.subdiv_feature ? params.subdiv_min_edge_mm : 0.f;
bool aborted = false;
std::vector<int> source;
// The budget is "triangles the refinement may *add*", so the mesh's own count is the
// baseline - otherwise the control would be meaningless (or a dead end) on a dense model.
indexed_triangle_set refined =
subdivide_mesh_adaptive(mesh.its, region, params.subdiv_target_mm,
int(mesh.its.indices.size()) + params.subdiv_added_triangles, &source,
sampler, tol, floor, params.subdiv_border_mm, [&](int pct) {
aborted = !report(50 + pct / 2);
return !aborted;
});
if (aborted)
return {};
if (refined.indices.size() != mesh.its.indices.size()) {
mesh = TriangleMesh(std::move(refined));
current = masks_after_subdivision(mesh, source, paint);
changed = true;
}
}
}
if (!report(100) || !changed)
return out; // an empty result: nothing to commit, which is not a failure
out.mesh = std::move(mesh.its);
out.masks = std::move(current);
return out;
}
void GLGizmoTextureDisplacement::subdivide_model_adaptive()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr)
if (mv == nullptr || m_subdivide_target_mm <= 0.f)
return;
update_model_object(); // flush any in-progress stroke into the committed masks first
@@ -3211,27 +3382,16 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive()
return;
}
// Plan before the snapshot, so a no-op leaves no empty undo step - mirrors remesh_model().
SubdivisionPlan plan;
if (!plan_adaptive_subdivision(*mv, plan)) {
show_error(nullptr, _u8L("Nothing to subdivide - the painted area already meets the target edge "
"length and detail tolerance, or the triangle budget is already used up."));
return;
}
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Adaptive subdivide for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
apply_adaptive_subdivision(*mv, std::move(plan));
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), mo);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
plater->changed_object(*mo);
update_from_model_object(false); // reload selectors/preview against the new mesh + carried paint
m_parent.set_as_dirty();
TextureDisplacementPrepareParams params;
params.subdiv_target_mm = m_subdivide_target_mm;
params.subdiv_detail_mm = m_subdivide_detail_mm;
params.subdiv_min_edge_mm = m_subdivide_min_edge_mm;
params.subdiv_border_mm = m_subdivide_border_mm;
params.subdiv_feature = m_subdivide_feature;
params.subdiv_added_triangles = m_subdivide_budget_k * 1000;
queue_prepare(params, _u8L("Adaptive subdivide for texture displacement"), /* then_bake */ false,
_u8L("Nothing to subdivide - the painted area already meets the target edge length and "
"detail tolerance, or the triangle budget is already used up."));
}
void GLGizmoTextureDisplacement::smooth_model()
@@ -3318,49 +3478,35 @@ void GLGizmoTextureDisplacement::smooth_model()
m_parent.set_as_dirty();
}
void GLGizmoTextureDisplacement::replace_mesh_keep_all_paint(ModelVolume &mv, TriangleMesh &&new_mesh)
{
const indexed_triangle_set old_its = mv.mesh().its;
std::array<TriangleSelector::TriangleSplittingData, TEXTURE_DISPLACEMENT_MAX_LAYERS> saved_texture;
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
saved_texture[size_t(i)] = mv.texture_displacement_facet(i).get_data();
std::optional<TriangleSelector::SavedPainting> saved_painting = mv.save_painting();
mv.set_mesh(std::move(new_mesh));
mv.set_new_unique_id();
mv.calculate_convex_hull();
mv.restore_painting(saved_painting); // clears the extra facets, then remaps the four standard channels
// ... and the same spatial remap for the texture-displacement masks, which restore_painting() knows
// nothing about. Without this a remesh would wipe the texture paint, which is fine for a standalone
// "Remesh" click but fatal for Standard mode's pipeline: it remeshes *after* the user has painted,
// and the subdivision and displacement that follow are both driven by that paint.
const Transform3d to_source = Slic3r::Geometry::translation_transform(mv.mesh().get_init_shift());
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i) {
if (saved_texture[size_t(i)].bitstream.empty())
continue;
TriangleSelector::TriangleSplittingData remapped =
TriangleSelector::remap_painting(old_its, saved_texture[size_t(i)], mv.mesh().its, to_source,
{}); // no existing paint to merge with: set_mesh() just cleared it
if (!remapped.bitstream.empty())
mv.texture_displacement_facet(i).set_data(std::move(remapped));
}
}
bool GLGizmoTextureDisplacement::plan_remesh(const ModelVolume &mv, float target_edge_mm, float sharp_angle_deg,
TriangleMesh &out)
bool GLGizmoTextureDisplacement::plan_remesh(const indexed_triangle_set &src, float target_edge_mm,
float sharp_angle_deg, indexed_triangle_set &out)
{
if (target_edge_mm <= 0.f)
return false;
// CGAL isotropic remeshing can be slow on a big mesh, which is why this is separated from applying
// it: the caller runs it before taking the snapshot so a failure leaves no empty undo step.
const indexed_triangle_set &src = mv.mesh().its;
indexed_triangle_set remeshed;
{
wxBusyCursor wait;
remeshed = MeshBoolean::cgal::remesh_isotropic(src, double(target_edge_mm), 3, double(sharp_angle_deg));
// Its cost and memory grow with the *square* of 1/target_edge, and it runs single-threaded on the
// UI thread, so the target has to be bounded against the part's actual surface area rather than
// taken at face value. Standard mode asks for 1 mm whatever it is handed, and the slider goes down
// to 0.1 mm: on a 150 mm part those are ~500 k and ~50 M triangles respectively - minutes to hours
// of frozen UI, which is indistinguishable from a hang. Raising the target instead still gives the
// subdivider the even density it needs, and the subdivision that follows is where the detail was
// always going to come from anyway. An equilateral triangle of edge L covers sqrt(3)/4 * L^2.
static constexpr double REMESH_MAX_TRIANGLES = 150000.0;
double area = 0.0;
for (const stl_triangle_vertex_indices &tri : src.indices)
area += 0.5 * double((src.vertices[size_t(tri[1])] - src.vertices[size_t(tri[0])])
.cross(src.vertices[size_t(tri[2])] - src.vertices[size_t(tri[0])])
.norm());
if (const double budget_edge = std::sqrt(area / (0.4330127 * REMESH_MAX_TRIANGLES));
budget_edge > double(target_edge_mm)) {
BOOST_LOG_TRIVIAL(info) << "Texture displacement: remesh target raised from " << target_edge_mm
<< " mm to " << budget_edge << " mm to stay within "
<< int(REMESH_MAX_TRIANGLES) << " triangles.";
target_edge_mm = float(budget_edge);
}
indexed_triangle_set remeshed =
MeshBoolean::cgal::remesh_isotropic(src, double(target_edge_mm), 3, double(sharp_angle_deg));
// remesh_isotropic() signals failure by handing the input straight back, so compare against it
// structurally. Vertex count alone is not enough: a remesh that only redistributes triangles at
// roughly the current density legitimately lands on the same count, and treating that as failure
@@ -3370,39 +3516,23 @@ bool GLGizmoTextureDisplacement::plan_remesh(const ModelVolume &mv, float target
remeshed.indices == src.indices))
return false;
out = TriangleMesh(std::move(remeshed));
out = std::move(remeshed);
return true;
}
void GLGizmoTextureDisplacement::remesh_model()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr || m_remesh_target_edge_mm <= 0.f)
if (texture_volume() == nullptr || m_remesh_target_edge_mm <= 0.f)
return;
TriangleMesh remeshed;
if (!plan_remesh(*mv, m_remesh_target_edge_mm, m_remesh_keep_sharp_edges ? m_remesh_sharp_angle_deg : 0.f,
remeshed)) {
show_error(nullptr, _u8L("Remeshing did not change the model. It may be non-manifold (open edges or "
"edges shared by more than two triangles), or the target edge length may "
"already be met."));
return;
}
update_model_object(); // the paint rides across the remesh, so flush any in-progress stroke first
Plater *plater = wxGetApp().plater();
Plater::TakeSnapshot snapshot(plater, _u8L("Remesh model for texture displacement"), UndoRedo::SnapshotType::GizmoAction);
replace_mesh_keep_all_paint(*mv, std::move(remeshed));
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), mo);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
plater->changed_object(*mo);
update_from_model_object(false);
m_parent.set_as_dirty();
TextureDisplacementPrepareParams params;
params.remesh_edge_mm = m_remesh_target_edge_mm;
params.remesh_sharp_deg = m_remesh_keep_sharp_edges ? m_remesh_sharp_angle_deg : 0.f;
queue_prepare(params, _u8L("Remesh model for texture displacement"), /* then_bake */ false,
_u8L("Remeshing did not change the model. It may be non-manifold (open edges or edges "
"shared by more than two triangles), or the target edge length may already be met."));
}
void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
@@ -3419,16 +3549,13 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
if (m_subdivide_adaptive) {
if (m_subdivide_target_mm <= 0.f)
return;
std::vector<uint8_t> region;
if (!collect_paint_region(region, nullptr))
const TextureDisplacementFacetsData facets = facets_data_of(*mv);
std::vector<uint8_t> region;
if (!collect_paint_region(mv->mesh(), facets, region, nullptr))
return; // nothing painted yet: nothing to preview
HeightFieldSampler sampler;
if (m_subdivide_feature) {
TextureDisplacementFacetsData facets{};
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
facets[size_t(i)] = mv->texture_displacement_facet(i).get_data();
if (m_subdivide_feature)
sampler = make_combined_displacement_sampler(mv->mesh().its, mv->texture_displacement_layers, facets);
}
// Feature mode: curvature (Detail tolerance) on top of the Max-edge baseline, down to the
// Min-edge floor. Plain adaptive: tol 0, so only the target-edge-length criterion applies.
const float tol = m_subdivide_feature ? m_subdivide_detail_mm : 0.f;
@@ -3605,8 +3732,7 @@ bool GLGizmoTextureDisplacement::apply_standard_mode_presets(ModelVolume *mv)
void GLGizmoTextureDisplacement::bake_standard()
{
ModelVolume *mv = texture_volume();
ModelObject *mo = m_c->selection_info()->model_object();
if (mv == nullptr || mo == nullptr || m_bake_in_progress)
if (mv == nullptr || m_bake_in_progress || m_prepare_in_progress)
return;
update_model_object(); // flush the active layer's in-progress strokes before anything reads them
@@ -3616,63 +3742,70 @@ void GLGizmoTextureDisplacement::bake_standard()
}
apply_standard_mode_presets(mv); // belt and braces: never bake with values the panel is not showing
// Both stages are planned before the snapshot so a stage that has nothing to do is simply skipped.
// Skipping is normal, not a failure: a mesh that is already even needs no remesh, and one that is
// already fine enough for the texture needs no subdivision.
TriangleMesh remeshed;
const bool do_remesh = plan_remesh(*mv, STD_REMESH_EDGE_MM, STD_REMESH_SHARP_DEG, remeshed);
// The whole recipe in one go. Either stage having nothing to do is normal, not a failure - a mesh
// that is already even needs no remesh, one that is already fine enough for the texture needs no
// subdivision - so no "nothing changed" message here: it goes straight on to the displacement.
TextureDisplacementPrepareParams params;
params.remesh_edge_mm = STD_REMESH_EDGE_MM;
params.remesh_sharp_deg = STD_REMESH_SHARP_DEG;
params.subdiv_target_mm = STD_SUBDIV_MAX_EDGE_MM;
params.subdiv_detail_mm = STD_SUBDIV_DETAIL_MM;
params.subdiv_min_edge_mm = STD_SUBDIV_MIN_EDGE_MM;
params.subdiv_border_mm = STD_SUBDIV_BORDER_MM;
params.subdiv_feature = true;
params.subdiv_added_triangles = m_subdivide_budget_k * 1000;
queue_prepare(params, _u8L("Bake texture displacement"), /* then_bake */ true, {});
}
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
// the bake" can sensibly mean when the bake is also what prepared the geometry. The background
// displacement job is told not to add its own (see queue_texture_displacement_bake's
// take_snapshot), because an undo step landing between the subdivision and the displacement
// leaves a mesh with 1.5 M extra triangles and no relief on it - and baking again from there
// subdivides that mesh a second time.
Plater::TakeSnapshot snapshot(plater, _u8L("Bake texture displacement"),
UndoRedo::SnapshotType::GizmoAction);
if (do_remesh)
replace_mesh_keep_all_paint(*mv, std::move(remeshed));
// 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.
// 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.
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()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), mo);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
plater->changed_object(*mo);
update_from_model_object(false); // 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."));
void GLGizmoTextureDisplacement::queue_prepare(const TextureDisplacementPrepareParams &params,
const std::string &snapshot_name, bool then_bake,
const std::string &unchanged_msg)
{
ModelVolume *mv = texture_volume();
if (mv == nullptr || m_prepare_in_progress || m_bake_in_progress)
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);
TextureDisplacementPrepareInput input;
input.volume_id = mv->id();
input.base_mesh = mv->mesh().its;
input.masks = facets_data_of(*mv);
input.layers = mv->texture_displacement_layers;
input.params = params;
input.snapshot_name = snapshot_name;
m_prepare_in_progress = true;
queue_texture_displacement_prepare(std::move(input), [this, then_bake, unchanged_msg](
TextureDisplacementPrepareOutcome outcome) {
m_prepare_in_progress = false;
// The commit replaced the volume's mesh (new id, new topology) without changing the object's id
// or volume count, which is not something GLGizmoPainterBase::data_changed() can detect - so the
// reload is explicit, exactly as it is after a bake. Done for every outcome: even a run that
// committed nothing may have left the panel showing a stale triangle count.
if (m_state == On && m_c->selection_info() && m_c->selection_info()->model_object())
update_from_model_object(false);
m_parent.set_as_dirty();
switch (outcome) {
case TextureDisplacementPrepareOutcome::Failed:
return; // cancelled, or the volume went away while the job ran - say nothing, do nothing
case TextureDisplacementPrepareOutcome::PaintLost:
show_error(nullptr, _u8L("The painted area could not be carried onto the remeshed model, so "
"nothing was changed. Switch to Pro mode and remesh before painting."));
return;
case TextureDisplacementPrepareOutcome::Unchanged:
if (!unchanged_msg.empty())
show_error(nullptr, unchanged_msg);
break;
case TextureDisplacementPrepareOutcome::Committed:
break;
}
// ... and then the displacement itself, in the background exactly as the Pro-mode button does.
// It commits into the snapshot the prepare opened - but only if the prepare opened one: a run
// that found nothing to do took none, and a bake chained onto that has to push its own or it
// would not be undoable at all.
if (then_bake)
bake(/* own_snapshot */ outcome == TextureDisplacementPrepareOutcome::Unchanged);
});
}
void GLGizmoTextureDisplacement::render_paint_cursor_hint()
@@ -4686,7 +4819,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
"commit it, or Done to leave the model as it is."),
m_imgui->scaled(20.f));
} else {
m_imgui->disabled_begin(!subdivide_ready);
m_imgui->disabled_begin(!subdivide_ready || m_prepare_in_progress || m_bake_in_progress);
if (m_imgui->button(_u8L("Apply"))) {
if (m_subdivide_adaptive) {
subdivide_model_adaptive(); // refines only the painted area, carrying the paint forward
@@ -4752,7 +4885,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->scaled(20.f));
}
m_imgui->disabled_begin(mv == nullptr);
m_imgui->disabled_begin(mv == nullptr || m_prepare_in_progress || m_bake_in_progress);
if (m_imgui->button(_u8L("Remesh")))
remesh_model();
m_imgui->disabled_end();
@@ -4783,8 +4916,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->disabled_end();
ImGui::SameLine();
m_imgui->disabled_begin(m_bake_in_progress || mv == nullptr || !mv->is_texture_displacement_painted());
if (m_imgui->button(m_bake_in_progress ? _L("Baking...") : m_desc.at("bake"))) {
m_imgui->disabled_begin(m_bake_in_progress || m_prepare_in_progress || mv == nullptr ||
!mv->is_texture_displacement_painted());
if (m_imgui->button(m_prepare_in_progress ? _L("Preparing...") :
m_bake_in_progress ? _L("Baking...") :
m_desc.at("bake"))) {
// Standard mode's Bake is the whole pipeline (remesh -> refine -> displace); Pro's is only the
// displacement, because there the user has already prepared the mesh with the controls above.
if (pro_mode())

View File

@@ -32,6 +32,24 @@ class GLGizmoTextureDisplacement : public GLGizmoPainterBase
public:
GLGizmoTextureDisplacement(GLCanvas3D& parent, const std::string& icon_filename, unsigned int sprite_id);
// The whole of mesh preparation - remesh, carry the paint across, refine where the texture bends -
// as one pure function over plain data: no ModelVolume, no Model, no GUI, no undo. That is what lets
// TextureDisplacementPrepareJob run it on the job worker instead of on the UI thread, where CGAL's
// remesher and a several-hundred-thousand-triangle refinement together freeze the window for tens
// of seconds with nothing to look at and no way to cancel.
//
// `progress` is called with 0..100 and aborts the run when it returns false; an aborted run reports
// an empty result. An empty result is also how "nothing needed doing" is reported - see
// TextureDisplacementPrepareResult.
static TextureDisplacementPrepareResult prepare_mesh(const indexed_triangle_set &base,
const TextureDisplacementFacetsData &masks,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementPrepareParams &params,
const DisplacementProgressFn &progress);
// The volume's eight texture-displacement masks, gathered into the array every pure function here
// (and every job input) takes.
static TextureDisplacementFacetsData facets_data_of(const ModelVolume &mv);
void render_painter_gizmo() override;
// Intercepts mouse input while "Adjust Texture" mode is on (dragging the on-canvas offset/
@@ -88,20 +106,26 @@ private:
// Standard mode's Bake: remesh to an even density, refine where the texture bends, then displace.
// The order matters and is the whole reason this is one button - a height map can only move
// existing vertices, so the mesh has to be prepared first, and remeshing after painting would drop
// the paint if it were not remapped across (see replace_mesh_keep_all_paint()).
// the paint if it were not carried across (see prepare_mesh()).
void bake_standard();
// Replaces `mv`'s mesh and carries *every* paint channel onto it, texture displacement included -
// the four standard channels via ModelVolume::restore_painting(), the eight texture-displacement
// masks via the same TriangleSelector::remap_painting() spatial remap, which restore_painting()
// does not cover. Shared by Remesh and by Standard mode's pipeline.
static void replace_mesh_keep_all_paint(ModelVolume &mv, TriangleMesh &&new_mesh);
// Remesh and adaptive Subdivide are each split into a *plan* (the heavy geometry work, touching
// nothing) and an *apply* (pure mutation). That split is what lets both the standalone buttons and
// Standard mode's one-button pipeline run the expensive part **before** taking the undo snapshot,
// 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.
// Queues one prepare_mesh() run on the job worker and commits its result when it lands. Every
// mesh-preparation button goes through here - Pro's Remesh, Pro's adaptive Subdivide and Standard's
// Bake differ only in which stages `params` enables and in what happens afterwards:
// - `snapshot_name` is the single undo step the commit opens. With `then_bake` it is also the step
// the displacement job that follows commits into, rather than pushing its own - an undo landing
// between the two would leave a mesh carrying every added triangle and no relief on it, and
// baking again from there would prepare it a second time.
// - `unchanged_msg`, when not empty, is shown if the run had nothing to do. Standard's Bake passes
// nothing: a mesh that already meets the criteria is not an error there, it just goes straight
// on to the displacement.
void queue_prepare(const TextureDisplacementPrepareParams &params, const std::string &snapshot_name,
bool then_bake, const std::string &unchanged_msg);
// Set from queue_prepare() until its job's result has been committed. Distinct from
// m_bake_in_progress because Standard's Bake sets both in turn, and because every button that would
// read or replace the mesh has to stay disabled for the whole of it.
bool m_prepare_in_progress = false;
// 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.
//
@@ -117,17 +141,16 @@ private:
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<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;
static void apply_adaptive_subdivision(ModelVolume &mv, SubdivisionPlan &&plan);
// False means the remesh failed or changed nothing (CGAL signals failure by handing the input back).
static bool plan_remesh(const ModelVolume &mv, float target_edge_mm, float sharp_angle_deg, TriangleMesh &out);
// Rebuilds every layer's mask on a subdivided mesh from `source` (new triangle -> the input triangle
// it descends from) and the pre-subdivision coverage in `paint`.
static TextureDisplacementFacetsData masks_after_subdivision(
const TriangleMesh &new_mesh, const std::vector<int> &source,
const std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> &paint);
// False means the remesh failed or changed nothing (CGAL signals failure by handing the input back),
// and `out` must not be used. `target_edge_mm` is a request rather than a promise: it is clamped
// against the part's surface area first, because CGAL's cost grows with the square of 1/target.
static bool plan_remesh(const indexed_triangle_set &src, float target_edge_mm, float sharp_angle_deg,
indexed_triangle_set &out);
// Marks every facet of every model-part volume as painted for the currently active layer -
// "whole model" as an alternative to brushing/clicking every triangle by hand.
@@ -417,8 +440,9 @@ private:
// 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<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint) const;
static bool collect_paint_region(const TriangleMesh &mesh, const TextureDisplacementFacetsData &facets,
std::vector<uint8_t> &region,
std::array<LayerPaintMap, TEXTURE_DISPLACEMENT_MAX_LAYERS> *paint);
// 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;
@@ -430,9 +454,9 @@ private:
// Isotropic remeshing (CGAL) to even out wildly varying triangle sizes so displacement has a
// consistent density to work with. Target edge length in mm; 0 means "not yet initialised", filled
// with the mesh's mean edge length the first time the control is shown. Like subdivide, it replaces
// the geometry, but unlike subdivide it keeps every paint channel: replace_mesh_keep_all_paint()
// remaps the texture-displacement masks spatially, which is also what lets Standard mode remesh
// *after* the user has painted.
// the geometry, but unlike subdivide it keeps every paint channel: prepare_mesh() carries the
// texture-displacement masks across spatially, which is also what lets Standard mode remesh *after*
// the user has painted.
float m_remesh_target_edge_mm = 0.f;
// Dihedral angle above which an edge counts as a hard feature and is held fixed by the remesher.
// Off by default would round every sharp edge off, so this is on; 0 disables the protection.

View File

@@ -0,0 +1,117 @@
#include "TextureDisplacementPrepareJob.hpp"
#include <algorithm>
#include <optional>
#include "libslic3r/Model.hpp"
#include "libslic3r/TriangleSelector.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/GUI_ObjectList.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/Plater.hpp"
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
#include "slic3r/Utils/UndoRedo.hpp"
namespace Slic3r::GUI {
TextureDisplacementPrepareJob::TextureDisplacementPrepareJob(
TextureDisplacementPrepareInput &&input, std::function<void(TextureDisplacementPrepareOutcome)> on_finished)
: m_input(std::move(input)), m_on_finished(std::move(on_finished))
{
}
void TextureDisplacementPrepareJob::process(Ctl &ctl)
{
const std::string status = _u8L("Preparing mesh for 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.
//
// The progress hook is not just cosmetic: the framework's notification only grows a close button
// once it reaches 100%, and below that it shows Cancel, which is wired through here. Reporting is
// throttled to whole percentage points because every call repaints the notification and wakes the
// idle loop.
int last_reported = 1;
m_result = GLGizmoTextureDisplacement::prepare_mesh(m_input.base_mesh, m_input.masks, m_input.layers,
m_input.params,
[&ctl, &status, &last_reported](int percent) {
if (ctl.was_canceled())
return false;
if (percent > last_reported) {
last_reported = percent;
ctl.update_status(percent, status);
}
return true;
});
ctl.update_status(100, status); // always finish at 100: this is what closes the notification
}
void TextureDisplacementPrepareJob::finalize(bool canceled, std::exception_ptr &eptr)
{
TextureDisplacementPrepareOutcome outcome = TextureDisplacementPrepareOutcome::Failed;
struct OnExit
{
std::function<void(TextureDisplacementPrepareOutcome)> fn;
const TextureDisplacementPrepareOutcome *outcome;
~OnExit() { if (fn) fn(*outcome); }
} on_exit{m_on_finished, &outcome};
if (canceled || eptr)
return;
if (m_result.paint_lost) {
outcome = TextureDisplacementPrepareOutcome::PaintLost;
return;
}
if (m_result.mesh.indices.empty()) {
outcome = TextureDisplacementPrepareOutcome::Unchanged;
return;
}
Plater *plater = wxGetApp().plater();
ModelVolume *volume = get_model_volume(m_input.volume_id, plater->model().objects);
// The lookup doubles as a staleness check: anything that replaces a volume's mesh also gives it a
// new id (set_new_unique_id()), so a prepare computed from a mesh that has since been replaced -
// by an undo, another bake, or a boolean - simply fails to find its volume and commits nothing.
if (volume == nullptr)
return;
ModelObject *object = volume->get_object();
if (object == nullptr)
return;
{
Plater::TakeSnapshot snapshot(plater, m_input.snapshot_name, UndoRedo::SnapshotType::GizmoAction);
// The four standard paint channels ride across on ModelVolume's own spatial remap. The eight
// texture-displacement masks do not go through it - prepare_mesh() has already carried them,
// exactly, from the source triangles the refinement records - so they are put back after
// restore_painting(), which resets every extra facet before remapping the channels it knows.
std::optional<TriangleSelector::SavedPainting> saved_painting = volume->save_painting();
volume->set_mesh(TriangleMesh(std::move(m_result.mesh)));
volume->set_new_unique_id();
volume->calculate_convex_hull();
volume->restore_painting(saved_painting);
for (int i = 0; i < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++i)
volume->texture_displacement_facet(i).set_data(std::move(m_result.masks[size_t(i)]));
if (ObjectList *obj_list = wxGetApp().obj_list()) {
const ModelObjectPtrs &objs = plater->model().objects;
auto it = std::find(objs.begin(), objs.end(), object);
if (it != objs.end())
obj_list->update_info_items(size_t(it - objs.begin()));
}
plater->changed_object(*object);
}
outcome = TextureDisplacementPrepareOutcome::Committed;
}
void queue_texture_displacement_prepare(TextureDisplacementPrepareInput &&input,
std::function<void(TextureDisplacementPrepareOutcome)> on_finished)
{
auto &worker = wxGetApp().plater()->get_ui_job_worker();
queue_job(worker, std::make_unique<TextureDisplacementPrepareJob>(std::move(input), std::move(on_finished)));
}
} // namespace Slic3r::GUI

View File

@@ -0,0 +1,72 @@
#ifndef slic3r_TextureDisplacementPrepareJob_hpp_
#define slic3r_TextureDisplacementPrepareJob_hpp_
#include <functional>
#include <string>
#include <vector>
#include "Job.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
namespace Slic3r {
class ModelVolume;
}
namespace Slic3r::GUI {
// Getting a mesh ready to receive displacement - the isotropic remesh, carrying the paint onto it, and
// the adaptive refinement - off the UI thread.
//
// All three used to run inline behind a wxBusyCursor, which on any part big enough to matter meant tens
// of seconds with the window not repainting and no way to stop it: CGAL's remesher is single threaded
// and its cost grows with the square of 1/target_edge, and the refinement that follows spends a budget
// of hundreds of thousands of triangles. Indistinguishable from a hang, and reported as one.
//
// The work itself is GLGizmoTextureDisplacement::prepare_mesh(), which is pure - it takes an
// indexed_triangle_set and the layers' masks and returns new ones, touching no Model and no GUI - so all
// this job adds is the worker thread, the progress notification with its Cancel button, and the commit.
enum class TextureDisplacementPrepareOutcome
{
Committed, // the volume now carries the prepared mesh and the paint carried onto it
Unchanged, // nothing needed doing - the mesh already met the criteria; the model was not touched
PaintLost, // the remesh landed but no layer's paint survived it, so nothing was committed
Failed, // cancelled, threw, or the volume went away while the job ran
};
struct TextureDisplacementPrepareInput
{
ObjectID volume_id;
indexed_triangle_set base_mesh;
TextureDisplacementFacetsData masks;
std::vector<TextureDisplacementLayer> layers;
TextureDisplacementPrepareParams params;
// The undo step the commit opens. Standard mode's Bake names it after the bake, because the
// displacement job that follows commits into this same step rather than pushing its own.
std::string snapshot_name;
};
class TextureDisplacementPrepareJob : public Job
{
TextureDisplacementPrepareInput m_input;
TextureDisplacementPrepareResult m_result;
std::function<void(TextureDisplacementPrepareOutcome)> m_on_finished;
public:
TextureDisplacementPrepareJob(TextureDisplacementPrepareInput &&input,
std::function<void(TextureDisplacementPrepareOutcome)> on_finished);
void process(Ctl &ctl) override;
void finalize(bool canceled, std::exception_ptr &eptr) override;
};
// `on_finished` runs on the UI thread once the result has been committed (or found not to need
// committing), and always runs exactly once.
void queue_texture_displacement_prepare(TextureDisplacementPrepareInput &&input,
std::function<void(TextureDisplacementPrepareOutcome)> on_finished);
} // namespace Slic3r::GUI
#endif // slic3r_TextureDisplacementPrepareJob_hpp_