From 2a461973221a29e28445b482cdbff877d85c7da7 Mon Sep 17 00:00:00 2001 From: ExPikaPaka Date: Tue, 28 Jul 2026 11:42:40 +0200 Subject: [PATCH] Rewrite adaptive subdivision to refine worst-first against a triangle budget --- TEXTURE_DISPLACEMENT.md | 101 +++- src/libslic3r/TextureDisplacement.cpp | 498 ++++++++++++++---- src/libslic3r/TextureDisplacement.hpp | 89 +++- .../GUI/Gizmos/GLGizmoTextureDisplacement.cpp | 280 +++++++--- .../GUI/Gizmos/GLGizmoTextureDisplacement.hpp | 16 +- tests/libslic3r/test_texture_displacement.cpp | 139 ++++- 6 files changed, 877 insertions(+), 246 deletions(-) diff --git a/TEXTURE_DISPLACEMENT.md b/TEXTURE_DISPLACEMENT.md index b4b904fea7..680c984998 100644 --- a/TEXTURE_DISPLACEMENT.md +++ b/TEXTURE_DISPLACEMENT.md @@ -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 `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 -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. +**Adaptive (`subdivide_mesh_adaptive()`)** — refine **only the painted area**, 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*. Only **terminal** edges are ever bisected +- 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. The edge to split for a triangle +that wants refining is found by **longest-edge propagation (LEPP)**: walk to the longest edge of +ever-longer-edged neighbours until a terminal one is reached, and bisect that. Edge length strictly +increases along the path (ties broken by 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. The transition triangles it pulls in just outside the +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. +**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 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). +layer. `collect_paint_region()` derives both: +- the union refine-region: **exactly** the original triangles the brush touched, read straight off + `TriangleSplittingData::triangles_to_split` (`serialize()` records an entry per original triangle that + is either split - i.e. partially painted, the patch boundary - or carries a non-default state). No + dilation. An earlier version marked every triangle sharing a *vertex* with the patch, which drags in a + 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 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) @@ -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. - **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. - Since the rewrite the bake is topology-preserving, so this is now a hard, explicit property rather - than something partly papered over by the old per-layer re-meshing. + matter how high-resolution the height map is - that is what the subdivision controls are for + (uniform, adaptive, or feature-adaptive; see the Subdivision section). Since the rewrite the bake is + 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 diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index 7bc855cf15..1d8df784e7 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -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); } +HeightFieldSampler make_combined_displacement_sampler(const indexed_triangle_set &base_mesh, + const std::vector &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>(); + + if (base_mesh.indices.empty()) + return nullptr; + + std::vector 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 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 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, const std::vector &refine_region, - float target_edge_length_mm, int max_iterations, - std::vector *out_source) + float target_edge_length_mm, int max_triangles, + std::vector *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 - // the caller can remap per-triangle data (paint masks) for free. - struct Tri { int v[3]; int src; }; + // Neighbour slots that are not a triangle index. + constexpr int NB_BOUNDARY = -1; // open edge: terminal on its own, bisected from this side alone + 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 verts = mesh.vertices; - std::vector tris; - tris.reserve(mesh.indices.size()); + std::vector tris(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) }); + 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 &t) -> indexed_triangle_set { + auto emit = [&]() -> indexed_triangle_set { indexed_triangle_set out; out.vertices = verts; - out.indices.reserve(t.size()); + out.indices.reserve(tris.size()); if (out_source) { out_source->clear(); - out_source->reserve(t.size()); + out_source->reserve(tris.size()); } - for (const Tri &tr : t) { - out.indices.emplace_back(tr.v[0], tr.v[1], tr.v[2]); + for (const Tri &t : tris) { + out.indices.emplace_back(t.v[0], t.v[1], t.v[2]); if (out_source) - out_source->push_back(tr.src); + out_source->push_back(t.src); } 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 // (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 (refine_region.size() != mesh.indices.size() || int(tris.size()) + 2 > max_triangles) + 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; })) - return emit(tris); // nothing flagged: no-op - const float target_sq = target_edge_length_mm * target_edge_length_mm; + return emit(); // nothing flagged: no-op auto edge_key = [](int a, int b) -> uint64_t { if (a > b) std::swap(a, b); return (uint64_t(uint32_t(a)) << 32) | uint32_t(b); }; - auto edge_len_sq = [&](uint64_t k) -> float { - return (verts[int(k >> 32)] - verts[int(uint32_t(k))]).squaredNorm(); - }; - // The one edge of a triangle chosen as its "longest": greatest squared length, ties broken by the - // smaller edge key. The tie-break is by mesh-vertex indices, which both triangles sharing an edge - // compute identically - so they never disagree about whether that shared edge is "the" longest, - // which is what the conformality argument rests on. - auto longest_key = [&](const Tri &t) -> uint64_t { - uint64_t best_k = edge_key(t.v[0], t.v[1]); - float best_len = edge_len_sq(best_k); - for (int e = 1; e < 3; ++e) { - const uint64_t k = edge_key(t.v[e], t.v[(e + 1) % 3]); - const float l = edge_len_sq(k); - if (l > best_len || (l == best_len && k < best_k)) { - best_len = l; - best_k = k; - } - } - return best_k; - }; - for (int iter = 0; iter < max_iterations; ++iter) { - // edge -> the (up to two) triangles sharing it. A third triangle on an edge means a - // non-manifold input; it is left in the second slot's place and simply not treated as - // terminal, so such an edge is never bisected (better a missed refinement than a torn mesh). - std::unordered_map> edge_tris; - edge_tris.reserve(tris.size() * 3); + // Edge adjacency, built once here and then maintained incrementally by bisect() below. Rebuilding + // it per refinement pass is what made the previous version's cost scale with the whole model + // instead of with the refined region, and what forced the tiny pass budget that stopped + // refinement short. + { + struct EdgeRec { int he[2]; int count; }; // he = encoded half-edge (triangle * 3 + local edge) + std::unordered_map edges; + edges.reserve(tris.size() * 2); for (int ti = 0; ti < int(tris.size()); ++ti) for (int e = 0; e < 3; ++e) { - const uint64_t k = edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]); - auto it = edge_tris.find(k); - if (it == edge_tris.end()) - edge_tris.emplace(k, std::array{ ti, -1 }); - else if (it->second[1] == -1) - it->second[1] = ti; - else - it->second[0] = -2; // >2 triangles: poison this edge (never terminal) + EdgeRec &r = edges.try_emplace(edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3]), + EdgeRec{ { -1, -1 }, 0 }) + .first->second; + if (r.count < 2) + r.he[r.count] = ti * 3 + e; + ++r.count; } - - std::vector tri_longest(tris.size()); for (int ti = 0; ti < int(tris.size()); ++ti) - tri_longest[ti] = longest_key(tris[ti]); - - // Which edges to bisect this pass: terminal (the longest edge of every triangle on it) AND - // long enough AND wanted by the region (either side in it). Terminal-ness is exactly what - // guarantees both sides split together, so no hanging node is ever produced. - std::unordered_set to_bisect; - for (const auto &[k, slot] : edge_tris) { - const int t0 = slot[0], t1 = slot[1]; - if (t0 < 0) - continue; // poisoned (non-manifold) - if (tri_longest[t0] != k) - continue; - if (t1 >= 0 && tri_longest[t1] != k) - continue; - if (edge_len_sq(k) <= target_sq) - continue; - const bool in_region = (refine_region[tris[t0].src] != 0) || - (t1 >= 0 && refine_region[tris[t1].src] != 0); - if (in_region) - to_bisect.insert(k); - } - if (to_bisect.empty()) - break; - - // One midpoint per bisected edge, shared by both sides. - std::unordered_map mid; - mid.reserve(to_bisect.size()); - for (const uint64_t k : to_bisect) { - const int a = int(k >> 32), b = int(uint32_t(k)); - mid.emplace(k, int(verts.size())); - verts.push_back((verts[a] + verts[b]) * 0.5f); - } - - std::vector next; - next.reserve(tris.size() + to_bisect.size()); - for (const Tri &t : tris) { - // At most one of a triangle's edges can be terminal (only its own longest can be), so at - // most one is in to_bisect - find that one. - int be = -1; - for (int e = 0; e < 3; ++e) - if (to_bisect.count(edge_key(t.v[e], t.v[(e + 1) % 3])) != 0) { - be = e; - break; - } - if (be == -1) { - next.push_back(t); - continue; + for (int e = 0; e < 3; ++e) { + const EdgeRec &r = edges.at(edge_key(tris[ti].v[e], tris[ti].v[(e + 1) % 3])); + if (r.count > 2) + tris[ti].nb[e] = NB_NONMANIFOLD; + else if (r.count == 2) + tris[ti].nb[e] = (r.he[0] == ti * 3 + e ? r.he[1] : r.he[0]) / 3; } - 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 vnormal; + std::vector vheight; + std::vector 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 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 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> 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 diff --git a/src/libslic3r/TextureDisplacement.hpp b/src/libslic3r/TextureDisplacement.hpp index 5bae458e43..39e3ce634f 100644 --- a/src/libslic3r/TextureDisplacement.hpp +++ b/src/libslic3r/TextureDisplacement.hpp @@ -2,6 +2,7 @@ #define slic3r_TextureDisplacement_hpp_ #include +#include #include #include #include @@ -493,6 +494,25 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set // and forwards to the overload above. 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; + +// 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 &layers, + const TextureDisplacementFacetsData &facets_data); + // 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 // 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. // -// 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). +// Unlike subdivide_mesh_uniform() this only densifies where asked - `refine_region` (indexed by +// input-triangle index; empty or all-false => no-op) flags the triangles allowed to drive refinement +// - 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. +// A triangle wants refining while it is over at least one of these, whichever applies: +// - length: its longest edge exceeds `target_edge_length_mm` (a *baseline* - it applies in feature +// mode too, and is what stops a coarse triangle from being declared flat merely because +// the four points the chord test samples happened to land at similar heights on a +// high-frequency texture: the classic aliasing stall); +// - feature: (only when `sampler` is set and `chord_tolerance_mm > 0`) the *displaced* surface +// departs from the flat triangle by more than `chord_tolerance_mm`, measured as the max +// 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` -// 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). +// Refinement runs to completion, not for a fixed number of passes: triangles are taken worst-first +// from a max-heap keyed by how many times over its criteria each one is, so a run that hits the +// `max_triangles` budget has spent it on the largest errors rather than wherever a sweep happened to +// reach. The budget is the only bound on a pathological height field; stopping on it leaves a +// perfectly valid, still-conformal mesh. +// +// 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, const std::vector &refine_region, - float target_edge_length_mm, int max_iterations = 12, - std::vector *out_source = nullptr); + float target_edge_length_mm, int max_triangles = 1000000, + std::vector *out_source = nullptr, + const HeightFieldSampler &sampler = nullptr, + float chord_tolerance_mm = 0.f, float min_edge_length_mm = 0.f); } // namespace Slic3r diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp index 0fd02a3ba6..ea489364aa 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.cpp @@ -166,7 +166,7 @@ void GLGizmoTextureDisplacement::on_shutdown() m_seam_path_mode = false; m_seam_path_anchor = -1; m_subdivide_editing = false; - m_subdivide_preview_count = -1; + m_subdivide_preview_tris = -1; m_subdivide_preview_glmodel.reset(); m_bump_active_chart = -1; m_bump_active_vertex.clear(); @@ -231,6 +231,11 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() m_bump_preview_dirty = false; } 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) { m_parent.toggle_model_objects_visibility(true); if (ModelVolume *mv = texture_volume()) @@ -244,11 +249,13 @@ void GLGizmoTextureDisplacement::render_painter_gizmo() m_c->selection_info()->get_active_instance(), mv); render_preview_mesh(); - glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); - glsafe(::glPolygonOffset(-1.0f, -1.0f)); - render_triangles(selection); - glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); - } else { + if (show_paint_overlay) { + glsafe(::glEnable(GL_POLYGON_OFFSET_FILL)); + glsafe(::glPolygonOffset(-1.0f, -1.0f)); + render_triangles(selection); + glsafe(::glDisable(GL_POLYGON_OFFSET_FILL)); + } + } else if (show_paint_overlay) { render_triangles(selection); } @@ -744,10 +751,9 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() if (base.vertices.size() != patch.vertices.size()) return; // shouldn't happen: get_facets_strict() always returns the full vertex array - std::vector is_painted(patch.vertices.size(), false); - for (const stl_triangle_vertex_indices &tri : patch.indices) - for (int i = 0; i < 3; ++i) - is_painted[tri[i]] = true; + // Unpainted triangles, so the surrounding surface still renders (the render path hides the real + // model in bump mode). get_facets_strict() returns the same vertex array whatever state is asked. + const indexed_triangle_set rest = m_triangle_selectors[0]->get_facets_strict(EnforcerBlockerType::NONE); // 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 @@ -761,26 +767,38 @@ void GLGizmoTextureDisplacement::rebuild_bump_preview_mesh() vertex_uv.clear(); GLModel::Geometry init_data; - // P3N3T2: normal.x carries the paint weight, tex_coord carries the precomputed uv (see the vertex - // shader). Reuses a standard GLModel layout rather than a bespoke vertex buffer. + // P3N3T2: normal.x carries the paint weight, tex_coord the precomputed uv (see the vertex shader). 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); - // Every triangle of the whole mesh is included (not just the painted ones) so the surrounding, - // un-bumped surface still renders - the per-vertex weight (0 outside the patch) is what fades - // the shader's bump effect to nothing there, exactly like the true-displacement preview fades - // to the untouched surface at its boundary. - // normal.y flags the island currently dragged in the UV editor, so the shader can move just that - // island through the island_delta uniform (see the bump shaders / on_island_edited). - const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty(); - for (size_t vi = 0; vi < base.vertices.size(); ++vi) { - const float active = (have_active && vi < m_bump_active_vertex.size() && m_bump_active_vertex[vi]) ? 1.f : 0.f; - const Vec3f weight_normal(is_painted[vi] ? 1.f : 0.f, active, 0.f); - const Vec2f uv = m_bump_preview_uses_vertex_uv ? vertex_uv[vi] : Vec2f::Zero(); - init_data.add_vertex(base.vertices[vi], weight_normal, uv); - } - for (const stl_triangle_vertex_indices &tri : base.indices) - init_data.add_triangle(unsigned(tri[0]), unsigned(tri[1]), unsigned(tri[2])); + + // Per-triangle weighting via a *flat* (unshared-vertex) mesh: every corner of a painted triangle + // gets weight 1, every corner of an unpainted one weight 0. This is what a coarse mesh needs - one + // painted face of a raw cube has no strictly-interior vertex (all 8 are shared), so per-vertex + // weighting would either bleed onto the neighbours (boundary weight 1) or vanish outright (boundary + // weight 0, which is what made a single face show nothing). Duplicating vertices costs no shading + // quality here because the bump shader takes its surface normal from screen-space derivatives of + // position (dFdx/dFdy), not from a per-vertex normal. normal.y flags the UV-editor island being + // dragged so the shader can move just that island via the island_delta uniform. + const bool have_active = m_bump_active_chart >= 0 && !m_bump_active_vertex.empty(); + const size_t tri_total = patch.indices.size() + rest.indices.size(); + init_data.reserve_vertices(tri_total * 3); + init_data.reserve_indices(tri_total * 3); + unsigned vcount = 0; + const auto emit_triangles = [&](const indexed_triangle_set &its, float weight) { + for (const stl_triangle_vertex_indices &tri : its.indices) { + 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)); // 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]); } + // 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) { const ModelObjectPtrs &mos = wxGetApp().model().objects; 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) 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. + // 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. Only + // the paint carry-forward needs it, and the live subdivide preview calls this on every slider + // frame, so it is not built for the region-only path. std::map, int> tri_by_verts; - for (size_t i = 0; i < ntri; ++i) { - std::array k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] }; - std::sort(k.begin(), k.end()); - tri_by_verts.emplace(k, int(i)); - } + if (painted_tri) + for (size_t i = 0; i < ntri; ++i) { + std::array k{ its.indices[i][0], its.indices[i][1], its.indices[i][2] }; + std::sort(k.begin(), k.end()); + tri_by_verts.emplace(k, int(i)); + } bool any_paint = false; for (int slot = 0; slot < int(TEXTURE_DISPLACEMENT_MAX_LAYERS); ++slot) { @@ -2693,40 +2719,34 @@ bool GLGizmoTextureDisplacement::collect_paint_region( 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); + // The refine region is exactly the original triangles the brush touched. `triangles_to_split` + // lists precisely those: serialize() records an entry for every original triangle that is + // 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 painted_vertex(nvert, 0); - if (painted_tri) + if (painted_tri) { + TriangleSelector sel(mv->mesh()); + sel.deserialize(data, false); (*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) { + for (const stl_triangle_vertex_indices &t : sel.get_facets_strict(EnforcerBlockerType::ENFORCER).indices) { + // A sub-triangle produced by a *partial* stroke always carries at least one appended + // (split) vertex, so "all three indices are original" is exactly the test for a whole, + // fully-painted triangle - the only kind whose paint can be inherited wholesale. + if (size_t(t[0]) >= nvert || size_t(t[1]) >= nvert || size_t(t[2]) >= nvert) + continue; std::array k{ t[0], t[1], t[2] }; std::sort(k.begin(), k.end()); if (auto it = tri_by_verts.find(k); it != tri_by_verts.end()) (*painted_tri)[slot][it->second] = 1; } } - - // A triangle is in the refine region if any of its vertices is painted. That deliberately - // over-includes a one-triangle ring just outside the strict patch, which is exactly the - // transition band the conformal bisection would pull in anyway - and it makes sure the patch - // boundary itself gets refined rather than staying coarse right where the relief ends. - for (size_t i = 0; i < ntri; ++i) - for (int k = 0; k < 3; ++k) - if (painted_vertex[its.indices[i][k]]) { - region[i] = 1; - break; - } any_paint = true; } return any_paint; @@ -2749,17 +2769,36 @@ void GLGizmoTextureDisplacement::subdivide_model_adaptive() 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 // 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 source; indexed_triangle_set refined; { 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()) { - show_error(nullptr, _u8L("Nothing to subdivide - the painted area is already at or below the target " - "edge length.")); + show_error(nullptr, _u8L("Nothing to subdivide - the painted area already meets the target edge " + "length and detail tolerance, or the triangle budget is already used up.")); return; } @@ -2852,7 +2891,7 @@ void GLGizmoTextureDisplacement::remesh_model() void GLGizmoTextureDisplacement::rebuild_subdivide_preview() { m_subdivide_preview_glmodel.reset(); - m_subdivide_preview_count = -1; + m_subdivide_preview_tris = -1; const ModelVolume *mv = texture_volume(); if (mv == nullptr) return; @@ -2866,7 +2905,20 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() std::vector region; if (!collect_paint_region(region, nullptr)) return; // nothing painted yet: nothing to preview - its = subdivide_mesh_adaptive(mv->mesh().its, region, m_subdivide_target_mm, 12, nullptr); + 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 { if (m_subdivide_count < 1) return; @@ -2874,7 +2926,7 @@ void GLGizmoTextureDisplacement::rebuild_subdivide_preview() } if (its.indices.empty()) return; - m_subdivide_preview_count = m_subdivide_count; + m_subdivide_preview_tris = int(its.indices.size()); GLModel::Geometry init_data; 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 // 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. + // 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))) { rebuild_preview(); // 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(); ++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)); - // "##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)) + // Live preview: rebuild the wireframe as the slider moves, not only on release, so it tracks + // the value. The rebuild is bounded by the painted region, so it stays responsive. + const auto preview_live = [this]() { + if (m_subdivide_editing) + rebuild_subdivide_preview(); + m_parent.set_as_dirty(); + }; + + if (ImGui::Checkbox(_u8L("Follow texture detail").c_str(), &m_subdivide_feature)) { + if (m_subdivide_editing) 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->tooltip(_u8L("Put triangles only where the texture actually bends - dense over hills, ridges and " + "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)); + + 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 { ImGui::PushItemWidth(m_imgui->scaled(8.4f)); 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(); if (m_imgui->button(_u8L("Done"))) { m_subdivide_editing = false; - m_subdivide_preview_count = -1; + m_subdivide_preview_tris = -1; m_subdivide_preview_glmodel.reset(); m_parent.set_as_dirty(); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp index 018e1f00c8..a4807eb61d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp @@ -302,7 +302,7 @@ private: // committed, i.e. the most expensive thing the panel can do, on every Apply. int m_subdivide_count = 1; 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; void rebuild_subdivide_preview(); void render_subdivide_preview(); @@ -314,6 +314,20 @@ private: // 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 + // 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(); // 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 diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index ed45ec66e6..6b3420845e 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -1,6 +1,7 @@ #define NOMINMAX #include +#include #include #include @@ -270,11 +271,20 @@ TEST_CASE("TextureDisplacement: adaptive subdivision is conformal and region-res { std::vector region(cube.indices.size(), 1); std::vector 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(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()); for (int s : source) 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); std::vector 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(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. + // Inside the region the target is actually met - refinement is not cut short by a pass budget. + // 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; - 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 + for (size_t i = 0; i < out.indices.size(); ++i) { + float &acc = region[source[i]] ? max_in : max_out; + acc = std::max(acc, longest_edge(out, out.indices[i])); + } + CHECK(max_in <= 2.f); + CHECK(max_out > 2.f); + + std::vector 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 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") { std::vector 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.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 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 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); + } +}