From 1e39a36a25ab5f662490687a888f1f4d1e8d0b6e Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 30 Sep 2026 19:32:01 +0800 Subject: [PATCH] Bake a second painted region as finely as the first Refinement now tapers off away from the paint, and the unpainted surface is kept out of simplification, so what one bake leaves unpainted is still the model's own triangles when a later bake paints there. A second bake used to refine the slivers and long triangles the first one left around its stroke, and came out many times denser, with walls off the texture's lines. --- .../TextureBake/TextureBakePipeline.cpp | 26 ++--- .../TextureBake/TextureBakePipeline.hpp | 11 ++- .../TextureBake/TextureBakeSubdivide.cpp | 63 +++++++++--- .../TextureBake/TextureBakeSubdivide.hpp | 19 +++- src/libslic3r/TextureDisplacement.cpp | 12 ++- tests/libslic3r/test_texture_displacement.cpp | 98 +++++++++++++++---- 6 files changed, 174 insertions(+), 55 deletions(-) diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.cpp b/src/libslic3r/TextureBake/TextureBakePipeline.cpp index 26f44671c7..2b678905b1 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.cpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.cpp @@ -149,7 +149,7 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, // 1. Refine to the target edge length. SubdivideResult sub = subdivide( input, settings.refine_length, face_excluded, /* fast */ false, settings.safety_cap, - [&](double f, size_t, double) { return report("subdivide", f); }); + [&](double f, size_t, double) { return report("subdivide", f); }, settings.paint_within); result.safety_cap_hit = sub.safety_cap_hit; lap("subdivide", sub.geometry); if (!report("subdivide", 1.0)) { @@ -180,7 +180,8 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, } sub = subdivide(reg.geometry, settings.refine_length * settings.regularize_second_pass_mul, excl, false, settings.safety_cap, - [&](double f, size_t, double) { return report("re-subdivide", f); }); + [&](double f, size_t, double) { return report("re-subdivide", f); }, + settings.paint_within); result.safety_cap_hit = result.safety_cap_hit || sub.safety_cap_hit; // The second pass renumbers faces, so the parent map has to be composed through it. std::vector composed(sub.face_parent_id.size()); @@ -199,12 +200,8 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, // 2b. Paint finer than the input triangles. The caller includes a source triangle when any part of // it is painted; now that the faces are small, ask once more per face and switch the unpainted - // ones off. They are pinned like the excluded region from here on (their own corners at weight - // 1, and the displacement's boundary sealing pins the stroke's rim on the painted side), but they - // are refined pieces of painted triangles, not original geometry, so `soft_excluded` keeps them - // out of the decimation lock below. Every stage between here and the decimation rewrites faces in - // place, so the per-face flag stays valid by index. - std::vector soft_excluded; + // ones off. They are pinned like the excluded region from here on: their own corners at weight 1, + // and the displacement's boundary sealing pins the stroke's rim on the painted side. if (settings.painted) { const size_t nf = sub.geometry.triangle_count(); const bool have_w = !sub.geometry.exclude_weight.empty(); @@ -228,7 +225,6 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, if (unpainted[t]) sub.geometry.exclude_weight[t * 3] = sub.geometry.exclude_weight[t * 3 + 1] = sub.geometry.exclude_weight[t * 3 + 2] = 1.f; - soft_excluded = std::move(unpainted); } lap("paint", sub.geometry, std::to_string(switched) + " faces switched off"); if (!report("paint", 1.0)) { @@ -294,18 +290,14 @@ PipelineResult run_pipeline(const TriSoup &input, const HeightSampleFn &sample, for (size_t t = 0; t < locked.size(); ++t) locked[t] = (displaced.exclude_weight[t * 3] + displaced.exclude_weight[t * 3 + 1] + displaced.exclude_weight[t * 3 + 2]) / 3.f > 0.99f ? 1 : 0; - // Faces the paint test switched off carry weight 1 too, but are refined pieces of - // painted triangles rather than original geometry: locking them would keep a partly - // painted source triangle at full refinement. Face indices survived relocate, flip - // and displace unchanged, so the flag still lines up. - for (size_t t = 0; t < locked.size() && t < soft_excluded.size(); ++t) - if (soft_excluded[t]) - locked[t] = 0; + // That includes the faces the paint test switched off: the harvest would re-triangulate + // them into long slivers, which a later bake painted there would refine instead of the + // grid the graded refinement left. preserved = size_t(std::count(locked.begin(), locked.end(), uint8_t(1))); } } // The budget is what this bake may spend on what it refines. Geometry it only preserves - the - // relief of an earlier bake, which this one does not paint - is counted on top of it: charged + // unpainted surface, and on it the relief of an earlier bake - is counted on top of it: charged // against the same budget, a second bake over a fresh area had to evict the first one's // triangles to fit, so every bake after the first came out coarser than the one before. const size_t target = settings.max_triangles + preserved; diff --git a/src/libslic3r/TextureBake/TextureBakePipeline.hpp b/src/libslic3r/TextureBake/TextureBakePipeline.hpp index 482cd36311..e230cf0d49 100644 --- a/src/libslic3r/TextureBake/TextureBakePipeline.hpp +++ b/src/libslic3r/TextureBake/TextureBakePipeline.hpp @@ -73,9 +73,16 @@ struct PipelineSettings // once, so it must be safe to call concurrently. std::function painted; + // Optional. Whether the paint comes within a radius of a point, which grades the refinement (see + // subdivide()): only the paint and its surroundings reach refine_length, and the unpainted surface + // further out stays in whole pieces of its own grid. That is what a later bake painted there + // refines, so it meets the same grid a first bake does. Called from several threads at once. + SubdivideWithinFn paint_within; + // Export mode only. - // What this bake may spend on what it refines. Geometry it only preserves (see preserve_untextured) - // is counted on top of it, so an earlier bake's relief does not have to be evicted to fit this one. + // What this bake may spend on what it refines. Geometry it only preserves (see preserve_untextured), + // the unpainted surface and an earlier bake's relief on it, is counted on top of it, so that relief + // does not have to be evicted to fit this one. size_t max_triangles = 750'000; // Keep removing zero-cost flat faces past the target. Only applies when decimation runs, i.e. when // the displaced mesh is over the budget - an under-budget mesh is never decimated. diff --git a/src/libslic3r/TextureBake/TextureBakeSubdivide.cpp b/src/libslic3r/TextureBake/TextureBakeSubdivide.cpp index 8e2a8c82d8..6b882b1fc8 100644 --- a/src/libslic3r/TextureBake/TextureBakeSubdivide.cpp +++ b/src/libslic3r/TextureBake/TextureBakeSubdivide.cpp @@ -68,7 +68,8 @@ struct PassResult // 2. Rebuild, allocating once at the now-known size. PassResult subdivide_pass(VertStore &verts, const std::vector &indices, double max_edge_length, int safety_cap, const std::vector &face_excluded, - QuantizedPointMap *pos_canon_map, const std::vector &face_parent_id) + QuantizedPointMap *pos_canon_map, const std::vector &face_parent_id, + const SubdivideWithinFn &within) { PassResult out; const double max_sq = max_edge_length * max_edge_length; @@ -94,15 +95,55 @@ PassResult subdivide_pass(VertStore &verts, const std::vector &indices, dou return (u < v ? split_edges.get_key(u, v, 0) : split_edges.get_key(v, u, 0)) != -1; }; - // Step 1. An excluded triangle marks none of its own edges, so its interior never refines; its - // boundary edges are still marked by an included neighbour, and it follows that split. - for (size_t t = 0; t < tri_count; ++t) { - if (!face_excluded.empty() && face_excluded[t]) - continue; + // Graded, an edge splits while it is longer than max_edge_length + SUBDIVIDE_GRADE * (d - len/2), + // d its midpoint's distance from the region; less half the length bounds the edge's own distance + // from below, so the test errs toward splitting. Solved for d, that is the region coming within the + // radius below. It reads the positions alone, so two triangles sharing the edge agree. + const auto too_long = [&](int a, int b) { + const double len_sq = edge_len_sq(verts, a, b); + if (len_sq <= max_sq) + return false; + if (!within) + return true; + const double len = std::sqrt(len_sq); + return within((verts.pos[size_t(a)] + verts.pos[size_t(b)]) * 0.5, + 0.5 * len + (len - max_edge_length) / SUBDIVIDE_GRADE); + }; + + // Step 1. An excluded triangle marks none of its own edges while left alone, so its interior never + // refines; its boundary edges are still marked by an included neighbour, and ungraded it follows + // that split. Graded, one the refinement reaches marks its own too-long edges too, and so on + // outward. An earlier bake's relief meets the paint along edges already at the target length, so + // it is never reached and stays as it was. + std::vector want(tri_count, 0); + tbb::parallel_for(tbb::blocked_range(0, tri_count), [&](const tbb::blocked_range &range) { + for (size_t t = range.begin(); t < range.end(); ++t) { + const int a = indices[t * 3], b = indices[t * 3 + 1], c = indices[t * 3 + 2]; + want[t] = uint8_t(too_long(a, b)) | uint8_t(too_long(b, c) << 1) | uint8_t(too_long(c, a) << 2); + } + }); + const auto mark_wanted = [&](size_t t) { const int a = indices[t * 3], b = indices[t * 3 + 1], c = indices[t * 3 + 2]; - if (edge_len_sq(verts, a, b) > max_sq) mark_edge(a, b); - if (edge_len_sq(verts, b, c) > max_sq) mark_edge(b, c); - if (edge_len_sq(verts, c, a) > max_sq) mark_edge(c, a); + if (want[t] & 1) mark_edge(a, b); + if (want[t] & 2) mark_edge(b, c); + if (want[t] & 4) mark_edge(c, a); + }; + std::vector unreached; + for (size_t t = 0; t < tri_count; ++t) + if (face_excluded.empty() || !face_excluded[t]) + mark_wanted(t); + else if (within && want[t] != 0) + unreached.push_back(t); + // Marks only accumulate, so the order the reached triangles are taken in does not matter. + for (size_t n = 0; n != unreached.size();) { + n = unreached.size(); + unreached.erase(std::remove_if(unreached.begin(), unreached.end(), [&](size_t t) { + const int a = indices[t * 3], b = indices[t * 3 + 1], c = indices[t * 3 + 2]; + const bool reached = is_marked(a, b) || is_marked(b, c) || is_marked(c, a); + if (reached) + mark_wanted(t); + return reached; + }), unreached.end()); } if (split_edges.size() == 0) { out.indices = indices; @@ -376,7 +417,7 @@ TriSoup to_non_indexed(const VertStore &verts, const std::vector &indices, SubdivideResult subdivide(const TriSoup &geometry, double max_edge_length, const std::vector &face_excluded, bool fast, int safety_cap, - const SubdivideProgressFn &on_progress) + const SubdivideProgressFn &on_progress, const SubdivideWithinFn &within) { SubdivideResult result; if (geometry.empty() || max_edge_length <= 0.0) { @@ -401,7 +442,7 @@ SubdivideResult subdivide(const TriSoup &geometry, double max_edge_length, } PassResult pass = subdivide_pass(indexed.verts, current_indices, max_edge_length, safety_cap, - current_excluded, canon_map, current_parent); + current_excluded, canon_map, current_parent, within); current_indices = std::move(pass.indices); if (!pass.face_excluded.empty()) current_excluded = std::move(pass.face_excluded); diff --git a/src/libslic3r/TextureBake/TextureBakeSubdivide.hpp b/src/libslic3r/TextureBake/TextureBakeSubdivide.hpp index 30ad56bf00..743c74eb49 100644 --- a/src/libslic3r/TextureBake/TextureBakeSubdivide.hpp +++ b/src/libslic3r/TextureBake/TextureBakeSubdivide.hpp @@ -26,6 +26,9 @@ static constexpr double SUBDIVIDE_SHARP_ANGLE_DEG = 30.0; // A depth bound, not a work bound: the loop stops as soon as a pass changes nothing. static constexpr int SUBDIVIDE_MAX_ITERATIONS = 12; +// Graded refinement: millimetres of edge length allowed per millimetre of distance from the region. +static constexpr double SUBDIVIDE_GRADE = 1.0; + // Built by the indexers, appended to by the passes. Double precision so repeated midpointing does // not drift. struct VertStore @@ -56,6 +59,9 @@ struct IndexedMesh // Fraction, triangle count, longest remaining edge. Returning false cancels; what comes back is // still watertight, because passes apply whole or not at all. using SubdivideProgressFn = std::function; +// Whether the region being refined comes within `radius` mm of `point`. Called from several threads +// at once. +using SubdivideWithinFn = std::function; struct SubdivideResult { @@ -65,13 +71,20 @@ struct SubdivideResult bool safety_cap_hit = false; }; -// `face_excluded`: one entry per input triangle; non-zero means its interior is never refined. Its -// edges still split when an included neighbour marks them, so no T-junction appears at the boundary. +// `face_excluded`: one entry per input triangle; non-zero means its interior is not refined unless the +// graded refinement reaches it (see `within`). Its edges still split when an included neighbour marks +// them, so no T-junction appears at the boundary. // `fast` selects the cheap position-only indexer for previews. +// `within`, when given, grades the refinement: an edge splits only while it is longer than +// max_edge_length plus SUBDIVIDE_GRADE times its distance from the region. Away from the region the +// triangles are then left as whole pieces of the input's own 1->4 grid rather than refined throughout. +// An excluded triangle the refinement reaches - one of its edges splits - splits the same graded way, +// instead of fanning from its far corner to follow the edge it shares with an included one. SubdivideResult subdivide(const TriSoup &geometry, double max_edge_length, const std::vector &face_excluded = {}, bool fast = false, int safety_cap = SUBDIVIDE_SAFETY_CAP, - const SubdivideProgressFn &on_progress = {}); + const SubdivideProgressFn &on_progress = {}, + const SubdivideWithinFn &within = {}); // Displacement needs the same welding and sharp-edge clustering. IndexedMesh to_indexed(const TriSoup &geometry); diff --git a/src/libslic3r/TextureDisplacement.cpp b/src/libslic3r/TextureDisplacement.cpp index ea52ac64ee..862c6c9044 100644 --- a/src/libslic3r/TextureDisplacement.cpp +++ b/src/libslic3r/TextureDisplacement.cpp @@ -2227,6 +2227,15 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set if (pieces_area < included_area * (1.0 - 1e-4)) settings.painted = painted_at; } + // Graded wherever some of the surface is unpainted. A yes/no to a radius, not a distance: most + // queries are far from the paint, and those the tree turns down at its root. + const bool all_painted = std::none_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }); + if (settings.painted || !all_painted) + settings.paint_within = [&painted_pieces, &painted_tree](const Vec3d &p, double radius) { + float r2 = float(radius * radius); + return AABBTreeIndirect::is_any_triangle_in_radius(painted_pieces.vertices, painted_pieces.indices, + painted_tree, Vec3f(p.cast()), r2); + }; TextureBake::DisplaceBounds bounds; bounds.min = bounds.max = mesh.vertices.empty() ? Vec3f::Zero() : mesh.vertices.front(); @@ -2310,8 +2319,7 @@ indexed_triangle_set build_texture_displacement_v2(const indexed_triangle_set std::vector out_color(out.indices.size(), 0); const ColorFieldSampler &sampler = color_sampler; if (sampler) { - const bool all_painted = std::none_of(excluded.begin(), excluded.end(), [](uint8_t e) { return e != 0; }); - float max_depth = 0.f; + float max_depth = 0.f; for (const TextureDisplacementLayer &layer : layers) max_depth = std::max(max_depth, std::abs(layer.depth_mm)); const float relief_tol = max_depth + paint_tol; diff --git a/tests/libslic3r/test_texture_displacement.cpp b/tests/libslic3r/test_texture_displacement.cpp index 95384dae46..04713d3fd9 100644 --- a/tests/libslic3r/test_texture_displacement.cpp +++ b/tests/libslic3r/test_texture_displacement.cpp @@ -4,12 +4,14 @@ #include #include +#include #include #include #include #include #include +#include "libslic3r/AABBMesh.hpp" #include "libslic3r/TextureDisplacement.hpp" #include "libslic3r/TextureBake/TextureBakeDecimate.hpp" #include "libslic3r/TextureBake/TextureBakeFlip.hpp" @@ -156,6 +158,25 @@ static TriangleSelector::TriangleSplittingData paint_whole_mesh(const indexed_tr return selector.serialize(); } +// Paints a disc of the top face of `mesh` (the plane z = `top`) the way the gizmo's brush does: the +// selector splits the triangles under it, so the stroke need not follow the mesh. +static TriangleSelector::TriangleSplittingData paint_top_disc(const indexed_triangle_set &mesh, float top, + const Vec2f ¢re, float radius) +{ + const TriangleMesh tm(mesh); + const Vec3f at(centre.x(), centre.y(), top); + int start = -1; + Vec3d foot; + AABBMesh(tm).squared_distance(at.cast(), start, foot); // the brush starts on the face under it + TriangleSelector selector(tm); + selector.select_patch(start, + TriangleSelector::SinglePointCursor::cursor_factory( + at, at + Vec3f(0.f, 0.f, 80.f), radius, TriangleSelector::CursorType::SPHERE, + Transform3d::Identity(), TriangleSelector::ClippingPlane()), + EnforcerBlockerType::ENFORCER, Transform3d::Identity(), /* triangle_splitting */ true); + return selector.serialize(); +} + // Regression test for the bug this feature shipped with: with two layers painted over the same // area, the second one was silently dropped (its paint mask was remapped onto the mesh the first // layer had already displaced, which routinely produced an empty bitstream). Every layer is now @@ -1889,27 +1910,9 @@ TEST_CASE("TextureDisplacement: a brush stroke smaller than a triangle displaces // A plain 12-triangle cube. The selector splits the top triangle under a 3 mm spherical brush, // so the painted pieces are far smaller than the triangle. The one-run pipeline used to include // the whole source triangle: the entire top face rose. - const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f); - const TriangleMesh mesh(cube); - int top = -1; - for (size_t t = 0; t < cube.indices.size() && top < 0; ++t) { - const auto &f = cube.indices[t]; - if (cube.vertices[size_t(f[0])].z() > 19.9f && cube.vertices[size_t(f[1])].z() > 19.9f && - cube.vertices[size_t(f[2])].z() > 19.9f) - // The triangle that contains the face centre: the brush starts there. - for (int k = 0; k < 3; ++k) - if ((cube.vertices[size_t(f[k])] - Vec3f(10.f, 10.f, 20.f)).norm() < 15.f) - top = int(t); - } - REQUIRE(top >= 0); - TriangleSelector selector(mesh); - selector.select_patch(top, - TriangleSelector::SinglePointCursor::cursor_factory( - Vec3f(10.f, 10.f, 20.f), Vec3f(10.f, 10.f, 100.f), 3.f, TriangleSelector::CursorType::SPHERE, - Transform3d::Identity(), TriangleSelector::ClippingPlane()), - EnforcerBlockerType::ENFORCER, Transform3d::Identity(), /* triangle_splitting */ true); + const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f); TextureDisplacementFacetsData facets; - facets[0] = selector.serialize(); + facets[0] = paint_top_disc(cube, 20.f, Vec2f(10.f, 10.f), 3.f); REQUIRE(TriangleSelector::has_facets(facets[0], EnforcerBlockerType::ENFORCER)); TextureDisplacementLayer layer; @@ -2054,3 +2057,58 @@ TEST_CASE("Cancelling while flat faces are harvested past the budget stops the d CHECK(canceled <= target); CHECK(canceled > full); } + +TEST_CASE("A second bake beside a first comes out as fine as a single bake", "[TextureDisplacement]") +{ + // A stroke in each of the cube's two top triangles, baked one after the other. The first bake used + // to leave the unpainted ground fanned into slivers from a far corner, or re-triangulated by the + // flat harvest into long triangles, and the second bake refined those shapes rather than the + // cube's own grid: its stroke came out many times as dense, with walls off the texture's lines. + const indexed_triangle_set cube = its_make_cube(20.f, 20.f, 20.f); + const Vec2f first_stroke(5.f, 15.f), second_stroke(15.f, 5.f); + constexpr float radius = 3.f; + + TextureDisplacementLayer layer; + layer.slot = 0; + layer.image_data = make_checkerboard_png(16, 16); + layer.tiling_scale = 5.f; + layer.depth_mm = 0.5f; + TextureDisplacementOptions options; + options.pipeline_v2 = true; + options.v2_refine_mm = 0.5f; + options.v2_max_triangles_k = 750; // export: the flat harvest runs + const auto bake = [&](const indexed_triangle_set &mesh, const Vec2f &stroke) { + TextureDisplacementFacetsData facets; + facets[0] = paint_top_disc(mesh, 20.f, stroke, radius); + return build_texture_displacement(mesh, { layer }, facets, options); + }; + // Aspect ratios of the top-face triangles whose centroid's distance from `centre` passes `keep`. + const auto top_aspects = [](const indexed_triangle_set &mesh, const Vec2f ¢re, auto keep) { + std::vector out; + for (const stl_triangle_vertex_indices &t : mesh.indices) { + const Vec3f &a = mesh.vertices[size_t(t[0])], &b = mesh.vertices[size_t(t[1])], &c = mesh.vertices[size_t(t[2])]; + const Vec3f g = (a + b + c) / 3.f; + if (g.z() > 19.9f && keep((Vec2f(g.x(), g.y()) - centre).norm())) + out.push_back(triangle_aspect(a, b, c)); + } + return out; + }; + const auto in_stroke = [&](float d) { return d < radius; }; + + const indexed_triangle_set first = bake(cube, first_stroke); + REQUIRE(first.indices.size() > cube.indices.size()); + // What the first bake leaves around its stroke is ground the second one refines: whole pieces of + // the cube's grid, not slivers. + const std::vector ground = top_aspects(first, first_stroke, [&](float d) { return d > radius + 1.f; }); + REQUIRE(!ground.empty()); + const float worst = *std::max_element(ground.begin(), ground.end()); + INFO("worst aspect ratio on the ground the first bake left: " << worst); + CHECK(worst < 6.f); + + const size_t single = top_aspects(bake(cube, second_stroke), second_stroke, in_stroke).size(); + const size_t second = top_aspects(bake(first, second_stroke), second_stroke, in_stroke).size(); + INFO("second stroke baked alone: " << single << " triangles, after the first: " << second); + REQUIRE(single > 0); + CHECK(second <= single * 5 / 4); +} +