From 045504c880fd4acd762047179022b57d20055d4b Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Thu, 27 Aug 2026 09:20:02 +0200 Subject: [PATCH] Move computation to background thread & resolve freeze --- src/libslic3r/TextureDisplacement.cpp | 14 +- src/libslic3r/TextureDisplacement.hpp | 40 +- src/slic3r/CMakeLists.txt | 2 + .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 574 +++++++++++------- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 80 ++- .../Jobs/TextureDisplacementPrepareJob.cpp | 117 ++++ .../Jobs/TextureDisplacementPrepareJob.hpp | 72 +++ 7 files changed, 650 insertions(+), 249 deletions(-) create mode 100644 src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp create mode 100644 src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 1a044ae956..e1a03a9eaf 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -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 *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) diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 74c590bbdb..10e30f9788 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -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 &refine_region, float target_edge_length_mm, int max_triangles = 1000000, std::vector *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 diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 4a9f20b8dc..6cdb530296 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 2fbaf7bf53..bff54e791e 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -2,6 +2,7 @@ #include +#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 +#include #include #include #include @@ -98,6 +101,117 @@ bool point_in_triangle_coplanar(const Vec3f &p, const std::array &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 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 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 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 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> part; + part.resize(size_t(part_start[ntri])); // not a constructor call: `vector 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 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 painted(dst.indices.size(), 0); + tbb::parallel_for(tbb::blocked_range(0, dst.indices.size()), + [&](const tbb::blocked_range &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 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 ®ion, - std::array *paint) const + std::array *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 &source, + const std::array &paint) { - if (m_subdivide_target_mm <= 0.f) - return false; - - std::vector 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 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 &layers, const TextureDisplacementPrepareParams ¶ms, + 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 region; + std::array 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 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 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 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 region; - if (!collect_paint_region(region, nullptr)) + const TextureDisplacementFacetsData facets = facets_data_of(*mv); + std::vector 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 ¶ms, + 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()) diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 7874f2656f..92461fdd1b 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -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 &layers, + const TextureDisplacementPrepareParams ¶ms, + 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 ¶ms, 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> part; // painted pieces of partly covered source triangles bool empty() const { return full.empty(); } }; - struct SubdivisionPlan - { - indexed_triangle_set refined; - std::vector source; // new tri -> input tri - std::array 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 &source, + const std::array &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 ®ion, - std::array *paint) const; + static bool collect_paint_region(const TriangleMesh &mesh, const TextureDisplacementFacetsData &facets, + std::vector ®ion, + std::array *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. diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp new file mode 100644 index 0000000000..5b6cdd246a --- /dev/null +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.cpp @@ -0,0 +1,117 @@ +#include "TextureDisplacementPrepareJob.hpp" + +#include +#include + +#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 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 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 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 on_finished) +{ + auto &worker = wxGetApp().plater()->get_ui_job_worker(); + queue_job(worker, std::make_unique(std::move(input), std::move(on_finished))); +} + +} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp new file mode 100644 index 0000000000..90d723d88f --- /dev/null +++ b/src/slic3r/GUI/Jobs/TextureDisplacementPrepareJob.hpp @@ -0,0 +1,72 @@ +#ifndef slic3r_TextureDisplacementPrepareJob_hpp_ +#define slic3r_TextureDisplacementPrepareJob_hpp_ + +#include +#include +#include + +#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 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 m_on_finished; + +public: + TextureDisplacementPrepareJob(TextureDisplacementPrepareInput &&input, + std::function 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 on_finished); + +} // namespace Slic3r::GUI + +#endif // slic3r_TextureDisplacementPrepareJob_hpp_