Add adaptive subdivision

This commit is contained in:
ExPikaPaka
2026-07-24 09:03:06 +02:00
parent 00f778e863
commit 15fd3fc97c
6 changed files with 552 additions and 43 deletions

View File

@@ -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 (**05**, 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 (**05**, 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

View File

@@ -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<uint8_t> &refine_region,
float target_edge_length_mm, int max_iterations,
std::vector<int> *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<Vec3f> verts = mesh.vertices;
std::vector<Tri> 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<Tri> &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<uint64_t, std::array<int, 2>> 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<int, 2>{ 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<uint64_t> 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<uint64_t> 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<uint64_t, int> 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<Tri> 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

View File

@@ -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<uint8_t> &refine_region,
float target_edge_length_mm, int max_iterations = 12,
std::vector<int> *out_source = nullptr);
} // namespace Slic3r
#endif // slic3r_TextureDisplacement_hpp_

View File

@@ -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<uint8_t> &region,
std::array<std::vector<uint8_t>, 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<std::array<int, 3>, int> tri_by_verts;
for (size_t i = 0; i < ntri; ++i) {
std::array<int, 3> k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] };
std::sort(k.begin(), k.end());
tri_by_verts.emplace(k, int(i));
}
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<uint8_t> 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<int, 3> k{ t[0], t[1], t[2] };
std::sort(k.begin(), k.end());
if (auto it = tri_by_verts.find(k); it != tri_by_verts.end())
(*painted_tri)[slot][it->second] = 1;
}
}
// 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<uint8_t> region;
std::array<std::vector<uint8_t>, 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<int> 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<TriangleSelector::SavedPainting> 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<uint8_t> 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;

View File

@@ -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<uint8_t> &region,
std::array<std::vector<uint8_t>, 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).

View File

@@ -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<std::pair<int, int>, 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<uint8_t> region(cube.indices.size(), 1);
std::vector<int> 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<uint8_t> 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<int> 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<uint8_t> 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());
}
}