From 15fd3fc97c1052af53b4f92d62f82c45331fe929 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Fri, 24 Jul 2026 09:03:06 +0200 Subject: [PATCH] Add adaptive subdivision --- TEXTURE_DISPLACEMENT.md | 65 +++-- src/libslic3r/TextureDisplacement.cpp | 144 ++++++++++ src/libslic3r/TextureDisplacement.hpp | 29 ++ .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 248 ++++++++++++++++-- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 21 +- tests/libslic3r/test_texture_displacement.cpp | 88 +++++++ 6 files changed, 552 insertions(+), 43 deletions(-) diff --git a/TEXTURE_DISPLACEMENT.md b/TEXTURE_DISPLACEMENT.md index d1109644e2..b4b904fea7 100644 --- a/TEXTURE_DISPLACEMENT.md +++ b/TEXTURE_DISPLACEMENT.md @@ -188,24 +188,43 @@ directly** - clamping the *coordinate* into range (what an earlier version did) border row/column of pixels outward to infinity in every direction, which is a real bug that was reported and fixed (visually: streaky lines radiating out from the painted patch). -### Subdivision (`subdivide_mesh_uniform()`) +### Subdivision — two modes -Deliberately **whole-mesh and uniform**, not limited to the painted patch. A patch-only / -adaptive subdivision would create a classic T-junction/cracking problem where the denser -(subdivided) and sparser (untouched) regions meet - the fine side has edge midpoints the coarse -side doesn't know about, producing a real (non-manifold-looking) crack in the baked geometry. This -was consciously scoped down from the original plan's "adaptive per-patch subdivider" idea to avoid -that correctness risk (a subtly-cracked mesh is a much worse outcome than "not implemented yet"). -Algorithm: recursive 1-to-4 triangle split via edge midpoints, with a shared per-pass midpoint cache -(keyed by sorted vertex-index pair) so triangles sharing an edge get the *same* new vertex - capped -at `max_iterations` (default 6) passes to bound worst-case triangle-count explosion. +**Uniform (`subdivide_mesh_uniform()`)** — whole-mesh, 1-to-4 split. Recursive edge-midpoint split with +a shared per-pass midpoint cache (keyed by sorted vertex-index pair) so triangles sharing an edge get +the *same* new vertex - capped at `max_iterations` (default 6). Whole-mesh so it never leaves a +T-junction, at the cost of densifying everywhere. Wired as a "Subdivide steps" slider (**0–5**, 0 = +no subdivision), Apply snaps back to 0. Drops texture-displacement paint (no remap) via the standard +`save_painting()`/`set_mesh()`/`restore_painting()` dance; the other four channels are remapped. -Wired as a "Subdivide steps" slider (**0–5**, where 0 means no subdivision and previews nothing) plus -Preview/Apply/Done in the gizmo panel. **Apply snaps the slider back to 0**. A real, committed geometry change (like -Bake), using the same `save_painting()`/`set_mesh()`/`restore_painting()` dance `GLGizmoSimplify` -uses: supported/seam/mmu/fuzzy-skin masks get remapped onto the new triangles, texture-displacement -paint does not (no remap support yet) and is dropped rather than left pointing at now-meaningless -triangle indices. +**Adaptive (`subdivide_mesh_adaptive()`)** — refine **only the painted area**, down to a target edge +length, by **Rivara longest-edge bisection**. This is the algorithm that was "scoped out" originally +for fear of the T-junction/crack problem; it is safe because it is *conformal by construction*. Each +pass bisects only **terminal** edges - an edge that is the longest edge of *every* triangle sharing it +- which splits both those triangles along one shared midpoint at once, so a hanging node is never +created. Only a triangle's own longest edge can be terminal, so each triangle is split by at most one +bisection per pass. When a triangle that still needs refining has a longest edge that is not yet +terminal, the neighbour across it has a strictly longer edge and is refined first; that propagation +grades the mesh down into the region and closes what would be cracks (pulling a thin, bounded band of +transition triangles just outside the painted patch). Tie-broken by mesh-vertex key so both sides of +an edge always agree on "the" longest. + +The win: a small decal on a big model no longer quadruples the *whole* model's triangle count. + +**It carries the paint forward**, which is what makes it usable (uniform/remesh both drop paint). Because +the refinement is *driven by* the paint, the remap is trivial: `subdivide_mesh_adaptive()` fills an +`out_source[new_tri] = input_tri` map (children inherit their parent), and the gizmo rebuilds each +layer's mask on the new mesh - a new triangle is painted iff its source was fully painted in that +layer. `collect_paint_region()` derives both the union refine-region (any vertex of a painted patch, +i.e. patch + a one-ring, so the boundary itself refines) and the per-layer fully-painted-triangle sets +(a `get_facets_strict(ENFORCER)` sub-triangle with all three *original* vertex indices == a whole, +fully-painted original triangle; a partial stroke's sub-triangles always carry a split vertex). The +other four channels still ride the normal `restore_painting()` remap. Covered by a conformality unit +test (`every_edge_used_twice` on a partially-refined cube - an exact crack detector for a closed mesh). + +Both share the gizmo's Preview/Apply/Done flow; the **"Only painted area (adaptive)"** checkbox picks +the mode, and the adaptive preview follows the paint live (`rebuild_preview()` refreshes the wireframe +while the subdivide preview is open in adaptive mode). ### Fast bump preview (GPU-only, no CPU meshing) @@ -353,11 +372,16 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward part of the mesh via the existing mesh serialization path) - what does *not* survive a project save/reload is any *unbaked* paint stroke and texture layer definition. - **No remap-across-topology-change** for texture-displacement paint (`ModelObject::split()`, mesh - boolean ops, Simplify, and now `subdivide_mesh_uniform()` all drop it via `reset_extra_facets()`). - The other four paint channels (supported/seam/mmu/fuzzy) do get remapped in these cases. + boolean ops, Simplify, uniform subdivide, and remesh all drop it via `reset_extra_facets()`). The + other four paint channels (supported/seam/mmu/fuzzy) do get remapped in these cases. The lone + exception is **adaptive subdivide**, which carries texture-displacement paint forward itself via its + source map (see the Subdivision section) - a targeted remap that only works because the operation is + driven by the paint. - **Cylindrical/Spherical axis/center are auto-picked heuristically**, not user-controllable - no UI to override the auto-detected wrap axis if it picks the "wrong" one for an odd shape. -- **Fast preview covers the active layer only** +- **Fast preview covers the active layer only** — and is now the **default** view when the gizmo + opens (`m_use_bump_preview = true`): it is the instant, no-CPU-meshing preview, so it is the better + first impression while painting. The exact true-displacement view is one click away in the View row. - **Displacement resolution is capped by the mesh's own vertex density.** Baking only ever *moves* existing vertices (it never inserts any), so a coarse patch cannot show fine texture detail no matter how high-resolution the height map is - that is what the "Subdivide model" button is for. @@ -368,7 +392,8 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward **libslic3r (core, no GUI dependency):** - `src/libslic3r/TextureDisplacement.hpp/.cpp` - data model, bake algorithm, projection methods, - tiling, subdivision. See doc comments throughout, they're kept accurate and up to date. + tiling, subdivision (uniform + adaptive longest-edge bisection). See doc comments throughout, + they're kept accurate and up to date. - `src/libslic3r/MeshBoolean.hpp/.cpp` - added `parameterize_lscm()` and `remesh_isotropic()` in the `cgal` sub-namespace, reusing the existing `CGALMesh`/`_EpicMesh`/conversion-helper infrastructure already there for diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 0a6325cd92..7bc855cf15 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -1346,4 +1346,148 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl return current; } +indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, + const std::vector &refine_region, + float target_edge_length_mm, int max_iterations, + std::vector *out_source) +{ + // Each triangle carries the input-triangle index it descends from, so children inherit it and + // the caller can remap per-triangle data (paint masks) for free. + struct Tri { int v[3]; int src; }; + + std::vector verts = mesh.vertices; + std::vector tris; + tris.reserve(mesh.indices.size()); + for (size_t i = 0; i < mesh.indices.size(); ++i) + tris.push_back({ { mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2] }, int(i) }); + + auto emit = [&](std::vector &t) -> indexed_triangle_set { + indexed_triangle_set out; + out.vertices = verts; + out.indices.reserve(t.size()); + if (out_source) { + out_source->clear(); + out_source->reserve(t.size()); + } + for (const Tri &tr : t) { + out.indices.emplace_back(tr.v[0], tr.v[1], tr.v[2]); + if (out_source) + out_source->push_back(tr.src); + } + return out; + }; + + // refine_region is indexed by input-triangle index, and every triangle's src stays in that range + // (children inherit their parent's src), so a wrong size would be an out-of-bounds read. Guard it. + if (target_edge_length_mm <= 0.f || refine_region.size() != mesh.indices.size()) + return emit(tris); + if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; })) + return emit(tris); // nothing flagged: no-op + const float target_sq = target_edge_length_mm * target_edge_length_mm; + + auto edge_key = [](int a, int b) -> uint64_t { + if (a > b) + std::swap(a, b); + return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); + }; + auto edge_len_sq = [&](uint64_t k) -> float { + return (verts[int(k >> 32)] - verts[int(uint32_t(k))]).squaredNorm(); + }; + // The one edge of a triangle chosen as its "longest": greatest squared length, ties broken by the + // smaller edge key. The tie-break is by mesh-vertex indices, which both triangles sharing an edge + // compute identically - so they never disagree about whether that shared edge is "the" longest, + // which is what the conformality argument rests on. + auto longest_key = [&](const Tri &t) -> uint64_t { + uint64_t best_k = edge_key(t.v[0], t.v[1]); + float best_len = edge_len_sq(best_k); + for (int e = 1; e < 3; ++e) { + const uint64_t k = edge_key(t.v[e], t.v[(e + 1) % 3]); + const float l = edge_len_sq(k); + if (l > best_len || (l == best_len && k < best_k)) { + best_len = l; + best_k = k; + } + } + return best_k; + }; + + for (int iter = 0; iter < max_iterations; ++iter) { + // edge -> the (up to two) triangles sharing it. A third triangle on an edge means a + // non-manifold input; it is left in the second slot's place and simply not treated as + // terminal, so such an edge is never bisected (better a missed refinement than a torn mesh). + std::unordered_map> edge_tris; + edge_tris.reserve(tris.size() * 3); + for (int ti = 0; ti < int(tris.size()); ++ti) + for (int e = 0; e < 3; ++e) { + const uint64_t k = edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]); + auto it = edge_tris.find(k); + if (it == edge_tris.end()) + edge_tris.emplace(k, std::array{ ti, -1 }); + else if (it->second[1] == -1) + it->second[1] = ti; + else + it->second[0] = -2; // >2 triangles: poison this edge (never terminal) + } + + std::vector tri_longest(tris.size()); + for (int ti = 0; ti < int(tris.size()); ++ti) + tri_longest[ti] = longest_key(tris[ti]); + + // Which edges to bisect this pass: terminal (the longest edge of every triangle on it) AND + // long enough AND wanted by the region (either side in it). Terminal-ness is exactly what + // guarantees both sides split together, so no hanging node is ever produced. + std::unordered_set to_bisect; + for (const auto &[k, slot] : edge_tris) { + const int t0 = slot[0], t1 = slot[1]; + if (t0 < 0) + continue; // poisoned (non-manifold) + if (tri_longest[t0] != k) + continue; + if (t1 >= 0 && tri_longest[t1] != k) + continue; + if (edge_len_sq(k) <= target_sq) + continue; + const bool in_region = (refine_region[tris[t0].src] != 0) || + (t1 >= 0 && refine_region[tris[t1].src] != 0); + if (in_region) + to_bisect.insert(k); + } + if (to_bisect.empty()) + break; + + // One midpoint per bisected edge, shared by both sides. + std::unordered_map mid; + mid.reserve(to_bisect.size()); + for (const uint64_t k : to_bisect) { + const int a = int(k >> 32), b = int(uint32_t(k)); + mid.emplace(k, int(verts.size())); + verts.push_back((verts[a] + verts[b]) * 0.5f); + } + + std::vector next; + next.reserve(tris.size() + to_bisect.size()); + for (const Tri &t : tris) { + // At most one of a triangle's edges can be terminal (only its own longest can be), so at + // most one is in to_bisect - find that one. + int be = -1; + for (int e = 0; e < 3; ++e) + if (to_bisect.count(edge_key(t.v[e], t.v[(e + 1) % 3])) != 0) { + be = e; + break; + } + if (be == -1) { + next.push_back(t); + continue; + } + const int a = t.v[be], b = t.v[(be + 1) % 3], c = t.v[(be + 2) % 3]; + const int m = mid.at(edge_key(a, b)); + next.push_back({ { a, m, c }, t.src }); // both children keep the original winding a->b->c + next.push_back({ { m, b, c }, t.src }); + } + tris.swap(next); + } + + return emit(tris); +} + } // namespace Slic3r diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index d3698eaec1..5bae458e43 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -511,6 +511,35 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume); // during baking. indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, float max_edge_length_mm, int max_iterations = 6); +// Adaptive subdivision by Rivara longest-edge bisection, restricted to a region. +// +// Unlike subdivide_mesh_uniform() this only densifies where asked - a triangle is refined when its +// source is flagged in `refine_region` and its longest edge exceeds target_edge_length_mm - so a +// small painted patch on a large model does not quadruple the whole model's triangle count. It is +// nonetheless *conformal*: it never leaves a T-junction/crack at the boundary between the refined +// and coarse regions (the trap that made subdivide_mesh_uniform() deliberately whole-mesh). +// +// How it stays crack-free: each pass bisects only "terminal" edges - an edge that is the longest +// edge of *every* triangle sharing it. Bisecting such an edge splits both its triangles 1->2 along +// the same new midpoint at once, so no hanging node is ever created. Because only a triangle's own +// longest edge can be terminal, each triangle is split by at most one terminal edge per pass. When +// a triangle that needs refining has a longest edge that is *not* yet terminal, the neighbour across +// it has a strictly longer edge and is refined first; that propagation is what grades the mesh down +// into the region and closes what would otherwise be cracks (Rivara, "New longest-edge algorithms"). +// The transition triangles it pulls in just outside the region are a thin, bounded band. +// +// `refine_region` is indexed by input-triangle index (empty or all-false => no-op). If `out_source` +// is non-null it is resized to the output triangle count and out_source[i] receives the input +// triangle that output triangle i descends from (children inherit their parent's index), so a caller +// can carry per-triangle data - e.g. a paint mask - across the topology change without a geometric +// remap. `max_iterations` bounds the worst case; ties in "longest edge" are broken by a mesh-vertex +// key so both triangles on an edge always agree, at the cost of occasionally stopping one pass early +// on a pathologically tie-heavy mesh (a quality shortfall, never a crack). +indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, + const std::vector &refine_region, + float target_edge_length_mm, int max_iterations = 12, + std::vector *out_source = nullptr); + } // namespace Slic3r #endif // slic3r_TextureDisplacement_hpp_ diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index d1bce40b6b..0fd02a3ba6 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -1153,6 +1153,11 @@ void GLGizmoTextureDisplacement::rebuild_preview() rebuild_bump_preview_mesh(); rebuild_uvcheck_mesh(); rebuild_seam_overlay(); + // The adaptive subdivision preview is driven by the painted area, so it has to follow the paint + // while it is open - a plain stroke changes which triangles would be refined. The uniform preview + // depends only on the mesh, so it is left to its own controls. + if (m_subdivide_editing && m_subdivide_adaptive) + rebuild_subdivide_preview(); const ModelVolume *mv = texture_volume(); if (mv == nullptr || !mv->is_texture_displacement_painted()) { @@ -2655,6 +2660,143 @@ void GLGizmoTextureDisplacement::subdivide_model() m_parent.set_as_dirty(); } +bool GLGizmoTextureDisplacement::collect_paint_region( + std::vector ®ion, + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const +{ + const ModelVolume *mv = texture_volume(); + if (mv == nullptr) + return false; + const indexed_triangle_set &its = mv->mesh().its; + const size_t ntri = its.indices.size(); + const size_t nvert = its.vertices.size(); + + region.assign(ntri, 0); + if (painted_tri) + for (auto &pt : *painted_tri) + pt.clear(); + + // Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes + // back with the original mesh's own three vertex indices) can be mapped to its source triangle. + // A sub-triangle produced by a *partial* brush stroke has at least one appended (split) vertex, + // so "all three indices are original" is exactly the test for a whole, fully-painted triangle. + std::map, int> tri_by_verts; + for (size_t i = 0; i < ntri; ++i) { + std::array k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] }; + std::sort(k.begin(), k.end()); + tri_by_verts.emplace(k, int(i)); + } + + 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(); + if (!TriangleSelector::has_facets(data, EnforcerBlockerType::ENFORCER)) + continue; + + TriangleSelector sel(mv->mesh()); + sel.deserialize(data, false); + const indexed_triangle_set patch = sel.get_facets_strict(EnforcerBlockerType::ENFORCER); + + std::vector painted_vertex(nvert, 0); + if (painted_tri) + (*painted_tri)[slot].assign(ntri, 0); + + for (const stl_triangle_vertex_indices &t : patch.indices) { + bool all_original = true; + for (int k = 0; k < 3; ++k) { + if (size_t(t[k]) < nvert) + painted_vertex[t[k]] = 1; // marks the refine region (any coverage, plus a ring) + else + all_original = false; // a split vertex -> this is a partial sub-triangle + } + if (all_original && painted_tri) { + std::array k{ t[0], t[1], t[2] }; + std::sort(k.begin(), k.end()); + if (auto it = tri_by_verts.find(k); it != tri_by_verts.end()) + (*painted_tri)[slot][it->second] = 1; + } + } + + // A triangle is in the refine region if any of its vertices is painted. That deliberately + // over-includes a one-triangle ring just outside the strict patch, which is exactly the + // transition band the conformal bisection would pull in anyway - and it makes sure the patch + // boundary itself gets refined rather than staying coarse right where the relief ends. + for (size_t i = 0; i < ntri; ++i) + for (int k = 0; k < 3; ++k) + if (painted_vertex[its.indices[i][k]]) { + region[i] = 1; + break; + } + any_paint = true; + } + return any_paint; +} + +void GLGizmoTextureDisplacement::subdivide_model_adaptive() +{ + ModelVolume *mv = texture_volume(); + ModelObject *mo = m_c->selection_info()->model_object(); + if (mv == nullptr || mo == nullptr || m_subdivide_target_mm <= 0.f) + return; + + update_model_object(); // flush any in-progress stroke into the committed masks first + + std::vector region; + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> painted_tri; + if (!collect_paint_region(region, &painted_tri)) { + show_error(nullptr, _u8L("Paint the area you want to subdivide first - adaptive subdivision only " + "refines where you have painted.")); + return; + } + + // Do the (potentially slow) refinement before taking the snapshot, so a no-op leaves no empty + // undo step - mirrors remesh_model(). + std::vector source; + indexed_triangle_set refined; + { + wxBusyCursor wait; + refined = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, 12, &source); + } + if (refined.indices.size() == mv->mesh().its.indices.size()) { + show_error(nullptr, _u8L("Nothing to subdivide - the painted area is already at or below the target " + "edge length.")); + return; + } + + Plater *plater = wxGetApp().plater(); + Plater::TakeSnapshot snapshot(plater, _u8L("Adaptive subdivide for texture displacement"), UndoRedo::SnapshotType::GizmoAction); + + // Other paint channels ride across via the standard remap; texture-displacement paint is rebuilt + // by hand below from the 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(refined))); // refined is not needed past here; source carries the paint map + 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: a new triangle is painted iff its source triangle + // was fully painted in that layer. Children inherit their parent's source, so this is exact. + for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { + if (painted_tri[slot].empty()) + continue; + TriangleSelector sel(mv->mesh()); + for (size_t i = 0; i < source.size(); ++i) + if (painted_tri[slot][source[i]]) + sel.set_facet(int(i), EnforcerBlockerType::ENFORCER); + mv->texture_displacement_facet(slot).set(sel); + } + + 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(); +} + void GLGizmoTextureDisplacement::remesh_model() { ModelVolume *mv = texture_volume(); @@ -2712,12 +2854,24 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() m_subdivide_preview_glmodel.reset(); m_subdivide_preview_count = -1; const ModelVolume *mv = texture_volume(); - if (mv == nullptr || m_subdivide_count < 1) + if (mv == nullptr) return; - // The same subdivision Apply would commit, but kept in a throwaway mesh and shown only as a + // The same subdivision Apply would commit, kept in a throwaway mesh and shown only as a // wireframe - the model itself is not touched until Apply. - const indexed_triangle_set its = subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count); + indexed_triangle_set its; + if (m_subdivide_adaptive) { + if (m_subdivide_target_mm <= 0.f) + return; + std::vector region; + if (!collect_paint_region(region, nullptr)) + return; // nothing painted yet: nothing to preview + its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, 12, nullptr); + } else { + if (m_subdivide_count < 1) + return; + its = subdivide_mesh_uniform(mv->mesh().its, 0.f, m_subdivide_count); + } if (its.indices.empty()) return; m_subdivide_preview_count = m_subdivide_count; @@ -3558,20 +3712,66 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float ImGui::Separator(); m_imgui->text(_u8L("Not enough vertices for fine detail?")); - ImGui::PushItemWidth(m_imgui->scaled(8.4f)); - if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) { - m_subdivide_count = std::clamp(m_subdivide_count, 0, 5); + + if (ImGui::Checkbox(_u8L("Only painted area (adaptive)").c_str(), &m_subdivide_adaptive)) { if (m_subdivide_editing) - rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one + rebuild_subdivide_preview(); // switch the wireframe between the uniform and adaptive result m_parent.set_as_dirty(); } - ImGui::PopItemWidth(); if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("How many times to split every triangle into four. Each step roughly quadruples the " - "triangle count, so there are enough vertices for the height texture to displace. " - "0 means no subdivision."), + m_imgui->tooltip(_u8L("Refine only where you have painted, down to a target edge length, instead of splitting " + "the whole model. Keeps the triangle count down on a big part with a small decal, and - " + "unlike whole-model subdivision - your paint is carried onto the finer mesh instead of " + "being cleared."), m_imgui->scaled(20.f)); + if (m_subdivide_adaptive) { + if (m_subdivide_target_mm <= 0.f && mv != nullptr) { + // Seed the target at about half the mesh's mean edge length, so the default already adds + // a useful amount of detail rather than landing on "no change". + const indexed_triangle_set &its = mv->mesh().its; + double sum = 0.0; size_t cnt = 0; + for (const stl_triangle_vertex_indices &tri : its.indices) + for (int i = 0; i < 3; ++i) { + sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm(); + ++cnt; + } + m_subdivide_target_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)) * 0.5f, 0.1f, 20.f) : 1.f; + } + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + // "##subdiv" keeps the visible label "Target edge (mm)" but gives it an ImGui ID distinct + // from the remesh slider below, which shows the same text - same label == same widget to + // ImGui, so without this the two would collide. + if (m_imgui->slider_float(std::string(_u8L("Target edge (mm)")) + "##subdiv", &m_subdivide_target_mm, + 0.1f, 20.f, "%.2f", ImGuiLogSlider)) { + if (m_subdivide_editing && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) + rebuild_subdivide_preview(); + m_parent.set_as_dirty(); + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("Triangles in the painted area are split until every edge is at or below this " + "length. Smaller means finer detail and more triangles."), + m_imgui->scaled(20.f)); + } else { + ImGui::PushItemWidth(m_imgui->scaled(8.4f)); + if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) { + m_subdivide_count = std::clamp(m_subdivide_count, 0, 5); + if (m_subdivide_editing) + rebuild_subdivide_preview(); // a count of 0 clears the preview, it doesn't compute one + m_parent.set_as_dirty(); + } + ImGui::PopItemWidth(); + if (ImGui::IsItemHovered()) + m_imgui->tooltip(_u8L("How many times to split every triangle into four. Each step roughly quadruples the " + "triangle count, so there are enough vertices for the height texture to displace. " + "0 means no subdivision."), + m_imgui->scaled(20.f)); + } + + // Apply is a no-op when there is nothing to commit: 0 uniform passes, or an adaptive target that + // is not set (the preview covers the painted-area check itself). + const bool subdivide_ready = m_subdivide_adaptive ? (m_subdivide_target_mm > 0.f) : (m_subdivide_count >= 1); if (!m_subdivide_editing) { if (m_imgui->button(_u8L("Preview subdivision"))) { m_subdivide_editing = true; @@ -3583,22 +3783,28 @@ 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(m_subdivide_count < 1); + m_imgui->disabled_begin(!subdivide_ready); if (m_imgui->button(_u8L("Apply"))) { - subdivide_model(); // commits m_subdivide_count passes (takes its own snapshot) - // Back to 0 rather than staying at the count just applied: the mesh is now up to 4^N - // times denser, so re-previewing the same N passes on top of it is both pointless (the - // density asked for is already committed) and by far the slowest thing this panel does. - // rebuild_subdivide_preview() at 0 just drops the wireframe. - m_subdivide_count = 0; + if (m_subdivide_adaptive) { + subdivide_model_adaptive(); // refines only the painted area, carrying the paint forward + } else { + subdivide_model(); // commits m_subdivide_count passes (takes its own snapshot) + // Back to 0 rather than staying at the count just applied: the mesh is now up to 4^N + // times denser, so re-previewing the same N passes on top of it is both pointless (the + // density asked for is already committed) and by far the slowest thing this panel does. + m_subdivide_count = 0; + } rebuild_subdivide_preview(); m_parent.set_as_dirty(); } m_imgui->disabled_end(); if (ImGui::IsItemHovered()) - m_imgui->tooltip(_u8L("Replaces the model's geometry with the subdivided mesh and clears any not-yet-baked " - "paint on it (already-baked bumps are unaffected)."), - m_imgui->scaled(20.f)); + m_imgui->tooltip(m_subdivide_adaptive ? + _u8L("Refines the painted area to the target edge length and carries your paint onto " + "the finer mesh. The rest of the model is left as it is.") : + _u8L("Replaces the model's geometry with the subdivided mesh and clears any not-yet-baked " + "paint on it (already-baked bumps are unaffected)."), + m_imgui->scaled(20.f)); ImGui::SameLine(); if (m_imgui->button(_u8L("Done"))) { m_subdivide_editing = false; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index bbcd355ebe..018e1f00c8 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -307,6 +307,20 @@ private: void rebuild_subdivide_preview(); void render_subdivide_preview(); + // Adaptive subdivision: refine only the painted area, down to a target edge length, via + // conformal longest-edge bisection (subdivide_mesh_adaptive()). Unlike the count-based uniform + // path it does not touch the unpainted rest of the model, and - because it is driven by the paint + // - it can carry that paint forward across the topology change (children of a painted triangle + // are painted), so the region survives the subdivision instead of being dropped. + bool m_subdivide_adaptive = false; + float m_subdivide_target_mm = 0.f; // 0 = not yet seeded; filled from the mesh on first show + void subdivide_model_adaptive(); + // Fills `region` (per current-mesh triangle, 1 = refine) from the union of every layer's painted + // area. If `painted_tri` is non-null, also fills, per layer, the fully-painted triangles to carry + // forward. Returns false when nothing is painted at all. Shared by the preview and the commit. + bool collect_paint_region(std::vector ®ion, + std::array, TEXTURE_DISPLACEMENT_MAX_LAYERS> *painted_tri) const; + // 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 @@ -326,8 +340,11 @@ private: // mouse button driving the drag hasn't been released yet - see on_render_input_window(). bool m_preview_params_dirty = false; - // See rebuild_bump_preview_mesh()/render_bump_preview_mesh(). - bool m_use_bump_preview = false; + // See rebuild_bump_preview_mesh()/render_bump_preview_mesh(). On by default: it is the cheap, + // instant-updating preview, so it is the better first impression while painting. The true- + // displacement view (a background CPU remesh) is one click away in the View row when the user + // wants an exact look at what Bake will produce. + bool m_use_bump_preview = true; // Set from the UV editor's per-move island edits instead of rebuilding the (potentially large) bump // mesh synchronously inside that mouse handler - doing the rebuild there stalled both the UV pane // and the 3D view. The rebuild is instead coalesced to once per 3D frame (render_painter_gizmo). diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index b7a9566e3f..ed45ec66e6 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -233,3 +233,91 @@ TEST_CASE("TextureDisplacement: boundary vertices shared with unpainted triangle CHECK_FALSE(still_at_original_position(fan.vertices[2])); // B: interior, must have moved CHECK_FALSE(still_at_original_position(fan.vertices[3])); // C: interior, must have moved } + +// Every undirected edge of a closed manifold mesh is shared by exactly two triangles. A T-junction +// (a hanging node where a refined region meets a coarse one) breaks that: the coarse side spans an +// edge that the fine side has replaced with two half-edges, so those three edges each show up an +// odd number of times. Counting edge uses is therefore an exact crack detector for a closed mesh. +static bool every_edge_used_twice(const indexed_triangle_set &its) +{ + std::map, int> uses; + for (const auto &t : its.indices) + for (int e = 0; e < 3; ++e) { + int a = t[e], b = t[(e + 1) % 3]; + if (a > b) + std::swap(a, b); + ++uses[{ a, b }]; + } + for (const auto &[edge, n] : uses) + if (n != 2) + return false; + return true; +} + +TEST_CASE("TextureDisplacement: adaptive subdivision is conformal and region-restricted", "[TextureDisplacement]") +{ + const indexed_triangle_set cube = its_make_cube(10., 10., 10.); + REQUIRE(every_edge_used_twice(cube)); // sanity: the input really is a closed manifold + + auto longest_edge = [](const indexed_triangle_set &its, const stl_triangle_vertex_indices &t) { + float m = 0.f; + for (int e = 0; e < 3; ++e) + m = std::max(m, (its.vertices[t[e]] - its.vertices[t[(e + 1) % 3]]).norm()); + return m; + }; + + SECTION("whole-mesh region refines everywhere and stays conformal") + { + std::vector region(cube.indices.size(), 1); + std::vector source; + const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 3.f, 12, &source); + + CHECK(out.indices.size() > cube.indices.size()); // it actually refined + CHECK(every_edge_used_twice(out)); // ... without opening a single crack + + REQUIRE(source.size() == out.indices.size()); + for (int s : source) + CHECK((s >= 0 && s < int(cube.indices.size()))); // every child names a real parent + } + + SECTION("a partial region refines only there, and the boundary is still crack-free") + { + // Refine only the triangles whose centroid is in the upper (z > 5) half of the cube. + std::vector region(cube.indices.size(), 0); + size_t region_count = 0; + for (size_t i = 0; i < cube.indices.size(); ++i) { + const auto &t = cube.indices[i]; + const float cz = (cube.vertices[t[0]].z() + cube.vertices[t[1]].z() + cube.vertices[t[2]].z()) / 3.f; + if (cz > 5.f) { + region[i] = 1; + ++region_count; + } + } + REQUIRE(region_count > 0); + + std::vector source; + const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 2.f, 12, &source); + + CHECK(out.indices.size() > cube.indices.size()); + CHECK(every_edge_used_twice(out)); // the refined/coarse seam has no T-junction + + // The region's own triangles came down in size; count how big the largest region-sourced + // output triangle is versus the largest region-sourced input triangle. + float max_in = 0.f, max_out = 0.f; + for (size_t i = 0; i < cube.indices.size(); ++i) + if (region[i]) + max_in = std::max(max_in, longest_edge(cube, cube.indices[i])); + for (size_t i = 0; i < out.indices.size(); ++i) + if (region[source[i]]) + max_out = std::max(max_out, longest_edge(out, out.indices[i])); + CHECK(max_out < max_in); // refinement genuinely happened inside the region + } + + SECTION("an empty region is a no-op") + { + std::vector region(cube.indices.size(), 0); + const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 1.f, 12, nullptr); + CHECK(out.indices.size() == cube.indices.size()); + CHECK(out.vertices.size() == cube.vertices.size()); + } +}