Rewrite adaptive subdivision to refine worst-first against a triangle budget

This commit is contained in:
ExPikaPaka
2026-07-28 11:42:40 +02:00
parent 15fd3fc97c
commit 2a46197322
6 changed files with 877 additions and 246 deletions

View File

@@ -197,34 +197,92 @@ T-junction, at the cost of densifying everywhere. Wired as a "Subdivide steps" s
no subdivision), Apply snaps back to 0. Drops texture-displacement paint (no remap) via the standard 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. `save_painting()`/`set_mesh()`/`restore_painting()` dance; the other four channels are remapped.
**Adaptive (`subdivide_mesh_adaptive()`)** — refine **only the painted area**, down to a target edge **Adaptive (`subdivide_mesh_adaptive()`)** — refine **only the painted area**, by **Rivara longest-edge
length, by **Rivara longest-edge bisection**. This is the algorithm that was "scoped out" originally bisection**. This is the algorithm that was "scoped out" originally for fear of the T-junction/crack
for fear of the T-junction/crack problem; it is safe because it is *conformal by construction*. Each problem; it is safe because it is *conformal by construction*. Only **terminal** edges are ever bisected
pass bisects only **terminal** edges - an edge that is the longest edge of *every* triangle sharing it - an edge that is the longest edge of *every* triangle sharing it - which splits both those triangles
- which splits both those triangles along one shared midpoint at once, so a hanging node is never along one shared midpoint at once, so a hanging node is never created. The edge to split for a triangle
created. Only a triangle's own longest edge can be terminal, so each triangle is split by at most one that wants refining is found by **longest-edge propagation (LEPP)**: walk to the longest edge of
bisection per pass. When a triangle that still needs refining has a longest edge that is not yet ever-longer-edged neighbours until a terminal one is reached, and bisect that. Edge length strictly
terminal, the neighbour across it has a strictly longer edge and is refined first; that propagation increases along the path (ties broken by mesh-vertex key, which both sides of an edge compute
grades the mesh down into the region and closes what would be cracks (pulling a thin, bounded band of identically), so the walk cannot cycle, and Rivara's result is that repeating it refines the original
transition triangles just outside the painted patch). Tie-broken by mesh-vertex key so both sides of triangle in a bounded number of bisections. The transition triangles it pulls in just outside the
an edge always agree on "the" longest. painted patch are the graded band that makes the size change conformal.
The win: a small decal on a big model no longer quadruples the *whole* model's triangle count. The win: a small decal on a big model no longer quadruples the *whole* model's triangle count.
**Run to completion, worst-first, against a triangle budget.** The refinement loop is not a fixed number
of sweeps: it holds every triangle that is over its criteria in a max-heap keyed by *how many times over*
it is, pops the worst, walks its LEPP, bisects, and re-scores. Edge adjacency (`nb[e]`, the triangle
across each edge) is built **once** and maintained incrementally through each bisection, so the cost
scales with the refined region rather than with the whole model. `max_triangles` is the only bound;
stopping on it leaves a perfectly valid, still-conformal mesh that spent its budget on the largest errors.
This shape replaced a first version that ran a fixed 12 sweeps, each rebuilding a whole-mesh edge map and
bisecting one terminal edge per active triangle. Two failure modes came out of that, and they are worth
remembering because they look like separate bugs and are not: the sweeps were consumed grading the
*coarse surroundings* (whose edges are the longest, so they win every terminal-edge contest), which both
**stopped refinement of the painted patch far short** of the requested detail and left the band outside
it looking wildly over-refined relative to the patch itself.
**It carries the paint forward**, which is what makes it usable (uniform/remesh both drop paint). Because **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 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 `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'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, layer. `collect_paint_region()` derives both:
i.e. patch + a one-ring, so the boundary itself refines) and the per-layer fully-painted-triangle sets - the union refine-region: **exactly** the original triangles the brush touched, read straight off
(a `get_facets_strict(ENFORCER)` sub-triangle with all three *original* vertex indices == a whole, `TriangleSplittingData::triangles_to_split` (`serialize()` records an entry per original triangle that
fully-painted original triangle; a partial stroke's sub-triangles always carry a split vertex). The is either split - i.e. partially painted, the patch boundary - or carries a non-default state). No
other four channels still ride the normal `restore_painting()` remap. Covered by a conformality unit dilation. An earlier version marked every triangle sharing a *vertex* with the patch, which drags in a
test (`every_edge_used_twice` on a partially-refined cube - an exact crack detector for a closed mesh). whole fan of huge unpainted neighbours and then refines *those* down to the resolution floor, since the
height field the detail test samples is not restricted to the painted area. The conformal closure
already grades the size change outward on its own; it does not need help.
- 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),
plus tests that the target edge length is actually *reached* and that the budget caps the result without
opening a crack.
Both share the gizmo's Preview/Apply/Done flow; the **"Only painted area (adaptive)"** checkbox picks 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 the mode, and the adaptive preview follows the paint live (`rebuild_preview()` refreshes the wireframe
while the subdivide preview is open in adaptive mode). while the subdivide preview is open in adaptive mode). The panel shows the previewed triangle count.
**Feature-adaptive (follow texture detail).** A sub-mode of adaptive (the **"Follow texture detail"**
checkbox) that puts triangles where the *displaced surface actually bends*, not evenly. The insight:
a flat region or a linear **ramp** needs no extra vertices (linear interpolation is exact for a ramp);
what needs them is **curvature** - the *second* derivative, not the gradient. So the extra predicate is a
**chord-error** test: sample the combined displacement at the triangle's three edge midpoints *and its
centroid* (sampling the interior is what catches a bump sitting inside a triangle, the blind spot of an
edge-only test) and take the largest departure from the flat triangle's barycentric interpolation. Refine
while that exceeds `chord_tolerance_mm` ("Detail (mm)"). Zero chord error on a ramp ⇒ untouched; high on
a bump/ridge/noise ⇒ refined until captured. Same conformal machinery, so still crack-free. The
per-triangle error is cached and recomputed only for the children of a split.
Four knobs bracket it, and all four matter:
- **"Max edge (mm)"** (`target_edge_length_mm`) is a **baseline that applies in feature mode too**.
Without it the chord test aliases: a big triangle over a fine pattern can sample four points that all
land at similar heights, report no error, and stall before refinement ever starts. The baseline
guarantees a sampling density fine enough for the curvature test to see the texture at all.
- **"Detail (mm)"** is the chord tolerance above.
- **"Min edge (mm)"** is a hard floor under both, and is what guarantees termination across a sharp
texture *step*, where the error never falls however fine the mesh gets.
- **"Added triangles (k)"** is the budget, passed as `max_triangles` (the model's own triangle count plus
the slider, so the control still means something on an already-dense model).
The height field is `make_combined_displacement_sampler()` - it mirrors `build_texture_displacement()`'s
per-layer setup (decode, patch centroid, cylinder axis, blend order, "lowest layer folds additively")
but evaluated per point. Two deliberate simplifications, both erring toward *more* detail (safe -
over-refinement is never a crack): every sampleable layer is sampled at every point (no per-point paint
test), and edge-smoothing falloff is ignored. Note the first one is *why* the refine region must not be
dilated - outside the paint the sampler still reports full relief. **LSCM layers are skipped** (no
per-point UV); a purely LSCM stack yields a null sampler and the code falls back to the length baseline
alone. Per-vertex heights are sampled lazily, so a small patch on a huge model never pays for the rest of
it. Covered by unit tests: a Gaussian bump refines densely at its center and leaves flat corners coarse,
a linear ramp produces *zero* extra triangles (the case a gradient criterion would over-refine), and a
flat field still honours the max-edge baseline.
### Fast bump preview (GPU-only, no CPU meshing) ### Fast bump preview (GPU-only, no CPU meshing)
@@ -384,9 +442,10 @@ of. Toolbar commands the canvas can't service itself (Average scale) are forward
first impression while painting. The exact true-displacement view is one click away in the View row. 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* - **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 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. matter how high-resolution the height map is - that is what the subdivision controls are for
Since the rewrite the bake is topology-preserving, so this is now a hard, explicit property rather (uniform, adaptive, or feature-adaptive; see the Subdivision section). Since the rewrite the bake is
than something partly papered over by the old per-layer re-meshing. topology-preserving, so this is now a hard, explicit property rather than something partly papered
over by the old per-layer re-meshing.
## File map ## File map

View File

@@ -1289,6 +1289,94 @@ indexed_triangle_set build_texture_displacement(const ModelVolume &volume)
return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data); return build_texture_displacement(volume.mesh().its, volume.texture_displacement_layers, facets_data);
} }
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data)
{
// One decoded texture + placement per sampleable layer, in blend (slot) order. Held by shared_ptr
// so the returned closure owns it for as long as the subdivider keeps calling back.
struct Prepared {
DecodedHeightTexture tex;
TextureDisplacementLayer layer; // a copy of the params (depth/tiling/rotation/offset/blend/...)
Vec3f center; // patch centroid, for Cylindrical/Spherical
Vec3f axis; // cylinder axis, for Cylindrical
};
auto prepared = std::make_shared<std::vector<Prepared>>();
if (base_mesh.indices.empty())
return nullptr;
std::vector<const TextureDisplacementLayer *> ordered;
for (const TextureDisplacementLayer &l : layers)
if (l.slot >= 0 && l.slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS))
ordered.push_back(&l);
std::sort(ordered.begin(), ordered.end(),
[](const TextureDisplacementLayer *a, const TextureDisplacementLayer *b) { return a->slot < b->slot; });
const std::vector<Vec3f> vertex_normals = texture_displacement_vertex_normals(base_mesh);
const TriangleMesh selector_mesh(base_mesh);
for (const TextureDisplacementLayer *layer : ordered) {
if (layer->projection_method == TextureProjectionMethod::LSCM)
continue; // no per-point UV -> not sampleable here (caller falls back to uniform for these)
const TriangleSelector::TriangleSplittingData &data = facets_data[size_t(layer->slot)];
if (data.triangles_to_split.empty())
continue;
const DecodedHeightTexture tex = decode_height_texture(*layer);
if (tex.empty())
continue;
TriangleSelector selector(selector_mesh);
selector.deserialize(data, false);
const indexed_triangle_set patch = selector.get_facets_strict(EnforcerBlockerType::ENFORCER);
if (patch.indices.empty())
continue;
// Patch centroid + cylinder axis, computed exactly as build_texture_displacement() does, so a
// Cylindrical/Spherical layer's detach criterion matches the geometry the bake will produce.
Vec3f average_normal = Vec3f::Zero();
Vec3f centroid = Vec3f::Zero();
int count = 0;
for (const stl_triangle_vertex_indices &tri : patch.indices)
for (int i = 0; i < 3; ++i) {
const int vi = tri[i];
centroid += patch.vertices[size_t(vi)];
++count;
if (vi < int(vertex_normals.size()))
average_normal += vertex_normals[size_t(vi)];
}
average_normal = (average_normal.norm() > 1e-8f) ? Vec3f(average_normal.normalized()) : Vec3f::UnitZ();
centroid = (count > 0) ? Vec3f(centroid / float(count)) : Vec3f::Zero();
Vec3f axis = Vec3f::UnitZ();
const Vec3f an = average_normal.cwiseAbs();
if (an.x() <= an.y() && an.x() <= an.z())
axis = Vec3f::UnitX();
else if (an.y() <= an.x() && an.y() <= an.z())
axis = Vec3f::UnitY();
prepared->push_back({ tex, *layer, centroid, axis });
}
if (prepared->empty())
return nullptr;
return [prepared](const Vec3f &pos, const Vec3f &normal) -> float {
float total = 0.f;
bool any = false;
for (const Prepared &p : *prepared) {
const float h = sample_layer_height(p.tex, p.layer, pos, normal, p.center, p.axis, nullptr);
const float sign = p.layer.invert ? -1.f : 1.f;
const float signed_h = (h - p.layer.midlevel) * p.layer.depth_mm * sign;
// The first (lowest) sampleable layer folds additively; the rest use their own blend mode -
// same rule build_texture_displacement() applies per vertex.
total = blend_displacement(total, signed_h, any ? p.layer.blend_mode : TextureBlendMode::Add);
any = true;
}
return total;
};
}
indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, float max_edge_length_mm, int max_iterations) indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, float max_edge_length_mm, int max_iterations)
{ {
indexed_triangle_set current = mesh; indexed_triangle_set current = mesh;
@@ -1348,146 +1436,340 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const std::vector<uint8_t> &refine_region, const std::vector<uint8_t> &refine_region,
float target_edge_length_mm, int max_iterations, float target_edge_length_mm, int max_triangles,
std::vector<int> *out_source) std::vector<int> *out_source, const HeightFieldSampler &sampler,
float chord_tolerance_mm, float min_edge_length_mm)
{ {
// Each triangle carries the input-triangle index it descends from, so children inherit it and // Neighbour slots that are not a triangle index.
// the caller can remap per-triangle data (paint masks) for free. constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone
struct Tri { int v[3]; int src; }; constexpr int NB_NONMANIFOLD = -2; // >2 triangles on the edge: never bisected, that would tear it
// v[] and nb[] are parallel: nb[e] is the triangle across edge (v[e], v[(e+1)%3]). `src` is the
// input triangle this one descends from - children inherit it, so a caller can carry per-triangle
// data (a paint mask) across the topology change with no geometric remap.
struct Tri { int v[3]; int nb[3]; int src; };
std::vector<Vec3f> verts = mesh.vertices; std::vector<Vec3f> verts = mesh.vertices;
std::vector<Tri> tris; std::vector<Tri> tris(mesh.indices.size());
tris.reserve(mesh.indices.size());
for (size_t i = 0; i < mesh.indices.size(); ++i) 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) }); tris[i] = { { mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2] },
{ NB_BOUNDARY, NB_BOUNDARY, NB_BOUNDARY }, int(i) };
auto emit = [&](std::vector<Tri> &t) -> indexed_triangle_set { auto emit = [&]() -> indexed_triangle_set {
indexed_triangle_set out; indexed_triangle_set out;
out.vertices = verts; out.vertices = verts;
out.indices.reserve(t.size()); out.indices.reserve(tris.size());
if (out_source) { if (out_source) {
out_source->clear(); out_source->clear();
out_source->reserve(t.size()); out_source->reserve(tris.size());
} }
for (const Tri &tr : t) { for (const Tri &t : tris) {
out.indices.emplace_back(tr.v[0], tr.v[1], tr.v[2]); out.indices.emplace_back(t.v[0], t.v[1], t.v[2]);
if (out_source) if (out_source)
out_source->push_back(tr.src); out_source->push_back(t.src);
} }
return out; return out;
}; };
// Feature-adaptive when a sampler and a positive tolerance are supplied; otherwise refinement is
// driven by the length baseline alone.
const bool feature_mode = bool(sampler) && chord_tolerance_mm > 0.f;
const float min_floor_sq = min_edge_length_mm > 0.f ? min_edge_length_mm * min_edge_length_mm : 0.f;
const float target_sq = target_edge_length_mm > 0.f ? target_edge_length_mm * target_edge_length_mm : 0.f;
// refine_region is indexed by input-triangle index, and every triangle's src stays in that range // 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. // (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()) if (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles)
return emit(tris); return emit();
if (!feature_mode && target_sq <= 0.f)
return emit(); // no criterion at all
if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; })) if (std::none_of(refine_region.begin(), refine_region.end(), [](uint8_t v) { return v != 0; }))
return emit(tris); // nothing flagged: no-op return emit(); // 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 { auto edge_key = [](int a, int b) -> uint64_t {
if (a > b) if (a > b)
std::swap(a, b); std::swap(a, b);
return (uint64_t(uint32_t(a)) << 32) | uint32_t(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 adjacency, built once here and then maintained incrementally by bisect() below. Rebuilding
// edge -> the (up to two) triangles sharing it. A third triangle on an edge means a // it per refinement pass is what made the previous version's cost scale with the whole model
// non-manifold input; it is left in the second slot's place and simply not treated as // instead of with the refined region, and what forced the tiny pass budget that stopped
// terminal, so such an edge is never bisected (better a missed refinement than a torn mesh). // refinement short.
std::unordered_map<uint64_t, std::array<int, 2>> edge_tris; {
edge_tris.reserve(tris.size() * 3); struct EdgeRec { int he[2]; int count; }; // he = encoded half-edge (triangle * 3 + local edge)
std::unordered_map<uint64_t, EdgeRec> edges;
edges.reserve(tris.size() * 2);
for (int ti = 0; ti < int(tris.size()); ++ti) for (int ti = 0; ti < int(tris.size()); ++ti)
for (int e = 0; e < 3; ++e) { for (int e = 0; e < 3; ++e) {
const uint64_t k = edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]); EdgeRec &r = edges.try_emplace(edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]),
auto it = edge_tris.find(k); EdgeRec{ { -1, -1 }, 0 })
if (it == edge_tris.end()) .first->second;
edge_tris.emplace(k, std::array<int, 2>{ ti, -1 }); if (r.count < 2)
else if (it->second[1] == -1) r.he[r.count] = ti * 3 + e;
it->second[1] = ti; ++r.count;
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) for (int ti = 0; ti < int(tris.size()); ++ti)
tri_longest[ti] = longest_key(tris[ti]); for (int e = 0; e < 3; ++e) {
const EdgeRec &r = edges.at(edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]));
// Which edges to bisect this pass: terminal (the longest edge of every triangle on it) AND if (r.count > 2)
// long enough AND wanted by the region (either side in it). Terminal-ness is exactly what tris[ti].nb[e] = NB_NONMANIFOLD;
// guarantees both sides split together, so no hanging node is ever produced. else if (r.count == 2)
std::unordered_set<uint64_t> to_bisect; tris[ti].nb[e] = (r.he[0] == ti * 3 + e ? r.he[1] : r.he[0]) / 3;
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); // Feature mode: per-vertex surface normal, and the sampled displacement height at each vertex.
// Heights are filled in lazily - on a big model only a small painted region is ever looked at, and
// a sampler call is a texture fetch (plus trig) per layer, so sampling every vertex of the whole
// mesh up front was pure waste. Both arrays grow in lockstep with `verts`.
std::vector<Vec3f> vnormal;
std::vector<float> vheight;
std::vector<uint8_t> vheight_valid;
if (feature_mode) {
vnormal.assign(verts.size(), Vec3f::Zero());
for (const Tri &t : tris) {
const Vec3f fn = (verts[t.v[1]] - verts[t.v[0]]).cross(verts[t.v[2]] - verts[t.v[0]]); // area-weighted
for (int i = 0; i < 3; ++i)
vnormal[t.v[i]] += fn;
}
for (Vec3f &n : vnormal) {
const float l = n.norm();
n = (l > 1e-12f) ? Vec3f(n / l) : Vec3f(Vec3f::UnitZ());
}
vheight.assign(verts.size(), 0.f);
vheight_valid.assign(verts.size(), 0);
}
auto height_of = [&](int v) -> float {
if (!vheight_valid[v]) {
vheight[v] = sampler(verts[v], vnormal[v]);
vheight_valid[v] = 1;
}
return vheight[v];
};
auto elen_sq = [&](int a, int b) -> float { return (verts[a] - verts[b]).squaredNorm(); };
// The one edge of a triangle taken as its "longest": greatest squared length, exact ties broken by
// the smaller (sorted) vertex-index key. Both triangles sharing an edge compute the same key for
// it, so they can never disagree about which of them is longest - the property the conformality
// argument and the LEPP walk's termination both rest on.
auto longest_local = [&](int ti) -> int {
const Tri &t = tris[ti];
int best = 0;
float bl = elen_sq(t.v[0], t.v[1]);
uint64_t bk = edge_key(t.v[0], t.v[1]);
for (int e = 1; e < 3; ++e) {
const float l = elen_sq(t.v[e], t.v[(e + 1) % 3]);
const uint64_t k = edge_key(t.v[e], t.v[(e + 1) % 3]);
if (l > bl || (l == bl && k < bk)) {
bl = l;
bk = k;
best = e;
}
}
return best;
};
// How far the *displaced* surface departs from the flat triangle, sampled across the WHOLE
// triangle - the three edge midpoints and the centroid - not just one edge midpoint. Sampling the
// interior is what catches a bump that sits inside a triangle (the blind spot of an edge-only
// test). Cached per triangle: it can only change when the triangle is split, and then both
// children are fresh entries.
std::vector<float> tri_err;
if (feature_mode)
tri_err.assign(tris.size(), -1.f);
auto detail_error = [&](int ti) -> float {
if (tri_err[ti] >= 0.f)
return tri_err[ti];
const Tri &t = tris[ti];
const Vec3f pa = verts[t.v[0]], pb = verts[t.v[1]], pc = verts[t.v[2]];
const Vec3f na = vnormal[t.v[0]], nb = vnormal[t.v[1]], nc = vnormal[t.v[2]];
const float ha = height_of(t.v[0]), hb = height_of(t.v[1]), hc = height_of(t.v[2]);
static const float BARY[4][3] = { { 0.5f, 0.5f, 0.f }, { 0.f, 0.5f, 0.5f },
{ 0.5f, 0.f, 0.5f }, { 1.f / 3, 1.f / 3, 1.f / 3 } };
float maxerr = 0.f;
for (const auto &w : BARY) {
Vec3f n = w[0] * na + w[1] * nb + w[2] * nc;
const float nl = n.norm();
n = (nl > 1e-12f) ? Vec3f(n / nl) : na;
const float actual = sampler(w[0] * pa + w[1] * pb + w[2] * pc, n);
maxerr = std::max(maxerr, std::abs(actual - (w[0] * ha + w[1] * hb + w[2] * hc)));
}
tri_err[ti] = maxerr;
return maxerr;
};
// How many times over its criteria a triangle is: <= 1 means "good enough, leave it alone", and
// the larger the value the more a split buys. Driving the heap with this is what makes a run that
// runs out of budget spend it on the worst offenders instead of wherever a sweep happened to
// reach. Triangles outside the region always score 0 - they are only ever touched by the conformal
// closure below, never refined on their own account.
auto priority = [&](int ti) -> float {
const Tri &t = tris[ti];
if (refine_region[t.src] == 0)
return 0.f;
const int le = longest_local(ti);
const float ll = elen_sq(t.v[le], t.v[(le + 1) % 3]);
if (ll <= min_floor_sq)
return 0.f; // at the resolution floor - also what stops a sharp texture step going forever
float p = (target_sq > 0.f) ? ll / target_sq : 0.f;
if (feature_mode)
p = std::max(p, detail_error(ti) / chord_tolerance_mm);
return p;
};
auto set_nb = [&](int ti, int u, int v, int val) {
if (ti < 0)
return;
Tri &t = tris[ti];
for (int e = 0; e < 3; ++e)
if ((t.v[e] == u && t.v[(e + 1) % 3] == v) || (t.v[e] == v && t.v[(e + 1) % 3] == u)) {
t.nb[e] = val;
return;
}
};
// Bisects triangle `ti` across its local edge `e`, which the caller has established is terminal.
// Both triangles on that edge are split in the one operation, around a single shared midpoint -
// which is exactly why a hanging node (and so a crack) can never appear. `ti` and the opposite
// triangle are each reused as one of their own children, so only two back-pointers in the
// surrounding mesh need repointing. Triangles whose geometry changed are left in `touched`.
std::vector<int> touched;
auto bisect = [&](int ti, int e) -> bool {
const int a = tris[ti].v[e], b = tris[ti].v[(e + 1) % 3], c = tris[ti].v[(e + 2) % 3];
const int n = tris[ti].nb[e];
// Locate the shared edge from the far side before touching anything: bailing out half way
// through would be the one way this could leave a crack.
int f = -1;
if (n >= 0) {
for (int k = 0; k < 3; ++k) {
const int u = tris[n].v[k], w = tris[n].v[(k + 1) % 3];
if ((u == a && w == b) || (u == b && w == a)) {
f = k;
break;
}
}
if (f < 0) {
tris[ti].nb[e] = NB_NONMANIFOLD; // inconsistent adjacency: refuse to split across it
return false;
}
}
const int m = int(verts.size());
verts.push_back(0.5f * (verts[a] + verts[b]));
if (feature_mode) {
const Vec3f mn = vnormal[a] + vnormal[b];
const float ml = mn.norm();
vnormal.push_back(ml > 1e-12f ? Vec3f(mn / ml) : vnormal[a]);
vheight.push_back(0.f);
vheight_valid.push_back(0);
}
// Near side: ti becomes (a, m, c), the new triangle is (m, b, c). Both keep the original
// a->b->c winding.
const int nb_bc = tris[ti].nb[(e + 1) % 3];
const int nb_ca = tris[ti].nb[(e + 2) % 3];
const int src = tris[ti].src;
const int t2 = int(tris.size());
tris.push_back(Tri{ { m, b, c }, { NB_BOUNDARY, nb_bc, ti }, src });
{
Tri &t1 = tris[ti];
t1.v[0] = a; t1.v[1] = m; t1.v[2] = c;
t1.nb[0] = NB_BOUNDARY; t1.nb[1] = t2; t1.nb[2] = nb_ca;
}
set_nb(nb_bc, b, c, t2); // that outer neighbour borders the second child now, not ti
if (feature_mode) {
tri_err.push_back(-1.f);
tri_err[ti] = -1.f;
}
touched.assign({ ti, t2 });
if (n < 0) {
return true; // boundary edge: nothing on the far side to split
}
// Far side, same shape: n becomes (p, m, d), the new triangle is (m, q, d).
const int p = tris[n].v[f], q = tris[n].v[(f + 1) % 3], d = tris[n].v[(f + 2) % 3];
const int nb_qd = tris[n].nb[(f + 1) % 3];
const int nb_dp = tris[n].nb[(f + 2) % 3];
const int nsrc = tris[n].src;
const int n2 = int(tris.size());
tris.push_back(Tri{ { m, q, d }, { NB_BOUNDARY, nb_qd, n }, nsrc });
{
Tri &n1 = tris[n];
n1.v[0] = p; n1.v[1] = m; n1.v[2] = d;
n1.nb[0] = NB_BOUNDARY; n1.nb[1] = n2; n1.nb[2] = nb_dp;
}
set_nb(nb_qd, q, d, n2);
if (feature_mode) {
tri_err.push_back(-1.f);
tri_err[n] = -1.f;
}
// Stitch the two sides back together: whichever far child holds `a` borders the near child
// that holds `a`. (Which one that is depends on how n happens to be wound.)
const int side_a = (p == a) ? n : n2;
const int side_b = (p == a) ? n2 : n;
tris[ti].nb[0] = side_a; // near child (a, m, c), edge (a, m)
tris[t2].nb[0] = side_b; // near child (m, b, c), edge (m, b)
set_nb(side_a, a, m, ti);
set_nb(side_b, b, m, t2);
touched.assign({ ti, t2, n, n2 });
return true;
};
// Worst-first. Entries go stale as their triangle is split; a stale entry is harmless - it is
// re-scored on pop and dropped or re-pushed. The ordering is a budget-allocation heuristic only:
// neither correctness nor conformality depends on it.
std::priority_queue<std::pair<float, int>> queue;
for (int ti = 0; ti < int(tris.size()); ++ti)
if (const float p = priority(ti); p > 1.f)
queue.emplace(p, ti);
// 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.
while (!queue.empty() && int(tris.size()) + 2 <= max_triangles) {
const int ti = queue.top().second;
queue.pop();
if (priority(ti) <= 1.f)
continue; // stale: already refined past its criteria
// Longest-Edge Propagation Path: step to the neighbour across the current longest edge for as
// long as that neighbour has a strictly longer one, then bisect the terminal edge we land on.
// Length strictly increases along the path, so it cannot cycle.
int cur = ti, split_edge = -1;
for (size_t guard = 0; guard <= tris.size(); ++guard) {
const int le = longest_local(cur);
const int nbr = tris[cur].nb[le];
if (nbr == NB_NONMANIFOLD)
break; // cannot split across it without tearing the mesh: give up on this path
if (nbr == NB_BOUNDARY) {
split_edge = le; // boundary longest edge -> terminal
break;
}
const int nle = longest_local(nbr);
if (edge_key(tris[nbr].v[nle], tris[nbr].v[(nle + 1) % 3]) ==
edge_key(tris[cur].v[le], tris[cur].v[(le + 1) % 3])) {
split_edge = le; // mutual longest edge -> terminal
break;
}
cur = nbr;
}
if (split_edge < 0 || !bisect(cur, split_edge))
continue; // ti sits in a non-manifold neighbourhood and cannot be refined safely
for (const int t : touched)
if (const float p = priority(t); p > 1.f)
queue.emplace(p, t);
// The bisection may have been a step on the way to ti rather than ti itself, in which case ti
// is not in `touched` and has to go back on the heap to be walked again.
if (cur != ti)
if (const float p = priority(ti); p > 1.f)
queue.emplace(p, ti);
}
return emit();
} }
} // namespace Slic3r } // namespace Slic3r

View File

@@ -2,6 +2,7 @@
#define slic3r_TextureDisplacement_hpp_ #define slic3r_TextureDisplacement_hpp_
#include <cstdint> #include <cstdint>
#include <functional>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -493,6 +494,25 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
// and forwards to the overload above. // and forwards to the overload above.
indexed_triangle_set build_texture_displacement(const ModelVolume &volume); indexed_triangle_set build_texture_displacement(const ModelVolume &volume);
// Returns a scalar height (in mm - a displacement magnitude) at a surface point, given that point's
// position and interpolated normal. This is what feature-adaptive subdivision samples to decide
// where the displaced surface has *curvature* worth spending triangles on. Called serially from the
// subdivider, so it only needs to be safe on the calling thread.
using HeightFieldSampler = std::function<float(const Vec3f &pos, const Vec3f &normal)>;
// Builds a sampler of the *combined* (all-layers) displacement height in mm at an arbitrary surface
// point, for feature-adaptive subdivision. It mirrors build_texture_displacement()'s per-layer setup
// (decode, patch centroid, cylinder axis, blend order, "lowest layer folds additively") but evaluates
// per point instead of per vertex. Two deliberate simplifications, both erring toward *more* detail
// (safe - over-refinement is never a crack): every sampleable layer is evaluated at every point (no
// per-point paint-mask test, so a point sees all layers' textures, not only the ones painted there),
// and edge-smoothing's boundary falloff is ignored. LSCM layers have no per-point UV and are skipped.
// Returns a null sampler (bool false) when no layer can be sampled - the caller then falls back to
// uniform adaptive subdivision.
HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh,
const std::vector<TextureDisplacementLayer> &layers,
const TextureDisplacementFacetsData &facets_data);
// Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a // Uniformly subdivides `mesh` (every triangle recursively split into 4 via edge midpoints, using a
// shared cache so a midpoint is computed once and reused by both triangles on either side of that // shared cache so a midpoint is computed once and reused by both triangles on either side of that
// edge) until every edge is at or below max_edge_length_mm, or max_iterations passes have run, // edge) until every edge is at or below max_edge_length_mm, or max_iterations passes have run,
@@ -513,32 +533,57 @@ indexed_triangle_set subdivide_mesh_uniform(const indexed_triangle_set &mesh, fl
// Adaptive subdivision by Rivara longest-edge bisection, restricted to a region. // 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 // Unlike subdivide_mesh_uniform() this only densifies where asked - `refine_region` (indexed by
// source is flagged in `refine_region` and its longest edge exceeds target_edge_length_mm - so a // input-triangle index; empty or all-false => no-op) flags the triangles allowed to drive refinement
// small painted patch on a large model does not quadruple the whole model's triangle count. It is // - so a small painted patch on a large model does not quadruple the whole model's triangle count.
// nonetheless *conformal*: it never leaves a T-junction/crack at the boundary between the refined // It is nonetheless *conformal*: it never leaves a T-junction/crack at the boundary between the
// and coarse regions (the trap that made subdivide_mesh_uniform() deliberately whole-mesh). // 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 // A triangle wants refining while it is over at least one of these, whichever applies:
// edge of *every* triangle sharing it. Bisecting such an edge splits both its triangles 1->2 along // - length: its longest edge exceeds `target_edge_length_mm` (a *baseline* - it applies in feature
// the same new midpoint at once, so no hanging node is ever created. Because only a triangle's own // mode too, and is what stops a coarse triangle from being declared flat merely because
// longest edge can be terminal, each triangle is split by at most one terminal edge per pass. When // the four points the chord test samples happened to land at similar heights on a
// a triangle that needs refining has a longest edge that is *not* yet terminal, the neighbour across // high-frequency texture: the classic aliasing stall);
// it has a strictly longer edge and is refined first; that propagation is what grades the mesh down // - feature: (only when `sampler` is set and `chord_tolerance_mm > 0`) the *displaced* surface
// into the region and closes what would otherwise be cracks (Rivara, "New longest-edge algorithms"). // departs from the flat triangle by more than `chord_tolerance_mm`, measured as the max
// The transition triangles it pulls in just outside the region are a thin, bounded band. // over the three edge midpoints AND the centroid of |sampled displacement - the flat
// triangle's barycentric interpolation|. Sampling the interior, not just edge midpoints,
// is what catches a bump that sits inside a triangle. This is a *curvature* test: it is
// exactly zero on a plane or a linear ramp (barycentric interpolation is exact there, so
// those stay coarse - the case a gradient criterion would over-refine) and large on a
// bump/ridge/noise.
// `min_edge_length_mm` is a hard floor under both: no triangle whose longest edge is already at or
// below it is ever refined, which is also what guarantees termination across a sharp texture step
// (where the chord error never falls below the tolerance no matter how fine the mesh gets).
// //
// `refine_region` is indexed by input-triangle index (empty or all-false => no-op). If `out_source` // Refinement runs to completion, not for a fixed number of passes: triangles are taken worst-first
// is non-null it is resized to the output triangle count and out_source[i] receives the input // from a max-heap keyed by how many times over its criteria each one is, so a run that hits the
// triangle that output triangle i descends from (children inherit their parent's index), so a caller // `max_triangles` budget has spent it on the largest errors rather than wherever a sweep happened to
// can carry per-triangle data - e.g. a paint mask - across the topology change without a geometric // reach. The budget is the only bound on a pathological height field; stopping on it leaves a
// remap. `max_iterations` bounds the worst case; ties in "longest edge" are broken by a mesh-vertex // perfectly valid, still-conformal mesh.
// 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). // How it stays crack-free: only "terminal" edges are ever bisected - an edge that is the longest edge
// of *both* triangles sharing it (or a boundary edge that is the longest of its one triangle).
// Bisecting such an edge splits both its triangles 1->2 around the same new midpoint, so a hanging
// node is never created. The edge to split for a triangle that wants refining is found by Rivara
// longest-edge propagation (LEPP): walk to the longest edge of ever-longer-edged neighbours until a
// terminal edge is reached, and bisect that. Edge length strictly increases along the path (ties
// broken by a mesh-vertex key, which both sides of an edge compute identically), so the walk cannot
// cycle, and Rivara's result is that repeating it refines the original triangle in a bounded number
// of bisections - closing the propagation gap a plain per-edge test leaves behind. The transition
// triangles this pulls in just outside the region are the graded band that makes the size change
// conformal; they are a bounded cost paid once, not a per-pass tax.
//
// 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.
indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh, indexed_triangle_set subdivide_mesh_adaptive(const indexed_triangle_set &mesh,
const std::vector<uint8_t> &refine_region, const std::vector<uint8_t> &refine_region,
float target_edge_length_mm, int max_iterations = 12, float target_edge_length_mm, int max_triangles = 1000000,
std::vector<int> *out_source = nullptr); std::vector<int> *out_source = nullptr,
const HeightFieldSampler &sampler = nullptr,
float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f);
} // namespace Slic3r } // namespace Slic3r

View File

@@ -166,7 +166,7 @@ void GLGizmoTextureDisplacement::on_shutdown()
m_seam_path_mode = false; m_seam_path_mode = false;
m_seam_path_anchor = -1; m_seam_path_anchor = -1;
m_subdivide_editing = false; m_subdivide_editing = false;
m_subdivide_preview_count = -1; m_subdivide_preview_tris = -1;
m_subdivide_preview_glmodel.reset(); m_subdivide_preview_glmodel.reset();
m_bump_active_chart = -1; m_bump_active_chart = -1;
m_bump_active_vertex.clear(); m_bump_active_vertex.clear();
@@ -231,6 +231,11 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
m_bump_preview_dirty = false; m_bump_preview_dirty = false;
} }
const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized(); const bool use_bump = m_use_bump_preview && m_bump_preview_glmodel.is_initialized();
// In Checker/Distortion mode the UV-check overlay *is* the surface visualization the user is
// looking at, so the opaque paint-selection highlight must not be drawn on top of it - same
// reasoning as skipping it for the bump preview (see bug #12). Without this the painted area
// covers the checker/heatmap and it can't be seen.
const bool show_paint_overlay = m_uv_check_mode == UVCheckMode::None;
if (use_bump) { if (use_bump) {
m_parent.toggle_model_objects_visibility(true); m_parent.toggle_model_objects_visibility(true);
if (ModelVolume *mv = texture_volume()) if (ModelVolume *mv = texture_volume())
@@ -244,11 +249,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo()
m_c->selection_info()->get_active_instance(), mv); m_c->selection_info()->get_active_instance(), mv);
render_preview_mesh(); render_preview_mesh();
glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); if (show_paint_overlay) {
glsafe(::glPolygonOffset(-1.0f, -1.0f)); glsafe(::glEnable(GL_POLYGON_OFFSET_FILL));
render_triangles(selection); glsafe(::glPolygonOffset(-1.0f, -1.0f));
glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); render_triangles(selection);
} else { glsafe(::glDisable(GL_POLYGON_OFFSET_FILL));
}
} else if (show_paint_overlay) {
render_triangles(selection); render_triangles(selection);
} }
@@ -744,10 +751,9 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
if (base.vertices.size() != patch.vertices.size()) if (base.vertices.size() != patch.vertices.size())
return; // shouldn't happen: get_facets_strict() always returns the full vertex array return; // shouldn't happen: get_facets_strict() always returns the full vertex array
std::vector<bool> is_painted(patch.vertices.size(), false); // Unpainted triangles, so the surrounding surface still renders (the render path hides the real
for (const stl_triangle_vertex_indices &tri : patch.indices) // model in bump mode). get_facets_strict() returns the same vertex array whatever state is asked.
for (int i = 0; i < 3; ++i) const indexed_triangle_set rest = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::NONE);
is_painted[tri[i]] = true;
// For LSCM we hand the shader the finished per-vertex texture uv (island placement + tiling/ // For LSCM we hand the shader the finished per-vertex texture uv (island placement + tiling/
// rotation/offset already folded in, exactly what the bake samples), because it cannot be // rotation/offset already folded in, exactly what the bake samples), because it cannot be
@@ -761,26 +767,38 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh()
vertex_uv.clear(); vertex_uv.clear();
GLModel::Geometry init_data; GLModel::Geometry init_data;
// P3N3T2: normal.x carries the paint weight, tex_coord carries the precomputed uv (see the vertex // P3N3T2: normal.x carries the paint weight, tex_coord the precomputed uv (see the vertex shader).
// shader). Reuses a standard GLModel layout rather than a bespoke vertex buffer.
init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 }; init_data.format = { GLModel::Geometry::EPrimitiveType::Triangles, GLModel::Geometry::EVertexLayout::P3N3T2 };
init_data.reserve_vertices(base.vertices.size());
init_data.reserve_indices(base.indices.size() * 3); // Per-triangle weighting via a *flat* (unshared-vertex) mesh: every corner of a painted triangle
// Every triangle of the whole mesh is included (not just the painted ones) so the surrounding, // gets weight 1, every corner of an unpainted one weight 0. This is what a coarse mesh needs - one
// un-bumped surface still renders - the per-vertex weight (0 outside the patch) is what fades // painted face of a raw cube has no strictly-interior vertex (all 8 are shared), so per-vertex
// the shader's bump effect to nothing there, exactly like the true-displacement preview fades // weighting would either bleed onto the neighbours (boundary weight 1) or vanish outright (boundary
// to the untouched surface at its boundary. // weight 0, which is what made a single face show nothing). Duplicating vertices costs no shading
// normal.y flags the island currently dragged in the UV editor, so the shader can move just that // quality here because the bump shader takes its surface normal from screen-space derivatives of
// island through the island_delta uniform (see the bump shaders / on_island_edited). // position (dFdx/dFdy), not from a per-vertex normal. normal.y flags the UV-editor island being
const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty(); // dragged so the shader can move just that island via the island_delta uniform.
for (size_t vi = 0; vi < base.vertices.size(); ++vi) { const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty();
const float active = (have_active && vi < m_bump_active_vertex.size() && m_bump_active_vertex[vi]) ? 1.f : 0.f; const size_t tri_total = patch.indices.size() + rest.indices.size();
const Vec3f weight_normal(is_painted[vi] ? 1.f : 0.f, active, 0.f); init_data.reserve_vertices(tri_total * 3);
const Vec2f uv = m_bump_preview_uses_vertex_uv ? vertex_uv[vi] : Vec2f::Zero(); init_data.reserve_indices(tri_total * 3);
init_data.add_vertex(base.vertices[vi], weight_normal, uv); unsigned vcount = 0;
} const auto emit_triangles = [&](const indexed_triangle_set &its, float weight) {
for (const stl_triangle_vertex_indices &tri : base.indices) for (const stl_triangle_vertex_indices &tri : its.indices) {
init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2])); for (int i = 0; i < 3; ++i) {
const int idx = tri[i];
const float act = (have_active && idx >= 0 && size_t(idx) < m_bump_active_vertex.size() &&
m_bump_active_vertex[size_t(idx)]) ? 1.f : 0.f;
const Vec2f uv = (weight > 0.5f && m_bump_preview_uses_vertex_uv && size_t(idx) < vertex_uv.size()) ?
vertex_uv[size_t(idx)] : Vec2f::Zero();
init_data.add_vertex(its.vertices[size_t(idx)], Vec3f(weight, act, 0.f), uv);
}
init_data.add_triangle(vcount, vcount + 1, vcount + 2);
vcount += 3;
}
};
emit_triangles(patch, 1.f); // painted -> bumped
emit_triangles(rest, 0.f); // untouched surface -> flat, so it still shows but isn't bumped
m_bump_preview_glmodel.init_from(std::move(init_data)); m_bump_preview_glmodel.init_from(std::move(init_data));
// GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal // GLModel::render() unconditionally re-sets the shader's "uniform_color" from this internal
@@ -2186,6 +2204,13 @@ void GLGizmoTextureDisplacement::update_model_object()
updated |= mv->texture_displacement_facet(m_active_layer_slot).set(*m_triangle_selectors[idx]); updated |= mv->texture_displacement_facet(m_active_layer_slot).set(*m_triangle_selectors[idx]);
} }
// The fast (bump) preview reads the live selector, so it has to be rebuilt after any stroke that
// flushes here - not only when set() reports a change. Rebuilding it via rebuild_preview() below
// is gated on `updated`, which misses e.g. the first paint into a slot; marking it dirty makes the
// render loop (render_painter_gizmo) rebuild it next frame regardless. Without this, fast preview -
// now the default view - stayed blank until a full reload (select-whole-model / reopen).
m_bump_preview_dirty = true;
if (updated) { if (updated) {
const ModelObjectPtrs &mos = wxGetApp().model().objects; const ModelObjectPtrs &mos = wxGetApp().model().objects;
wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin()); wxGetApp().obj_list()->update_info_items(std::find(mos.begin(), mos.end(), mo) - mos.begin());
@@ -2676,16 +2701,17 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
for (auto &pt : *painted_tri) for (auto &pt : *painted_tri)
pt.clear(); pt.clear();
// Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes // Sorted-vertex-triple -> triangle index, so a fully-painted patch sub-triangle (which comes back
// back with the original mesh's own three vertex indices) can be mapped to its source triangle. // with the original mesh's own three vertex indices) can be mapped to its source triangle. Only
// A sub-triangle produced by a *partial* brush stroke has at least one appended (split) vertex, // the paint carry-forward needs it, and the live subdivide preview calls this on every slider
// so "all three indices are original" is exactly the test for a whole, fully-painted triangle. // frame, so it is not built for the region-only path.
std::map<std::array<int, 3>, int> tri_by_verts; std::map<std::array<int, 3>, int> tri_by_verts;
for (size_t i = 0; i < ntri; ++i) { if (painted_tri)
std::array<int, 3> k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] }; for (size_t i = 0; i < ntri; ++i) {
std::sort(k.begin(), k.end()); std::array<int, 3> k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] };
tri_by_verts.emplace(k, int(i)); std::sort(k.begin(), k.end());
} tri_by_verts.emplace(k, int(i));
}
bool any_paint = false; bool any_paint = false;
for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) {
@@ -2693,40 +2719,34 @@ bool GLGizmoTextureDisplacement::collect_paint_region(
if (!TriangleSelector::has_facets(data, EnforcerBlockerType::ENFORCER)) if (!TriangleSelector::has_facets(data, EnforcerBlockerType::ENFORCER))
continue; continue;
TriangleSelector sel(mv->mesh()); // The refine region is exactly the original triangles the brush touched. `triangles_to_split`
sel.deserialize(data, false); // lists precisely those: serialize() records an entry for every original triangle that is
const indexed_triangle_set patch = sel.get_facets_strict(EnforcerBlockerType::ENFORCER); // either split (i.e. partially painted, which is the patch boundary) or carries a non-default
// state (fully painted). No dilation - an earlier version marked every triangle sharing a
// *vertex* with the patch, which on a coarse model pulls in a whole fan of huge unpainted
// neighbours and then refines them to the resolution floor, since the height field the detail
// test samples is not restricted to the painted area. The conformal closure inside
// subdivide_mesh_adaptive() already grades the size change outward on its own.
for (const TriangleSelector::TriangleBitStreamMapping &m : data.triangles_to_split)
if (size_t(m.triangle_idx) < ntri)
region[m.triangle_idx] = 1;
std::vector<uint8_t> painted_vertex(nvert, 0); if (painted_tri) {
if (painted_tri) TriangleSelector sel(mv->mesh());
sel.deserialize(data, false);
(*painted_tri)[slot].assign(ntri, 0); (*painted_tri)[slot].assign(ntri, 0);
for (const stl_triangle_vertex_indices &t : sel.get_facets_strict(EnforcerBlockerType::ENFORCER).indices) {
for (const stl_triangle_vertex_indices &t : patch.indices) { // A sub-triangle produced by a *partial* stroke always carries at least one appended
bool all_original = true; // (split) vertex, so "all three indices are original" is exactly the test for a whole,
for (int k = 0; k < 3; ++k) { // fully-painted triangle - the only kind whose paint can be inherited wholesale.
if (size_t(t[k]) < nvert) if (size_t(t[0]) >= nvert || size_t(t[1]) >= nvert || size_t(t[2]) >= nvert)
painted_vertex[t[k]] = 1; // marks the refine region (any coverage, plus a ring) continue;
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::array<int, 3> k{ t[0], t[1], t[2] };
std::sort(k.begin(), k.end()); std::sort(k.begin(), k.end());
if (auto it = tri_by_verts.find(k); it != tri_by_verts.end()) if (auto it = tri_by_verts.find(k); it != tri_by_verts.end())
(*painted_tri)[slot][it->second] = 1; (*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; any_paint = true;
} }
return any_paint; return any_paint;
@@ -2749,17 +2769,36 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive()
return; return;
} }
// Feature-adaptive: sample the combined displacement so refinement follows texture curvature. A
// null sampler (only LSCM layers, or nothing decodable) falls back to the uniform target below.
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);
}
// Do the (potentially slow) refinement before taking the snapshot, so a no-op leaves no empty // Do the (potentially slow) refinement before taking the snapshot, so a no-op leaves no empty
// undo step - mirrors remesh_model(). // undo step - mirrors remesh_model().
// "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;
std::vector<int> source; std::vector<int> source;
indexed_triangle_set refined; indexed_triangle_set refined;
{ {
wxBusyCursor wait; wxBusyCursor wait;
refined = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, 12, &source); // 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.
refined = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
&source, sampler, tol, floor);
} }
if (refined.indices.size() == mv->mesh().its.indices.size()) { 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 " show_error(nullptr, _u8L("Nothing to subdivide - the painted area already meets the target edge "
"edge length.")); "length and detail tolerance, or the triangle budget is already used up."));
return; return;
} }
@@ -2852,7 +2891,7 @@ void GLGizmoTextureDisplacement::remesh_model()
void GLGizmoTextureDisplacement::rebuild_subdivide_preview() void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
{ {
m_subdivide_preview_glmodel.reset(); m_subdivide_preview_glmodel.reset();
m_subdivide_preview_count = -1; m_subdivide_preview_tris = -1;
const ModelVolume *mv = texture_volume(); const ModelVolume *mv = texture_volume();
if (mv == nullptr) if (mv == nullptr)
return; return;
@@ -2866,7 +2905,20 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
std::vector<uint8_t> region; std::vector<uint8_t> region;
if (!collect_paint_region(region, nullptr)) if (!collect_paint_region(region, nullptr))
return; // nothing painted yet: nothing to preview return; // nothing painted yet: nothing to preview
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, 12, nullptr); 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);
}
// 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;
const float floor = m_subdivide_feature ? m_subdivide_min_edge_mm : 0.f;
its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm,
int(mv->mesh().its.indices.size()) + m_subdivide_budget_k * 1000,
nullptr, sampler, tol, floor);
} else { } else {
if (m_subdivide_count < 1) if (m_subdivide_count < 1)
return; return;
@@ -2874,7 +2926,7 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview()
} }
if (its.indices.empty()) if (its.indices.empty())
return; return;
m_subdivide_preview_count = m_subdivide_count; m_subdivide_preview_tris = int(its.indices.size());
GLModel::Geometry init_data; GLModel::Geometry init_data;
init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 }; init_data.format = { GLModel::Geometry::EPrimitiveType::Lines, GLModel::Geometry::EVertexLayout::P3 };
@@ -3701,6 +3753,13 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// once on release - rebuilding the preview (a real CPU mesh recompute) on every one of those // once on release - rebuilding the preview (a real CPU mesh recompute) on every one of those
// frames is what made dragging these sliders feel slow. Only rebuild once the mouse button // frames is what made dragging these sliders feel slow. Only rebuild once the mouse button
// that's driving the drag is released, i.e. once per edit instead of dozens of times per drag. // that's driving the drag is released, i.e. once per edit instead of dozens of times per drag.
// The feature-adaptive subdivision follows the *displaced* surface (it samples depth * texture),
// so depth / tile-size / rotation / invert all change where it puts triangles. The heavy
// displacement preview below only rebuilds on release, which made the subdivide wireframe look
// like it ignored those edits - so rebuild it live here (during the drag), the same cadence its
// own sliders use. Cheap: it is bounded by the painted region.
if (m_preview_params_dirty && m_subdivide_editing && m_subdivide_adaptive && m_subdivide_feature)
rebuild_subdivide_preview();
if (m_preview_params_dirty && (m_auto_update || !ImGui::IsMouseDown(ImGuiMouseButton_Left))) { if (m_preview_params_dirty && (m_auto_update || !ImGui::IsMouseDown(ImGuiMouseButton_Left))) {
rebuild_preview(); rebuild_preview();
// Same edits (tile size, rotation, offset, a new texture) are what the projector window // Same edits (tile size, rotation, offset, a new texture) are what the projector window
@@ -3736,23 +3795,80 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm(); sum += (its.vertices[tri[i]] - its.vertices[tri[(i + 1) % 3]]).norm();
++cnt; ++cnt;
} }
m_subdivide_target_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)) * 0.5f, 0.1f, 20.f) : 1.f; m_subdivide_target_mm = cnt > 0 ? std::clamp(float(sum / double(cnt)) * 0.5f, 0.001f, 20.f) : 1.f;
} }
ImGui::PushItemWidth(m_imgui->scaled(8.4f)); // Live preview: rebuild the wireframe as the slider moves, not only on release, so it tracks
// "##subdiv" keeps the visible label "Target edge (mm)" but gives it an ImGui ID distinct // the value. The rebuild is bounded by the painted region, so it stays responsive.
// from the remesh slider below, which shows the same text - same label == same widget to const auto preview_live = [this]() {
// ImGui, so without this the two would collide. if (m_subdivide_editing)
if (m_imgui->slider_float(std::string(_u8L("Target edge (mm)")) + "##subdiv", &m_subdivide_target_mm, rebuild_subdivide_preview();
0.1f, 20.f, "%.2f", ImGuiLogSlider)) { m_parent.set_as_dirty();
if (m_subdivide_editing && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) };
if (ImGui::Checkbox(_u8L("Follow texture detail").c_str(), &m_subdivide_feature)) {
if (m_subdivide_editing)
rebuild_subdivide_preview(); rebuild_subdivide_preview();
m_parent.set_as_dirty(); m_parent.set_as_dirty();
} }
ImGui::PopItemWidth();
if (ImGui::IsItemHovered()) if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("Triangles in the painted area are split until every edge is at or below this " m_imgui->tooltip(_u8L("Put triangles only where the texture actually bends - dense over hills, ridges and "
"length. Smaller means finer detail and more triangles."), "noise, sparse over flat areas and smooth slopes - instead of an even density "
"everywhere. Uses the combined displacement of all painted layers."),
m_imgui->scaled(20.f)); m_imgui->scaled(20.f));
ImGui::PushItemWidth(m_imgui->scaled(8.4f));
// The edge-length target is a baseline in both modes. In feature mode it is what guarantees
// the curvature test can actually see the texture: left too coarse, a big triangle over a
// fine pattern can sample four points that all happen to land at similar heights, report no
// error, and stall before refinement ever starts. "##subdiv" avoids an ID clash with the
// remesh "Target edge (mm)" slider below (same label == same widget to ImGui).
if (m_imgui->slider_float(std::string(m_subdivide_feature ? _u8L("Max edge (mm)") : _u8L("Target edge (mm)")) +
"##subdiv",
&m_subdivide_target_mm, 0.001f, 20.f, "%.3f", ImGuiLogSlider))
preview_live();
if (ImGui::IsItemHovered())
m_imgui->tooltip(m_subdivide_feature ?
_u8L("Nothing in the painted area stays coarser than this, even where the texture is "
"flat. Keep it near the size of the features you want picked up - too coarse and "
"fine detail can be missed entirely.") :
_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));
if (m_subdivide_feature) {
// On top of the baseline: the chord-error tolerance, and the resolution floor.
if (m_imgui->slider_float(std::string(_u8L("Detail (mm)")) + "##subdivdetail", &m_subdivide_detail_mm,
0.001f, 1.f, "%.3f", ImGuiLogSlider))
preview_live();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("How closely the mesh follows the texture's relief. Smaller captures finer bumps; "
"larger only chases the big features."),
m_imgui->scaled(20.f));
if (m_imgui->slider_float(std::string(_u8L("Min edge (mm)")) + "##subdivmin", &m_subdivide_min_edge_mm,
0.001f, 20.f, "%.3f", ImGuiLogSlider))
preview_live();
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("The finest triangle size refinement will ever produce. Smaller captures finer "
"relief; also stops runaway subdivision at a sharp texture step, where the "
"surface never becomes flat."),
m_imgui->scaled(20.f));
}
// The budget. Refinement is worst-error-first, so a run that hits it has still spent its
// triangles on the biggest deviations - raising it buys detail, it does not redistribute it.
if (ImGui::SliderInt((_u8L("Added triangles (k)") + "##subdivbudget").c_str(), &m_subdivide_budget_k, 10, 2000)) {
m_subdivide_budget_k = std::clamp(m_subdivide_budget_k, 10, 2000);
preview_live();
}
if (ImGui::IsItemHovered())
m_imgui->tooltip(_u8L("How many thousand triangles the refinement may add. It always splits the "
"worst-fitting triangle first, so a run that uses the whole budget has still spent "
"it where it shows most - raise this if the preview still looks too coarse."),
m_imgui->scaled(20.f));
ImGui::PopItemWidth();
if (m_subdivide_editing && m_subdivide_preview_tris > 0)
m_imgui->text(Slic3r::format(_u8L("Preview: %1% triangles"), m_subdivide_preview_tris));
} else { } else {
ImGui::PushItemWidth(m_imgui->scaled(8.4f)); ImGui::PushItemWidth(m_imgui->scaled(8.4f));
if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) { if (ImGui::SliderInt(_u8L("Subdivide steps").c_str(), &m_subdivide_count, 0, 5)) {
@@ -3808,7 +3924,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
ImGui::SameLine(); ImGui::SameLine();
if (m_imgui->button(_u8L("Done"))) { if (m_imgui->button(_u8L("Done"))) {
m_subdivide_editing = false; m_subdivide_editing = false;
m_subdivide_preview_count = -1; m_subdivide_preview_tris = -1;
m_subdivide_preview_glmodel.reset(); m_subdivide_preview_glmodel.reset();
m_parent.set_as_dirty(); m_parent.set_as_dirty();
} }

View File

@@ -302,7 +302,7 @@ private:
// committed, i.e. the most expensive thing the panel can do, on every Apply. // committed, i.e. the most expensive thing the panel can do, on every Apply.
int m_subdivide_count = 1; int m_subdivide_count = 1;
bool m_subdivide_editing = false; bool m_subdivide_editing = false;
int m_subdivide_preview_count = -1; // the count m_subdivide_preview_glmodel was built for int m_subdivide_preview_tris = -1; // triangle count of the previewed result, shown in the panel
GLModel m_subdivide_preview_glmodel; GLModel m_subdivide_preview_glmodel;
void rebuild_subdivide_preview(); void rebuild_subdivide_preview();
void render_subdivide_preview(); void render_subdivide_preview();
@@ -314,6 +314,20 @@ private:
// are painted), so the region survives the subdivision instead of being dropped. // are painted), so the region survives the subdivision instead of being dropped.
bool m_subdivide_adaptive = false; bool m_subdivide_adaptive = false;
float m_subdivide_target_mm = 0.f; // 0 = not yet seeded; filled from the mesh on first show float m_subdivide_target_mm = 0.f; // 0 = not yet seeded; filled from the mesh on first show
// Feature-adaptive sub-mode: put the triangles where the *displaced surface* bends (texture
// curvature) rather than spreading them evenly. `detail_mm` is the chord-error tolerance ("Detail"
// slider: how far the true surface may sit off the flat triangle before it is split); the target
// above stays in play as a coarse baseline ("Max edge"), and `min_edge_mm` is the hard floor
// ("Min edge"). See subdivide_mesh_adaptive().
bool m_subdivide_feature = false;
float m_subdivide_detail_mm = 0.05f;
float m_subdivide_min_edge_mm = 0.1f;
// How many thousand triangles refinement may *add* (the mesh's own count is added on before it is
// passed as subdivide_mesh_adaptive()'s absolute cap, so the control still means something on a
// dense model). Refinement is worst-error-first, so hitting the budget still yields the best mesh
// that many triangles can buy - and it is what keeps a fine "Detail" over a noisy texture from
// turning into an out-of-memory, or an unrenderable preview wireframe.
int m_subdivide_budget_k = 200;
void subdivide_model_adaptive(); void subdivide_model_adaptive();
// Fills `region` (per current-mesh triangle, 1 = refine) from the union of every layer's painted // 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 // area. If `painted_tri` is non-null, also fills, per layer, the fully-painted triangles to carry

View File

@@ -1,6 +1,7 @@
#define NOMINMAX #define NOMINMAX
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <cmath>
#include <fstream> #include <fstream>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
@@ -270,11 +271,20 @@ TEST_CASE("TextureDisplacement: adaptive subdivision is conformal and region-res
{ {
std::vector<uint8_t> region(cube.indices.size(), 1); std::vector<uint8_t> region(cube.indices.size(), 1);
std::vector<int> source; std::vector<int> source;
const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 3.f, 12, &source); const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 3.f, 100000, &source);
CHECK(out.indices.size() > cube.indices.size()); // it actually refined CHECK(out.indices.size() > cube.indices.size()); // it actually refined
CHECK(every_edge_used_twice(out)); // ... without opening a single crack CHECK(every_edge_used_twice(out)); // ... without opening a single crack
// Refinement runs to completion, not for a fixed number of passes: with the whole mesh in the
// region and budget to spare, *every* edge really does end up at or below the target. This is
// the regression that matters - an earlier version quietly stopped a long way short, having
// spent its pass budget grading the coarse surroundings.
float worst = 0.f;
for (const auto &t : out.indices)
worst = std::max(worst, longest_edge(out, t));
CHECK(worst <= 3.f);
REQUIRE(source.size() == out.indices.size()); REQUIRE(source.size() == out.indices.size());
for (int s : source) for (int s : source)
CHECK((s >= 0 && s < int(cube.indices.size()))); // every child names a real parent CHECK((s >= 0 && s < int(cube.indices.size()))); // every child names a real parent
@@ -296,28 +306,133 @@ TEST_CASE("TextureDisplacement: adaptive subdivision is conformal and region-res
REQUIRE(region_count > 0); REQUIRE(region_count > 0);
std::vector<int> source; std::vector<int> source;
const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 2.f, 12, &source); const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 2.f, 100000, &source);
CHECK(out.indices.size() > cube.indices.size()); CHECK(out.indices.size() > cube.indices.size());
CHECK(every_edge_used_twice(out)); // the refined/coarse seam has no T-junction 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 // Inside the region the target is actually met - refinement is not cut short by a pass budget.
// output triangle is versus the largest region-sourced input triangle. // Outside it, only the graded transition band conformality requires is touched, so plenty of
// the unpainted mesh is still coarser than the target: the region was not a suggestion.
float max_in = 0.f, max_out = 0.f; float max_in = 0.f, max_out = 0.f;
for (size_t i = 0; i < cube.indices.size(); ++i) for (size_t i = 0; i < out.indices.size(); ++i) {
if (region[i]) float &acc = region[source[i]] ? max_in : max_out;
max_in = std::max(max_in, longest_edge(cube, cube.indices[i])); acc = std::max(acc, longest_edge(out, out.indices[i]));
for (size_t i = 0; i < out.indices.size(); ++i) }
if (region[source[i]]) CHECK(max_in <= 2.f);
max_out = std::max(max_out, longest_edge(out, out.indices[i])); CHECK(max_out > 2.f);
CHECK(max_out < max_in); // refinement genuinely happened inside the region
std::vector<uint8_t> all(cube.indices.size(), 1);
const indexed_triangle_set whole = subdivide_mesh_adaptive(cube, all, 2.f, 100000);
CHECK(out.indices.size() < whole.indices.size()); // ... and it cost less than doing the lot
}
SECTION("the triangle budget caps the result and still leaves a conformal mesh")
{
std::vector<uint8_t> region(cube.indices.size(), 1);
const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 0.05f, /*max_triangles*/ 500);
CHECK(out.indices.size() <= 500);
CHECK(out.indices.size() > cube.indices.size()); // it spent the budget rather than giving up
CHECK(every_edge_used_twice(out)); // stopping on the budget is not a crack
} }
SECTION("an empty region is a no-op") SECTION("an empty region is a no-op")
{ {
std::vector<uint8_t> region(cube.indices.size(), 0); std::vector<uint8_t> region(cube.indices.size(), 0);
const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 1.f, 12, nullptr); const indexed_triangle_set out = subdivide_mesh_adaptive(cube, region, 1.f, 100000, nullptr);
CHECK(out.indices.size() == cube.indices.size()); CHECK(out.indices.size() == cube.indices.size());
CHECK(out.vertices.size() == cube.vertices.size()); CHECK(out.vertices.size() == cube.vertices.size());
} }
} }
TEST_CASE("TextureDisplacement: feature-adaptive subdivision follows curvature, not slope", "[TextureDisplacement]")
{
// A flat sheet, tessellated into a regular grid to give the bisector something to work with.
indexed_triangle_set plane;
plane.vertices = { { 0.f, 0.f, 0.f }, { 1.f, 0.f, 0.f }, { 1.f, 1.f, 0.f }, { 0.f, 1.f, 0.f } };
plane.indices = { { 0, 1, 2 }, { 0, 2, 3 } };
const indexed_triangle_set grid = subdivide_mesh_uniform(plane, 0.15f, 5); // ~uniform grid of small triangles
REQUIRE(grid.indices.size() > 32);
const std::vector<uint8_t> region(grid.indices.size(), 1);
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;
};
auto centroid_xy = [](const indexed_triangle_set &its, const stl_triangle_vertex_indices &t) {
return Vec2f((its.vertices[t[0]].x() + its.vertices[t[1]].x() + its.vertices[t[2]].x()) / 3.f,
(its.vertices[t[0]].y() + its.vertices[t[1]].y() + its.vertices[t[2]].y()) / 3.f);
};
SECTION("a sharp bump refines densely at its center and leaves flat corners coarse")
{
// A tight Gaussian bump at the sheet's center: strong curvature near (0.5, 0.5), flat far away.
HeightFieldSampler bump = [](const Vec3f &p, const Vec3f &) {
const float r2 = (p.x() - 0.5f) * (p.x() - 0.5f) + (p.y() - 0.5f) * (p.y() - 0.5f);
return 1.0f * std::exp(-r2 / 0.02f);
};
// Baseline max edge 0.3 is coarser than the grid's own edges, so the baseline adds nothing
// here - this isolates the *curvature* contribution (the grid already meets the baseline).
std::vector<int> source;
const indexed_triangle_set out =
subdivide_mesh_adaptive(grid, region, /*max edge*/ 0.3f, 200000, &source, bump, /*tol*/ 0.02f,
/*min_edge*/ 0.01f);
CHECK(out.indices.size() > grid.indices.size()); // the bump forced real refinement
// The largest triangle near the bump's center must be much smaller than the largest in a flat
// corner - i.e. triangles went where the curvature is, not spread evenly.
float near_max = 0.f, far_max = 0.f;
for (const auto &t : out.indices) {
const Vec2f c = centroid_xy(out, t);
const float r = (c - Vec2f(0.5f, 0.5f)).norm();
const float len = longest_edge(out, t);
if (r < 0.1f)
near_max = std::max(near_max, len);
else if (r > 0.45f)
far_max = std::max(far_max, len);
}
REQUIRE(near_max > 0.f);
REQUIRE(far_max > 0.f);
CHECK(near_max < far_max); // finer at the hill than on the flats
}
SECTION("a linear ramp has zero curvature and is left untouched")
{
// Height varies, but linearly - a flat triangle represents it exactly, so the chord error is
// zero everywhere and nothing should be split. This is the case a gradient-based criterion
// would wrongly over-refine.
HeightFieldSampler ramp = [](const Vec3f &p, const Vec3f &) { return 2.0f * p.x(); };
// Same coarse baseline (0.3) that the grid already meets, so any split would be curvature-
// driven - and a ramp has none.
const indexed_triangle_set out =
subdivide_mesh_adaptive(grid, region, /*max edge*/ 0.3f, 200000, nullptr, ramp, /*tol*/ 0.02f,
/*min_edge*/ 0.01f);
CHECK(out.indices.size() == grid.indices.size()); // not one extra triangle
}
SECTION("the max-edge baseline still applies in feature mode")
{
// A height field that is flat everywhere the four sample points of a coarse triangle happen to
// land, but not in between - the aliasing case where a chord test alone reports no error and
// refinement stalls before it ever starts. The baseline is what stops that: it guarantees a
// sampling density fine enough for the curvature test to see the texture at all.
HeightFieldSampler flat = [](const Vec3f &, const Vec3f &) { return 0.f; };
const indexed_triangle_set out =
subdivide_mesh_adaptive(grid, region, /*max edge*/ 0.03f, 200000, nullptr, flat, /*tol*/ 0.02f,
/*min_edge*/ 0.001f);
CHECK(out.indices.size() > grid.indices.size());
float worst = 0.f;
for (const auto &t : out.indices)
worst = std::max(worst, longest_edge(out, t));
CHECK(worst <= 0.03f);
}
}